Add rice transplant planning feature

This commit is contained in:
akira
2026-04-04 17:26:55 +09:00
parent f236fe2f90
commit 0c57dd7886
15 changed files with 1458 additions and 13 deletions

View File

@@ -0,0 +1,59 @@
# Generated by Django 5.2 on 2026-04-04 00:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fields', '0006_e1c_chusankan_17_fields'),
('plans', '0004_crop_base_temp'),
]
operations = [
migrations.AddField(
model_name='crop',
name='seed_inventory_kg',
field=models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='種もみ在庫(kg)'),
),
migrations.AddField(
model_name='variety',
name='default_seedling_boxes_per_tan',
field=models.DecimalField(decimal_places=2, default=0, max_digits=6, verbose_name='反当苗箱枚数デフォルト'),
),
migrations.CreateModel(
name='RiceTransplantPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='計画名')),
('year', models.IntegerField(verbose_name='年度')),
('default_seed_grams_per_box', models.DecimalField(decimal_places=2, default=0, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)デフォルト')),
('notes', models.TextField(blank=True, default='', verbose_name='備考')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('variety', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='rice_transplant_plans', to='plans.variety', verbose_name='品種')),
],
options={
'verbose_name': '田植え計画',
'verbose_name_plural': '田植え計画',
'ordering': ['-year', 'variety'],
},
),
migrations.CreateModel(
name='RiceTransplantEntry',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('seedling_boxes_per_tan', models.DecimalField(decimal_places=2, max_digits=6, verbose_name='反当苗箱枚数')),
('seed_grams_per_box', models.DecimalField(decimal_places=2, max_digits=8, verbose_name='苗箱1枚あたり種もみ(g)')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rice_transplant_entries', to='fields.field', verbose_name='圃場')),
('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='plans.ricetransplantplan', verbose_name='田植え計画')),
],
options={
'verbose_name': '田植え計画エントリ',
'verbose_name_plural': '田植え計画エントリ',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('plan', 'field')},
},
),
]

View File

