分配計画を運搬計画に再設計: 軽トラ1回分を基本単位とする運搬回モデルを導入
実運用のワークフロー(複数施肥計画混在・軽トラ複数回・肥料指定)に合わせ、 旧 DistributionPlan/Group/GroupField を DeliveryPlan/Group/GroupField/Trip/TripItem に置き換え。 施肥計画への直接FK廃止→年度ベースで全施肥計画を横断。 回ごとの日付記録、圃場の回間移動、対象肥料フィルタ、回ごとPDF出力に対応。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal file
127
backend/apps/fertilizer/migrations/0007_delivery_models.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
@@ -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}袋"
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user