Compare commits

..

2 Commits

Author SHA1 Message Date
Akira
1c27a66691 分配計画を運搬計画に再設計: 軽トラ1回分を基本単位とする運搬回モデルを導入
実運用のワークフロー(複数施肥計画混在・軽トラ複数回・肥料指定)に合わせ、
旧 DistributionPlan/Group/GroupField を DeliveryPlan/Group/GroupField/Trip/TripItem に置き換え。
施肥計画への直接FK廃止→年度ベースで全施肥計画を横断。
回ごとの日付記録、圃場の回間移動、対象肥料フィルタ、回ごとPDF出力に対応。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:29:01 +09:00
Akira
eba6267495 変更したドキュメント
ファイル	変更内容
14_マスタードキュメント_分配計画編.md	全面改訂: 旧「分配計画」→ 新「運搬計画」。データモデル5テーブル、API仕様、画面UI操作、PDFフォーマットを記載
CLAUDE.md	データモデル概要(Distribution* → Delivery* に差し替え)、実装状況セクション、更新履歴を更新
13_マスタードキュメント_施肥計画編.md	OUT スコープの「圃場への配置計画」を「運搬計画」への参照に修正
内容を確認して、問題なければ実装に進みます。
2026-03-16 16:05:46 +09:00
18 changed files with 1943 additions and 1031 deletions

View File

@@ -64,7 +64,8 @@
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_0b7fd53b80bd968a__ echo \"=== Stock summary \\(should show reserved\\) ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Read(//c/Users/akira/Develop/keinasystem_t02/**)",
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzczNTY4MTAxLCJpYXQiOjE3NzM0ODE3MDEsImp0aSI6ImM1N2EyNzVjMzkyNTRmMjFiZmUxMzA4ZmU4ZjQ3ZWExIiwidXNlcl9pZCI6Mn0.LOZIpPOHN48YZuf7UBaDrPJfxb12hIm15QRnUPc9sYM\" __NEW_LINE_74a785697e4cd919__ echo \"=== After confirm: stock summary ===\" curl -s http://localhost:8000/api/materials/stock-summary/?material_type=fertilizer -H \"Authorization: Bearer $TOKEN\")",
"Bash(git diff:*)"
"Bash(git diff:*)",
"mcp__serena__find_symbol"
],
"additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp",

View File

