diff --git a/CLAUDE.md b/CLAUDE.md
index a1943a2..7633de6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -190,6 +190,23 @@ FertilizationEntry (施肥エントリ・中間テーブル)
├── fertilizer (FK to Fertilizer, PROTECT) ← 使用中の肥料は削除不可
├── bags(袋数、Decimal)
└── unique_together = ['plan', 'field', 'fertilizer']
+
+DistributionPlan (分配計画)
+├── fertilization_plan (FK to FertilizationPlan, CASCADE)
+├── name(計画名)
+└── groups → DistributionGroup
+
+DistributionGroup (分配グループ)
+├── distribution_plan (FK to DistributionPlan, CASCADE)
+├── name(グループ名)
+├── order(表示順)
+└── unique_together = ['distribution_plan', 'name']
+
+DistributionGroupField (グループ圃場割り当て)
+├── distribution_plan (FK to DistributionPlan, CASCADE) ← 一意制約用
+├── group (FK to DistributionGroup, CASCADE)
+├── field (FK to fields.Field, PROTECT)
+└── unique_together = ['distribution_plan', 'field'] ← 1圃場=1グループ/1計画
```
### 重要な設計判断
@@ -324,7 +341,14 @@ FertilizationEntry (施肥エントリ・中間テーブル)
- API(JWT認証): `GET/POST /api/fertilizer/fertilizers/`, `GET/POST /api/fertilizer/plans/?year=`, `GET /api/fertilizer/plans/{id}/pdf/`, `GET /api/fertilizer/candidate_fields/?year=&variety_id=`, `POST /api/fertilizer/calculate/`
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new`・`/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
- - スコープ外(将来): 購入管理、配置計画
+ - スコープ外(将来): 購入管理
+11. **分配計画機能**(2026-03-02 実装):
+ - Django `apps/fertilizer` アプリに3モデル追加(DistributionPlan, DistributionGroup, DistributionGroupField)
+ - API(JWT認証): `GET/POST /api/fertilizer/distribution/?year=`, `GET/PUT/DELETE /api/fertilizer/distribution/{id}/`, `GET /api/fertilizer/distribution/{id}/pdf/`
+ - 施肥計画を元に圃場をカスタムグループに割り当て、グループ×肥料の集計表を生成
+ - PDF出力(A4横向き・グループ合計行★+圃場サブ行)
+ - フロントエンド: `/distribution/`(一覧), `/distribution/new`・`/distribution/[id]/edit`(編集)
+ - マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
### 🚧 既知の課題・技術的負債
@@ -451,6 +475,7 @@ docker-compose exec backend python manage.py migrate
- **メール通知機能**: `document/11_マスタードキュメント_メール通知関連編.md`
- **気象データ機能**: `document/12_マスタードキュメント_気象データ編.md`
- **施肥計画機能**: `document/13_マスタードキュメント_施肥計画編.md`
+- **分配計画機能**: `document/14_マスタードキュメント_分配計画編.md`
### 設計ドキュメント(プロジェクト横断)
@@ -476,6 +501,7 @@ docker-compose exec backend python manage.py migrate
## 📝 更新履歴
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除
+- 2026-03-02: 分配計画機能を実装。`apps/fertilizer` に DistributionPlan/DistributionGroup/DistributionGroupField 追加、API `/api/fertilizer/distribution/`、PDF出力(A4横・グループ★行+圃場サブ行)、フロントエンド `/distribution/`。マスタードキュメント `document/14_マスタードキュメント_分配計画編.md` 追加
- 2026-03-01: 施肥計画機能を実装・本番稼働。`apps/fertilizer`(Fertilizer, FertilizationPlan, FertilizationEntry, 自動計算3方式, PDF出力, PROTECT migration 0002)、フロントエンド `/fertilizer/`(一覧・編集・肥料マスタ)。施肥機能全体で alert/confirm 廃止・インラインバナーに統一。マスタードキュメント `document/13_マスタードキュメント_施肥計画編.md` 追加
- 2026-02-28: 気象データ機能を実装・本番稼働。`apps/weather`(WeatherRecord, 5 API)、Windmill `f/weather/weather_sync`(毎朝6時)、フロントエンド `/weather`(年別集計・期間指定・Rechartsグラフ)。`Crop.base_temp` 追加。デプロイコマンドの本番パス修正(/home/keinasystem/)。マスタードキュメント `document/12_マスタードキュメント_気象データ編.md` 追加
- 2026-02-25: CLAUDE.md更新。パスワード変更機能追記。メールフィルタリング機能を本番稼働済みに更新。マスタードキュメント `document/11_マスタードキュメント_メール通知関連編.md` リンク追加。デプロイコマンド(`--env-file .env.production` 必須)をトラブルシューティングに追加
diff --git a/backend/apps/fertilizer/admin.py b/backend/apps/fertilizer/admin.py
index 12503c4..49824ad 100644
--- a/backend/apps/fertilizer/admin.py
+++ b/backend/apps/fertilizer/admin.py
@@ -1,5 +1,5 @@
from django.contrib import admin
-from .models import Fertilizer, FertilizationPlan, FertilizationEntry
+from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
@admin.register(Fertilizer)
@@ -17,3 +17,27 @@ class FertilizationPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'variety']
list_filter = ['year']
inlines = [FertilizationEntryInline]
+
+
+class DistributionGroupFieldInline(admin.TabularInline):
+ model = DistributionGroupField
+ extra = 0
+ readonly_fields = ['distribution_plan']
+
+
+class DistributionGroupInline(admin.TabularInline):
+ model = DistributionGroup
+ extra = 0
+
+
+@admin.register(DistributionPlan)
+class DistributionPlanAdmin(admin.ModelAdmin):
+ list_display = ['name', 'fertilization_plan', 'created_at']
+ list_filter = ['fertilization_plan__year']
+ inlines = [DistributionGroupInline]
+
+
+@admin.register(DistributionGroup)
+class DistributionGroupAdmin(admin.ModelAdmin):
+ list_display = ['name', 'distribution_plan', 'order']
+ inlines = [DistributionGroupFieldInline]
diff --git a/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py b/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py
new file mode 100644
index 0000000..f2b2bab
--- /dev/null
+++ b/backend/apps/fertilizer/migrations/0003_distributionplan_distributiongroup_and_more.py
@@ -0,0 +1,60 @@
+# Generated by Django 5.0 on 2026-03-01 15:46
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fertilizer', '0002_alter_fertilizationentry_fertilizer'),
+ ('fields', '0006_e1c_chusankan_17_fields'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='DistributionPlan',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200, verbose_name='計画名')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('fertilization_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='distribution_plans', to='fertilizer.fertilizationplan', verbose_name='施肥計画')),
+ ],
+ options={
+ 'verbose_name': '分配計画',
+ 'verbose_name_plural': '分配計画',
+ 'ordering': ['-fertilization_plan__year', 'name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='DistributionGroup',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, verbose_name='グループ名')),
+ ('order', models.PositiveIntegerField(default=0, verbose_name='表示順')),
+ ('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.distributionplan', verbose_name='分配計画')),
+ ],
+ options={
+ 'verbose_name': '分配グループ',
+ 'verbose_name_plural': '分配グループ',
+ 'ordering': ['order', 'id'],
+ 'unique_together': {('distribution_plan', 'name')},
+ },
+ ),
+ migrations.CreateModel(
+ name='DistributionGroupField',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
+ ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='field_assignments', to='fertilizer.distributiongroup', verbose_name='グループ')),
+ ('distribution_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.distributionplan', verbose_name='分配計画')),
+ ],
+ options={
+ 'verbose_name': 'グループ圃場割り当て',
+ 'verbose_name_plural': 'グループ圃場割り当て',
+ 'ordering': ['field__display_order', 'field__id'],
+ 'unique_together': {('distribution_plan', 'field')},
+ },
+ ),
+ ]
diff --git a/backend/apps/fertilizer/models.py b/backend/apps/fertilizer/models.py
index 22cc524..b50da4c 100644
--- a/backend/apps/fertilizer/models.py
+++ b/backend/apps/fertilizer/models.py
@@ -67,3 +67,64 @@ class FertilizationEntry(models.Model):
def __str__(self):
return f"{self.plan} / {self.field} / {self.fertilizer}: {self.bags}袋"
+
+
+class DistributionPlan(models.Model):
+ """分配計画:施肥計画の圃場をカスタムグループに割り当て、配置場所単位で集計する"""
+ fertilization_plan = models.ForeignKey(
+ FertilizationPlan, on_delete=models.CASCADE,
+ related_name='distribution_plans', verbose_name='施肥計画'
+ )
+ name = models.CharField(max_length=200, 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 = ['-fertilization_plan__year', 'name']
+
+ def __str__(self):
+ return f"{self.fertilization_plan.year} {self.name}"
+
+
+class DistributionGroup(models.Model):
+ """分配グループ:ある場所にまとめて置く圃場のグループ"""
+ distribution_plan = models.ForeignKey(
+ DistributionPlan, on_delete=models.CASCADE,
+ related_name='groups', verbose_name='分配計画'
+ )
+ name = models.CharField(max_length=100, verbose_name='グループ名')
+ order = models.PositiveIntegerField(default=0, verbose_name='表示順')
+
+ class Meta:
+ verbose_name = '分配グループ'
+ verbose_name_plural = '分配グループ'
+ unique_together = [['distribution_plan', 'name']]
+ ordering = ['order', 'id']
+
+ def __str__(self):
+ return f"{self.distribution_plan} / {self.name}"
+
+
+class DistributionGroupField(models.Model):
+ """圃場のグループへの割り当て(1圃場=1グループ/1分配計画)"""
+ distribution_plan = models.ForeignKey(
+ DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
+ )
+ group = models.ForeignKey(
+ DistributionGroup, on_delete=models.CASCADE,
+ related_name='field_assignments', verbose_name='グループ'
+ )
+ field = models.ForeignKey(
+ 'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
+ )
+
+ class Meta:
+ verbose_name = 'グループ圃場割り当て'
+ verbose_name_plural = 'グループ圃場割り当て'
+ unique_together = [['distribution_plan', 'field']]
+ ordering = ['field__display_order', 'field__id']
+
+ def __str__(self):
+ return f"{self.group.name} / {self.field.name}"
diff --git a/backend/apps/fertilizer/serializers.py b/backend/apps/fertilizer/serializers.py
index 538a038..7308b07 100644
--- a/backend/apps/fertilizer/serializers.py
+++ b/backend/apps/fertilizer/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import Fertilizer, FertilizationPlan, FertilizationEntry
+from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
class FertilizerSerializer(serializers.ModelSerializer):
@@ -79,3 +79,140 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
fertilizer_id=entry['fertilizer_id'],
bags=entry['bags'],
)
+
+
+# ─── 分配計画 ────────────────────────────────────────────────────────────
+
+class DistributionGroupFieldSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(source='field.id', read_only=True)
+ name = serializers.CharField(source='field.name', read_only=True)
+ area_tan = serializers.DecimalField(
+ source='field.area_tan', max_digits=6, decimal_places=4, read_only=True
+ )
+
+ class Meta:
+ model = DistributionGroupField
+ fields = ['id', 'name', 'area_tan']
+
+
+class DistributionGroupReadSerializer(serializers.ModelSerializer):
+ fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
+
+ class Meta:
+ model = DistributionGroup
+ fields = ['id', 'name', 'order', 'fields']
+
+
+class FertilizationPlanForDistributionSerializer(serializers.ModelSerializer):
+ """分配計画詳細に埋め込む施肥計画情報(肥料一覧・entries 含む)"""
+ variety_name = serializers.SerializerMethodField()
+ crop_name = serializers.SerializerMethodField()
+ fertilizers = serializers.SerializerMethodField()
+ entries = serializers.SerializerMethodField()
+
+ class Meta:
+ model = FertilizationPlan
+ fields = ['id', 'name', 'year', 'variety_name', 'crop_name', 'fertilizers', 'entries']
+
+ def get_variety_name(self, obj):
+ return obj.variety.name
+
+ def get_crop_name(self, obj):
+ return obj.variety.crop.name
+
+ def get_fertilizers(self, obj):
+ fert_ids = obj.entries.values_list('fertilizer_id', flat=True).distinct()
+ from .models import Fertilizer as F
+ fertilizers = F.objects.filter(id__in=fert_ids).order_by('name')
+ return [{'id': f.id, 'name': f.name} for f in fertilizers]
+
+ def get_entries(self, obj):
+ return [
+ {'field': e.field_id, 'fertilizer': e.fertilizer_id, 'bags': str(e.bags)}
+ for e in obj.entries.all()
+ ]
+
+
+class DistributionPlanListSerializer(serializers.ModelSerializer):
+ fertilization_plan_id = serializers.IntegerField(source='fertilization_plan.id', read_only=True)
+ fertilization_plan_name = serializers.CharField(source='fertilization_plan.name', read_only=True)
+ year = serializers.IntegerField(source='fertilization_plan.year', read_only=True)
+ variety_name = serializers.SerializerMethodField()
+ crop_name = serializers.SerializerMethodField()
+ group_count = serializers.SerializerMethodField()
+ field_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = DistributionPlan
+ fields = [
+ 'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
+ 'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
+ 'created_at', 'updated_at',
+ ]
+
+ def get_variety_name(self, obj):
+ return obj.fertilization_plan.variety.name
+
+ def get_crop_name(self, obj):
+ return obj.fertilization_plan.variety.crop.name
+
+ def get_group_count(self, obj):
+ return obj.groups.count()
+
+ def get_field_count(self, obj):
+ return obj.distributiongroupfield_set.count()
+
+
+class DistributionPlanReadSerializer(serializers.ModelSerializer):
+ fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
+ groups = DistributionGroupReadSerializer(many=True, read_only=True)
+ unassigned_fields = serializers.SerializerMethodField()
+
+ class Meta:
+ model = DistributionPlan
+ fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
+
+ def get_unassigned_fields(self, obj):
+ assigned_ids = obj.distributiongroupfield_set.values_list('field_id', flat=True)
+ plan_field_ids = obj.fertilization_plan.entries.values_list('field_id', flat=True).distinct()
+ from apps.fields.models import Field as F
+ unassigned = F.objects.filter(id__in=plan_field_ids).exclude(id__in=assigned_ids).order_by('display_order', 'id')
+ return [{'id': f.id, 'name': f.name, 'area_tan': str(f.area_tan)} for f in unassigned]
+
+
+class DistributionPlanWriteSerializer(serializers.ModelSerializer):
+ fertilization_plan_id = serializers.IntegerField(write_only=True)
+ groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
+
+ class Meta:
+ model = DistributionPlan
+ fields = ['id', 'name', 'fertilization_plan_id', 'groups']
+
+ def create(self, validated_data):
+ groups_data = validated_data.pop('groups', [])
+ plan = DistributionPlan.objects.create(**validated_data)
+ self._save_groups(plan, groups_data)
+ return plan
+
+ def update(self, instance, validated_data):
+ groups_data = validated_data.pop('groups', None)
+ instance.name = validated_data.get('name', instance.name)
+ instance.save()
+ if groups_data is not None:
+ instance.groups.all().delete()
+ self._save_groups(instance, groups_data)
+ return instance
+
+ def _save_groups(self, plan, groups_data):
+ for g_data in groups_data:
+ group = DistributionGroup.objects.create(
+ distribution_plan=plan,
+ name=g_data['name'],
+ order=g_data.get('order', 0),
+ )
+ for field_id in g_data.get('field_ids', []):
+ DistributionGroupField.objects.create(
+ distribution_plan=plan,
+ group=group,
+ field_id=field_id,
+ )
diff --git a/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html b/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html
new file mode 100644
index 0000000..b1bb436
--- /dev/null
+++ b/backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+分配計画書
+
+ {{ fert_plan.year }}年度 {{ fert_plan.variety.crop.name }} / {{ fert_plan.variety.name }}
+ /施肥計画「{{ fert_plan.name }}」
+ /分配計画「{{ dist_plan.name }}」
+
+
+
+
+
+ | グループ / 圃場 |
+ {% for fert in fertilizers %}
+ {{ fert.name }} (袋) |
+ {% endfor %}
+ 合計袋数 |
+
+
+
+ {% for group in group_rows %}
+ {# グループ合計行 #}
+
+ | ★{{ group.name }} |
+ {% for total in group.totals %}
+ {% if total %}{{ total }}{% else %}-{% endif %} |
+ {% endfor %}
+ {{ group.row_total }} |
+
+ {# 圃場サブ行 #}
+ {% for row in group.field_rows %}
+
+ | {{ row.field.name }}({{ row.field.area_tan }}反) |
+ {% for cell in row.cells %}
+ {% if cell %}{{ cell }}{% else %}-{% endif %} |
+ {% endfor %}
+ {{ row.total }} |
+
+ {% endfor %}
+ {% endfor %}
+
+
+
+ | 合計 |
+ {% for total in fert_totals %}
+ {{ total }} |
+ {% endfor %}
+ {{ grand_total }} |
+
+
+
+
+
diff --git a/backend/apps/fertilizer/urls.py b/backend/apps/fertilizer/urls.py
index 27ffd9b..b7f7728 100644
--- a/backend/apps/fertilizer/urls.py
+++ b/backend/apps/fertilizer/urls.py
@@ -5,6 +5,7 @@ from . import views
router = DefaultRouter()
router.register(r'fertilizers', views.FertilizerViewSet, basename='fertilizer')
router.register(r'plans', views.FertilizationPlanViewSet, basename='fertilization-plan')
+router.register(r'distribution', views.DistributionPlanViewSet, basename='distribution-plan')
urlpatterns = [
path('', include(router.urls)),
diff --git a/backend/apps/fertilizer/views.py b/backend/apps/fertilizer/views.py
index 0ab310e..3d0522f 100644
--- a/backend/apps/fertilizer/views.py
+++ b/backend/apps/fertilizer/views.py
@@ -11,11 +11,14 @@ from weasyprint import HTML
from apps.fields.models import Field
from apps.plans.models import Plan, Variety
-from .models import Fertilizer, FertilizationPlan
+from .models import Fertilizer, FertilizationPlan, DistributionPlan
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
+ DistributionPlanListSerializer,
+ DistributionPlanReadSerializer,
+ DistributionPlanWriteSerializer,
)
@@ -194,3 +197,128 @@ class CalculateView(APIView):
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
return Response(results)
+
+
+class DistributionPlanViewSet(viewsets.ModelViewSet):
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ qs = DistributionPlan.objects.select_related(
+ 'fertilization_plan', 'fertilization_plan__variety', 'fertilization_plan__variety__crop'
+ ).prefetch_related(
+ 'groups', 'groups__field_assignments', 'groups__field_assignments__field',
+ 'fertilization_plan__entries', 'fertilization_plan__entries__field',
+ 'fertilization_plan__entries__fertilizer',
+ 'distributiongroupfield_set',
+ )
+ year = self.request.query_params.get('year')
+ if year:
+ qs = qs.filter(fertilization_plan__year=year)
+ return qs
+
+ def get_serializer_class(self):
+ if self.action in ['create', 'update', 'partial_update']:
+ return DistributionPlanWriteSerializer
+ if self.action == 'list':
+ return DistributionPlanListSerializer
+ return DistributionPlanReadSerializer
+
+ @action(detail=True, methods=['get'])
+ def pdf(self, request, pk=None):
+ dist_plan = self.get_object()
+ fert_plan = dist_plan.fertilization_plan
+
+ # 施肥計画の肥料一覧(名前順)
+ fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
+ fertilizers = sorted(
+ Fertilizer.objects.filter(id__in=fert_ids),
+ key=lambda f: f.name
+ )
+
+ # entries を (field_id, fertilizer_id) → bags のマトリクスに変換
+ entry_map = {}
+ for e in fert_plan.entries.all():
+ entry_map[(e.field_id, e.fertilizer_id)] = e.bags
+
+ # グループ行の構築
+ groups = dist_plan.groups.prefetch_related('field_assignments__field').all()
+ group_rows = []
+ for group in groups:
+ fields_in_group = [
+ a.field for a in group.field_assignments.select_related('field').order_by('field__display_order', 'field__id')
+ ]
+ # グループ合計(肥料ごと)
+ group_totals = []
+ for fert in fertilizers:
+ total = sum(
+ entry_map.get((f.id, fert.id), Decimal('0'))
+ for f in fields_in_group
+ )
+ group_totals.append(total)
+ group_row_total = sum(group_totals)
+
+ # 圃場サブ行
+ field_rows = []
+ for field in fields_in_group:
+ cells = [entry_map.get((field.id, fert.id), '') for fert in fertilizers]
+ row_total = sum(v for v in cells if v != '')
+ field_rows.append({'field': field, 'cells': cells, 'total': row_total})
+
+ group_rows.append({
+ 'name': group.name,
+ 'totals': group_totals,
+ 'row_total': group_row_total,
+ 'field_rows': field_rows,
+ })
+
+ # 未割り当て圃場
+ assigned_ids = dist_plan.distributiongroupfield_set.values_list('field_id', flat=True)
+ plan_field_ids = fert_plan.entries.values_list('field_id', flat=True).distinct()
+ unassigned_fields = Field.objects.filter(
+ id__in=plan_field_ids
+ ).exclude(id__in=assigned_ids).order_by('display_order', 'id')
+
+ unassigned_rows = []
+ if unassigned_fields.exists():
+ ua_totals = []
+ for fert in fertilizers:
+ total = sum(
+ entry_map.get((f.id, fert.id), Decimal('0'))
+ for f in unassigned_fields
+ )
+ ua_totals.append(total)
+ unassigned_rows = [{
+ 'name': '未割り当て',
+ 'totals': ua_totals,
+ 'row_total': sum(ua_totals),
+ 'field_rows': [
+ {
+ 'field': f,
+ 'cells': [entry_map.get((f.id, fert.id), '') for fert in fertilizers],
+ 'total': sum(entry_map.get((f.id, fert.id), Decimal('0')) for fert in fertilizers),
+ }
+ for f in unassigned_fields
+ ],
+ }]
+
+ all_group_rows = group_rows + unassigned_rows
+ fert_totals = [
+ sum(r['totals'][i] for r in all_group_rows)
+ for i in range(len(fertilizers))
+ ]
+
+ context = {
+ 'dist_plan': dist_plan,
+ 'fert_plan': fert_plan,
+ 'fertilizers': fertilizers,
+ 'group_rows': all_group_rows,
+ 'fert_totals': fert_totals,
+ 'grand_total': sum(fert_totals),
+ }
+ html_string = render_to_string('fertilizer/distribution_pdf.html', context)
+ pdf_file = HTML(string=html_string).write_pdf()
+ response = HttpResponse(pdf_file, content_type='application/pdf')
+ response['Content-Disposition'] = (
+ f'attachment; filename="distribution_{fert_plan.year}_{dist_plan.id}.pdf"'
+ )
+ return response
diff --git a/document/14_マスタードキュメント_分配計画編.md b/document/14_マスタードキュメント_分配計画編.md
new file mode 100644
index 0000000..536c1dc
--- /dev/null
+++ b/document/14_マスタードキュメント_分配計画編.md
@@ -0,0 +1,241 @@
+# マスタードキュメント:分配計画機能
+
+> **作成**: 2026-03-02
+> **最終更新**: 2026-03-02
+> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計)
+> **実装状況**: 実装完了
+
+---
+
+## 概要
+
+施肥計画(FertilizationPlan)で決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
+例:「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
+
+### 機能スコープ
+
+| IN(実装済み) | OUT(対象外) |
+|---|---|
+| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
+| グループ×肥料の集計表(画面表示) | 実施記録 |
+| PDF出力(グループ合計行+圃場サブ行) | |
+| グループの順序変更・名前変更 | |
+
+---
+
+## データモデル
+
+### DistributionPlan(分配計画)
+
+| フィールド | 型 | 制約 | 説明 |
+|---|---|---|---|
+| id | int | PK | |
+| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
+| name | varchar(200) | required | 計画名 |
+| created_at / updated_at | datetime | auto | |
+
+- `ordering = ['-fertilization_plan__year', 'name']`
+- 1つの施肥計画に対して複数の分配計画を作れる(OneToOneではなくFK)
+
+### DistributionGroup(分配グループ)
+
+| フィールド | 型 | 制約 | 説明 |
+|---|---|---|---|
+| id | int | PK | |
+| distribution_plan | FK(DistributionPlan) | CASCADE | |
+| name | varchar(100) | required | グループ名 |
+| order | PositiveIntegerField | default=0 | 表示順 |
+
+- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
+- `ordering = ['order', 'id']`
+
+### DistributionGroupField(グループ圃場割り当て)
+
+| フィールド | 型 | 制約 | 説明 |
+|---|---|---|---|
+| id | int | PK | |
+| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
+| group | FK(DistributionGroup) | CASCADE | |
+| field | FK(fields.Field) | PROTECT | 圃場 |
+
+- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
+- `ordering = ['field__display_order', 'field__id']`
+
+---
+
+## API エンドポイント
+
+すべて JWT 認証(`Authorization: Bearer `)。
+
+| メソッド | URL | 説明 |
+|---|---|---|
+| GET | `/api/fertilizer/distribution/?year={year}` | 一覧(年度フィルタ) |
+| POST | `/api/fertilizer/distribution/` | 新規作成 |
+| GET | `/api/fertilizer/distribution/{id}/` | 詳細(groups/entries/unassigned込み) |
+| PUT | `/api/fertilizer/distribution/{id}/` | 更新(groups全置換) |
+| DELETE | `/api/fertilizer/distribution/{id}/` | 削除 |
+| GET | `/api/fertilizer/distribution/{id}/pdf/` | PDF出力(application/pdf) |
+
+### 一覧レスポンス(DistributionPlanListSerializer)
+
+```json
+{
+ "id": 1,
+ "name": "2025年コシヒカリ 分配計画",
+ "fertilization_plan_id": 3,
+ "fertilization_plan_name": "2025年コシヒカリ施肥計画",
+ "year": 2025,
+ "variety_name": "コシヒカリ",
+ "crop_name": "米",
+ "group_count": 3,
+ "field_count": 12,
+ "created_at": "...",
+ "updated_at": "..."
+}
+```
+
+### 詳細レスポンス(DistributionPlanReadSerializer)
+
+```json
+{
+ "id": 1,
+ "name": "2025年コシヒカリ 分配計画",
+ "fertilization_plan": {
+ "id": 3,
+ "name": "2025年コシヒカリ施肥計画",
+ "year": 2025,
+ "variety_name": "コシヒカリ",
+ "crop_name": "米",
+ "fertilizers": [{"id": 1, "name": "一発肥料"}],
+ "entries": [{"field": 5, "fertilizer": 1, "bags": "2.40"}]
+ },
+ "groups": [
+ {
+ "id": 10,
+ "name": "田中エリア",
+ "order": 0,
+ "fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
+ }
+ ],
+ "unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
+}
+```
+
+### 書き込みリクエスト(POST/PUT)
+
+```json
+{
+ "name": "2025年コシヒカリ 分配計画",
+ "fertilization_plan_id": 3,
+ "groups": [
+ {"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
+ {"name": "奥地エリア", "order": 1, "field_ids": [7]}
+ ]
+}
+```
+
+PUT は groups を全削除→再作成する全置換方式。
+
+---
+
+## フロントエンド画面
+
+### 分配計画一覧 `/distribution`
+
+- 年度セレクタ(`localStorage distributionYear` で保持)
+- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場数
+- アクション: PDF・編集・削除
+- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
+
+### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
+
+**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
+
+#### State構成
+
+```typescript
+// 基本情報
+const [name, setName] = useState('')
+const [fertilizationPlanId, setFertilizationPlanId] = useState('')
+
+// 施肥計画詳細(施肥計画選択後に取得)
+const [fertPlanDetail, setFertPlanDetail] = useState(null)
+
+// ローカルグループ(tempId で管理、保存時にサーバーへ送信)
+const [groups, setGroups] = useState([])
+// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
+```
+
+#### UI構成
+
+1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
+2. **グループ割り当て**:
+ - 新規グループ追加(名前入力 + 追加ボタン)
+ - グループカード(↑↓順序変更・鉛筆名前変更・×削除)
+ - グループ内圃場(×解除)+ 肥料別袋数をインライン表示
+ - 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て)
+3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
+
+---
+
+## PDF 出力
+
+`GET /api/fertilizer/distribution/{id}/pdf/`
+
+- WeasyPrint(既存施肥計画PDFと同じ仕組み)
+- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
+- フォーマット: A4横向き
+- 内容:
+ - ★グループ合計行(太字・緑背景)
+ - 圃場サブ行(小フォント・灰色背景)
+ - 肥料列合計・総合計
+- ファイル名: `distribution_{year}_{plan_id}.pdf`
+
+---
+
+## ファイル構成
+
+### Backend
+
+```
+backend/apps/fertilizer/
+├── models.py # DistributionPlan/Group/GroupField 追加(migration 0003)
+├── serializers.py # Distribution* シリアライザ追加
+├── views.py # DistributionPlanViewSet 追加
+├── urls.py # router.register('distribution', ...) 追加
+├── admin.py # DistributionPlan/Group の admin 登録
+└── templates/fertilizer/
+ └── distribution_pdf.html # A4横 PDF テンプレート
+```
+
+### Frontend
+
+```
+frontend/src/app/distribution/
+├── page.tsx # 一覧ページ
+├── new/page.tsx # 新規作成(ラッパー)
+├── [id]/edit/page.tsx # 編集(ラッパー)
+└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
+```
+
+---
+
+## 注意点
+
+### 集計は全クライアントサイド計算
+
+集計プレビューは API を呼ばず、`fertPlanDetail.entries` と `groups.fieldIds` からクライアントで計算する。
+PDF生成時のみサーバーサイドで同じ計算を実施。
+
+### PUT の全置換方式
+
+PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
+
+### 未割り当て圃場の扱い
+
+- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
+- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
+
+### エラー表示方針
+
+施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。
diff --git a/frontend/src/app/distribution/[id]/edit/page.tsx b/frontend/src/app/distribution/[id]/edit/page.tsx
new file mode 100644
index 0000000..4902177
--- /dev/null
+++ b/frontend/src/app/distribution/[id]/edit/page.tsx
@@ -0,0 +1,5 @@
+import DistributionEditPage from '../../_components/DistributionEditPage';
+
+export default function DistributionEditRoute({ params }: { params: { id: string } }) {
+ return ;
+}
diff --git a/frontend/src/app/distribution/_components/DistributionEditPage.tsx b/frontend/src/app/distribution/_components/DistributionEditPage.tsx
new file mode 100644
index 0000000..66682a8
--- /dev/null
+++ b/frontend/src/app/distribution/_components/DistributionEditPage.tsx
@@ -0,0 +1,651 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Plus, X, ChevronUp, ChevronDown, Pencil, Check } from 'lucide-react';
+import Navbar from '@/components/Navbar';
+import { DistributionPlan, FertilizationPlan } from '@/types';
+import { api } from '@/lib/api';
+
+const CURRENT_YEAR = new Date().getFullYear();
+
+// ローカル管理用のグループ型(ID未採番の新規グループも持てる)
+interface LocalGroup {
+ tempId: string;
+ name: string;
+ order: number;
+ fieldIds: number[];
+ isRenamingName?: string; // 名前変更中の一時値
+}
+
+interface FieldInfo {
+ id: number;
+ name: string;
+ area_tan: string;
+}
+
+interface Props {
+ planId?: number; // 編集時のみ
+}
+
+export default function DistributionEditPage({ planId }: Props) {
+ const router = useRouter();
+ const isEdit = planId !== undefined;
+
+ // 基本情報
+ const [name, setName] = useState('');
+ const [fertilizationPlanId, setFertilizationPlanId] = useState('');
+ const [year] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
+ }
+ return CURRENT_YEAR;
+ });
+
+ // 施肥計画一覧(セレクタ用)
+ const [fertilizationPlans, setFertilizationPlans] = useState([]);
+ // 選択中の施肥計画の詳細(肥料・entries)
+ const [fertPlanDetail, setFertPlanDetail] = useState(null);
+
+ // ローカルグループ状態
+ const [groups, setGroups] = useState([]);
+ const [newGroupName, setNewGroupName] = useState('');
+
+ // UI状態
+ const [saveError, setSaveError] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [loading, setLoading] = useState(true);
+
+ // ── 初期データ読み込み ──────────────────────────────────
+
+ useEffect(() => {
+ const init = async () => {
+ try {
+ // 施肥計画一覧を全年度取得(分配計画のベースになる)
+ const res = await api.get('/fertilizer/plans/');
+ setFertilizationPlans(res.data);
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (isEdit && planId) {
+ try {
+ // 既存の分配計画を読み込む
+ const detailRes = await api.get(`/fertilizer/distribution/${planId}/`);
+ const detail: DistributionPlan = detailRes.data;
+ setName(detail.name);
+ setFertilizationPlanId(detail.fertilization_plan.id);
+ setFertPlanDetail(detail.fertilization_plan);
+ // グループを LocalGroup 形式に変換
+ setGroups(
+ detail.groups.map((g, i) => ({
+ tempId: String(g.id),
+ name: g.name,
+ order: g.order ?? i,
+ fieldIds: g.fields.map(f => f.id),
+ }))
+ );
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ setLoading(false);
+ };
+ init();
+ }, [planId]);
+
+ // 施肥計画が変わったら詳細を取得
+ useEffect(() => {
+ if (!fertilizationPlanId) {
+ setFertPlanDetail(null);
+ if (!isEdit) setGroups([]);
+ return;
+ }
+ if (isEdit && fertPlanDetail?.id === fertilizationPlanId) return;
+
+ const fetchDetail = async () => {
+ try {
+ const res = await api.get(`/fertilizer/plans/${fertilizationPlanId}/`);
+ const data: FertilizationPlan = res.data;
+ // FertilizationPlanForDistributionSerializer と同じ構造に合わせる
+ const ferts = Array.from(
+ new Map(
+ data.entries.map(e => [e.fertilizer, { id: e.fertilizer, name: e.fertilizer_name || '' }])
+ ).values()
+ ).sort((a, b) => a.name.localeCompare(b.name));
+ setFertPlanDetail({
+ id: data.id,
+ name: data.name,
+ year: data.year,
+ variety_name: data.variety_name,
+ crop_name: data.crop_name,
+ fertilizers: ferts,
+ entries: data.entries.map(e => ({
+ field: e.field,
+ fertilizer: e.fertilizer,
+ bags: String(e.bags),
+ })),
+ });
+ if (!isEdit) setGroups([]);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+ fetchDetail();
+ }, [fertilizationPlanId]);
+
+ // ── 計算ヘルパー ──────────────────────────────────────
+
+ // 全圃場一覧(施肥計画のentries に含まれる圃場)
+ const allPlanFields: FieldInfo[] = (() => {
+ if (!fertPlanDetail) return [];
+ const seen = new Map();
+ for (const e of fertPlanDetail.entries) {
+ if (!seen.has(e.field)) {
+ // field名は後述の fertilizationPlans から取る
+ seen.set(e.field, { id: e.field, name: String(e.field), area_tan: '0' });
+ }
+ }
+ return Array.from(seen.values());
+ })();
+
+ // fertilizationPlans から field情報を取得(FertilizationPlanSerializer の entries に field_name が含まれる)
+ const fieldInfoMap = (() => {
+ const map = new Map();
+ if (!fertPlanDetail) return map;
+ const plan = fertilizationPlans.find(p => p.id === fertPlanDetail.id);
+ if (plan) {
+ for (const e of plan.entries) {
+ if (e.field && !map.has(e.field)) {
+ map.set(e.field, {
+ id: e.field,
+ name: e.field_name || String(e.field),
+ area_tan: e.field_area_tan || '0',
+ });
+ }
+ }
+ }
+ return map;
+ })();
+
+ const getFieldInfo = (fieldId: number): FieldInfo =>
+ fieldInfoMap.get(fieldId) ?? { id: fieldId, name: `圃場#${fieldId}`, area_tan: '0' };
+
+ // 割り当て済みフィールドIDセット
+ const assignedFieldIds = new Set(groups.flatMap(g => g.fieldIds));
+
+ // 未割り当て圃場
+ const unassignedFields = fertPlanDetail
+ ? Array.from(
+ new Map(
+ fertPlanDetail.entries
+ .map(e => e.field)
+ .filter(id => !assignedFieldIds.has(id))
+ .map(id => [id, getFieldInfo(id)])
+ ).values()
+ )
+ : [];
+
+ // bags取得
+ const getBags = (fieldId: number, fertilizerId: number): number => {
+ if (!fertPlanDetail) return 0;
+ const entry = fertPlanDetail.entries.find(
+ e => e.field === fieldId && e.fertilizer === fertilizerId
+ );
+ return entry ? parseFloat(entry.bags) : 0;
+ };
+
+ // グループごとの集計
+ const groupSummaries = groups.map(g => {
+ const fertTotals = (fertPlanDetail?.fertilizers || []).map(fert => ({
+ fertilizerId: fert.id,
+ fertilizerName: fert.name,
+ total: g.fieldIds.reduce((sum, fId) => sum + getBags(fId, fert.id), 0),
+ }));
+ const rowTotal = fertTotals.reduce((s, f) => s + f.total, 0);
+ return { ...g, fertTotals, rowTotal };
+ });
+
+ // 未割り当てグループの集計
+ const unassignedSummary = {
+ fertTotals: (fertPlanDetail?.fertilizers || []).map(fert => ({
+ fertilizerId: fert.id,
+ fertilizerName: fert.name,
+ total: unassignedFields.reduce((sum, f) => sum + getBags(f.id, fert.id), 0),
+ })),
+ rowTotal: 0 as number,
+ };
+ unassignedSummary.rowTotal = unassignedSummary.fertTotals.reduce((s, f) => s + f.total, 0);
+
+ // 肥料合計行
+ const fertColumnTotals = (fertPlanDetail?.fertilizers || []).map(fert => {
+ const groupTotal = groupSummaries.reduce(
+ (sum, g) => sum + (g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0),
+ 0
+ );
+ const unassignedTotal = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
+ return { id: fert.id, total: groupTotal + unassignedTotal };
+ });
+ const grandTotal = fertColumnTotals.reduce((s, f) => s + f.total, 0);
+
+ // ── グループ操作 ──────────────────────────────────────
+
+ const addGroup = () => {
+ const n = newGroupName.trim();
+ if (!n) return;
+ if (groups.some(g => g.name === n)) {
+ setSaveError(`グループ名「${n}」はすでに存在します`);
+ return;
+ }
+ setSaveError(null);
+ setGroups(prev => [
+ ...prev,
+ { tempId: crypto.randomUUID(), name: n, order: prev.length, fieldIds: [] },
+ ]);
+ setNewGroupName('');
+ };
+
+ const removeGroup = (tempId: string) => {
+ setGroups(prev => prev.filter(g => g.tempId !== tempId));
+ };
+
+ const moveGroup = (tempId: string, dir: -1 | 1) => {
+ setGroups(prev => {
+ const idx = prev.findIndex(g => g.tempId === tempId);
+ if (idx < 0 || idx + dir < 0 || idx + dir >= prev.length) return prev;
+ const next = [...prev];
+ [next[idx], next[idx + dir]] = [next[idx + dir], next[idx]];
+ return next.map((g, i) => ({ ...g, order: i }));
+ });
+ };
+
+ const startRename = (tempId: string) => {
+ setGroups(prev =>
+ prev.map(g => (g.tempId === tempId ? { ...g, isRenamingName: g.name } : g))
+ );
+ };
+
+ const commitRename = (tempId: string) => {
+ setGroups(prev =>
+ prev.map(g => {
+ if (g.tempId !== tempId) return g;
+ const newName = (g.isRenamingName || '').trim();
+ if (!newName || newName === g.name) return { ...g, isRenamingName: undefined };
+ if (prev.some(other => other.tempId !== tempId && other.name === newName)) {
+ setSaveError(`グループ名「${newName}」はすでに存在します`);
+ return { ...g, isRenamingName: undefined };
+ }
+ return { ...g, name: newName, isRenamingName: undefined };
+ })
+ );
+ };
+
+ const assignFieldToGroup = (fieldId: number, groupTempId: string) => {
+ setGroups(prev =>
+ prev.map(g => {
+ if (g.tempId === groupTempId) {
+ return { ...g, fieldIds: [...g.fieldIds, fieldId] };
+ }
+ return { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) };
+ })
+ );
+ };
+
+ const removeFieldFromGroup = (fieldId: number, groupTempId: string) => {
+ setGroups(prev =>
+ prev.map(g =>
+ g.tempId === groupTempId ? { ...g, fieldIds: g.fieldIds.filter(id => id !== fieldId) } : g
+ )
+ );
+ };
+
+ // ── 保存 ──────────────────────────────────────────────
+
+ const handleSave = async () => {
+ setSaveError(null);
+ if (!name.trim()) { setSaveError('計画名を入力してください'); return; }
+ if (!fertilizationPlanId) { setSaveError('施肥計画を選択してください'); return; }
+
+ setSaving(true);
+ const payload = {
+ name: name.trim(),
+ fertilization_plan_id: fertilizationPlanId,
+ groups: groups.map((g, i) => ({
+ name: g.name,
+ order: i,
+ field_ids: g.fieldIds,
+ })),
+ };
+
+ try {
+ if (isEdit) {
+ await api.put(`/fertilizer/distribution/${planId}/`, payload);
+ } else {
+ await api.post('/fertilizer/distribution/', payload);
+ }
+ setSaving(false);
+ router.push('/distribution');
+ } catch (e: unknown) {
+ setSaving(false);
+ const axiosErr = e as { response?: { data?: unknown } };
+ const errData = axiosErr?.response?.data;
+ setSaveError(errData ? JSON.stringify(errData) : '保存に失敗しました');
+ }
+ };
+
+ // ── レンダリング ──────────────────────────────────────
+
+ if (loading) {
+ return (
+
+
+ 読み込み中...
+
+ );
+ }
+
+ const fertilizers = fertPlanDetail?.fertilizers || [];
+
+ return (
+
+
+
+ {/* ヘッダー */}
+
+
+
+
+
+ {isEdit ? '分配計画を編集' : '分配計画を新規作成'}
+
+
+ {saveError && (
+
+ {saveError}
+
+
+ )}
+
+ {/* 基本情報 */}
+
+
+
+
+ setName(e.target.value)}
+ placeholder="例: 2025年コシヒカリ 分配計画"
+ className="flex-1 border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
+ />
+
+
+
+
+
+
+
+
+ {!fertPlanDetail ? (
+
+ 施肥計画を選択するとグループ割り当て画面が表示されます
+
+ ) : (
+ <>
+ {/* グループ割り当て */}
+
+
グループ割り当て
+
+ {/* 新規グループ追加 */}
+
+
setNewGroupName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && addGroup()}
+ placeholder="新規グループ名"
+ className="border border-gray-300 rounded-md px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500 w-48"
+ />
+
+
+
+ {/* グループ一覧 */}
+
+ {groups.map((group, idx) => (
+
+ {/* グループヘッダー */}
+
+ {group.isRenamingName !== undefined ? (
+ <>
+
+ setGroups(prev =>
+ prev.map(g =>
+ g.tempId === group.tempId ? { ...g, isRenamingName: e.target.value } : g
+ )
+ )
+ }
+ onKeyDown={e => e.key === 'Enter' && commitRename(group.tempId)}
+ className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-green-500"
+ autoFocus
+ />
+
+ >
+ ) : (
+ <>
+
{group.name}
+
+
+
+
+ >
+ )}
+
+ {/* グループ内圃場 */}
+
+ {group.fieldIds.length === 0 ? (
+
圃場が割り当てられていません
+ ) : (
+ group.fieldIds.map(fId => {
+ const fi = getFieldInfo(fId);
+ const bags = fertilizers.map(fert => getBags(fId, fert.id));
+ return (
+
+
+ {fi.name}
+ {fi.area_tan}反
+
+ {fertilizers.map((fert, i) => (
+
+ {i > 0 && ' / '}
+ {fert.name}: {bags[i].toFixed(2)}袋
+
+ ))}
+
+
+ );
+ })
+ )}
+
+
+ ))}
+
+
+ {/* 未割り当て圃場 */}
+ {unassignedFields.length > 0 && (
+
+
未割り当て圃場
+
+ {unassignedFields.map(fi => (
+
+ {fi.name}
+ {fi.area_tan}反
+
+
+ ))}
+
+
+ )}
+
+
+ {/* 集計プレビュー */}
+ {(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
+
+
集計プレビュー
+
+
+
+
+ | グループ |
+ {fertilizers.map(fert => (
+
+ {fert.name}
+ |
+ ))}
+ 合計(袋) |
+
+
+
+ {groupSummaries.map(g => (
+
+ | {g.name} |
+ {fertilizers.map(fert => {
+ const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
+ return (
+
+ {t > 0 ? t.toFixed(2) : -}
+ |
+ );
+ })}
+
+ {g.rowTotal.toFixed(2)}
+ |
+
+ ))}
+ {unassignedSummary.rowTotal > 0 && (
+
+ | 未割り当て |
+ {fertilizers.map(fert => {
+ const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
+ return (
+
+ {t > 0 ? t.toFixed(2) : -}
+ |
+ );
+ })}
+
+ {unassignedSummary.rowTotal.toFixed(2)}
+ |
+
+ )}
+
+
+
+ | 合計 |
+ {fertColumnTotals.map(f => (
+
+ {f.total.toFixed(2)}
+ |
+ ))}
+
+ {grandTotal.toFixed(2)}
+ |
+
+
+
+
+
+ )}
+ >
+ )}
+
+ {/* フッターボタン */}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/distribution/new/page.tsx b/frontend/src/app/distribution/new/page.tsx
new file mode 100644
index 0000000..96525b8
--- /dev/null
+++ b/frontend/src/app/distribution/new/page.tsx
@@ -0,0 +1,5 @@
+import DistributionEditPage from '../_components/DistributionEditPage';
+
+export default function DistributionNewPage() {
+ return ;
+}
diff --git a/frontend/src/app/distribution/page.tsx b/frontend/src/app/distribution/page.tsx
new file mode 100644
index 0000000..6696bad
--- /dev/null
+++ b/frontend/src/app/distribution/page.tsx
@@ -0,0 +1,180 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
+import Navbar from '@/components/Navbar';
+import { api } from '@/lib/api';
+import { DistributionPlanListItem } from '@/types';
+
+const CURRENT_YEAR = new Date().getFullYear();
+const YEAR_KEY = 'distributionYear';
+
+export default function DistributionListPage() {
+ const router = useRouter();
+ const [year, setYear] = useState(() => {
+ if (typeof window !== 'undefined') {
+ return parseInt(localStorage.getItem(YEAR_KEY) || String(CURRENT_YEAR), 10);
+ }
+ return CURRENT_YEAR;
+ });
+ const [plans, setPlans] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [deleteError, setDeleteError] = useState(null);
+
+ const years = Array.from({ length: 5 }, (_, i) => CURRENT_YEAR + 1 - i);
+
+ useEffect(() => {
+ localStorage.setItem(YEAR_KEY, String(year));
+ fetchPlans();
+ }, [year]);
+
+ const fetchPlans = async () => {
+ setLoading(true);
+ try {
+ const res = await api.get(`/fertilizer/distribution/?year=${year}`);
+ setPlans(res.data);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDelete = async (id: number) => {
+ setDeleteError(null);
+ try {
+ await api.delete(`/fertilizer/distribution/${id}/`);
+ setPlans(prev => prev.filter(p => p.id !== id));
+ } catch (e) {
+ console.error(e);
+ setDeleteError('削除できませんでした。');
+ }
+ };
+
+ const handlePdf = async (id: number, planName: string) => {
+ try {
+ const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
+ const url = URL.createObjectURL(res.data);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `distribution_${planName}.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
分配計画
+
+
+
+
+ {/* 年度セレクタ */}
+
+
+
+
+
+ {deleteError && (
+
+ {deleteError}
+
+
+ )}
+
+ {loading ? (
+ 読み込み中...
+ ) : plans.length === 0 ? (
+
+
+
{year}年の分配計画はありません
+
施肥計画を元に分配計画を作成できます
+
+
+ ) : (
+
+
+
+
+ | 計画名 |
+ 施肥計画 |
+ 作物/品種 |
+ グループ数 |
+ 圃場数 |
+ |
+
+
+
+ {plans.map(plan => (
+
+ | {plan.name} |
+ {plan.fertilization_plan_name} |
+ {plan.crop_name} / {plan.variety_name} |
+ {plan.group_count} |
+ {plan.field_count} |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx
index 26fcbf9..fe46e8b 100644
--- a/frontend/src/components/Navbar.tsx
+++ b/frontend/src/components/Navbar.tsx
@@ -1,7 +1,7 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
-import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout } from 'lucide-react';
+import { LogOut, Wheat, MapPin, FileText, Upload, LayoutDashboard, Mail, History, Shield, KeyRound, Cloud, Sprout, FlaskConical } from 'lucide-react';
import { logout } from '@/lib/api';
export default function Navbar() {
@@ -122,6 +122,17 @@ export default function Navbar() {
施肥計画
+
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 5155a9a..b722e9f 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -91,6 +91,53 @@ export interface FertilizationPlan {
updated_at: string;
}
+export interface DistributionGroupField {
+ id: number;
+ name: string;
+ area_tan: string;
+}
+
+export interface DistributionGroup {
+ id: number;
+ name: string;
+ order: number;
+ fields: DistributionGroupField[];
+}
+
+export interface DistributionPlanFertilizationPlan {
+ id: number;
+ name: string;
+ year: number;
+ variety_name: string;
+ crop_name: string;
+ fertilizers: { id: number; name: string }[];
+ entries: { field: number; fertilizer: number; bags: string }[];
+}
+
+export interface DistributionPlan {
+ id: number;
+ name: string;
+ fertilization_plan: DistributionPlanFertilizationPlan;
+ groups: DistributionGroup[];
+ unassigned_fields: DistributionGroupField[];
+ created_at: string;
+ updated_at: string;
+}
+
+export interface DistributionPlanListItem {
+ id: number;
+ name: string;
+ fertilization_plan_id: number;
+ fertilization_plan_name: string;
+ year: number;
+ variety_name: string;
+ crop_name: string;
+ group_count: number;
+ field_count: number;
+ created_at: string;
+ updated_at: string;
+}
+
export interface MailSender {
id: number;
type: 'address' | 'domain';