Add fertilization plan merge workflow

This commit is contained in:
akira
2026-04-06 16:49:44 +09:00
parent c675b7b7ae
commit c90c6210e1
8 changed files with 475 additions and 6 deletions

View File

@@ -74,6 +74,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
spread_status = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True)
is_variety_change_plan = serializers.SerializerMethodField()
class Meta:
model = FertilizationPlan
@@ -94,6 +95,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
'spread_status',
'is_confirmed',
'confirmed_at',
'is_variety_change_plan',
'created_at',
'updated_at',
]
@@ -134,6 +136,9 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
return 'partial'
return 'completed'
def get_is_variety_change_plan(self, obj):
return obj.name.endswith('(品種変更移動)')
class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False)

View File

@@ -3,12 +3,22 @@ from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from apps.materials.stock_service import create_reserves_for_plan
from apps.materials.stock_service import create_reserves_for_plan, delete_reserves_for_plan
from apps.materials.models import StockTransaction
from apps.workrecords.services import sync_spreading_work_record
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem
class FertilizationPlanMergeError(Exception):
pass
class FertilizationPlanMergeConflict(FertilizationPlanMergeError):
def __init__(self, conflicts):
super().__init__('merge conflict')
self.conflicts = conflicts
def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
pairs = {
(int(field_id), int(fertilizer_id))
@@ -103,3 +113,84 @@ def move_fertilization_entries_for_variety_change(change):
moved_count += len(entries_to_move)
return moved_count
@transaction.atomic
def merge_fertilization_plan_into(source_plan, target_plan):
if source_plan.id == target_plan.id:
raise FertilizationPlanMergeError('同じ施肥計画にはマージできません。')
if source_plan.year != target_plan.year:
raise FertilizationPlanMergeError('年度が異なる施肥計画にはマージできません。')
if source_plan.variety_id != target_plan.variety_id:
raise FertilizationPlanMergeError('品種が異なる施肥計画にはマージできません。')
if source_plan.is_confirmed or target_plan.is_confirmed:
raise FertilizationPlanMergeError('散布確定済みの施肥計画はマージできません。')
source_entries = list(
source_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
if not source_entries:
raise FertilizationPlanMergeError('移動元の施肥計画にマージ対象の entry がありません。')
source_pairs = {(entry.field_id, entry.fertilizer_id) for entry in source_entries}
target_entries = list(
target_plan.entries.select_related('field', 'fertilizer').order_by('field_id', 'fertilizer_id')
)
target_pairs = {(entry.field_id, entry.fertilizer_id): entry for entry in target_entries}
conflicts = [
{
'field_id': entry.field_id,
'field_name': entry.field.name,
'fertilizer_id': entry.fertilizer_id,
'fertilizer_name': entry.fertilizer.name,
}
for entry in source_entries
if (entry.field_id, entry.fertilizer_id) in target_pairs
]
if conflicts:
raise FertilizationPlanMergeConflict(conflicts)
FertilizationEntry.objects.filter(
id__in=[entry.id for entry in source_entries]
).update(plan=target_plan)
target_plan.calc_settings = _merge_calc_settings(
target_plan.calc_settings,
source_plan.calc_settings,
)
target_plan.save()
create_reserves_for_plan(target_plan)
moved_count = len(source_entries)
deleted_source_plan = False
if not FertilizationEntry.objects.filter(plan=source_plan).exists():
delete_reserves_for_plan(source_plan)
source_plan.delete()
deleted_source_plan = True
else:
create_reserves_for_plan(source_plan)
return {
'moved_entry_count': moved_count,
'deleted_source_plan': deleted_source_plan,
}
def _merge_calc_settings(target_settings, source_settings):
merged = list(target_settings or [])
existing_fertilizer_ids = {
setting.get('fertilizer_id')
for setting in merged
if isinstance(setting, dict)
}
for setting in source_settings or []:
if not isinstance(setting, dict):
continue
fertilizer_id = setting.get('fertilizer_id')
if fertilizer_id in existing_fertilizer_ids:
continue
merged.append(setting)
existing_fertilizer_ids.add(fertilizer_id)
return merged

View File

@@ -0,0 +1,156 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIClient
from apps.fields.models import Field
from apps.materials.models import Material, StockTransaction
from apps.materials.stock_service import create_reserves_for_plan
from apps.plans.models import Crop, Variety
from .models import FertilizationEntry, FertilizationPlan, Fertilizer
class FertilizationPlanMergeTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = get_user_model().objects.create_user(
username='merge-user',
password='secret12345',
)
self.client.force_authenticate(user=self.user)
crop = Crop.objects.create(name='水稲')
self.variety = Variety.objects.create(crop=crop, name='たちはるか特栽')
self.field_a = Field.objects.create(
name='足川北上',
address='高知県高岡郡',
area_tan='1.2000',
area_m2=1200,
owner_name='吉田',
group_name='',
display_order=1,
)
self.field_b = Field.objects.create(
name='足川南',
address='高知県高岡郡',
area_tan='0.8000',
area_m2=800,
owner_name='吉田',
group_name='',
display_order=2,
)
material_a = Material.objects.create(
name='高度化成14号',
material_type=Material.MaterialType.FERTILIZER,
)
material_b = Material.objects.create(
name='分げつ一発',
material_type=Material.MaterialType.FERTILIZER,
)
self.fertilizer_a = Fertilizer.objects.create(name='高度化成14号', material=material_a)
self.fertilizer_b = Fertilizer.objects.create(name='分げつ一発', material=material_b)
def test_merge_into_moves_entries_and_deletes_empty_source_plan(self):
target_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 元肥',
year=2026,
variety=self.variety,
calc_settings=[{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'}],
)
source_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
year=2026,
variety=self.variety,
calc_settings=[{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'}],
)
target_entry = FertilizationEntry.objects.create(
plan=target_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='3.00',
actual_bags='1.0000',
)
source_entry = FertilizationEntry.objects.create(
plan=source_plan,
field=self.field_b,
fertilizer=self.fertilizer_b,
bags='2.00',
actual_bags='2.0000',
)
create_reserves_for_plan(target_plan)
create_reserves_for_plan(source_plan)
response = self.client.post(
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
{'target_plan_id': target_plan.id},
format='json',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['moved_entry_count'], 1)
self.assertTrue(response.data['deleted_source_plan'])
source_entry.refresh_from_db()
self.assertEqual(source_entry.plan_id, target_plan.id)
self.assertFalse(FertilizationPlan.objects.filter(id=source_plan.id).exists())
target_plan.refresh_from_db()
self.assertEqual(
target_plan.calc_settings,
[
{'fertilizer_id': self.fertilizer_a.id, 'method': 'per_tan', 'param': '1.2'},
{'fertilizer_id': self.fertilizer_b.id, 'method': 'per_tan', 'param': '0.8'},
],
)
reserves = list(
StockTransaction.objects.filter(
fertilization_plan=target_plan,
transaction_type=StockTransaction.TransactionType.RESERVE,
).order_by('material__name')
)
self.assertEqual(len(reserves), 2)
self.assertEqual(
{(reserve.material_id, reserve.quantity) for reserve in reserves},
{
(self.fertilizer_a.material_id, Decimal(str(target_entry.bags))),
(self.fertilizer_b.material_id, Decimal(str(source_entry.bags))),
},
)
def test_merge_into_stops_on_field_fertilizer_conflict(self):
target_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 元肥',
year=2026,
variety=self.variety,
)
source_plan = FertilizationPlan.objects.create(
name='2026年度 たちはるか特栽 施肥計画(品種変更移動)',
year=2026,
variety=self.variety,
)
FertilizationEntry.objects.create(
plan=target_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='3.00',
)
source_entry = FertilizationEntry.objects.create(
plan=source_plan,
field=self.field_a,
fertilizer=self.fertilizer_a,
bags='2.00',
)
response = self.client.post(
f'/api/fertilizer/plans/{source_plan.id}/merge_into/',
{'target_plan_id': target_plan.id},
format='json',
)
self.assertEqual(response.status_code, 409)
self.assertEqual(len(response.data['conflicts']), 1)
source_entry.refresh_from_db()
self.assertEqual(source_entry.plan_id, source_plan.id)
self.assertTrue(FertilizationPlan.objects.filter(id=source_plan.id).exists())

View File

@@ -31,7 +31,12 @@ from .serializers import (
SpreadingSessionSerializer,
SpreadingSessionWriteSerializer,
)
from .services import sync_actual_bags_for_pairs
from .services import (
FertilizationPlanMergeConflict,
FertilizationPlanMergeError,
merge_fertilization_plan_into,
sync_actual_bags_for_pairs,
)
class FertilizerViewSet(viewsets.ModelViewSet):
@@ -123,6 +128,55 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response
@action(detail=True, methods=['get'])
def merge_targets(self, request, pk=None):
source_plan = self.get_object()
targets = (
FertilizationPlan.objects
.filter(year=source_plan.year, variety_id=source_plan.variety_id)
.exclude(id=source_plan.id)
.prefetch_related('entries')
.order_by('-updated_at', 'id')
)
data = [
{
'id': plan.id,
'name': plan.name,
'field_count': plan.entries.values('field').distinct().count(),
'planned_total_bags': str(sum((entry.bags or Decimal('0')) for entry in plan.entries.all())),
'is_confirmed': plan.is_confirmed,
}
for plan in targets
]
return Response(data)
@action(detail=True, methods=['post'])
def merge_into(self, request, pk=None):
source_plan = self.get_object()
target_plan_id = request.data.get('target_plan_id')
if not target_plan_id:
return Response({'error': 'target_plan_id が必要です。'}, status=status.HTTP_400_BAD_REQUEST)
try:
target_plan = FertilizationPlan.objects.get(id=target_plan_id)
except FertilizationPlan.DoesNotExist:
return Response({'error': 'マージ先の施肥計画が見つかりません。'}, status=status.HTTP_404_NOT_FOUND)
try:
result = merge_fertilization_plan_into(source_plan, target_plan)
except FertilizationPlanMergeConflict as exc:
return Response(
{
'error': '競合する圃場・肥料があるためマージできません。',
'conflicts': exc.conflicts,
},
status=status.HTTP_409_CONFLICT,
)
except FertilizationPlanMergeError as exc:
return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(result)
class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す"""
permission_classes = [IsAuthenticated]