完璧に動作しています。
テスト 結果 確定取消 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:
@@ -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='散布確定日時'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
"""作付け計画から圃場候補を返す"""
|
||||
|
||||
@@ -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='施肥計画',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
100
backend/apps/materials/stock_service.py
Normal file
100
backend/apps/materials/stock_service.py
Normal 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')
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user