feat: add levee work records

This commit is contained in:
akira
2026-04-04 11:32:26 +09:00
parent c773c7d3b8
commit b7b9818855
20 changed files with 929 additions and 6 deletions

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,17 @@
from django.contrib import admin
from .models import LeveeWorkSession, LeveeWorkSessionItem
class LeveeWorkSessionItemInline(admin.TabularInline):
model = LeveeWorkSessionItem
extra = 0
@admin.register(LeveeWorkSession)
class LeveeWorkSessionAdmin(admin.ModelAdmin):
list_display = ['date', 'title', 'year', 'created_at']
list_filter = ['year', 'date']
search_fields = ['title', 'items__field__name']
inlines = [LeveeWorkSessionItemInline]

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class LeveeWorkConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.levee_work'
verbose_name = '畔塗作業'

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.2 on 2026-04-04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0004_crop_base_temp'),
]
operations = [
migrations.CreateModel(
name='LeveeWorkSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(verbose_name='年度')),
('date', models.DateField(verbose_name='畔塗日')),
('title', models.CharField(default='水稲畔塗', max_length=100, verbose_name='タイトル')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '畔塗記録',
'verbose_name_plural': '畔塗記録',
'ordering': ['-date', '-id'],
},
),
migrations.CreateModel(
name='LeveeWorkSessionItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('crop_name_snapshot', models.CharField(max_length=100, verbose_name='作物名スナップショット')),
('variety_name_snapshot', models.CharField(blank=True, default='', max_length=100, verbose_name='品種名スナップショット')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='plans.plan', verbose_name='作付け計画')),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='levee_work.leveeworksession', verbose_name='畔塗記録')),
],
options={
'verbose_name': '畔塗対象圃場',
'verbose_name_plural': '畔塗対象圃場',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('session', 'field')},
},
),
]

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,59 @@
from django.db import models
class LeveeWorkSession(models.Model):
year = models.IntegerField(verbose_name='年度')
date = models.DateField(verbose_name='畔塗日')
title = models.CharField(max_length=100, default='水稲畔塗', verbose_name='タイトル')
notes = models.TextField(blank=True, default='', verbose_name='備考')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '畔塗記録'
verbose_name_plural = '畔塗記録'
ordering = ['-date', '-id']
def __str__(self):
return f'{self.date} {self.title}'
class LeveeWorkSessionItem(models.Model):
session = models.ForeignKey(
LeveeWorkSession,
on_delete=models.CASCADE,
related_name='items',
verbose_name='畔塗記録',
)
field = models.ForeignKey(
'fields.Field',
on_delete=models.PROTECT,
verbose_name='圃場',
)
plan = models.ForeignKey(
'plans.Plan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name='作付け計画',
)
crop_name_snapshot = models.CharField(max_length=100, verbose_name='作物名スナップショット')
variety_name_snapshot = models.CharField(
max_length=100,
blank=True,
default='',
verbose_name='品種名スナップショット',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = '畔塗対象圃場'
verbose_name_plural = '畔塗対象圃場'
unique_together = [['session', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.session} / {self.field.name}'

View File

@@ -0,0 +1,142 @@
from django.db import transaction
from rest_framework import serializers
from apps.plans.models import Plan
from apps.workrecords.services import sync_levee_work_record
from .models import LeveeWorkSession, LeveeWorkSessionItem
class LeveeWorkSessionItemReadSerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
field_area_tan = serializers.DecimalField(
source='field.area_tan',
max_digits=6,
decimal_places=4,
read_only=True,
)
group_name = serializers.CharField(source='field.group_name', read_only=True, allow_null=True)
class Meta:
model = LeveeWorkSessionItem
fields = [
'id',
'field',
'field_name',
'field_area_tan',
'group_name',
'plan',
'crop_name_snapshot',
'variety_name_snapshot',
]
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()
class Meta:
model = LeveeWorkSession
fields = [
'id',
'year',
'date',
'title',
'notes',
'work_record_id',
'item_count',
'items',
'created_at',
'updated_at',
]
def get_item_count(self, obj):
return len(obj.items.all())
class LeveeWorkSessionItemWriteInputSerializer(serializers.Serializer):
field = serializers.IntegerField()
plan = serializers.IntegerField(required=False, allow_null=True)
class LeveeWorkSessionWriteSerializer(serializers.ModelSerializer):
items = LeveeWorkSessionItemWriteInputSerializer(many=True, write_only=True)
class Meta:
model = LeveeWorkSession
fields = ['id', 'year', 'date', 'title', 'notes', 'items']
def validate(self, attrs):
year = attrs.get('year', getattr(self.instance, 'year', None))
date = attrs.get('date', getattr(self.instance, 'date', None))
if year is not None and date is not None and year != date.year:
raise serializers.ValidationError({'year': 'year は date.year と一致させてください。'})
return attrs
def validate_items(self, value):
if not value:
raise serializers.ValidationError('items を1件以上指定してください。')
seen = set()
for item in value:
key = item['field']
if key in seen:
raise serializers.ValidationError('同一 session 内で同じ圃場を重複登録できません。')
seen.add(key)
return value
@transaction.atomic
def create(self, validated_data):
items_data = validated_data.pop('items', [])
validated_data['title'] = (validated_data.get('title') or '').strip() or '水稲畔塗'
session = LeveeWorkSession.objects.create(**validated_data)
self._replace_items(session, items_data)
sync_levee_work_record(session)
return session
@transaction.atomic
def update(self, instance, validated_data):
items_data = validated_data.pop('items', None)
for attr, value in validated_data.items():
if attr == 'title':
value = (value or '').strip() or '水稲畔塗'
setattr(instance, attr, value)
if 'title' not in validated_data:
instance.title = (instance.title or '').strip() or '水稲畔塗'
instance.save()
if items_data is not None:
self._replace_items(instance, items_data)
sync_levee_work_record(instance)
return instance
def _replace_items(self, session, items_data):
session.items.all().delete()
for item in items_data:
plan = self._resolve_plan(session.year, item['field'], item.get('plan'))
LeveeWorkSessionItem.objects.create(
session=session,
field_id=item['field'],
plan=plan,
crop_name_snapshot=plan.crop.name,
variety_name_snapshot=plan.variety.name if plan.variety else '',
)
def _resolve_plan(self, year, field_id, plan_id):
queryset = Plan.objects.select_related('crop', 'variety').filter(
year=year,
field_id=field_id,
crop__name='水稲',
)
if plan_id is not None:
try:
return queryset.get(id=plan_id)
except Plan.DoesNotExist as exc:
raise serializers.ValidationError(
{'items': f'field={field_id} に対応する水稲作付け計画(plan={plan_id})が見つかりません。'}
) from exc
plan = queryset.first()
if plan is None:
raise serializers.ValidationError(
{'items': f'field={field_id} は当年の水稲作付け圃場ではありません。'}
)
return plan

View File

@@ -0,0 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import LeveeWorkCandidatesView, LeveeWorkSessionViewSet
router = DefaultRouter()
router.register(r'sessions', LeveeWorkSessionViewSet, basename='levee-work-session')
urlpatterns = [
path('candidates/', LeveeWorkCandidatesView.as_view(), name='levee-work-candidates'),
path('', include(router.urls)),
]

View File

@@ -0,0 +1,70 @@
from rest_framework import status, viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.plans.models import Plan
from .models import LeveeWorkSession
from .serializers import LeveeWorkSessionSerializer, LeveeWorkSessionWriteSerializer
class LeveeWorkSessionViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = LeveeWorkSession.objects.prefetch_related(
'items',
'items__field',
'items__plan',
).select_related('work_record')
year = self.request.query_params.get('year')
if year:
queryset = queryset.filter(year=year)
return queryset
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return LeveeWorkSessionWriteSerializer
return LeveeWorkSessionSerializer
class LeveeWorkCandidatesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
year = request.query_params.get('year')
if not year:
return Response(
{'detail': 'year が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
year = int(year)
except (TypeError, ValueError):
return Response(
{'detail': 'year は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
plans = (
Plan.objects.select_related('field', 'crop', 'variety')
.filter(year=year, crop__name='水稲')
.order_by('field__display_order', 'field__id')
)
data = [
{
'field_id': plan.field_id,
'field_name': plan.field.name,
'field_area_tan': str(plan.field.area_tan),
'group_name': plan.field.group_name,
'plan_id': plan.id,
'crop_name': plan.crop.name,
'variety_name': plan.variety.name if plan.variety else '',
'selected': True,
}
for plan in plans
]
return Response(data)