分配計画を運搬計画に再設計: 軽トラ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:
Akira
2026-03-16 16:29:01 +09:00
parent eba6267495
commit 1c27a66691
14 changed files with 1640 additions and 903 deletions

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