from decimal import Decimal, InvalidOperation from django.db.models import Sum 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.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 ( create_reserves_for_plan, delete_reserves_for_plan, ) from apps.plans.models import Plan from .models import ( Fertilizer, FertilizationPlan, FertilizationEntry, DeliveryPlan, DeliveryGroup, DeliveryGroupField, DeliveryTrip, DeliveryTripItem, SpreadingSession, SpreadingSessionItem, ) from .serializers import ( FertilizerSerializer, FertilizationPlanSerializer, FertilizationPlanWriteSerializer, DeliveryPlanListSerializer, DeliveryPlanReadSerializer, DeliveryPlanWriteSerializer, SpreadingSessionSerializer, SpreadingSessionWriteSerializer, ) from .services import sync_actual_bags_for_pairs 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): 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 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 class SpreadingSessionViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): queryset = SpreadingSession.objects.prefetch_related( 'items', 'items__field', 'items__fertilizer', ).select_related('work_record') year = self.request.query_params.get('year') if year: queryset = queryset.filter(year=year) return queryset def get_serializer_class(self): if self.action in ['create', 'update', 'partial_update']: return SpreadingSessionWriteSerializer return SpreadingSessionSerializer def perform_destroy(self, instance): from apps.materials.models import StockTransaction year = instance.year affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()} StockTransaction.objects.filter(spreading_item__session=instance).delete() instance.delete() sync_actual_bags_for_pairs(year, affected_pairs) class SpreadingCandidatesView(APIView): permission_classes = [IsAuthenticated] def get(self, request): year = request.query_params.get('year') session_id = request.query_params.get('session_id') delivery_plan_id = request.query_params.get('delivery_plan_id') plan_id = request.query_params.get('plan_id') if not year: return Response( {'detail': 'year が必要です。'}, status=status.HTTP_400_BAD_REQUEST, ) try: year = int(year) except (TypeError, ValueError): return Response( {'detail': 'year は数値で指定してください。'}, status=status.HTTP_400_BAD_REQUEST, ) if delivery_plan_id: try: delivery_plan_id = int(delivery_plan_id) except (TypeError, ValueError): return Response( {'detail': 'delivery_plan_id は数値で指定してください。'}, status=status.HTTP_400_BAD_REQUEST, ) if plan_id: try: plan_id = int(plan_id) except (TypeError, ValueError): return Response( {'detail': 'plan_id は数値で指定してください。'}, status=status.HTTP_400_BAD_REQUEST, ) current_session = None current_map = {} if session_id: try: current_session = SpreadingSession.objects.prefetch_related('items').get( pk=session_id, year=year, ) except SpreadingSession.DoesNotExist: return Response( {'detail': '散布実績が見つかりません。'}, status=status.HTTP_404_NOT_FOUND, ) current_map = { (item.field_id, item.fertilizer_id): { 'actual_bags': item.actual_bags, 'field_name': item.field.name, 'field_area_tan': str(item.field.area_tan), 'fertilizer_name': item.fertilizer.name, } for item in current_session.items.all() } candidates = {} plan_queryset = FertilizationEntry.objects.filter(plan__year=year) if plan_id: plan_queryset = plan_queryset.filter(plan_id=plan_id) plan_rows = ( plan_queryset .values( 'field_id', 'field__name', 'field__area_tan', 'fertilizer_id', 'fertilizer__name', ) .annotate(planned_bags=Sum('bags')) ) for row in plan_rows: key = (row['field_id'], row['fertilizer_id']) candidates.setdefault( key, { 'field': row['field_id'], 'field_name': row['field__name'], 'field_area_tan': str(row['field__area_tan']), 'fertilizer': row['fertilizer_id'], 'fertilizer_name': row['fertilizer__name'], 'planned_bags': Decimal('0'), 'delivered_bags': Decimal('0'), 'spread_bags': Decimal('0'), 'current_session_bags': Decimal('0'), }, )['planned_bags'] = row['planned_bags'] or Decimal('0') delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year) if delivery_plan_id: delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id) else: delivery_queryset = delivery_queryset.filter(trip__date__isnull=False) delivery_rows = delivery_queryset.values( 'field_id', 'field__name', 'field__area_tan', 'fertilizer_id', 'fertilizer__name', ).annotate(delivered_bags=Sum('bags')) for row in delivery_rows: key = (row['field_id'], row['fertilizer_id']) candidates.setdefault( key, { 'field': row['field_id'], 'field_name': row['field__name'], 'field_area_tan': str(row['field__area_tan']), 'fertilizer': row['fertilizer_id'], 'fertilizer_name': row['fertilizer__name'], 'planned_bags': Decimal('0'), 'delivered_bags': Decimal('0'), 'spread_bags': Decimal('0'), 'current_session_bags': Decimal('0'), }, )['delivered_bags'] = row['delivered_bags'] or Decimal('0') spread_queryset = SpreadingSessionItem.objects.filter(session__year=year) if current_session is not None: spread_queryset = spread_queryset.exclude(session=current_session) spread_rows = ( spread_queryset .values( 'field_id', 'field__name', 'field__area_tan', 'fertilizer_id', 'fertilizer__name', ) .annotate(spread_bags=Sum('actual_bags')) ) for row in spread_rows: key = (row['field_id'], row['fertilizer_id']) candidates.setdefault( key, { 'field': row['field_id'], 'field_name': row['field__name'], 'field_area_tan': str(row['field__area_tan']), 'fertilizer': row['fertilizer_id'], 'fertilizer_name': row['fertilizer__name'], 'planned_bags': Decimal('0'), 'delivered_bags': Decimal('0'), 'spread_bags': Decimal('0'), 'current_session_bags': Decimal('0'), }, )['spread_bags'] = row['spread_bags'] or Decimal('0') for key, current_data in current_map.items(): candidates.setdefault( key, { 'field': key[0], 'field_name': current_data['field_name'], 'field_area_tan': current_data['field_area_tan'], 'fertilizer': key[1], 'fertilizer_name': current_data['fertilizer_name'], 'planned_bags': Decimal('0'), 'delivered_bags': Decimal('0'), 'spread_bags': Decimal('0'), 'current_session_bags': Decimal('0'), }, )['current_session_bags'] = current_data['actual_bags'] or Decimal('0') rows = [] for candidate in candidates.values(): delivered = candidate['delivered_bags'] planned = candidate['planned_bags'] current_bags = candidate['current_session_bags'] if delivery_plan_id: include_row = delivered > 0 or current_bags > 0 elif plan_id: include_row = planned > 0 or current_bags > 0 else: include_row = delivered > 0 or current_bags > 0 if not include_row: continue remaining = delivered - candidate['spread_bags'] rows.append( { 'field': candidate['field'], 'field_name': candidate['field_name'], 'field_area_tan': candidate['field_area_tan'], 'fertilizer': candidate['fertilizer'], 'fertilizer_name': candidate['fertilizer_name'], 'planned_bags': str(planned), 'delivered_bags': str(delivered), 'spread_bags': str(candidate['spread_bags'] + current_bags), 'spread_bags_other': str(candidate['spread_bags']), 'current_session_bags': str(current_bags), 'remaining_bags': str(remaining), } ) rows.sort(key=lambda row: (row['field_name'], row['fertilizer_name'])) return Response(rows)