Files
keinasystem/backend/apps/fertilizer/views.py
Akira f207f5de27 施肥計画機能を追加(年度×品種単位のマトリクス管理)
- 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>
2026-03-01 12:14:29 +09:00

197 lines
7.6 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.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)