@@ -191,22 +191,37 @@ FertilizationEntry (施肥エントリ・中間テーブル)
├── bags袋数、Decimal
└── unique_together = ['plan', 'field', 'fertilizer']
DistributionPlan (分配計画)
├── fertilization_plan (FK to FertilizationPlan, CASCADE)
DeliveryPlan (運搬計画) ← 旧 DistributionPlan を置き換え2026-03-16 再設計)
├── year年度← 施肥計画へのFK廃止、年度ベースで全施肥計画を横断
├── name計画名
── groups → DistributionGroup
── groups → DeliveryGroup
└── trips → DeliveryTrip
DistributionGroup (配グループ)
├── distribution_plan (FK to DistributionPlan, CASCADE)
DeliveryGroup (配送先グループ)
├── delivery_plan (FK to DeliveryPlan, CASCADE)
├── nameグループ名
├── order表示順
└── unique_together = ['distribution_plan', 'name']
└── unique_together = ['delivery_plan', 'name']
DistributionGroupField (グループ圃場割り当て)
├── distribution_plan (FK to DistributionPlan, CASCADE) ← 一意制約用
├── group (FK to DistributionGroup, CASCADE)
DeliveryGroupField (グループ圃場割り当て)
├── delivery_plan (FK to DeliveryPlan, CASCADE) ← 一意制約用
├── group (FK to DeliveryGroup, CASCADE)
├── field (FK to fields.Field, PROTECT)
└── unique_together = ['distribution_plan', 'field'] ← 1圃場=1グループ/1計画
└── unique_together = ['delivery_plan', 'field'] ← 1圃場=1グループ/1計画
DeliveryTrip (運搬回)
├── delivery_plan (FK to DeliveryPlan, CASCADE)
├── order何回目
├── name任意の名前
├── date運搬日、nullable、デフォルト=1回目の日付
└── items → DeliveryTripItem
DeliveryTripItem (運搬明細)
├── trip (FK to DeliveryTrip, CASCADE)
├── field (FK to fields.Field, PROTECT)
├── fertilizer (FK to Fertilizer, PROTECT)
├── bags袋数、Decimal
└── unique_together = ['trip', 'field', 'fertilizer']
```
### 重要な設計判断
@@ -343,12 +358,15 @@ DistributionGroupField (グループ圃場割り当て)
- 自動計算3方式: 反当袋数(per_tan)、均等配分(even)、反当チッソ(nitrogen)
- フロントエンド: `/fertilizer/`(一覧), `/fertilizer/new``/fertilizer/[id]/edit`(編集・マトリクス表), `/fertilizer/masters/`(肥料マスタ)
- スコープ外(将来): 購入管理
11. **分配計画機能**2026-03-02 実装:
- Django `apps/fertilizer` アプリに3モデル追加DistributionPlan, DistributionGroup, DistributionGroupField
- APIJWT認証: `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`(編集
11. **運搬計画機能**旧・分配計画、2026-03-16 再設計中:
- 旧 DistributionPlan/Group/GroupField → 新 DeliveryPlan/Group/GroupField/Trip/TripItem に移行
- 施肥計画への直接FK廃止 → 年度ベースで全施肥計画を横断
- 「軽トラ1回分」を基本単位とする運搬回DeliveryTripを追加
- 運搬明細DeliveryTripItemで圃場×肥料単位の袋数を管理
- 運搬回ごとの日付記録(作業記録としても機能
- APIJWT認証: `/api/fertilizer/delivery/` 配下
- PDF出力A4横向き・回ごとに1ページ
- フロントエンド: `/distribution/`(一覧・編集)
- マスタードキュメント: `document/14_マスタードキュメント_分配計画編.md`
### 🚧 既知の課題・技術的負債
@@ -503,6 +521,8 @@ docker-compose exec backend python manage.py migrate
## 📝 更新履歴
- 2026-03-16: 分配計画を「運搬計画」に再設計。実運用のワークフロー軽トラ複数回・複数施肥計画混在・肥料指定に合わせ、DeliveryPlan/Trip/TripItem モデルへ移行。施肥計画へのFK廃止→年度ベース。マスタードキュメント14を全面改訂
- 2026-03-05: メール通知機能を更新。MailEmail.account を xserver1〜xserver6 で識別可能に変更。Windmill mail_filter に To ヘッダー宛先補正を追加し、Gmail先行取り込みでも Xserver 宛先ラベルが崩れないよう修正。マスタードキュメント/仕様書を同期。
- 2026-02-28: Cursor連携を廃止。Claude Code 単独運用に変更。`document/20_Cursor_Claude連携ガイド.md` を削除

View File

@@ -1,5 +1,8 @@
from django.contrib import admin
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
)
@admin.register(Fertilizer)
@@ -19,25 +22,41 @@ class FertilizationPlanAdmin(admin.ModelAdmin):
inlines = [FertilizationEntryInline]
class DistributionGroupFieldInline(admin.TabularInline):
model = DistributionGroupField
class DeliveryGroupFieldInline(admin.TabularInline):
model = DeliveryGroupField
extra = 0
readonly_fields = ['distribution_plan']
readonly_fields = ['delivery_plan']
class DistributionGroupInline(admin.TabularInline):
model = DistributionGroup
class DeliveryGroupInline(admin.TabularInline):
model = DeliveryGroup
extra = 0
@admin.register(DistributionPlan)
class DistributionPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'fertilization_plan', 'created_at']
list_filter = ['fertilization_plan__year']
inlines = [DistributionGroupInline]
class DeliveryTripItemInline(admin.TabularInline):
model = DeliveryTripItem
extra = 0
@admin.register(DistributionGroup)
class DistributionGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'distribution_plan', 'order']
inlines = [DistributionGroupFieldInline]
class DeliveryTripInline(admin.TabularInline):
model = DeliveryTrip
extra = 0
@admin.register(DeliveryPlan)
class DeliveryPlanAdmin(admin.ModelAdmin):
list_display = ['name', 'year', 'created_at']
list_filter = ['year']
inlines = [DeliveryGroupInline, DeliveryTripInline]
@admin.register(DeliveryGroup)
class DeliveryGroupAdmin(admin.ModelAdmin):
list_display = ['name', 'delivery_plan', 'order']
inlines = [DeliveryGroupFieldInline]
@admin.register(DeliveryTrip)
class DeliveryTripAdmin(admin.ModelAdmin):
list_display = ['delivery_plan', 'order', 'name', 'date']
inlines = [DeliveryTripItemInline]

View File

@@ -0,0 +1,127 @@
# Generated by Django 5.0 on 2026-03-16 07:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0006_fertilizationplan_confirmation'),
('fields', '0006_e1c_chusankan_17_fields'),
]
operations = [
migrations.CreateModel(
name='DeliveryGroup',
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='表示順')),
],
options={
'verbose_name': '配送先グループ',
'verbose_name_plural': '配送先グループ',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='DeliveryPlan',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('year', models.IntegerField(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)),
],
options={
'verbose_name': '運搬計画',
'verbose_name_plural': '運搬計画',
'ordering': ['-year', 'name'],
},
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='group',
),
migrations.AlterUniqueTogether(
name='distributiongroupfield',
unique_together=None,
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='distribution_plan',
),
migrations.RemoveField(
model_name='distributiongroupfield',
name='field',
),
migrations.RemoveField(
model_name='distributionplan',
name='fertilization_plan',
),
migrations.CreateModel(
name='DeliveryGroupField',
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.deliverygroup', verbose_name='グループ')),
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fertilizer.deliveryplan', verbose_name='運搬計画')),
],
options={
'verbose_name': 'グループ圃場割り当て',
'verbose_name_plural': 'グループ圃場割り当て',
'ordering': ['field__display_order', 'field__id'],
'unique_together': {('delivery_plan', 'field')},
},
),
migrations.AddField(
model_name='deliverygroup',
name='delivery_plan',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='fertilizer.deliveryplan', verbose_name='運搬計画'),
),
migrations.CreateModel(
name='DeliveryTrip',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0, verbose_name='何回目')),
('name', models.CharField(blank=True, max_length=100, verbose_name='名前')),
('date', models.DateField(blank=True, null=True, verbose_name='運搬日')),
('delivery_plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trips', to='fertilizer.deliveryplan', verbose_name='運搬計画')),
],
options={
'verbose_name': '運搬回',
'verbose_name_plural': '運搬回',
'ordering': ['order', 'id'],
},
),
migrations.CreateModel(
name='DeliveryTripItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bags', models.DecimalField(decimal_places=4, max_digits=10, verbose_name='袋数')),
('fertilizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fertilizer.fertilizer', verbose_name='肥料')),
('field', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fields.field', verbose_name='圃場')),
('trip', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fertilizer.deliverytrip', verbose_name='運搬回')),
],
options={
'verbose_name': '運搬明細',
'verbose_name_plural': '運搬明細',
'ordering': ['field__display_order', 'field__id', 'fertilizer__name'],
'unique_together': {('trip', 'field', 'fertilizer')},
},
),
migrations.DeleteModel(
name='DistributionGroup',
),
migrations.DeleteModel(
name='DistributionGroupField',
),
migrations.DeleteModel(
name='DistributionPlan',
),
migrations.AlterUniqueTogether(
name='deliverygroup',
unique_together={('delivery_plan', 'name')},
),
]

View File

@@ -80,51 +80,48 @@ class FertilizationEntry(models.Model):
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='施肥計画'
)
class DeliveryPlan(models.Model):
"""運搬計画:施肥計画の肥料を軽トラで運ぶ単位で計画・記録する"""
year = models.IntegerField(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']
verbose_name = '運搬計画'
verbose_name_plural = '運搬計画'
ordering = ['-year', 'name']
def __str__(self):
return f"{self.fertilization_plan.year} {self.name}"
return f"{self.year} {self.name}"
class DistributionGroup(models.Model):
"""配グループ:ある場所にまとめて置く圃場のグループ"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE,
related_name='groups', verbose_name='分配計画'
class DeliveryGroup(models.Model):
"""送先グループ:まとめて運ぶ圃場のグループ"""
delivery_plan = models.ForeignKey(
DeliveryPlan, 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']]
verbose_name = '送先グループ'
verbose_name_plural = '送先グループ'
unique_together = [['delivery_plan', 'name']]
ordering = ['order', 'id']
def __str__(self):
return f"{self.distribution_plan} / {self.name}"
return f"{self.delivery_plan} / {self.name}"
class DistributionGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1分配計画)"""
distribution_plan = models.ForeignKey(
DistributionPlan, on_delete=models.CASCADE, verbose_name='分配計画'
class DeliveryGroupField(models.Model):
"""圃場のグループへの割り当て1圃場=1グループ/1運搬計画)"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE, verbose_name='運搬計画'
)
group = models.ForeignKey(
DistributionGroup, on_delete=models.CASCADE,
DeliveryGroup, on_delete=models.CASCADE,
related_name='field_assignments', verbose_name='グループ'
)
field = models.ForeignKey(
@@ -134,8 +131,51 @@ class DistributionGroupField(models.Model):
class Meta:
verbose_name = 'グループ圃場割り当て'
verbose_name_plural = 'グループ圃場割り当て'
unique_together = [['distribution_plan', 'field']]
unique_together = [['delivery_plan', 'field']]
ordering = ['field__display_order', 'field__id']
def __str__(self):
return f"{self.group.name} / {self.field.name}"
class DeliveryTrip(models.Model):
"""運搬回軽トラ1回分の積載"""
delivery_plan = models.ForeignKey(
DeliveryPlan, on_delete=models.CASCADE,
related_name='trips', verbose_name='運搬計画'
)
order = models.PositiveIntegerField(default=0, verbose_name='何回目')
name = models.CharField(max_length=100, blank=True, verbose_name='名前')
date = models.DateField(null=True, blank=True, verbose_name='運搬日')
class Meta:
verbose_name = '運搬回'
verbose_name_plural = '運搬回'
ordering = ['order', 'id']
def __str__(self):
return f"{self.delivery_plan} / {self.order + 1}回目"
class DeliveryTripItem(models.Model):
"""運搬明細:圃場×肥料単位の袋数"""
trip = models.ForeignKey(
DeliveryTrip, on_delete=models.CASCADE,
related_name='items', verbose_name='運搬回'
)
field = models.ForeignKey(
'fields.Field', on_delete=models.PROTECT, verbose_name='圃場'
)
fertilizer = models.ForeignKey(
Fertilizer, on_delete=models.PROTECT, verbose_name='肥料'
)
bags = models.DecimalField(max_digits=10, decimal_places=4, verbose_name='袋数')
class Meta:
verbose_name = '運搬明細'
verbose_name_plural = '運搬明細'
unique_together = [['trip', 'field', 'fertilizer']]
ordering = ['field__display_order', 'field__id', 'fertilizer__name']
def __str__(self):
return f"{self.trip} / {self.field.name} / {self.fertilizer.name}: {self.bags}"

View File

@@ -1,5 +1,8 @@
from rest_framework import serializers
from .models import Fertilizer, FertilizationPlan, FertilizationEntry, DistributionPlan, DistributionGroup, DistributionGroupField
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
)
class FertilizerSerializer(serializers.ModelSerializer):
@@ -100,9 +103,9 @@ class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
)
# ─── 分配計画 ────────────────────────────────────────────────────────────
# ─── 運搬計画 ────────────────────────────────────────────────────────────
class DistributionGroupFieldSerializer(serializers.ModelSerializer):
class DeliveryGroupFieldSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source='field.id', read_only=True)
name = serializers.CharField(source='field.name', read_only=True)
area_tan = serializers.DecimalField(
@@ -110,128 +113,163 @@ class DistributionGroupFieldSerializer(serializers.ModelSerializer):
)
class Meta:
model = DistributionGroupField
model = DeliveryGroupField
fields = ['id', 'name', 'area_tan']
class DistributionGroupReadSerializer(serializers.ModelSerializer):
fields = DistributionGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class DeliveryGroupReadSerializer(serializers.ModelSerializer):
fields = DeliveryGroupFieldSerializer(source='field_assignments', many=True, read_only=True)
class Meta:
model = DistributionGroup
model = DeliveryGroup
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 DeliveryTripItemSerializer(serializers.ModelSerializer):
field_name = serializers.CharField(source='field.name', read_only=True)
fertilizer_name = serializers.CharField(source='fertilizer.name', read_only=True)
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()
]
model = DeliveryTripItem
fields = ['id', 'field', 'field_name', 'fertilizer', 'fertilizer_name', 'bags']
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()
class DeliveryTripReadSerializer(serializers.ModelSerializer):
items = DeliveryTripItemSerializer(many=True, read_only=True)
class Meta:
model = DeliveryTrip
fields = ['id', 'order', 'name', 'date', 'items']
class DeliveryPlanListSerializer(serializers.ModelSerializer):
group_count = serializers.SerializerMethodField()
field_count = serializers.SerializerMethodField()
trip_count = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
model = DeliveryPlan
fields = [
'id', 'name', 'fertilization_plan_id', 'fertilization_plan_name',
'year', 'variety_name', 'crop_name', 'group_count', 'field_count',
'id', 'year', 'name', 'group_count', 'trip_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()
def get_trip_count(self, obj):
return obj.trips.count()
class DistributionPlanReadSerializer(serializers.ModelSerializer):
fertilization_plan = FertilizationPlanForDistributionSerializer(read_only=True)
groups = DistributionGroupReadSerializer(many=True, read_only=True)
class DeliveryPlanReadSerializer(serializers.ModelSerializer):
groups = DeliveryGroupReadSerializer(many=True, read_only=True)
trips = DeliveryTripReadSerializer(many=True, read_only=True)
unassigned_fields = serializers.SerializerMethodField()
available_fertilizers = serializers.SerializerMethodField()
all_entries = serializers.SerializerMethodField()
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan', 'groups', 'unassigned_fields', 'created_at', 'updated_at']
model = DeliveryPlan
fields = [
'id', 'year', 'name', 'groups', 'trips',
'unassigned_fields', 'available_fertilizers', 'all_entries',
'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')
assigned_ids = DeliveryGroupField.objects.filter(
delivery_plan=obj
).values_list('field_id', flat=True)
# 年度の施肥計画に含まれる全圃場
plan_field_ids = FertilizationEntry.objects.filter(
plan__year=obj.year
).values_list('field_id', flat=True).distinct()
from apps.fields.models import Field
unassigned = Field.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]
def get_available_fertilizers(self, obj):
fert_ids = FertilizationEntry.objects.filter(
plan__year=obj.year
).values_list('fertilizer_id', flat=True).distinct()
fertilizers = Fertilizer.objects.filter(id__in=fert_ids).order_by('name')
return [{'id': f.id, 'name': f.name} for f in fertilizers]
class DistributionPlanWriteSerializer(serializers.ModelSerializer):
fertilization_plan_id = serializers.IntegerField(write_only=True)
def get_all_entries(self, obj):
"""年度の全施肥計画のエントリ(フロントで袋数計算に使用)"""
entries = FertilizationEntry.objects.filter(
plan__year=obj.year
).select_related('field', 'fertilizer')
return [
{
'field': e.field_id,
'field_name': e.field.name,
'field_area_tan': str(e.field.area_tan),
'fertilizer': e.fertilizer_id,
'fertilizer_name': e.fertilizer.name,
'bags': str(e.bags),
}
for e in entries
]
class DeliveryPlanWriteSerializer(serializers.ModelSerializer):
groups = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
trips = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)
class Meta:
model = DistributionPlan
fields = ['id', 'name', 'fertilization_plan_id', 'groups']
model = DeliveryPlan
fields = ['id', 'year', 'name', 'groups', 'trips']
def create(self, validated_data):
groups_data = validated_data.pop('groups', [])
plan = DistributionPlan.objects.create(**validated_data)
trips_data = validated_data.pop('trips', [])
plan = DeliveryPlan.objects.create(**validated_data)
self._save_groups(plan, groups_data)
self._save_trips(plan, trips_data)
return plan
def update(self, instance, validated_data):
groups_data = validated_data.pop('groups', None)
trips_data = validated_data.pop('trips', None)
instance.name = validated_data.get('name', instance.name)
instance.year = validated_data.get('year', instance.year)
instance.save()
if groups_data is not None:
instance.groups.all().delete()
self._save_groups(instance, groups_data)
if trips_data is not None:
instance.trips.all().delete()
self._save_trips(instance, trips_data)
return instance
def _save_groups(self, plan, groups_data):
for g_data in groups_data:
group = DistributionGroup.objects.create(
distribution_plan=plan,
group = DeliveryGroup.objects.create(
delivery_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,
DeliveryGroupField.objects.create(
delivery_plan=plan,
group=group,
field_id=field_id,
)
def _save_trips(self, plan, trips_data):
for t_data in trips_data:
trip = DeliveryTrip.objects.create(
delivery_plan=plan,
order=t_data.get('order', 0),
name=t_data.get('name', ''),
date=t_data.get('date'),
)
for item in t_data.get('items', []):
DeliveryTripItem.objects.create(
trip=trip,
field_id=item['field_id'],
fertilizer_id=item['fertilizer_id'],
bags=item['bags'],
)

View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4 landscape; margin: 12mm; }
body { font-family: "Noto Sans CJK JP", "Hiragino Kaku Gothic Pro", sans-serif; font-size: 10pt; }
h1 { font-size: 14pt; text-align: center; margin-bottom: 4px; }
.subtitle { text-align: center; font-size: 9pt; color: #555; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 6px; }
th, td { border: 1px solid #888; padding: 4px 6px; text-align: right; }
th { background: #e8f5e9; text-align: center; }
.col-name { text-align: left; }
.group-row { font-weight: bold; background: #c8e6c9; }
.group-row td { font-size: 10pt; }
.group-star { color: #2e7d32; margin-right: 2px; }
.field-row td { font-size: 8.5pt; color: #444; background: #fafafa; }
.field-indent { padding-left: 14px; }
tr.total-row { font-weight: bold; background: #f5f5f5; }
.zero { color: #bbb; }
.page-break { page-break-before: always; }
</style>
</head>
<body>
{% for page in trip_pages %}
{% if not forloop.first %}<div class="page-break"></div>{% endif %}
<h1>運搬計画書 {{ page.trip.order|add:1 }}回目</h1>
<p class="subtitle">
{{ plan.year }}年度 「{{ plan.name }}」
{% if page.trip.name %}{{ page.trip.name }}{% endif %}
{% if page.trip.date %}{{ page.trip.date }}{% endif %}
</p>
<table>
<thead>
<tr>
<th class="col-name">グループ / 圃場</th>
{% for fert in page.fertilizers %}
<th>{{ fert.name }}<br><small>(袋)</small></th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for group in page.group_rows %}
{# グループ合計行 #}
<tr class="group-row">
<td class="col-name"><span class="group-star"></span>{{ group.name }}</td>
{% for total in group.totals %}
<td>{% if total %}{{ total }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
</tr>
{# 圃場サブ行 #}
{% for row in group.field_rows %}
<tr class="field-row">
<td class="col-name field-indent">{{ row.field.name }}{{ row.field.area_tan }}反)</td>
{% for cell in row.cells %}
<td>{% if cell %}{{ cell }}{% else %}<span class="zero">-</span>{% endif %}</td>
{% endfor %}
</tr>
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr class="total-row">
<td class="col-name">合計</td>
{% for total in page.fert_totals %}
<td>{{ total }}</td>
{% endfor %}
</tr>
</tfoot>
</table>
{% endfor %}
</body>
</html>

View File

@@ -5,7 +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')
router.register(r'delivery', views.DeliveryPlanViewSet, basename='delivery-plan')
urlpatterns = [
path('', include(router.urls)),

View File

@@ -18,14 +18,17 @@ from apps.materials.stock_service import (
unconfirm_spreading,
)
from apps.plans.models import Plan, Variety
from .models import Fertilizer, FertilizationPlan, DistributionPlan
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
)
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
DistributionPlanListSerializer,
DistributionPlanReadSerializer,
DistributionPlanWriteSerializer,
DeliveryPlanListSerializer,
DeliveryPlanReadSerializer,
DeliveryPlanWriteSerializer,
)
@@ -281,126 +284,140 @@ class CalculateView(APIView):
return Response(results)
class DistributionPlanViewSet(viewsets.ModelViewSet):
class DeliveryPlanViewSet(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(
qs = DeliveryPlan.objects.prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
'fertilization_plan__entries', 'fertilization_plan__entries__field',
'fertilization_plan__entries__fertilizer',
'distributiongroupfield_set',
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(fertilization_plan__year=year)
qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return DistributionPlanWriteSerializer
return DeliveryPlanWriteSerializer
if self.action == 'list':
return DistributionPlanListSerializer
return DistributionPlanReadSerializer
return DeliveryPlanListSerializer
return DeliveryPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
dist_plan = self.get_object()
fert_plan = dist_plan.fertilization_plan
plan = self.get_object()
# 施肥計画の肥料一覧(名前順)
fert_ids = fert_plan.entries.values_list('fertilizer_id', flat=True).distinct()
# 全tripのitemから使用肥料を収集
all_items = DeliveryTripItem.objects.filter(
trip__delivery_plan=plan
).select_related('field', 'fertilizer')
fert_ids = all_items.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
# グループ情報: field_id → group_name
field_group_map = {}
for gf in DeliveryGroupField.objects.filter(
delivery_plan=plan
).select_related('group', 'field'):
field_group_map[gf.field_id] = gf.group
# グループ行の構築
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')
# 回ごとにページを構築
trip_pages = []
for trip in plan.trips.prefetch_related('items__field', 'items__fertilizer').all():
items = trip.items.all()
if not items:
continue
# この回の肥料一覧
trip_fert_ids = set(item.fertilizer_id for item in items)
trip_fertilizers = [f for f in fertilizers if f.id in trip_fert_ids]
# items を (field_id, fertilizer_id) → bags のマトリクスに変換
item_map = {}
for item in items:
item_map[(item.field_id, item.fertilizer_id)] = item.bags
# グループごとにまとめる
groups_dict = {} # group_name → {'group': group, 'fields': [field, ...]}
ungrouped_fields = []
for item in items:
group = field_group_map.get(item.field_id)
if group:
if group.name not in groups_dict:
groups_dict[group.name] = {'group': group, 'fields': []}
if item.field not in groups_dict[group.name]['fields']:
groups_dict[group.name]['fields'].append(item.field)
else:
if item.field not in ungrouped_fields:
ungrouped_fields.append(item.field)
# グループを order 順にソート
sorted_groups = sorted(groups_dict.values(), key=lambda g: (g['group'].order, g['group'].id))
group_rows = []
for g_data in sorted_groups:
fields_in_group = sorted(g_data['fields'], key=lambda f: (f.display_order, f.id))
group_totals = []
for fert in trip_fertilizers:
total = sum(
item_map.get((f.id, fert.id), Decimal('0'))
for f in fields_in_group
)
group_totals.append(total)
field_rows = []
for field in fields_in_group:
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
field_rows.append({'field': field, 'cells': cells})
group_rows.append({
'name': g_data['group'].name,
'totals': group_totals,
'field_rows': field_rows,
})
# 未グループ圃場
if ungrouped_fields:
ungrouped_fields = sorted(ungrouped_fields, key=lambda f: (f.display_order, f.id))
ua_totals = [
sum(item_map.get((f.id, fert.id), Decimal('0')) for f in ungrouped_fields)
for fert in trip_fertilizers
]
field_rows = []
for field in ungrouped_fields:
cells = [item_map.get((field.id, fert.id), '') for fert in trip_fertilizers]
field_rows.append({'field': field, 'cells': cells})
group_rows.append({
'name': '未グループ',
'totals': ua_totals,
'field_rows': field_rows,
})
fert_totals = [
sum(r['totals'][i] for r in group_rows)
for i in range(len(trip_fertilizers))
]
# グループ合計(肥料ごと)
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,
trip_pages.append({
'trip': trip,
'fertilizers': trip_fertilizers,
'group_rows': group_rows,
'fert_totals': fert_totals,
})
# 未割り当て圃場
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),
'plan': plan,
'trip_pages': trip_pages,
}
html_string = render_to_string('fertilizer/distribution_pdf.html', context)
html_string = render_to_string('fertilizer/delivery_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"'
f'attachment; filename="delivery_{plan.year}_{plan.id}.pdf"'
)
return response

View File

@@ -17,7 +17,7 @@
| IN実装済み | OUT対象外 |
|---|---|
| 肥料マスタ管理 | 肥料購入管理 |
| 施肥計画の作成・編集・削除 | 圃場への配置計画(置き場所割り当て |
| 施肥計画の作成・編集・削除 | 運搬計画(→ `14_マスタードキュメント_分配計画編.md` 参照 |
| 3方式の自動計算 | 個別作業日報の詳細管理 |
| 作付け計画からの圃場自動取得 | |
| PDF出力圃場×肥料マトリクス表 | |

View File

@@ -1,65 +1,140 @@
# マスタードキュメント:分配計画機能
# マスタードキュメント:運搬計画機能(旧・分配計画)
> **作成**: 2026-03-02
> **最終更新**: 2026-03-02
> **対象機能**: 分配計画(施肥計画の圃場をグループ化し配置場所単位で集計
> **実装状況**: 実装完了
> **最終更新**: 2026-03-16
> **対象機能**: 運搬計画(施肥計画の肥料を軽トラで運ぶ単位で計画・記録する
> **実装状況**: 再設計中(旧分配計画から運搬計画へ移行)
---
## 概要
施肥計画FertilizationPlanで決めた圃場ごとの袋数を、**実際に肥料を配置する場所の単位**でまとめる機能。
「田中エリアにはA肥料12袋・B肥料6袋を持っていく」という単位で計画・PDF出力できる。
施肥計画で決めた圃場ごとの肥料袋数を、**軽トラ1回分の積載単位**で運搬計画にまとめる機能。
実際の作業では一度に全部運べないため、「何回目にどのグループのどの肥料を何袋運ぶか」を計画・記録する。
### 旧設計(分配計画)からの変更理由
旧設計は「1つの施肥計画の圃場をグループ分けする」だけだった。
実運用で以下のギャップが判明2026-03-16
1. **複数の施肥計画が混在する** - 軽トラには品種をまたいで積む
2. **単一の施肥計画が分割される** - 1回で運びきれない
3. **全肥料を一度に運ぶわけではない** - 運ぶ肥料を指定する必要がある
4. **圃場単位の合計袋数は不要** - グループ×肥料の合計が重要
5. **同じグループの圃場を回ごとに分割する** - 載りきらないときは次の回に
6. **作業記録でもある** - 運搬した日付を記録したい
### 機能スコープ
| IN実装済み | OUT対象外 |
| IN実装対象 | OUT対象外 |
|---|---|
| 施肥計画を元に圃場をカスタムグループに割り当て | 購入管理 |
| グループ×肥料の集計表(画面表示) | 実施記録 |
| PDF出力グループ合計行圃場サブ行 | |
| グループの順序変更・名前変更 | |
| 年度単位の運搬計画作成 | 購入管理 |
| 配送先グループへの圃場割り当て | 肥料の在庫管理 |
| 運搬回ごとの圃場×肥料割り当て | ルート最適化 |
| 回ごとの積載合計リアルタイム表示 | |
| 圃場を回の間で移動する操作 | |
| 「残り全部」一括割り当て | |
| 回ごとの運搬日記録 | |
| PDF出力回ごとに1ページ | |
---
## データモデル
### DistributionPlan分配計画
### 旧モデルからの移行
| 旧(削除) | 新(追加) | 備考 |
|---|---|---|
| DistributionPlan | DeliveryPlan | FK(FertilizationPlan) 廃止 → year ベース |
| DistributionGroup | DeliveryGroup | ほぼ同等 |
| DistributionGroupField | DeliveryGroupField | ほぼ同等 |
| (なし) | DeliveryTrip | 新規:運搬回 |
| (なし) | DeliveryTripItem | 新規:運搬明細(圃場×肥料単位) |
### DeliveryPlan運搬計画
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| fertilization_plan | FK(FertilizationPlan) | CASCADE | |
| year | int | required | 年度 |
| name | varchar(200) | required | 計画名 |
| created_at / updated_at | datetime | auto | |
- `ordering = ['-fertilization_plan__year', 'name']`
- 1つの施肥計画に対して複数の分配計画を作れるOneToOneではなくFK
- `ordering = ['-year', 'name']`
- 施肥計画への直接FK なし(年度ベースで全施肥計画を横断
### DistributionGroup配グループ)
### DeliveryGroup送先グループ)
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | |
| name | varchar(100) | required | グループ名 |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| name | varchar(100) | required | グループ名(例: キウイ, 足川北) |
| order | PositiveIntegerField | default=0 | 表示順 |
- `unique_together = [['distribution_plan', 'name']]` → 同一計画内でグループ名重複不可
- `unique_together = [['delivery_plan', 'name']]`
- `ordering = ['order', 'id']`
### DistributionGroupFieldグループ圃場割り当て
### DeliveryGroupFieldグループ圃場割り当て
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| distribution_plan | FK(DistributionPlan) | CASCADE | 一意制約のために冗長保持 |
| group | FK(DistributionGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | 圃場 |
| delivery_plan | FK(DeliveryPlan) | CASCADE | 一意制約 |
| group | FK(DeliveryGroup) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
- `unique_together = [['distribution_plan', 'field']]` → 1圃場=1グループ/1計画
- `ordering = ['field__display_order', 'field__id']`
- `unique_together = [['delivery_plan', 'field']]` → 1圃場=1グループ/1計画
### DeliveryTrip運搬回
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| delivery_plan | FK(DeliveryPlan) | CASCADE | |
| order | PositiveIntegerField | default=0 | 何回目(表示順) |
| name | varchar(100) | blank | 任意の名前(例: "たちはるか電気炉さい" |
| date | DateField | nullable | 運搬日(デフォルト: 1回目の日付を引き継ぎ |
- `ordering = ['order', 'id']`
### DeliveryTripItem運搬明細
| フィールド | 型 | 制約 | 説明 |
|---|---|---|---|
| id | int | PK | |
| trip | FK(DeliveryTrip) | CASCADE | |
| field | FK(fields.Field) | PROTECT | |
| fertilizer | FK(Fertilizer) | PROTECT | |
| bags | Decimal(10,4) | required | 袋数 |
- `unique_together = [['trip', 'field', 'fertilizer']]`
- bags は施肥計画の FertilizationEntry から自動計算で初期値を設定するが、手動上書きも可能
### ER図概念
```
DeliveryPlan (運搬計画)
├── year, name
├── groups → DeliveryGroup (配送先グループ)
│ ├── name, order
│ └── fields → DeliveryGroupField → Field
└── trips → DeliveryTrip (運搬回)
├── order, name, date
└── items → DeliveryTripItem
├── field → Field
├── fertilizer → Fertilizer
└── bags
```
### 袋数の算出ルール
1. 運搬計画作成時、年度の全 FertilizationEntry を参照して「グループ×肥料→圃場×袋数」を自動算出
2. ユーザーが運搬回に圃場を割り当てると、該当する FertilizationEntry の bags が DeliveryTripItem.bags にコピーされる
3. 手動で bags を上書きすることも可能(施肥計画との差異は許容)
4. 「残り全部」操作: 施肥計画の合計 既に割り当て済みの回の合計 = 残り
---
@@ -69,143 +144,209 @@
| メソッド | 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 |
| GET | `/api/fertilizer/delivery/?year={year}` | 一覧(年度フィルタ) |
| POST | `/api/fertilizer/delivery/` | 新規作成 |
| GET | `/api/fertilizer/delivery/{id}/` | 詳細groups/trips/items 込み) |
| PUT | `/api/fertilizer/delivery/{id}/` | 更新groups・trips 全置換) |
| DELETE | `/api/fertilizer/delivery/{id}/` | 削除 |
| GET | `/api/fertilizer/delivery/{id}/pdf/` | 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,
"year": 2026,
"name": "2026春 肥料運搬",
"group_count": 5,
"trip_count": 3,
"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"}]
},
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{
"id": 10,
"name": "田中エリア",
"name": "キウイ",
"order": 0,
"fields": [{"id": 5, "name": "田中上", "area_tan": "1.2000"}]
"fields": [
{"id": 5, "name": "キウイ畑1", "area_tan": "1.2000"}
]
}
],
"unassigned_fields": [{"id": 7, "name": "未割り当て圃場", "area_tan": "0.5000"}]
"trips": [
{
"id": 1,
"order": 0,
"name": "1回目 たちはるか電気炉さい",
"date": "2026-03-16",
"items": [
{"field": 5, "fertilizer": 1, "bags": "4.00"}
]
}
],
"unassigned_fields": [],
"available_fertilizers": [
{"id": 1, "name": "電気炉さい"},
{"id": 2, "name": "ミネラルホウ素"}
]
}
```
- `available_fertilizers`: 該当年度の全施肥計画で使われている肥料の一覧
- `unassigned_fields`: グループに割り当てられていない圃場
### 書き込みリクエストPOST/PUT
```json
{
"name": "2025年コシヒカリ 分配計画",
"fertilization_plan_id": 3,
"year": 2026,
"name": "2026春 肥料運搬",
"groups": [
{"name": "田中エリア", "order": 0, "field_ids": [5, 6]},
{"name": "奥地エリア", "order": 1, "field_ids": [7]}
{"name": "キウイ", "order": 0, "field_ids": [5, 6]}
],
"trips": [
{
"order": 0,
"name": "1回目",
"date": "2026-03-16",
"items": [
{"field_id": 5, "fertilizer_id": 1, "bags": "4.00"}
]
}
]
}
```
PUT は groups を全削除→再作成する全置換方式。
PUT は groups・trips を全削除→再作成する全置換方式。
---
## フロントエンド画面
### 分配計画一覧 `/distribution`
### 運搬計画一覧 `/distribution`
- 年度セレクタ(`localStorage distributionYear` で保持)
- テーブル: 計画名・施肥計画・作物/品種・グループ数・圃場
- テーブル: 計画名・グループ数・
- アクション: PDF・編集・削除
- 削除エラー: インラインバナー(確認なし・失敗したらバナー表示)
### 分配計画編集 `/distribution/new` / `/distribution/[id]/edit`
### 運搬計画編集 `/distribution/new` / `/distribution/[id]/edit`
**共通コンポーネント**: `frontend/src/app/distribution/_components/DistributionEditPage.tsx`
#### 画面レイアウト
#### State構成
```
[計画名: ________________] [年度: 2026]
```typescript
// 基本情報
const [name, setName] = useState('')
const [fertilizationPlanId, setFertilizationPlanId] = useState<number|''>('')
━━━ グループ定義 ━━━━━━━━━━━━━━━━━━━
(既存の方式: グループ追加・圃場割り当て・並び替え)
// 施肥計画詳細(施肥計画選択後に取得)
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null)
━━━ 対象肥料 ━━━━━━━━━━━━━━━━━━━━━
☑電気炉さい ☑ミネラルホウ素 ☐有機100号 ...
(年度の施肥計画に含まれる肥料をチェックボックスで選択)
// ローカルグループtempId で管理、保存時にサーバーへ送信)
const [groups, setGroups] = useState<LocalGroup[]>([])
// LocalGroup = { tempId: string, name: string, order: number, fieldIds: number[], isRenamingName?: string }
━━━ 未割り当て ━━━━━━━━━━━━━━━━━━━━
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
圃場A 電気炉さい:2 ミネラルホウ素:3 [→1回目 ▼]
圃場B 電気炉さい:2 ミネラルホウ素:2 [→1回目 ▼]
★ 足川北 (小計: 電気炉さい 12, ミネラルホウ素 6)
圃場D ...
━━━ 1回目 (2026-03-16) ━━━ 積載: 46袋 ━━━
日付: [2026-03-16] 名前: [たちはるか電気炉さい]
★ たちはるか (小計: 電気炉さい 46)
圃場X 電気炉さい:10 [←戻す]
圃場Y 電気炉さい:12 [←戻す]
...
━━━ 2回目 (2026-03-16) ━━━ 積載: 39袋 ━━━
日付: [2026-03-16] 名前: [____________]
★ キウイ (小計: 電気炉さい 4, ミネラルホウ素 5)
...
[+回を追加] [残り全部→新しい回] [保存]
```
#### UI構成
#### 主要な操作
1. **計画基本情報**: 計画名テキスト + 施肥計画セレクタ
2. **グループ割り当て**:
- 新規グループ追加(名前入力 + 追加ボタン)
- グループカード(↑↓順序変更・鉛筆名前変更・×削除)
- グループ内圃場(×解除)+ 肥料別袋数をインライン表示
- 未割り当て圃場セクション(グループ選択ドロップダウンで割り当て)
3. **集計プレビュー**: グループ×肥料マトリクス(リアルタイム・サーバー通信なし)
| 操作 | 方法 | 説明 |
|---|---|---|
| 圃場を回に割り当て | 圃場行の「→N回目」ドロップダウン | 未割り当て→指定回に移動 |
| 圃場を回から戻す | 圃場行の「←戻す」ボタン | 回→未割り当てに移動 |
| 圃場を別の回に移動 | 戻す→再割り当て、または直接ドロップダウン | 回の間で移動 |
| 残り全部を一括割り当て | 「残り全部→新しい回」ボタン | 未割り当て圃場を新しい回に追加 |
| 回の追加 | 「+回を追加」ボタン | 空の回を追加 |
| 回の削除 | 回ヘッダーの「×」ボタン | 回を削除、中の圃場は未割り当てに戻る |
| 回の日付設定 | 日付入力フィールド | デフォルトは1回目の日付 |
| 対象肥料の絞り込み | チェックボックス | 選択した肥料だけ表示 |
#### 積載合計のリアルタイム表示
各回のヘッダーに、その回の肥料ごとの合計袋数と総袋数を表示。
圃場を追加・削除するたびに即時再計算(サーバー通信なし)。
---
## PDF 出力
`GET /api/fertilizer/distribution/{id}/pdf/`
`GET /api/fertilizer/delivery/{id}/pdf/`
- WeasyPrint既存施肥計画PDFと同じ仕組み
- テンプレート: `backend/apps/fertilizer/templates/fertilizer/distribution_pdf.html`
- フォーマット: A4横向き
- 内容:
- ★グループ合計行(太字・緑背景)
- 圃場サブ行(小フォント・灰色背景)
- 肥料列合計・総合計
- ファイル名: `distribution_{year}_{plan_id}.pdf`
### フォーマット
- WeasyPrint、A4横向き
- **回ごとに1ページ**1回目=1ページ目、2回目=2ページ目...
### 各ページの内容
```
━━━ 2回目 2026-03-16 ━━━━━━━━━━━━━━━
電気炉さい ミネラルホウ素
★ キウイ 4 5
圃場A 2 3
圃場B 2 2
★ 池田さんちの前 2 2
圃場C 2 2
★ 足川北 12 6
圃場D 4 2
圃場E 4 2
圃場F 4 2
★ 出祥邸 - 8
圃場G - 4
圃場H - 4
─────────────────────────────────────
合計 18 21
```
- ★行: グループ小計(肥料ごと)、太字・緑背景
- 圃場行: 各圃場の肥料ごとの袋数(**合計列なし**
- 最下行: 回全体の肥料ごと合計
- 日付を各ページのヘッダーに記載
- ファイル名: `delivery_{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 登録
├── models.py # DeliveryPlan/Group/GroupField/Trip/TripItem
├── serializers.py # Delivery* シリアライザ
├── views.py # DeliveryPlanViewSet
├── urls.py # router.register('delivery', ...)
├── admin.py # DeliveryPlan 等の admin 登録
├── migrations/
│ └── 000X_delivery_*.py # 旧Distribution → 新Delivery マイグレーション
└── templates/fertilizer/
└── distribution_pdf.html # A4横 PDF テンプレート
└── delivery_pdf.html # 回ごと1ページ PDF テンプレート
```
### Frontend
@@ -215,27 +356,40 @@ frontend/src/app/distribution/
├── page.tsx # 一覧ページ
├── new/page.tsx # 新規作成(ラッパー)
├── [id]/edit/page.tsx # 編集(ラッパー)
└── _components/DistributionEditPage.tsx # 編集共通コンポーネント
└── _components/DeliveryEditPage.tsx # 編集共通コンポーネント
```
---
## マイグレーション方針
### 旧モデルDistribution*)の扱い
1. 新モデルDelivery*)を追加するマイグレーションを作成
2. 旧モデルDistribution*)は削除マイグレーションで除去
3. 旧データは少量のため、データ移行は行わない(手動で再作成)
### マイグレーション順序
1. `000X_add_delivery_models.py` - DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem を追加
2. `000Y_remove_distribution_models.py` - DistributionPlan, DistributionGroup, DistributionGroupField を削除
---
## 注意点
### 集計は全クライアントサイド計算
### 施肥計画との関係
集計プレビューは API を呼ばず、`fertPlanDetail.entries``groups.fieldIds` からクライアントで計算する。
- 運搬計画は施肥計画への直接FKを持たない
- 年度ベースで、その年度の全 FertilizationEntry を参照して圃場×肥料の袋数を取得する
- 施肥計画を変更すると、未割り当ての圃場の袋数は自動で反映される
- 既に運搬回に割り当て済みの DeliveryTripItem.bags は変わらない(コピー済み)
### 集計はクライアントサイド計算
画面上の集計(グループ小計・回の積載合計)は API を呼ばずクライアントで計算。
PDF生成時のみサーバーサイドで同じ計算を実施。
### PUT の全置換方式
PUT 時は `groups.all().delete()` → 再作成。部分更新は非対応。
### 未割り当て圃場の扱い
- 施肥計画に含まれる圃場のうちグループに割り当てられていないものは「未割り当て」として表示
- PDF にも「未割り当て」グループとして出力される(ゼロの場合は出力なし)
### エラー表示方針
施肥計画機能と同じく alert/confirm 廃止・インラインバナーに統一。

