完璧に動作しています。

テスト	結果
確定取消 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):
"""作付け計画から圃場候補を返す"""

View File

@@ -0,0 +1,25 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('materials', '0001_initial'),
('fertilizer', '0005_fertilizer_material'),
]
operations = [
migrations.AddField(
model_name='stocktransaction',
name='fertilization_plan',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='stock_reservations',
to='fertilizer.fertilizationplan',
verbose_name='施肥計画',
),
),
]

View File

@@ -162,6 +162,7 @@ class StockTransaction(models.Model):
class TransactionType(models.TextChoices):
PURCHASE = 'purchase', '入庫'
USE = 'use', '使用'
RESERVE = 'reserve', '引当'
ADJUSTMENT_PLUS = 'adjustment_plus', '棚卸増'
ADJUSTMENT_MINUS = 'adjustment_minus', '棚卸減'
DISCARD = 'discard', '廃棄'
@@ -172,6 +173,7 @@ class StockTransaction(models.Model):
}
DECREASE_TYPES = {
TransactionType.USE,
TransactionType.RESERVE,
TransactionType.ADJUSTMENT_MINUS,
TransactionType.DISCARD,
}
@@ -195,6 +197,14 @@ class StockTransaction(models.Model):
)
occurred_on = models.DateField(verbose_name='発生日')
note = models.TextField(blank=True, default='', verbose_name='備考')
fertilization_plan = models.ForeignKey(
'fertilizer.FertilizationPlan',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='stock_reservations',
verbose_name='施肥計画',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:

View File

@@ -194,6 +194,7 @@ class StockTransactionSerializer(serializers.ModelSerializer):
'stock_unit_display',
'occurred_on',
'note',
'fertilization_plan',
'created_at',
]
read_only_fields = ['created_at']
@@ -209,4 +210,6 @@ class StockSummarySerializer(serializers.Serializer):
stock_unit_display = serializers.CharField()
is_active = serializers.BooleanField()
current_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
reserved_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
available_stock = serializers.DecimalField(max_digits=10, decimal_places=3)
last_transaction_date = serializers.DateField(allow_null=True)

View File

@@ -0,0 +1,100 @@
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.utils import timezone
from .models import StockTransaction
@transaction.atomic
def create_reserves_for_plan(plan):
"""施肥計画の引当を全置換で作り直す。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
if plan.is_confirmed:
return
occurred_on = (
plan.updated_at.date() if getattr(plan, 'updated_at', None) else timezone.localdate()
)
for entry in plan.entries.select_related('fertilizer__material'):
material = getattr(entry.fertilizer, 'material', None)
if material is None:
continue
StockTransaction.objects.create(
material=material,
transaction_type=StockTransaction.TransactionType.RESERVE,
quantity=entry.bags,
occurred_on=occurred_on,
note=f'施肥計画「{plan.name}」からの引当',
fertilization_plan=plan,
)
@transaction.atomic
def delete_reserves_for_plan(plan):
"""施肥計画に紐づく引当のみ削除する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).delete()
@transaction.atomic
def confirm_spreading(plan, actual_entries):
"""引当を使用実績へ変換して施肥計画を確定済みにする。"""
from apps.fertilizer.models import Fertilizer
delete_reserves_for_plan(plan)
for entry_data in actual_entries:
actual_bags = _to_decimal(entry_data.get('actual_bags'))
if actual_bags <= 0:
continue
fertilizer = (
Fertilizer.objects.select_related('material')
.filter(id=entry_data['fertilizer_id'])
.first()
)
if fertilizer is None or fertilizer.material is None:
continue
StockTransaction.objects.create(
material=fertilizer.material,
transaction_type=StockTransaction.TransactionType.USE,
quantity=actual_bags,
occurred_on=timezone.localdate(),
note=f'施肥計画「{plan.name}」散布確定',
fertilization_plan=plan,
)
plan.is_confirmed = True
plan.confirmed_at = timezone.now()
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
@transaction.atomic
def unconfirm_spreading(plan):
"""散布確定を取り消し、USE トランザクションを削除して引当を再作成する。"""
StockTransaction.objects.filter(
fertilization_plan=plan,
transaction_type=StockTransaction.TransactionType.USE,
).delete()
plan.is_confirmed = False
plan.confirmed_at = None
plan.save(update_fields=['is_confirmed', 'confirmed_at'])
create_reserves_for_plan(plan)
def _to_decimal(value):
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return Decimal('0')

View File

@@ -14,4 +14,5 @@ router.register(
urlpatterns = [
path('', include(router.urls)),
path('stock-summary/', views.StockSummaryView.as_view(), name='stock-summary'),
path('fertilizer-stock/', views.FertilizerStockView.as_view(), name='fertilizer-stock'),
]

View File

@@ -103,32 +103,62 @@ class StockSummaryView(generics.ListAPIView):
results = []
for material in queryset:
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
last_date = max((txn.occurred_on for txn in transactions), default=None)
results.append(
{
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': increase - decrease if transactions else Decimal('0'),
'last_transaction_date': last_date,
}
)
results.append(_build_stock_summary(material))
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
class FertilizerStockView(generics.ListAPIView):
"""施肥計画画面用: 肥料の在庫情報を返す"""
permission_classes = [IsAuthenticated]
serializer_class = StockSummarySerializer
def get_queryset(self):
return Material.objects.none()
def list(self, request, *args, **kwargs):
queryset = Material.objects.filter(
material_type=Material.MaterialType.FERTILIZER,
is_active=True,
).prefetch_related('stock_transactions').order_by('name')
results = [_build_stock_summary(material) for material in queryset]
serializer = self.get_serializer(results, many=True)
return Response(serializer.data)
def _build_stock_summary(material):
transactions = list(material.stock_transactions.all())
increase = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.INCREASE_TYPES
)
decrease = sum(
txn.quantity
for txn in transactions
if txn.transaction_type in StockTransaction.DECREASE_TYPES
)
reserved = sum(
txn.quantity
for txn in transactions
if txn.transaction_type == StockTransaction.TransactionType.RESERVE
)
available = increase - decrease if transactions else Decimal('0')
last_date = max((txn.occurred_on for txn in transactions), default=None)
return {
'material_id': material.id,
'name': material.name,
'material_type': material.material_type,
'material_type_display': material.get_material_type_display(),
'maker': material.maker,
'stock_unit': material.stock_unit,
'stock_unit_display': material.get_stock_unit_display(),
'is_active': material.is_active,
'current_stock': available + reserved,
'reserved_stock': reserved,
'available_stock': available,
'last_transaction_date': last_date,
}