施肥計画の圃場を配置場所単位でグループ化し、グループ×肥料の集計表を 表示・PDF出力できる機能を追加。 - Backend: DistributionPlan/Group/GroupField モデル (migration 0003) - API: GET/POST/PUT/DELETE/PDF (/api/fertilizer/distribution/) - Frontend: 一覧・新規作成・編集画面 (/distribution) - Navbar に分配計画メニューを追加 - 集計プレビューはクライアントサイド計算(API不要) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
325 lines
13 KiB
Python
325 lines
13 KiB
Python
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.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.plans.models import Plan, Variety
|
||
from .models import Fertilizer, FertilizationPlan, DistributionPlan
|
||
from .serializers import (
|
||
FertilizerSerializer,
|
||
FertilizationPlanSerializer,
|
||
FertilizationPlanWriteSerializer,
|
||
DistributionPlanListSerializer,
|
||
DistributionPlanReadSerializer,
|
||
DistributionPlanWriteSerializer,
|
||
)
|
||
|
||
|
||
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'
|
||
)
|
||
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
|
||
|
||
@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 DistributionPlanViewSet(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(
|
||
'groups', 'groups__field_assignments', 'groups__field_assignments__field',
|
||
'fertilization_plan__entries', 'fertilization_plan__entries__field',
|
||
'fertilization_plan__entries__fertilizer',
|
||
'distributiongroupfield_set',
|
||
)
|
||
year = self.request.query_params.get('year')
|
||
if year:
|
||
qs = qs.filter(fertilization_plan__year=year)
|
||
return qs
|
||
|
||
def get_serializer_class(self):
|
||
if self.action in ['create', 'update', 'partial_update']:
|
||
return DistributionPlanWriteSerializer
|
||
if self.action == 'list':
|
||
return DistributionPlanListSerializer
|
||
return DistributionPlanReadSerializer
|
||
|
||
@action(detail=True, methods=['get'])
|
||
def pdf(self, request, pk=None):
|
||
dist_plan = self.get_object()
|
||
fert_plan = dist_plan.fertilization_plan
|
||
|
||
# 施肥計画の肥料一覧(名前順)
|
||
fert_ids = fert_plan.entries.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
|
||
|
||
# グループ行の構築
|
||
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')
|
||
]
|
||
# グループ合計(肥料ごと)
|
||
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,
|
||
})
|
||
|
||
# 未割り当て圃場
|
||
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),
|
||
}
|
||
html_string = render_to_string('fertilizer/distribution_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"'
|
||
)
|
||
return response
|