From 0c57dd78868afae46e41bc0b3d25ad9a126ac153 Mon Sep 17 00:00:00 2001 From: akira Date: Sat, 4 Apr 2026 17:26:55 +0900 Subject: [PATCH] Add rice transplant planning feature --- CLAUDE.md | 1 + TASK_CONTEXT.md | 7 +- ...iety_seedling_boxes_and_rice_transplant.py | 59 +++ backend/apps/plans/models.py | 74 +++ backend/apps/plans/serializers.py | 155 ++++++ backend/apps/plans/urls.py | 1 + backend/apps/plans/views.py | 56 ++- .../16_マスタードキュメント_田植え計画編.md | 308 ++++++++++++ frontend/src/app/allocation/page.tsx | 149 +++++- .../app/rice-transplant/[id]/edit/page.tsx | 5 + .../_components/RiceTransplantEditPage.tsx | 447 ++++++++++++++++++ frontend/src/app/rice-transplant/new/page.tsx | 5 + frontend/src/app/rice-transplant/page.tsx | 159 +++++++ frontend/src/components/Navbar.tsx | 13 +- frontend/src/types/index.ts | 32 ++ 15 files changed, 1458 insertions(+), 13 deletions(-) create mode 100644 backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py create mode 100644 document/16_マスタードキュメント_田植え計画編.md create mode 100644 frontend/src/app/rice-transplant/[id]/edit/page.tsx create mode 100644 frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx create mode 100644 frontend/src/app/rice-transplant/new/page.tsx create mode 100644 frontend/src/app/rice-transplant/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a1ad7fb..df467c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,7 @@ ssh keinafarm-claude 'cd /home/keinasystem/keinasystem_t02 && \ | 気象データ | `document/12_マスタードキュメント_気象データ編.md` | | 施肥計画 | `document/13_マスタードキュメント_施肥計画編.md` | | 運搬計画 | `document/14_マスタードキュメント_分配計画編.md` | +| 田植え計画 | `document/16_マスタードキュメント_田植え計画編.md` | | データモデル全体 | `document/03_データ仕様書.md` | --- diff --git a/TASK_CONTEXT.md b/TASK_CONTEXT.md index 954e998..c1c4dc8 100644 --- a/TASK_CONTEXT.md +++ b/TASK_CONTEXT.md @@ -1,6 +1,6 @@ # 現在の作業状況 -> **最終更新**: 2026-03-16 +> **最終更新**: 2026-04-04 > **現在のフェーズ**: Phase 1 (MVP) - 全タスク完了、Phase 2 移行準備中 ## 実装済み機能(Phase 1 - MVP) @@ -34,6 +34,11 @@ - 軽トラ1回分単位、グループ一括割り当て、回間移動 - マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md` 12. **作業記録索引**: `apps/workrecords`、運搬/散布の自動upsert +13. **田植え計画**(MVP実装): + - 年度×品種単位で苗箱枚数・種もみ使用量を計画 + - 作物単位の種もみ在庫kg、品種単位の反当苗箱枚数デフォルト + - 作付け計画から候補圃場を自動取得 + - マスタードキュメント: `document/16_マスタードキュメント_田植え計画編.md` ## 既知の課題・技術的負債 diff --git a/backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py b/backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py new file mode 100644 index 0000000..27d461f --- /dev/null +++ b/backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/plans/models.py b/backend/apps/plans/models.py index 5d797f8..d3870a9 100644 --- a/backend/apps/plans/models.py +++ b/backend/apps/plans/models.py @@ -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}枚/反' diff --git a/backend/apps/plans/serializers.py b/backend/apps/plans/serializers.py index 9491ff6..212fa82 100644 --- a/backend/apps/plans/serializers.py +++ b/backend/apps/plans/serializers.py @@ -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'], + ) diff --git a/backend/apps/plans/urls.py b/backend/apps/plans/urls.py index 297f004..8057ead 100644 --- a/backend/apps/plans/urls.py +++ b/backend/apps/plans/urls.py @@ -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 = [ diff --git a/backend/apps/plans/views.py b/backend/apps/plans/views.py index 45419f1..6e5f897 100644 --- a/backend/apps/plans/views.py +++ b/backend/apps/plans/views.py @@ -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) diff --git a/document/16_マスタードキュメント_田植え計画編.md b/document/16_マスタードキュメント_田植え計画編.md new file mode 100644 index 0000000..cc1ba69 --- /dev/null +++ b/document/16_マスタードキュメント_田植え計画編.md @@ -0,0 +1,308 @@ +# マスタードキュメント:田植え計画機能 + +> **作成**: 2026-04-04 +> **最終更新**: 2026-04-04 +> **対象機能**: 田植え計画(年度・品種を軸に複数回作成できる苗箱・種もみ使用量計画) +> **実装状況**: MVP実装完了 + +--- + +## 概要 + +農業生産者が「年度 × 品種」を軸に、田植え前の播種・育苗準備量を見積もる機能。 +各圃場について「反当何枚の苗箱を使うか」「苗箱1枚あたり種もみを何g使うか」を記録し、圃場別・計画全体の苗箱枚数と種もみ使用量を自動集計する。 + +圃場候補は既存の作付け計画から自動取得し、種もみ在庫は作物単位、反当苗箱枚数の初期値は品種単位で管理する。 +同じ年度・同じ品種でも、播種時期や育苗ロットを分けるために複数の田植え計画を作成できる。 + +### 機能スコープ(IN / OUT) + +| IN(実装済み) | OUT(対象外) | +|---|---| +| 田植え計画の作成・編集・削除 | 育苗日程のカレンダー管理 | +| 作付け計画からの候補圃場自動取得 | 実播種実績の記録 | +| 圃場ごとの苗箱枚数/反の個別調整 | 種もみロット管理 | +| 圃場ごとの種もみg/箱の個別調整 | 在庫の自動引当 | +| 苗箱合計・種もみkg合計の自動集計 | PDF出力 | +| 作物ごとの種もみ在庫kg管理 | 品種ごとの播種日管理 | +| 品種ごとの反当苗箱枚数デフォルト管理 | | + +--- + +## 業務ルール + +1. 田植え計画は `年度 × 品種` を軸に作成する +2. 対象圃場は、その年度・品種の作付け計画が登録されている圃場から取得する +3. 種もみ在庫は作物単位で管理する +4. 反当苗箱枚数の初期値は品種単位で管理する +5. ただし実際の計画値は圃場単位で上書きできる +6. 種もみg/箱は計画全体のデフォルト値を持ちつつ、圃場単位で上書きできる +7. 在庫不足はエラーで保存停止せず、一覧・編集画面で残在庫見込みとして可視化する +8. 同じ年度・同じ品種で複数の計画を作成してよい +9. 複数回に分ける場合は、`計画名` で「第1回」「第2回」「4/10播種分」などを区別する + +--- + +## 計算式 + +### 圃場ごとの苗箱合計 + +`苗箱合計 = 圃場面積(反) × 反当苗箱枚数` + +### 圃場ごとの種もみ使用量 + +`種もみkg = 苗箱合計 × 苗箱1枚あたり種もみ(g) ÷ 1000` + +### 計画全体の残在庫見込み + +`残在庫見込み = 作物の種もみ在庫(kg) - 計画全体の種もみkg合計` + +--- + +## データモデル + +### Crop(作物マスタ) + +既存 `plans.Crop` に以下を追加。 + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| seed_inventory_kg | decimal(10,3) | default=0 | 作物単位の種もみ在庫(kg) | + +### Variety(品種マスタ) + +既存 `plans.Variety` に以下を追加。 + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| default_seedling_boxes_per_tan | decimal(6,2) | default=0 | 反当苗箱枚数の初期値 | + +### RiceTransplantPlan(田植え計画) + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| id | int | PK | | +| name | varchar(200) | required | 計画名 | +| year | int | required | 年度 | +| variety | FK(plans.Variety) | PROTECT | 品種 | +| default_seed_grams_per_box | decimal(8,2) | default=0 | 苗箱1枚あたり種もみ(g)の初期値 | +| notes | text | blank | 備考 | +| created_at | datetime | auto | | +| updated_at | datetime | auto | | + +- `year + variety` の一意制約は持たない +- 同一年度・同一品種で複数レコード作成可能 + +#### 表示用計算項目(APIレスポンスに含まれる) + +| 項目 | 型 | 説明 | +|---|---|---| +| field_count | int | 対象圃場数 | +| total_seedling_boxes | decimal | 苗箱枚数合計 | +| total_seed_kg | decimal | 種もみ使用量合計(kg) | +| crop_seed_inventory_kg | decimal | 作物在庫(kg) | +| remaining_seed_kg | decimal | 残在庫見込み(kg) | + +### RiceTransplantEntry(田植え計画エントリ) + +| フィールド | 型 | 制約 | 説明 | +|---|---|---|---| +| id | int | PK | | +| plan | FK(RiceTransplantPlan) | CASCADE | | +| field | FK(fields.Field) | CASCADE | | +| seedling_boxes_per_tan | decimal(6,2) | required | 反当苗箱枚数 | +| seed_grams_per_box | decimal(8,2) | required | 苗箱1枚あたり種もみ(g) | + +- `unique_together = ['plan', 'field']` +- 順序: `field__display_order, field__id` + +#### 表示用計算項目(entryレスポンスに含まれる) + +| 項目 | 型 | 説明 | +|---|---|---| +| field_name | string | 圃場名 | +| field_area_tan | decimal | 圃場面積(反) | +| planned_boxes | decimal | 圃場ごとの苗箱合計 | +| planned_seed_kg | decimal | 圃場ごとの種もみkg | + +--- + +## API エンドポイント + +すべて JWT 認証(`Authorization: Bearer `)が必要。 + +### 田植え計画 + +| メソッド | URL | 説明 | +|---|---|---| +| GET | `/api/plans/rice-transplant-plans/?year={year}` | 年度別一覧 | +| POST | `/api/plans/rice-transplant-plans/` | 新規作成 | +| GET | `/api/plans/rice-transplant-plans/{id}/` | 詳細取得 | +| PUT/PATCH | `/api/plans/rice-transplant-plans/{id}/` | 更新 | +| DELETE | `/api/plans/rice-transplant-plans/{id}/` | 削除 | +| GET | `/api/plans/rice-transplant-plans/candidate_fields/?year={year}&variety_id={id}` | 作付け計画から候補圃場取得 | + +一覧レスポンス例: + +```json +{ + "id": 1, + "name": "2026年度 コシヒカリ 田植え計画", + "year": 2026, + "variety": 3, + "variety_name": "コシヒカリ", + "crop_name": "水稲", + "default_seed_grams_per_box": "200.00", + "notes": "", + "field_count": 8, + "total_seedling_boxes": "98.40", + "total_seed_kg": "19.680", + "crop_seed_inventory_kg": "25.000", + "remaining_seed_kg": "5.320", + "entries": [ + { + "id": 10, + "field": 5, + "field_name": "田中上", + "field_area_tan": "1.2000", + "seedling_boxes_per_tan": "12.00", + "seed_grams_per_box": "200.00", + "planned_boxes": "14.40", + "planned_seed_kg": "2.880" + } + ] +} +``` + +POST/PUT リクエスト例: + +```json +{ + "name": "2026年度 コシヒカリ 田植え計画", + "year": 2026, + "variety": 3, + "default_seed_grams_per_box": "200.00", + "notes": "", + "entries": [ + { + "field_id": 5, + "seedling_boxes_per_tan": "12.00", + "seed_grams_per_box": "200.00" + }, + { + "field_id": 6, + "seedling_boxes_per_tan": "11.50", + "seed_grams_per_box": "190.00" + } + ] +} +``` + +更新時は `entries` を全置換する。 + +### 作物・品種マスタ更新 + +田植え計画に必要な既定値は既存 API で更新する。 + +| メソッド | URL | 更新項目 | +|---|---|---| +| PATCH | `/api/plans/crops/{id}/` | `seed_inventory_kg` | +| PATCH | `/api/plans/varieties/{id}/` | `default_seedling_boxes_per_tan` | + +--- + +## 画面仕様 + +### 1. 田植え計画一覧 `/rice-transplant` + +- 年度切替 +- 田植え計画の一覧表示 +- 同一年度・同一品種の計画が複数並ぶことを想定する +- 表示列: + - 計画名 + - 作物 / 品種 + - 圃場数 + - 苗箱合計 + - 種もみ計画kg + - 残在庫見込みkg +- 行アクション: + - 編集 + - 削除 + +### 2. 田植え計画編集 `/rice-transplant/new`, `/rice-transplant/{id}/edit` + +- 基本情報: + - 計画名 + - 同一年度・同一品種の複数計画を区別できる名称を付ける + - 例: `2026年度 コシヒカリ 第1回`, `2026年度 コシヒカリ 4/15播種分` + - 年度 + - 品種 + - 苗箱1枚あたり種もみ(g) デフォルト + - 備考 +- 対象圃場: + - 品種選択後に作付け計画から候補圃場を自動取得 + - 新規作成時は候補圃場を初期選択 + - 圃場の追加・除外が可能 +- 初期値: + - `反当苗箱枚数` は品種マスタの `default_seedling_boxes_per_tan` + - `種もみg/箱` は計画ヘッダの `default_seed_grams_per_box` +- 圃場テーブル: + - 圃場 + - 面積(反) + - 反当苗箱枚数 + - 種もみg/箱 + - 苗箱合計 + - 種もみkg +- サマリー: + - 対象圃場数 + - 苗箱合計 + - 種もみ計画kg + - 作物在庫kg + - 残在庫見込みkg +- 補助操作: + - 初期値を一括反映 + +### 3. 品種管理モーダル `/allocation` + +既存の作付け計画画面内の品種管理モーダルを拡張。 + +- 作物単位: + - 種もみ在庫(kg) を更新可能 +- 品種単位: + - 反当苗箱枚数デフォルトを更新可能 + +--- + +## バリデーション・運用ルール + +1. 計画名は必須 +2. 品種は必須 +3. 圃場は1件以上必要 +4. `seedling_boxes_per_tan` と `seed_grams_per_box` は 0 以上の数値を想定 +5. 在庫不足でも保存は許可し、UIで不足を可視化する +6. 候補圃場の抽出元は既存 `Plan`(作付け計画)であるため、先に作付け計画が必要 + +--- + +## 既知の制約 + +1. 種もみ在庫は作物単位のみで、品種別在庫には未対応 +2. 田植え計画の PDF 出力は未実装 +3. 在庫管理 `materials` app とは未連携で、引当・使用実績は持たない +4. 実播種や田植え実績との連携は未実装 + +--- + +## 関連ファイル + +| 種別 | パス | +|---|---| +| モデル | `backend/apps/plans/models.py` | +| シリアライザ | `backend/apps/plans/serializers.py` | +| ViewSet | `backend/apps/plans/views.py` | +| URL | `backend/apps/plans/urls.py` | +| マイグレーション | `backend/apps/plans/migrations/0005_crop_seed_inventory_variety_seedling_boxes_and_rice_transplant.py` | +| 一覧画面 | `frontend/src/app/rice-transplant/page.tsx` | +| 編集画面 | `frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx` | +| ナビゲーション | `frontend/src/components/Navbar.tsx` | +| 品種管理モーダル | `frontend/src/app/allocation/page.tsx` | diff --git a/frontend/src/app/allocation/page.tsx b/frontend/src/app/allocation/page.tsx index 1ff2fca..1d59cfe 100644 --- a/frontend/src/app/allocation/page.tsx +++ b/frontend/src/app/allocation/page.tsx @@ -367,6 +367,34 @@ export default function AllocationPage() { } }; + const handleUpdateCropSeedInventory = async (cropId: number, seedInventoryKg: string) => { + try { + const crop = crops.find((item) => item.id === cropId); + if (!crop) return; + await api.patch(`/plans/crops/${cropId}/`, { + seed_inventory_kg: seedInventoryKg, + }); + await fetchData(true); + } catch (error) { + console.error('Failed to update crop seed inventory:', error); + alert('種もみ在庫の更新に失敗しました'); + } + }; + + const handleUpdateVarietyDefaultBoxes = async (varietyId: number, defaultBoxes: string) => { + try { + const variety = crops.flatMap((crop) => crop.varieties).find((item) => item.id === varietyId); + if (!variety) return; + await api.patch(`/plans/varieties/${varietyId}/`, { + default_seedling_boxes_per_tan: defaultBoxes, + }); + await fetchData(true); + } catch (error) { + console.error('Failed to update variety default boxes:', error); + alert('品種デフォルトの更新に失敗しました'); + } + }; + const toggleFieldSelection = (fieldId: number) => { setSelectedFields((prev) => { const next = new Set(prev); @@ -1029,18 +1057,34 @@ export default function AllocationPage() {
+ {managerCropId && ( +
+

田植え計画用設定

+ crop.id === managerCropId) || null} + onSave={handleUpdateCropSeedInventory} + /> +
+ )} {managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
    {getVarietiesForCrop(managerCropId).map((v) => ( -
  • - {v.name} - +
  • +
    + {v.name} + +
    +
  • ))}
@@ -1105,3 +1149,90 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
); } + +function CropSeedInventoryForm({ + crop, + onSave, +}: { + crop: Crop | null; + onSave: (cropId: number, seedInventoryKg: string) => Promise; +}) { + const [value, setValue] = useState(crop?.seed_inventory_kg ?? '0'); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValue(crop?.seed_inventory_kg ?? '0'); + }, [crop?.id, crop?.seed_inventory_kg]); + + const handleSave = async () => { + if (!crop) return; + setSaving(true); + await onSave(crop.id, value); + setSaving(false); + }; + + return ( +
+
+ + setValue(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + inputMode="decimal" + /> +
+ +
+ ); +} + +function VarietyDefaultBoxesForm({ + varietyId, + initialValue, + onSave, +}: { + varietyId: number; + initialValue: string; + onSave: (varietyId: number, defaultBoxes: string) => Promise; +}) { + const [value, setValue] = useState(initialValue); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + const handleSave = async () => { + setSaving(true); + await onSave(varietyId, value); + setSaving(false); + }; + + return ( +
+
+ + setValue(e.target.value)} + className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + inputMode="decimal" + /> +
+ +
+ ); +} diff --git a/frontend/src/app/rice-transplant/[id]/edit/page.tsx b/frontend/src/app/rice-transplant/[id]/edit/page.tsx new file mode 100644 index 0000000..cb30736 --- /dev/null +++ b/frontend/src/app/rice-transplant/[id]/edit/page.tsx @@ -0,0 +1,5 @@ +import RiceTransplantEditPage from '../../_components/RiceTransplantEditPage'; + +export default function EditRiceTransplantPage({ params }: { params: { id: string } }) { + return ; +} diff --git a/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx b/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx new file mode 100644 index 0000000..04ddd49 --- /dev/null +++ b/frontend/src/app/rice-transplant/_components/RiceTransplantEditPage.tsx @@ -0,0 +1,447 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ChevronLeft, Save } from 'lucide-react'; + +import Navbar from '@/components/Navbar'; +import { api } from '@/lib/api'; +import { Crop, Field, RiceTransplantPlan } from '@/types'; + +type EntryInput = { + seedling_boxes_per_tan: string; + seed_grams_per_box: string; +}; + +type EntryMap = Record; + +const currentYear = new Date().getFullYear(); + +export default function RiceTransplantEditPage({ planId }: { planId?: number }) { + const router = useRouter(); + const isNew = !planId; + + const [name, setName] = useState(''); + const [year, setYear] = useState(currentYear); + const [varietyId, setVarietyId] = useState(''); + const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200'); + const [notes, setNotes] = useState(''); + + const [crops, setCrops] = useState([]); + const [allFields, setAllFields] = useState([]); + const [candidateFields, setCandidateFields] = useState([]); + const [selectedFields, setSelectedFields] = useState([]); + const [entries, setEntries] = useState({}); + + const [loading, setLoading] = useState(!isNew); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i); + + const getVariety = (id: number) => + crops.flatMap((crop) => crop.varieties).find((variety) => variety.id === id); + + const getSelectedCrop = () => { + if (!varietyId) return null; + return crops.find((crop) => crop.varieties.some((variety) => variety.id === varietyId)) ?? null; + }; + + const initializeEntry = (fieldId: number, nextDefaultSeedGramsPerBox = defaultSeedGramsPerBox) => { + const variety = varietyId ? getVariety(varietyId) : null; + const defaultBoxes = variety?.default_seedling_boxes_per_tan ?? '0'; + return { + seedling_boxes_per_tan: String(defaultBoxes), + seed_grams_per_box: nextDefaultSeedGramsPerBox, + }; + }; + + useEffect(() => { + const init = async () => { + setError(null); + try { + const [cropsRes, fieldsRes] = await Promise.all([ + api.get('/plans/crops/'), + api.get('/fields/?ordering=display_order,id'), + ]); + setCrops(cropsRes.data); + setAllFields(fieldsRes.data); + + if (!isNew && planId) { + const planRes = await api.get(`/plans/rice-transplant-plans/${planId}/`); + const plan: RiceTransplantPlan = planRes.data; + setName(plan.name); + setYear(plan.year); + setVarietyId(plan.variety); + setDefaultSeedGramsPerBox(plan.default_seed_grams_per_box); + setNotes(plan.notes); + + const fieldIds = new Set(plan.entries.map((entry) => entry.field)); + const planFields = fieldsRes.data.filter((field: Field) => fieldIds.has(field.id)); + setSelectedFields(planFields); + setCandidateFields(planFields); + setEntries( + plan.entries.reduce((acc: EntryMap, entry) => { + acc[entry.field] = { + seedling_boxes_per_tan: String(entry.seedling_boxes_per_tan), + seed_grams_per_box: String(entry.seed_grams_per_box), + }; + return acc; + }, {}) + ); + } + } catch (e) { + console.error(e); + setError('データの読み込みに失敗しました。'); + } finally { + setLoading(false); + } + }; + init(); + }, [isNew, planId]); + + useEffect(() => { + const fetchCandidates = async () => { + if (!varietyId || !year || (!isNew && loading)) return; + try { + const res = await api.get(`/plans/rice-transplant-plans/candidate_fields/?year=${year}&variety_id=${varietyId}`); + const nextCandidates: Field[] = res.data; + setCandidateFields(nextCandidates); + if (isNew) { + setSelectedFields(nextCandidates); + setEntries((prev) => { + const next = { ...prev }; + nextCandidates.forEach((field) => { + if (!next[field.id]) { + next[field.id] = initializeEntry(field.id); + } + }); + return next; + }); + } + } catch (e) { + console.error(e); + setError('候補圃場の取得に失敗しました。'); + } + }; + fetchCandidates(); + }, [varietyId, year, isNew, loading]); + + const updateEntry = (fieldId: number, key: keyof EntryInput, value: string) => { + setEntries((prev) => ({ + ...prev, + [fieldId]: { + ...(prev[fieldId] ?? initializeEntry(fieldId)), + [key]: value, + }, + })); + }; + + const applyDefaultsToSelected = () => { + setEntries((prev) => { + const next = { ...prev }; + selectedFields.forEach((field) => { + next[field.id] = initializeEntry(field.id, defaultSeedGramsPerBox); + }); + return next; + }); + }; + + const addField = (field: Field) => { + if (selectedFields.some((selected) => selected.id === field.id)) return; + setSelectedFields((prev) => [...prev, field]); + setEntries((prev) => ({ + ...prev, + [field.id]: prev[field.id] ?? initializeEntry(field.id), + })); + }; + + const removeField = (fieldId: number) => { + setSelectedFields((prev) => prev.filter((field) => field.id !== fieldId)); + }; + + const fieldRows = useMemo( + () => selectedFields.map((field) => ({ field, entry: entries[field.id] ?? initializeEntry(field.id) })), + [selectedFields, entries, varietyId, defaultSeedGramsPerBox] + ); + + const calculateBoxes = (field: Field, entry: EntryInput) => { + const areaTan = parseFloat(field.area_tan || '0'); + const boxesPerTan = parseFloat(entry.seedling_boxes_per_tan || '0'); + return areaTan * boxesPerTan; + }; + + const calculateSeedKg = (field: Field, entry: EntryInput) => { + const boxes = calculateBoxes(field, entry); + const gramsPerBox = parseFloat(entry.seed_grams_per_box || '0'); + return (boxes * gramsPerBox) / 1000; + }; + + const totalBoxes = fieldRows.reduce((sum, row) => sum + calculateBoxes(row.field, row.entry), 0); + const totalSeedKg = fieldRows.reduce((sum, row) => sum + calculateSeedKg(row.field, row.entry), 0); + const cropSeedInventoryKg = parseFloat(getSelectedCrop()?.seed_inventory_kg ?? '0'); + const remainingSeedKg = cropSeedInventoryKg - totalSeedKg; + + const handleSave = async () => { + setError(null); + if (!name.trim()) { + setError('計画名を入力してください。'); + return; + } + if (!varietyId) { + setError('品種を選択してください。'); + return; + } + if (selectedFields.length === 0) { + setError('圃場を1つ以上選択してください。'); + return; + } + + const payload = { + name, + year, + variety: varietyId, + default_seed_grams_per_box: defaultSeedGramsPerBox, + notes, + entries: selectedFields.map((field) => ({ + field_id: field.id, + seedling_boxes_per_tan: entries[field.id]?.seedling_boxes_per_tan ?? initializeEntry(field.id).seedling_boxes_per_tan, + seed_grams_per_box: entries[field.id]?.seed_grams_per_box ?? defaultSeedGramsPerBox, + })), + }; + + setSaving(true); + try { + if (isNew) { + await api.post('/plans/rice-transplant-plans/', payload); + } else { + await api.put(`/plans/rice-transplant-plans/${planId}/`, payload); + } + router.push('/rice-transplant'); + } catch (e) { + console.error(e); + setError('保存に失敗しました。'); + } finally { + setSaving(false); + } + }; + + const unselectedFields = (candidateFields.length > 0 ? candidateFields : allFields).filter( + (field) => !selectedFields.some((selected) => selected.id === field.id) + ); + + if (loading) { + return ( +
+ +
読み込み中...
+
+ ); + } + + return ( +
+ +
+
+
+ +

+ {isNew ? '田植え計画 新規作成' : '田植え計画 編集'} +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setName(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + placeholder="例: 2026年度 コシヒカリ 第1回" + /> +

同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。

+
+
+ + +
+
+ + +
+
+ + setDefaultSeedGramsPerBox(e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + inputMode="decimal" + /> +
+
+ +
+
+
+

対象圃場

+ +
+
+ {selectedFields.map((field) => ( + + ))} + {selectedFields.length === 0 &&

圃場が選択されていません。

} +
+ {unselectedFields.length > 0 && ( +
+

追加可能

+
+ {unselectedFields.map((field) => ( + + ))} +
+
+ )} +
+ +
+

集計

+
+
+ 対象圃場 + {selectedFields.length}筆 +
+
+ 苗箱合計 + {totalBoxes.toFixed(2)}枚 +
+
+ 種もみ計画 + {totalSeedKg.toFixed(3)}kg +
+
+ {getSelectedCrop()?.name ?? '作物'} 在庫 + {cropSeedInventoryKg.toFixed(3)}kg +
+
+ 残在庫見込み + {remainingSeedKg.toFixed(3)}kg +
+
+
+
+ +
+ +