View File

@@ -1,5 +1,5 @@
import DistributionEditPage from '../../_components/DistributionEditPage';
import DeliveryEditPage from '../../_components/DeliveryEditPage';
export default function DistributionEditRoute({ params }: { params: { id: string } }) {
return <DistributionEditPage planId={Number(params.id)} />;
export default function DeliveryEditRoute({ params }: { params: { id: string } }) {
return <DeliveryEditPage planId={Number(params.id)} />;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,651 +0,0 @@
'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<number | ''>('');
const [year] = useState<number>(() => {
if (typeof window !== 'undefined') {
return parseInt(localStorage.getItem('distributionYear') || String(CURRENT_YEAR), 10);
}
return CURRENT_YEAR;
});
// 施肥計画一覧(セレクタ用)
const [fertilizationPlans, setFertilizationPlans] = useState<FertilizationPlan[]>([]);
// 選択中の施肥計画の詳細肥料・entries
const [fertPlanDetail, setFertPlanDetail] = useState<DistributionPlan['fertilization_plan'] | null>(null);
// ローカルグループ状態
const [groups, setGroups] = useState<LocalGroup[]>([]);
const [newGroupName, setNewGroupName] = useState('');
// UI状態
const [saveError, setSaveError] = useState<string | null>(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<number, FieldInfo>();
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<number, FieldInfo>();
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 (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8 text-gray-500 text-sm">...</main>
</div>
);
}
const fertilizers = fertPlanDetail?.fertilizers || [];
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main className="max-w-5xl mx-auto px-4 py-8">
{/* ヘッダー */}
<div className="flex items-center gap-2 mb-6">
<button onClick={() => router.push('/distribution')} className="text-sm text-gray-500 hover:text-gray-700">
</button>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-6">
{isEdit ? '分配計画を編集' : '分配計画を新規作成'}
</h1>
{saveError && (
<div className="flex items-start gap-2 bg-red-50 border border-red-300 text-red-700 rounded-md px-4 py-3 mb-4 text-sm">
<span className="flex-1">{saveError}</span>
<button onClick={() => setSaveError(null)}><X className="h-4 w-4" /></button>
</div>
)}
{/* 基本情報 */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2 flex-1 min-w-48">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<input
type="text"
value={name}
onChange={e => 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"
/>
</div>
<div className="flex items-center gap-2 flex-1 min-w-64">
<label className="text-sm font-medium text-gray-700 whitespace-nowrap"></label>
<select
value={fertilizationPlanId}
onChange={e => setFertilizationPlanId(e.target.value ? Number(e.target.value) : '')}
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"
>
<option value="">-- --</option>
{fertilizationPlans.map(p => (
<option key={p.id} value={p.id}>
{p.year} {p.name}{p.crop_name}/{p.variety_name}
</option>
))}
</select>
</div>
</div>
</div>
{!fertPlanDetail ? (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-400 text-sm">
</div>
) : (
<>
{/* グループ割り当て */}
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-4"></h2>
{/* 新規グループ追加 */}
<div className="flex items-center gap-2 mb-4">
<input
type="text"
value={newGroupName}
onChange={e => 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"
/>
<button
onClick={addGroup}
className="flex items-center gap-1 px-3 py-1.5 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{/* グループ一覧 */}
<div className="space-y-3">
{groups.map((group, idx) => (
<div key={group.tempId} className="border border-gray-200 rounded-md overflow-hidden">
{/* グループヘッダー */}
<div className="flex items-center gap-2 bg-green-50 px-3 py-2">
{group.isRenamingName !== undefined ? (
<>
<input
type="text"
value={group.isRenamingName}
onChange={e =>
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
/>
<button
onClick={() => commitRename(group.tempId)}
className="p-1 text-green-700 hover:text-green-900"
>
<Check className="h-4 w-4" />
</button>
</>
) : (
<>
<span className="font-medium text-sm text-gray-800 flex-1">{group.name}</span>
<button
onClick={() => moveGroup(group.tempId, -1)}
disabled={idx === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronUp className="h-4 w-4" />
</button>
<button
onClick={() => moveGroup(group.tempId, 1)}
disabled={idx === groups.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronDown className="h-4 w-4" />
</button>
<button
onClick={() => startRename(group.tempId)}
className="p-1 text-gray-400 hover:text-gray-600"
title="名前変更"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => removeGroup(group.tempId)}
className="p-1 text-gray-400 hover:text-red-600"
title="グループを削除"
>
<X className="h-4 w-4" />
</button>
</>
)}
</div>
{/* グループ内圃場 */}
<div className="px-3 py-2 space-y-1">
{group.fieldIds.length === 0 ? (
<p className="text-xs text-gray-400 italic"></p>
) : (
group.fieldIds.map(fId => {
const fi = getFieldInfo(fId);
const bags = fertilizers.map(fert => getBags(fId, fert.id));
return (
<div key={fId} className="flex items-center gap-2 text-sm">
<button
onClick={() => removeFieldFromGroup(fId, group.tempId)}
className="text-gray-400 hover:text-red-500 flex-shrink-0"
title="グループから外す"
>
<X className="h-3.5 w-3.5" />
</button>
<span className="text-gray-800 font-medium w-32 truncate">{fi.name}</span>
<span className="text-gray-400 text-xs w-16 text-right">{fi.area_tan}</span>
<span className="text-gray-400 text-xs">
{fertilizers.map((fert, i) => (
<span key={fert.id}>
{i > 0 && ' / '}
{fert.name}: {bags[i].toFixed(2)}
</span>
))}
</span>
</div>
);
})
)}
</div>
</div>
))}
</div>
{/* 未割り当て圃場 */}
{unassignedFields.length > 0 && (
<div className="mt-4 border-t border-gray-200 pt-4">
<p className="text-xs font-medium text-gray-500 uppercase mb-2"></p>
<div className="rounded border border-gray-200 overflow-hidden">
{unassignedFields.map((fi, idx) => (
<div key={fi.id} className={`flex items-center gap-2 text-sm px-3 py-1.5 ${idx % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}>
<span className="text-gray-800 font-medium flex-1 min-w-0 truncate" title={fi.name}>{fi.name}</span>
<span className="text-gray-400 text-xs w-16 shrink-0 text-right">{fi.area_tan}</span>
<select
defaultValue=""
onChange={e => {
if (e.target.value) {
assignFieldToGroup(fi.id, e.target.value);
e.target.value = '';
}
}}
className="border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-green-500"
>
<option value="">...</option>
{groups.map(g => (
<option key={g.tempId} value={g.tempId}>{g.name}</option>
))}
</select>
</div>
))}
</div>
</div>
)}
</div>
{/* 集計プレビュー */}
{(groups.length > 0 || unassignedFields.length > 0) && fertilizers.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4 mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-3"></h2>
<div className="overflow-x-auto">
<table className="min-w-full text-sm border-collapse">
<thead>
<tr>
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-medium text-gray-600 text-xs"></th>
{fertilizers.map(fert => (
<th key={fert.id} className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs">
{fert.name}
</th>
))}
<th className="border border-gray-200 bg-gray-50 px-3 py-2 text-right font-medium text-gray-600 text-xs"></th>
</tr>
</thead>
<tbody>
{groupSummaries.map(g => (
<tr key={g.tempId} className="hover:bg-green-50">
<td className="border border-gray-200 px-3 py-2 font-medium text-gray-800">{g.name}</td>
{fertilizers.map(fert => {
const t = g.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-700">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right font-medium text-gray-800">
{g.rowTotal.toFixed(2)}
</td>
</tr>
))}
{unassignedSummary.rowTotal > 0 && (
<tr className="bg-yellow-50">
<td className="border border-gray-200 px-3 py-2 text-gray-500 italic"></td>
{fertilizers.map(fert => {
const t = unassignedSummary.fertTotals.find(f => f.fertilizerId === fert.id)?.total || 0;
return (
<td key={fert.id} className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{t > 0 ? t.toFixed(2) : <span className="text-gray-300">-</span>}
</td>
);
})}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-500">
{unassignedSummary.rowTotal.toFixed(2)}
</td>
</tr>
)}
</tbody>
<tfoot>
<tr className="font-bold bg-gray-50">
<td className="border border-gray-200 px-3 py-2 text-gray-800"></td>
{fertColumnTotals.map(f => (
<td key={f.id} className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{f.total.toFixed(2)}
</td>
))}
<td className="border border-gray-200 px-3 py-2 text-right text-gray-800">
{grandTotal.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
</>
)}
{/* フッターボタン */}
<div className="flex justify-end gap-3">
<button
onClick={() => router.push('/distribution')}
className="px-4 py-2 text-sm border border-gray-300 rounded-md hover:bg-gray-100 text-gray-700"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-medium"
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</main>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import DistributionEditPage from '../_components/DistributionEditPage';
import DeliveryEditPage from '../_components/DeliveryEditPage';
export default function DistributionNewPage() {
return <DistributionEditPage />;
export default function DeliveryNewPage() {
return <DeliveryEditPage />;
}

View File

@@ -2,15 +2,15 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { FlaskConical, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
import { Truck, Plus, FileDown, Pencil, Trash2, X } from 'lucide-react';
import Navbar from '@/components/Navbar';
import { api } from '@/lib/api';
import { DistributionPlanListItem } from '@/types';
import { DeliveryPlanListItem } from '@/types';
const CURRENT_YEAR = new Date().getFullYear();
const YEAR_KEY = 'distributionYear';
export default function DistributionListPage() {
export default function DeliveryListPage() {
const router = useRouter();
const [year, setYear] = useState<number>(() => {
if (typeof window !== 'undefined') {
@@ -18,7 +18,7 @@ export default function DistributionListPage() {
}
return CURRENT_YEAR;
});
const [plans, setPlans] = useState<DistributionPlanListItem[]>([]);
const [plans, setPlans] = useState<DeliveryPlanListItem[]>([]);
const [loading, setLoading] = useState(true);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -32,7 +32,7 @@ export default function DistributionListPage() {
const fetchPlans = async () => {
setLoading(true);
try {
const res = await api.get(`/fertilizer/distribution/?year=${year}`);
const res = await api.get(`/fertilizer/delivery/?year=${year}`);
setPlans(res.data);
} catch (e) {
console.error(e);
@@ -44,7 +44,7 @@ export default function DistributionListPage() {
const handleDelete = async (id: number) => {
setDeleteError(null);
try {
await api.delete(`/fertilizer/distribution/${id}/`);
await api.delete(`/fertilizer/delivery/${id}/`);
setPlans(prev => prev.filter(p => p.id !== id));
} catch (e) {
console.error(e);
@@ -54,11 +54,11 @@ export default function DistributionListPage() {
const handlePdf = async (id: number, planName: string) => {
try {
const res = await api.get(`/fertilizer/distribution/${id}/pdf/`, { responseType: 'blob' });
const res = await api.get(`/fertilizer/delivery/${id}/pdf/`, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `distribution_${planName}.pdf`;
a.download = `delivery_${planName}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
@@ -72,8 +72,8 @@ export default function DistributionListPage() {
<main className="max-w-6xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<FlaskConical className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
<Truck className="h-7 w-7 text-green-700" />
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
<button
onClick={() => router.push('/distribution/new')}
@@ -109,9 +109,9 @@ export default function DistributionListPage() {
<p className="text-gray-500 text-sm">...</p>
) : plans.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<FlaskConical className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{year}</p>
<p className="text-sm mb-6"></p>
<Truck className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-lg font-medium mb-1">{year}</p>
<p className="text-sm mb-6"></p>
<button
onClick={() => router.push('/distribution/new')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm"
@@ -125,10 +125,8 @@ export default function DistributionListPage() {
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">/</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
@@ -136,10 +134,8 @@ export default function DistributionListPage() {
{plans.map(plan => (
<tr key={plan.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{plan.name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.fertilization_plan_name}</td>
<td className="px-4 py-3 text-sm text-gray-600">{plan.crop_name} / {plan.variety_name}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.group_count}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.field_count}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-right">{plan.trip_count}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button

View File

@@ -131,7 +131,7 @@ export default function Navbar() {
}`}
>
<FlaskConical className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/materials')}

View File

@@ -160,49 +160,64 @@ export interface FertilizationPlan {
updated_at: string;
}
export interface DistributionGroupField {
export interface DeliveryGroupField {
id: number;
name: string;
area_tan: string;
}
export interface DistributionGroup {
export interface DeliveryGroup {
id: number;
name: string;
order: number;
fields: DistributionGroupField[];
fields: DeliveryGroupField[];
}
export interface DistributionPlanFertilizationPlan {
export interface DeliveryTripItem {
id: number;
field: number;
field_name: string;
fertilizer: number;
fertilizer_name: string;
bags: string;
}
export interface DeliveryTrip {
id: number;
order: number;
name: string;
date: string | null;
items: DeliveryTripItem[];
}
export interface DeliveryAllEntry {
field: number;
field_name: string;
field_area_tan: string;
fertilizer: number;
fertilizer_name: string;
bags: string;
}
export interface DeliveryPlan {
id: number;
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[];
groups: DeliveryGroup[];
trips: DeliveryTrip[];
unassigned_fields: DeliveryGroupField[];
available_fertilizers: { id: number; name: string }[];
all_entries: DeliveryAllEntry[];
created_at: string;
updated_at: string;
}
export interface DistributionPlanListItem {
export interface DeliveryPlanListItem {
id: number;
name: string;
fertilization_plan_id: number;
fertilization_plan_name: string;
year: number;
variety_name: string;
crop_name: string;
name: string;
group_count: number;
field_count: number;
trip_count: number;
created_at: string;
updated_at: string;
}