#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:
akira
2026-04-06 17:23:06 +09:00
parent c90c6210e1
commit 4a1db5ef27
4 changed files with 76 additions and 2 deletions

View File

@@ -1,4 +1,5 @@
from django.db import transaction from django.db import transaction
from decimal import Decimal
from rest_framework import serializers from rest_framework import serializers
from apps.plans.models import Plan from apps.plans.models import Plan
@@ -34,6 +35,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True) items = LeveeWorkSessionItemReadSerializer(many=True, read_only=True)
work_record_id = serializers.IntegerField(source='work_record.id', read_only=True) work_record_id = serializers.IntegerField(source='work_record.id', read_only=True)
item_count = serializers.SerializerMethodField() item_count = serializers.SerializerMethodField()
total_area_tan = serializers.SerializerMethodField()
class Meta: class Meta:
model = LeveeWorkSession model = LeveeWorkSession
@@ -45,6 +47,7 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
'notes', 'notes',
'work_record_id', 'work_record_id',
'item_count', 'item_count',
'total_area_tan',
'items', 'items',
'created_at', 'created_at',
'updated_at', 'updated_at',
@@ -53,6 +56,10 @@ class LeveeWorkSessionSerializer(serializers.ModelSerializer):
def get_item_count(self, obj): def get_item_count(self, obj):
return len(obj.items.all()) 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): class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
field = serializers.IntegerField() field = serializers.IntegerField()

View 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')

View File

@@ -203,6 +203,10 @@ function LeveeWorkPageContent() {
return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id)); return sortedCandidates.filter((candidate) => form.selectedFieldIds.has(candidate.field_id));
}, [form, sortedCandidates]); }, [form, sortedCandidates]);
const selectedAreaTan = useMemo(() => {
return selectedCandidates.reduce((sum, candidate) => sum + Number(candidate.field_area_tan || '0'), 0);
}, [selectedCandidates]);
const handleSort = (nextKey: SortKey) => { const handleSort = (nextKey: SortKey) => {
if (sortKey === nextKey) { if (sortKey === nextKey) {
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); 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="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-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> </button>
))} ))}
</div> </div>
@@ -388,7 +394,9 @@ function LeveeWorkPageContent() {
<div className="mb-3 flex flex-wrap items-center justify-between gap-3"> <div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="text-sm font-medium text-gray-900"></h2> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button

View File

@@ -345,6 +345,7 @@ export interface LeveeWorkSession {
notes: string; notes: string;
work_record_id: number | null; work_record_id: number | null;
item_count: number; item_count: number;
total_area_tan: string;
items: LeveeWorkSessionItem[]; items: LeveeWorkSessionItem[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;