Files
keinasystem/backend/apps/fertilizer/views.py
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

424 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from decimal import Decimal, InvalidOperation
from django.http import HttpResponse
from django.template.loader import render_to_string
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from weasyprint import HTML
from apps.fields.models import Field
from apps.materials.stock_service import (
confirm_spreading as confirm_spreading_service,
create_reserves_for_plan,
delete_reserves_for_plan,
unconfirm_spreading,
)
from apps.plans.models import Plan, Variety
from .models import (
Fertilizer, FertilizationPlan, FertilizationEntry,
DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem,
)
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
DeliveryPlanListSerializer,
DeliveryPlanReadSerializer,
DeliveryPlanWriteSerializer,
)
class FertilizerViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
queryset = Fertilizer.objects.all()
serializer_class = FertilizerSerializer
class FertilizationPlanViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return FertilizationPlanWriteSerializer
return FertilizationPlanSerializer
def perform_create(self, serializer):
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_update(self, serializer):
if serializer.instance.is_confirmed:
raise ValidationError({'detail': '確定済みの施肥計画は編集できません。'})
instance = serializer.save()
create_reserves_for_plan(instance)
def perform_destroy(self, instance):
delete_reserves_for_plan(instance)
instance.delete()
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
plan = self.get_object()
entries = plan.entries.select_related('field', 'fertilizer').order_by(
'field__display_order', 'field__id', 'fertilizer__name'
)
# 圃場・肥料の一覧を整理
fields_map = {}
fertilizers_map = {}
for entry in entries:
fields_map[entry.field_id] = entry.field
fertilizers_map[entry.fertilizer_id] = entry.fertilizer
fields = sorted(fields_map.values(), key=lambda f: (f.display_order, f.id))
fertilizers = sorted(fertilizers_map.values(), key=lambda f: f.name)
# マトリクスデータ生成
matrix = {}
for entry in entries:
matrix[(entry.field_id, entry.fertilizer_id)] = entry.bags
rows = []
for field in fields:
cells = [matrix.get((field.id, fert.id), '') for fert in fertilizers]
total = sum(v for v in cells if v != '')
rows.append({
'field': field,
'cells': cells,
'total': total,
})
# 肥料ごとの合計
fert_totals = []
for fert in fertilizers:
total = sum(
matrix.get((field.id, fert.id), Decimal('0'))
for field in fields
)
fert_totals.append(total)
context = {
'plan': plan,
'fertilizers': fertilizers,
'rows': rows,
'fert_totals': fert_totals,
'grand_total': sum(fert_totals),
}
html_string = render_to_string('fertilizer/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="fertilization_{plan.year}_{plan.id}.pdf"'
return response
@action(detail=True, methods=['post'], url_path='confirm_spreading')
def confirm_spreading(self, request, pk=None):
plan = self.get_object()
if plan.is_confirmed:
return Response(
{'detail': 'この計画は既に散布確定済みです。'},
status=status.HTTP_400_BAD_REQUEST,
)
entries_data = request.data.get('entries', [])
if not entries_data:
return Response(
{'detail': '実績データが空です。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries = []
for entry in entries_data:
field_id = entry.get('field_id')
fertilizer_id = entry.get('fertilizer_id')
if not field_id or not fertilizer_id:
return Response(
{'detail': 'field_id と fertilizer_id が必要です。'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
actual_bags = Decimal(str(entry.get('actual_bags', 0)))
except InvalidOperation:
return Response(
{'detail': 'actual_bags は数値で指定してください。'},
status=status.HTTP_400_BAD_REQUEST,
)
actual_entries.append(
{
'field_id': field_id,
'fertilizer_id': fertilizer_id,
'actual_bags': actual_bags,
}
)
confirm_spreading_service(plan, actual_entries)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
@action(detail=True, methods=['post'], url_path='unconfirm')
def unconfirm(self, request, pk=None):
plan = self.get_object()
if not plan.is_confirmed:
return Response(
{'detail': 'この計画はまだ確定されていません。'},
status=status.HTTP_400_BAD_REQUEST,
)
unconfirm_spreading(plan)
plan.refresh_from_db()
serializer = self.get_serializer(plan)
return Response(serializer.data)
class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す"""
permission_classes = [IsAuthenticated]
def get(self, request):
year = request.query_params.get('year')
variety_id = request.query_params.get('variety_id')
if not year or not variety_id:
return Response({'error': 'year と variety_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
field_ids = Plan.objects.filter(
year=year, variety_id=variety_id
).values_list('field_id', flat=True)
fields = Field.objects.filter(id__in=field_ids).order_by('display_order', 'id')
data = [
{
'id': f.id,
'name': f.name,
'area_tan': str(f.area_tan),
'area_m2': f.area_m2,
'group_name': f.group_name,
}
for f in fields
]
return Response(data)
class CalculateView(APIView):
"""自動計算(保存しない)"""
permission_classes = [IsAuthenticated]
def post(self, request):
method = request.data.get('method') # 'nitrogen' | 'even' | 'per_tan'
param = request.data.get('param') # 数値パラメータ
fertilizer_id = request.data.get('fertilizer_id')
field_ids = request.data.get('field_ids', [])
if not method or param is None or not field_ids:
return Response({'error': 'method, param, field_ids が必要です'}, status=status.HTTP_400_BAD_REQUEST)
try:
param = Decimal(str(param))
except InvalidOperation:
return Response({'error': 'param は数値で指定してください'}, status=status.HTTP_400_BAD_REQUEST)
fields = Field.objects.filter(id__in=field_ids)
if not fields.exists():
return Response({'error': '圃場が見つかりません'}, status=status.HTTP_400_BAD_REQUEST)
results = []
if method == 'per_tan':
# 反当袋数配分: S = Sa × A
for field in fields:
area = Decimal(str(field.area_tan))
bags = (param * area).quantize(Decimal('0.01'))
results.append({'field_id': field.id, 'bags': float(bags)})
elif method == 'even':
# 在庫/指定数量均等配分: S = (SS / Sum(A)) × A
total_area = sum(Decimal(str(f.area_tan)) for f in fields)
if total_area == 0:
return Response({'error': '圃場の面積が0です'}, status=status.HTTP_400_BAD_REQUEST)
for field in fields:
area = Decimal(str(field.area_tan))
bags = (param * area / total_area).quantize(Decimal('0.01'))
results.append({'field_id': field.id, 'bags': float(bags)})
elif method == 'nitrogen':
# 反当チッソ成分量配分: S = (Nr / (C × Nd/100)) × A
if not fertilizer_id:
return Response({'error': 'nitrogen 方式には fertilizer_id が必要です'}, status=status.HTTP_400_BAD_REQUEST)
try:
fertilizer = Fertilizer.objects.get(id=fertilizer_id)
except Fertilizer.DoesNotExist:
return Response({'error': '肥料が見つかりません'}, status=status.HTTP_404_NOT_FOUND)
if not fertilizer.capacity_kg or not fertilizer.nitrogen_pct:
return Response(
{'error': 'この肥料には1袋重量(kg)と窒素含有率(%)の登録が必要です'},
status=status.HTTP_400_BAD_REQUEST
)
c = Decimal(str(fertilizer.capacity_kg))
nd = Decimal(str(fertilizer.nitrogen_pct))
# 1袋あたりの窒素量 (kg)
nc = c * nd / Decimal('100')
if nc == 0:
return Response({'error': '窒素含有量が0のため計算できません'}, status=status.HTTP_400_BAD_REQUEST)
for field in fields:
area = Decimal(str(field.area_tan))
bags = (param / nc * area).quantize(Decimal('0.01'))
results.append({'field_id': field.id, 'bags': float(bags)})
else:
return Response({'error': 'method は nitrogen / even / per_tan のいずれかです'}, status=status.HTTP_400_BAD_REQUEST)
return Response(results)
class DeliveryPlanViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
qs = DeliveryPlan.objects.prefetch_related(
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
'trips', 'trips__items', 'trips__items__field', 'trips__items__fertilizer',
)
year = self.request.query_params.get('year')
if year:
qs = qs.filter(year=year)
return qs
def get_serializer_class(self):
if self.action in ['create', 'update', 'partial_update']:
return DeliveryPlanWriteSerializer
if self.action == 'list':
return DeliveryPlanListSerializer
return DeliveryPlanReadSerializer
@action(detail=True, methods=['get'])
def pdf(self, request, pk=None):
plan = self.get_object()
# 全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
)
# グループ情報: 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
# 回ごとにページを構築
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))
]
trip_pages.append({
'trip': trip,
'fertilizers': trip_fertilizers,
'group_rows': group_rows,
'fert_totals': fert_totals,
})
context = {
'plan': plan,
'trip_pages': trip_pages,
}
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="delivery_{plan.year}_{plan.id}.pdf"'
)
return response