@@ -5,6 +5,12 @@ from apps.fields.models import Field
class Crop(models.Model):
name = models.CharField(max_length=100, unique=True, verbose_name="作物名")
base_temp = models.FloatField(default=0.0, verbose_name="有効積算温度 基準温度(℃)")
seed_inventory_kg = models.DecimalField(
max_digits=10,
decimal_places=3,
default=0,
verbose_name="種もみ在庫(kg)",
)
class Meta:
verbose_name = "作物マスタ"
@@ -17,6 +23,12 @@ class Crop(models.Model):
class Variety(models.Model):
crop = models.ForeignKey(Crop, on_delete=models.CASCADE, related_name='varieties', verbose_name="作物")
name = models.CharField(max_length=100, verbose_name="品種名")
default_seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
default=0,
verbose_name="反当苗箱枚数デフォルト",
)
class Meta:
verbose_name = "品種マスタ"
@@ -42,3 +54,65 @@ class Plan(models.Model):
def __str__(self):
return f"{self.field.name} - {self.year} - {self.crop.name}"
class RiceTransplantPlan(models.Model):
name = models.CharField(max_length=200, verbose_name='計画名')
year = models.IntegerField(verbose_name='年度')
variety = models.ForeignKey(
Variety,
on_delete=models.PROTECT,
related_name='rice_transplant_plans',
verbose_name='品種',
)
default_seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
default=0,
verbose_name='苗箱1枚あたり種もみ(g)デフォルト',
)
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 = ['-year', 'variety']
def __str__(self):
return f'{self.year} {self.name}'
class RiceTransplantEntry(models.Model):
plan = models.ForeignKey(
RiceTransplantPlan,
on_delete=models.CASCADE,
related_name='entries',
verbose_name='田植え計画',
)
field = models.ForeignKey(
Field,
on_delete=models.CASCADE,
related_name='rice_transplant_entries',
verbose_name='圃場',
)
seedling_boxes_per_tan = models.DecimalField(
max_digits=6,
decimal_places=2,
verbose_name='反当苗箱枚数',
)
seed_grams_per_box = models.DecimalField(
max_digits=8,
decimal_places=2,
verbose_name='苗箱1枚あたり種もみ(g)',
)
class Meta:
verbose_name = '田植え計画エントリ'
verbose_name_plural = '田植え計画エントリ'
unique_together = [['plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f'{self.plan} / {self.field} / {self.seedling_boxes_per_tan}枚/反'

View File

@@ -1,5 +1,9 @@
from decimal import Decimal
from rest_framework import serializers
from apps.fields.models import Field
from .models import Crop, Variety, Plan
from .models import RiceTransplantEntry, RiceTransplantPlan
class VarietySerializer(serializers.ModelSerializer):
@@ -34,3 +38,154 @@ class PlanSerializer(serializers.ModelSerializer):
setattr(instance, attr, value)
instance.save()
return instance
class RiceTransplantEntrySerializer(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,
)
planned_boxes = serializers.SerializerMethodField()
planned_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantEntry
fields = [
'id',
'field',
'field_name',
'field_area_tan',
'seedling_boxes_per_tan',
'seed_grams_per_box',
'planned_boxes',
'planned_seed_kg',
]
def get_planned_boxes(self, obj):
area = Decimal(str(obj.field.area_tan))
return str((area * obj.seedling_boxes_per_tan).quantize(Decimal('0.01')))
def get_planned_seed_kg(self, obj):
area = Decimal(str(obj.field.area_tan))
boxes = area * obj.seedling_boxes_per_tan
seed_kg = (boxes * obj.seed_grams_per_box / Decimal('1000')).quantize(Decimal('0.001'))
return str(seed_kg)
class RiceTransplantPlanSerializer(serializers.ModelSerializer):
variety_name = serializers.CharField(source='variety.name', read_only=True)
crop_name = serializers.CharField(source='variety.crop.name', read_only=True)
entries = RiceTransplantEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
total_seedling_boxes = serializers.SerializerMethodField()
total_seed_kg = serializers.SerializerMethodField()
crop_seed_inventory_kg = serializers.SerializerMethodField()
remaining_seed_kg = serializers.SerializerMethodField()
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'variety_name',
'crop_name',
'default_seed_grams_per_box',
'notes',
'entries',
'field_count',
'total_seedling_boxes',
'total_seed_kg',
'crop_seed_inventory_kg',
'remaining_seed_kg',
'created_at',
'updated_at',
]
def get_field_count(self, obj):
return obj.entries.count()
def get_total_seedling_boxes(self, obj):
total = sum(
Decimal(str(entry.field.area_tan)) * entry.seedling_boxes_per_tan
for entry in obj.entries.all()
)
return str(total.quantize(Decimal('0.01')))
def get_total_seed_kg(self, obj):
total = sum(
(
Decimal(str(entry.field.area_tan))
* entry.seedling_boxes_per_tan
* entry.seed_grams_per_box
/ Decimal('1000')
)
for entry in obj.entries.all()
)
return str(total.quantize(Decimal('0.001')))
def get_crop_seed_inventory_kg(self, obj):
return str(obj.variety.crop.seed_inventory_kg)
def get_remaining_seed_kg(self, obj):
total_seed = Decimal(self.get_total_seed_kg(obj))
return str((obj.variety.crop.seed_inventory_kg - total_seed).quantize(Decimal('0.001')))
class RiceTransplantPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = RiceTransplantPlan
fields = [
'id',
'name',
'year',
'variety',
'default_seed_grams_per_box',
'notes',
'entries',
]
def create(self, validated_data):
entries_data = validated_data.pop('entries', [])
plan = RiceTransplantPlan.objects.create(**validated_data)
self._save_entries(plan, entries_data)
return plan
def update(self, instance, validated_data):
entries_data = validated_data.pop('entries', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if entries_data is not None:
instance.entries.all().delete()
self._save_entries(instance, entries_data)
return instance
def validate(self, attrs):
entries_data = attrs.get('entries')
if entries_data is None:
return attrs
field_ids = [entry.get('field_id') for entry in entries_data if entry.get('field_id') is not None]
existing_ids = set(Field.objects.filter(id__in=field_ids).values_list('id', flat=True))
missing_ids = sorted(set(field_ids) - existing_ids)
if missing_ids:
raise serializers.ValidationError({
'entries': f'存在しない圃場IDが含まれています: {", ".join(str(field_id) for field_id in missing_ids)}'
})
return attrs
def _save_entries(self, plan, entries_data):
for entry in entries_data:
RiceTransplantEntry.objects.create(
plan=plan,
field_id=entry['field_id'],
seedling_boxes_per_tan=entry['seedling_boxes_per_tan'],
seed_grams_per_box=entry['seed_grams_per_box'],
)

View File

@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter()
router.register(r'crops', views.CropViewSet)
router.register(r'varieties', views.VarietyViewSet)
router.register(r'rice-transplant-plans', views.RiceTransplantPlanViewSet, basename='rice-transplant-plan')
router.register(r'', views.PlanViewSet)
urlpatterns = [

View File

@@ -2,8 +2,14 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Sum
from .models import Crop, Variety, Plan
from .serializers import CropSerializer, VarietySerializer, PlanSerializer
from .models import Crop, Variety, Plan, RiceTransplantPlan
from .serializers import (
CropSerializer,
VarietySerializer,
PlanSerializer,
RiceTransplantPlanSerializer,
RiceTransplantPlanWriteSerializer,
)
from apps.fields.models import Field
@@ -130,3 +136,49 @@ class PlanViewSet(viewsets.ModelViewSet):
def get_crops_with_varieties(self, request):
crops = Crop.objects.prefetch_related('varieties').all()
return Response(CropSerializer(crops, many=True).data)
class RiceTransplantPlanViewSet(viewsets.ModelViewSet):
queryset = RiceTransplantPlan.objects.select_related(
'variety',
'variety__crop',
).prefetch_related('entries', 'entries__field')
def get_queryset(self):
queryset = self.queryset
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 RiceTransplantPlanWriteSerializer
return RiceTransplantPlanSerializer
@action(detail=False, methods=['get'])
def candidate_fields(self, request):
year = request.query_params.get('year')
variety_id = request.query_params.get('variety_id')
if not year or not variety_id:
return Response(
{'error': 'year と variety_id が必要です'},
status=status.HTTP_400_BAD_REQUEST,
)
field_ids = Plan.objects.filter(
year=year,
variety_id=variety_id,
).values_list('field_id', flat=True)
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
data = [
{
'id': field.id,
'name': field.name,
'area_tan': str(field.area_tan),
'area_m2': field.area_m2,
'group_name': field.group_name,
}
for field in fields
]
return Response(data)