#12 対応を入れました。
畔塗記録 API に total_area_tan を追加して、一覧の各記録に「圃場数 / 面積合計」が出るようにしました。あわせて、作成・編集フォームの「対象圃場一覧」にも、選択中の合計面積を表示しています。主な変更は serializers.py、tests.py、page.tsx、index.ts です。 確認できたこと: docker compose -f docker-compose.develop.yml exec backend python manage.py test apps.levee_work OK docker exec keinasystem_frontend npm run build OK まだコミットはしていません。必要ならこのままコミットして push します。
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from django.db import transaction
|
||||
from decimal import Decimal
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.plans.models import Plan
|
||||
@@ -34,6 +35,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
|
||||
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
|
||||
item_count = serializers.SerializerMethodField()
|
||||
total_area_tan = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LeveeWorkSession
|
||||
@@ -45,6 +47,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
'notes',
|
||||
'work_record_id',
|
||||
'item_count',
|
||||
'total_area_tan',
|
||||
'items',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
@@ -53,6 +56,10 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
|
||||
def get_item_count(self, obj):
|
||||
return len(obj.items.all())
|
||||
|
||||
def get_total_area_tan(self, obj):
|
||||
total = sum((item.field.area_tan or Decimal('0')) for item in obj.items.all())
|
||||
return str(total)
|
||||
|
||||
|
||||
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
|
||||
field = serializers.IntegerField()
|
||||
|
||||
58
backend/apps/levee_work/tests.py
Normal file
58
backend/apps/levee_work/tests.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.fields.models import Field
|
||||
from apps.plans.models import Crop, Plan, Variety
|
||||
|
||||
from .models import LeveeWorkSession, LeveeWorkSessionItem
|
||||
from .serializers import LeveeWorkSessionSerializer
|
||||
|
||||
|
||||
class LeveeWorkSessionSerializerTests(TestCase):
|
||||
def test_total_area_tan_is_included(self):
|
||||
crop = Crop.objects.create(name='水稲')
|
||||
variety = Variety.objects.create(crop=crop, name='にこまる')
|
||||
field_a = Field.objects.create(
|
||||
name='足川北上',
|
||||
address='高知県高岡郡',
|
||||
area_tan='1.2000',
|
||||
area_m2=1200,
|
||||
owner_name='吉田',
|
||||
group_name='北',
|
||||
display_order=1,
|
||||
)
|
||||
field_b = Field.objects.create(
|
||||
name='足川南',
|
||||
address='高知県高岡郡',
|
||||
area_tan='0.8000',
|
||||
area_m2=800,
|
||||
owner_name='吉田',
|
||||
group_name='南',
|
||||
display_order=2,
|
||||
)
|
||||
plan_a = Plan.objects.create(field=field_a, year=2026, crop=crop, variety=variety, notes='')
|
||||
plan_b = Plan.objects.create(field=field_b, year=2026, crop=crop, variety=variety, notes='')
|
||||
session = LeveeWorkSession.objects.create(
|
||||
year=2026,
|
||||
date='2026-04-06',
|
||||
title='水稲畔塗',
|
||||
notes='',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_a,
|
||||
plan=plan_a,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
LeveeWorkSessionItem.objects.create(
|
||||
session=session,
|
||||
field=field_b,
|
||||
plan=plan_b,
|
||||
crop_name_snapshot='水稲',
|
||||
variety_name_snapshot='にこまる',
|
||||
)
|
||||
|
||||
data = LeveeWorkSessionSerializer(session).data
|
||||
|
||||
self.assertEqual(data['item_count'], 2)
|
||||
self.assertEqual(data['total_area_tan'], '2.0000')
|
||||
@@ -203,6 +203,10 @@ function LeveeWorkPageContent() {
|
||||
return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
|
||||
}, [form, sortedCandidates]);
|
||||
|
||||
const selectedAreaTan = useMemo(() => {
|
||||
return selectedCandidates.reduce((sum, candidate) => sum + Number(candidate.field_area_tan || '0'), 0);
|
||||
}, [selectedCandidates]);
|
||||
|
||||
const handleSort = (nextKey: SortKey) => {
|
||||
if (sortKey === nextKey) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
@@ -335,7 +339,9 @@ function LeveeWorkPageContent() {
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">{session.title}</div>
|
||||
<div className="mt-1 text-sm text-gray-600">{session.date}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{session.item_count}圃場</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{session.item_count}圃場 / {Number(session.total_area_tan).toFixed(2)}反
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -388,7 +394,9 @@ function LeveeWorkPageContent() {
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-900">対象圃場一覧</h2>
|
||||
<p className="text-xs text-gray-500">{selectedCount} / {candidates.length} 圃場を選択中</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{selectedCount} / {candidates.length} 圃場を選択中 / 合計 {selectedAreaTan.toFixed(2)}反
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -345,6 +345,7 @@ export interface LeveeWorkSession {
|
||||
notes: string;
|
||||
work_record_id: number | null;
|
||||
item_count: number;
|
||||
total_area_tan: string;
|
||||
items: LeveeWorkSessionItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
Reference in New Issue
Block a user