施肥計画機能を追加(年度×品種単位のマトリクス管理)

- Backend: apps/fertilizer を新規追加
  - Fertilizer(肥料マスタ)、FertilizationPlan、FertilizationEntry モデル
  - 肥料マスタ・施肥計画 CRUD API
  - 3方式の自動計算API(反当袋数・均等配分・反当チッソ成分量)
  - 作付け計画から圃場候補を取得する API
  - WeasyPrint による PDF 出力(圃場×肥料=袋数 マトリクス表)
- Frontend: app/fertilizer を新規追加
  - 施肥計画一覧(年度セレクタ・PDF出力・編集・削除)
  - 肥料マスタ管理(インライン編集)
  - 施肥計画編集(品種選択→圃場自動取得→肥料追加→自動計算→マトリクス手動調整)
- Navbar に「施肥計画」メニューを追加(Sprout アイコン)
- Cursor ルールファイル・連携ガイドを削除(Claude Code 単独運用へ)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Akira
2026-03-01 12:14:29 +09:00
parent 371e40236c
commit f207f5de27
23 changed files with 1695 additions and 174 deletions

View File

@@ -0,0 +1,196 @@
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
from .serializers import (
FertilizerSerializer,
FertilizationPlanSerializer,
FertilizationPlanWriteSerializer,
)
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)