完璧に動作しています。

テスト	結果
確定取消 API	 is_confirmed: false, confirmed_at: null
USE トランザクション削除	 current_stock が 27.5→32 に復帰
引当再作成	 reserved_stock = 5.000 に復帰
追加した変更:

stock_service.py:81-93 — unconfirm_spreading(): USE削除→確定フラグリセット→引当再作成
fertilizer/views.py — unconfirm アクション(POST /api/fertilizer/plans/{id}/unconfirm/)
fertilizer/page.tsx — 一覧に「確定取消」ボタン(確定済み計画のみ表示)
FertilizerEditPage.tsx — 編集画面ヘッダーに「確定取消」ボタン + 在庫情報再取得
This commit is contained in:
Akira
2026-03-15 13:28:02 +09:00
parent 42b11a5df8
commit 72b4d670fe
18 changed files with 807 additions and 60 deletions

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='fertilizationplan',
name='is_confirmed',
field=models.BooleanField(default=False, verbose_name='散布確定済み'),
),
migrations.AddField(
model_name='fertilizationplan',
name='confirmed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='散布確定日時'),
),
]

View File

@@ -43,6 +43,8 @@ class FertilizationPlan(models.Model):
related_name='fertilization_plans', verbose_name='品種'
)
calc_settings = models.JSONField(default=list, blank=True, verbose_name='計算設定')
is_confirmed = models.BooleanField(default=False, verbose_name='散布確定済み')
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='散布確定日時')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -3,9 +3,25 @@ from .models import Fertilizer, FertilizationPlan, FertilizationEntry, Distribut
class FertilizerSerializer(serializers.ModelSerializer):
material_id = serializers.SerializerMethodField()
class Meta:
model = Fertilizer
fields = '__all__'
fields = [
'id',
'name',
'maker',
'capacity_kg',
'nitrogen_pct',
'phosphorus_pct',
'potassium_pct',
'notes',
'material',
'material_id',
]
def get_material_id(self, obj):
return obj.material_id
class FertilizationEntrySerializer(serializers.ModelSerializer):
@@ -26,12 +42,15 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
entries = FertilizationEntrySerializer(many=True, read_only=True)
field_count = serializers.SerializerMethodField()
fertilizer_count = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
class Meta:
model = FertilizationPlan
fields = [
'id', 'name', 'year', 'variety', 'variety_name', 'crop_name',
'calc_settings', 'entries', 'field_count', 'fertilizer_count', 'created_at', 'updated_at'
'calc_settings', 'entries', 'field_count', 'fertilizer_count',
'is_confirmed', 'confirmed_at', 'created_at', 'updated_at'
]
def get_variety_name(self, obj):

View File

@@ -4,12 +4,19 @@ 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, DistributionPlan
from .serializers import (
@@ -33,7 +40,7 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = FertilizationPlan.objects.select_related('variety', 'variety__crop').prefetch_related(
'entries', 'entries__field', 'entries__fertilizer'
'entries', 'entries__field', 'entries__fertilizer', 'entries__fertilizer__material'
)
year = self.request.query_params.get('year')
if year:
@@ -45,6 +52,20 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
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()
@@ -99,6 +120,67 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
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):
"""作付け計画から圃場候補を返す"""