Add rice transplant planning feature
This commit is contained in:
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 既知の課題・技術的負債
|
||||
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}枚/反'
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
308
document/16_マスタードキュメント_田植え計画編.md
Normal file
308
document/16_マスタードキュメント_田植え計画編.md
Normal file
@@ -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 <token>`)が必要。
|
||||
|
||||
### 田植え計画
|
||||
|
||||
| メソッド | 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` |
|
||||
@@ -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,11 +1057,21 @@ export default function AllocationPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{managerCropId && (
|
||||
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-amber-800">田植え計画用設定</p>
|
||||
<CropSeedInventoryForm
|
||||
crop={crops.find((crop) => crop.id === managerCropId) || null}
|
||||
onSave={handleUpdateCropSeedInventory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{managerCropId && getVarietiesForCrop(managerCropId).length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{getVarietiesForCrop(managerCropId).map((v) => (
|
||||
<li key={v.id} className="flex items-center justify-between p-2 rounded hover:bg-gray-50">
|
||||
<span className="text-sm text-gray-900">{v.name}</span>
|
||||
<li key={v.id} className="rounded border border-gray-200 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{v.name}</span>
|
||||
<button
|
||||
onClick={() => handleDeleteVariety(v.id, v.name)}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
@@ -1041,6 +1079,12 @@ export default function AllocationPage() {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<VarietyDefaultBoxesForm
|
||||
varietyId={v.id}
|
||||
initialValue={v.default_seedling_boxes_per_tan}
|
||||
onSave={handleUpdateVarietyDefaultBoxes}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1105,3 +1149,90 @@ function VarietyAddForm({ cropId, onAdd }: { cropId: number | null; onAdd: (name
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CropSeedInventoryForm({
|
||||
crop,
|
||||
onSave,
|
||||
}: {
|
||||
crop: Crop | null;
|
||||
onSave: (cropId: number, seedInventoryKg: string) => Promise<void>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-gray-600">種もみ在庫(kg)</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!crop || saving}
|
||||
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VarietyDefaultBoxesForm({
|
||||
varietyId,
|
||||
initialValue,
|
||||
onSave,
|
||||
}: {
|
||||
varietyId: number;
|
||||
initialValue: string;
|
||||
onSave: (varietyId: number, defaultBoxes: string) => Promise<void>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-gray-600">反当苗箱枚数デフォルト</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-md bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
5
frontend/src/app/rice-transplant/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function EditRiceTransplantPage({ params }: { params: { id: string } }) {
|
||||
return <RiceTransplantEditPage planId={parseInt(params.id, 10)} />;
|
||||
}
|
||||
@@ -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<number, EntryInput>;
|
||||
|
||||
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<number | ''>('');
|
||||
const [defaultSeedGramsPerBox, setDefaultSeedGramsPerBox] = useState('200');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const [crops, setCrops] = useState<Crop[]>([]);
|
||||
const [allFields, setAllFields] = useState<Field[]>([]);
|
||||
const [candidateFields, setCandidateFields] = useState<Field[]>([]);
|
||||
const [selectedFields, setSelectedFields] = useState<Field[]>([]);
|
||||
const [entries, setEntries] = useState<EntryMap>({});
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 text-gray-500">読み込み中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => router.push('/rice-transplant')} className="text-gray-500 hover:text-gray-700">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
{isNew ? '田植え計画 新規作成' : '田植え計画 編集'}
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid gap-4 rounded-lg bg-white p-4 shadow md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">計画名</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => 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回"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">同じ年度・同じ品種でも、第1回や播種日ごとに複数計画を作れます。</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">年度</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
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"
|
||||
>
|
||||
{years.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">品種</label>
|
||||
<select
|
||||
value={varietyId}
|
||||
onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
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"
|
||||
>
|
||||
<option value="">選択してください</option>
|
||||
{crops.map((crop) => (
|
||||
<optgroup key={crop.id} label={crop.name}>
|
||||
{crop.varieties.map((variety) => (
|
||||
<option key={variety.id} value={variety.id}>
|
||||
{variety.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">苗箱1枚あたり種もみ(g) デフォルト</label>
|
||||
<input
|
||||
value={defaultSeedGramsPerBox}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-[2fr,1fr]">
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800">対象圃場</h2>
|
||||
<button
|
||||
onClick={applyDefaultsToSelected}
|
||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
初期値を一括反映
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{selectedFields.map((field) => (
|
||||
<button
|
||||
key={field.id}
|
||||
onClick={() => removeField(field.id)}
|
||||
className="rounded-full border border-green-200 bg-green-50 px-3 py-1 text-xs text-green-800"
|
||||
>
|
||||
{field.name} ×
|
||||
</button>
|
||||
))}
|
||||
{selectedFields.length === 0 && <p className="text-sm text-gray-500">圃場が選択されていません。</p>}
|
||||
</div>
|
||||
{unselectedFields.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-gray-500">追加可能</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unselectedFields.map((field) => (
|
||||
<button
|
||||
key={field.id}
|
||||
onClick={() => addField(field)}
|
||||
className="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{field.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-white p-4 shadow">
|
||||
<h2 className="mb-3 text-sm font-semibold text-gray-800">集計</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>対象圃場</span>
|
||||
<span>{selectedFields.length}筆</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>苗箱合計</span>
|
||||
<span>{totalBoxes.toFixed(2)}枚</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>種もみ計画</span>
|
||||
<span>{totalSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>{getSelectedCrop()?.name ?? '作物'} 在庫</span>
|
||||
<span>{cropSeedInventoryKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
<div className={`flex justify-between font-semibold ${remainingSeedKg < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
<span>残在庫見込み</span>
|
||||
<span>{remainingSeedKg.toFixed(3)}kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg bg-white p-4 shadow">
|
||||
<label className="mb-1 block text-xs font-medium text-gray-600">備考</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">圃場</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">面積(反)</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">反当苗箱枚数</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">種もみg/箱</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">苗箱合計</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">種もみkg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{fieldRows.map(({ field, entry }) => (
|
||||
<tr key={field.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{field.name}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{field.area_tan}</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
value={entry.seedling_boxes_per_tan}
|
||||
onChange={(e) => updateEntry(field.id, 'seedling_boxes_per_tan', e.target.value)}
|
||||
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
value={entry.seed_grams_per_box}
|
||||
onChange={(e) => updateEntry(field.id, 'seed_grams_per_box', e.target.value)}
|
||||
className="w-24 rounded-md border border-gray-300 px-2 py-1 text-right focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
inputMode="decimal"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateBoxes(field, entry).toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-700">{calculateSeedKg(field, entry).toFixed(3)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
5
frontend/src/app/rice-transplant/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import RiceTransplantEditPage from '../_components/RiceTransplantEditPage';
|
||||
|
||||
export default function NewRiceTransplantPage() {
|
||||
return <RiceTransplantEditPage />;
|
||||
}
|
||||
159
frontend/src/app/rice-transplant/page.tsx
Normal file
159
frontend/src/app/rice-transplant/page.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Pencil, Plus, Sprout, Trash2 } from 'lucide-react';
|
||||
|
||||
import Navbar from '@/components/Navbar';
|
||||
import { api } from '@/lib/api';
|
||||
import { RiceTransplantPlan } from '@/types';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
export default function RiceTransplantPage() {
|
||||
const router = useRouter();
|
||||
const [year, setYear] = useState<number>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('riceTransplantYear');
|
||||
if (saved) return parseInt(saved, 10);
|
||||
}
|
||||
return currentYear;
|
||||
});
|
||||
const [plans, setPlans] = useState<RiceTransplantPlan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get(`/plans/rice-transplant-plans/?year=${year}`);
|
||||
setPlans(res.data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError('田植え計画の読み込みに失敗しました。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('riceTransplantYear', String(year));
|
||||
fetchPlans();
|
||||
}, [year]);
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
setError(null);
|
||||
if (!confirm(`「${name}」を削除しますか?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/plans/rice-transplant-plans/${id}/`);
|
||||
await fetchPlans();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(`「${name}」の削除に失敗しました。`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sprout className="h-6 w-6 text-emerald-600" />
|
||||
<h1 className="text-2xl font-bold text-gray-800">田植え計画</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant/new')}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新規作成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<label className="text-sm font-medium text-gray-700">年度:</label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={(e) => setYear(parseInt(e.target.value, 10))}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
{years.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}年度
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500">読み込み中...</p>
|
||||
) : plans.length === 0 ? (
|
||||
<div className="rounded-lg bg-white p-12 text-center text-gray-400 shadow">
|
||||
<Sprout className="mx-auto mb-3 h-12 w-12 opacity-30" />
|
||||
<p>{year}年度の田植え計画はありません</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg bg-white shadow">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">計画名</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-700">作物 / 品種</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">圃場</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">苗箱合計</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">種もみ計画</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-700">残在庫見込み</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{plans.map((plan) => (
|
||||
<tr key={plan.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{plan.name}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600">{plan.field_count}筆</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seedling_boxes}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums text-gray-600">{plan.total_seed_kg}kg</td>
|
||||
<td className={`px-4 py-3 text-right tabular-nums ${parseFloat(plan.remaining_seed_kg) < 0 ? 'text-red-600' : 'text-emerald-700'}`}>
|
||||
{plan.remaining_seed_kg}kg
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => router.push(`/rice-transplant/${plan.id}/edit`)}
|
||||
className="flex items-center gap-1 rounded border border-blue-300 px-2.5 py-1.5 text-xs text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
編集
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(plan.id, plan.name)}
|
||||
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction } from 'lucide-react';
|
||||
import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, History, Shield, KeyRound, Cloud, Sprout, FlaskConical, Package, NotebookText, PencilLine, Construction, Tractor } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -122,6 +122,17 @@ export default function Navbar() {
|
||||
<Sprout className="h-4 w-4 mr-2" />
|
||||
施肥計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/rice-transplant')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
pathname?.startsWith('/rice-transplant')
|
||||
? 'text-green-700 bg-green-50'
|
||||
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Tractor className="h-4 w-4 mr-2" />
|
||||
田植え計画
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/fertilizer/spreading')}
|
||||
className={`flex items-center px-3 py-2 text-sm rounded-md transition-colors ${
|
||||
|
||||
@@ -36,11 +36,13 @@ export interface Variety {
|
||||
id: number;
|
||||
crop: number;
|
||||
name: string;
|
||||
default_seedling_boxes_per_tan: string;
|
||||
}
|
||||
|
||||
export interface Crop {
|
||||
id: number;
|
||||
name: string;
|
||||
seed_inventory_kg: string;
|
||||
varieties: Variety[];
|
||||
}
|
||||
|
||||
@@ -165,6 +167,36 @@ export interface FertilizationPlan {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RiceTransplantEntry {
|
||||
id?: number;
|
||||
field: number;
|
||||
field_name?: string;
|
||||
field_area_tan?: string;
|
||||
seedling_boxes_per_tan: string;
|
||||
seed_grams_per_box: string;
|
||||
planned_boxes: string;
|
||||
planned_seed_kg: string;
|
||||
}
|
||||
|
||||
export interface RiceTransplantPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
year: number;
|
||||
variety: number;
|
||||
variety_name: string;
|
||||
crop_name: string;
|
||||
default_seed_grams_per_box: string;
|
||||
notes: string;
|
||||
entries: RiceTransplantEntry[];
|
||||
field_count: number;
|
||||
total_seedling_boxes: string;
|
||||
total_seed_kg: string;
|
||||
crop_seed_inventory_kg: string;
|
||||
remaining_seed_kg: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DeliveryGroupField {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user