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,12 +74,15 @@
"mcp__butler__inspect_runtime_config", "mcp__butler__inspect_runtime_config",
"mcp__butler__execute_task", "mcp__butler__execute_task",
"Bash(git -C /home/akira/develop/keinasystem remote -v)", "Bash(git -C /home/akira/develop/keinasystem remote -v)",
"Bash(cat butler/skills/read_from_gitea*)" "Bash(cat butler/skills/read_from_gitea*)",
"Bash(bash ~/.claude/scripts/gitea.sh GET /repos/akira/keinasystem/issues/11)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"C:\\Users\\akira\\AppData\\Local\\Temp", "C:\\Users\\akira\\AppData\\Local\\Temp",
"C:\\Users\\akira\\Develop\\keinasystem_t02", "C:\\Users\\akira\\Develop\\keinasystem_t02",
"/home/akira/develop" "/home/akira/develop",
"/home/akira/.docker",
"/tmp"
] ]
} }
} }

View File

@@ -74,6 +74,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
spread_status = serializers.SerializerMethodField() spread_status = serializers.SerializerMethodField()
is_confirmed = serializers.BooleanField(read_only=True) is_confirmed = serializers.BooleanField(read_only=True)
confirmed_at = serializers.DateTimeField(read_only=True) confirmed_at = serializers.DateTimeField(read_only=True)
is_variety_change_plan = serializers.SerializerMethodField()
class Meta: class Meta:
model = FertilizationPlan model = FertilizationPlan
@@ -94,6 +95,7 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
'spread_status', 'spread_status',
'is_confirmed', 'is_confirmed',
'confirmed_at', 'confirmed_at',
'is_variety_change_plan',
'created_at', 'created_at',
'updated_at', 'updated_at',
] ]
@@ -134,6 +136,9 @@ class FertilizationPlanSerializer(serializers.ModelSerializer):
return 'partial' return 'partial'
return 'completed' return 'completed'
def get_is_variety_change_plan(self, obj):
return obj.name.endswith('(品種変更移動)')
class FertilizationPlanWriteSerializer(serializers.ModelSerializer): class FertilizationPlanWriteSerializer(serializers.ModelSerializer):
entries = serializers.ListField(child=serializers.DictField(), write_only=True, required=False) 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 import transaction
from django.db.models import Sum 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.materials.models import StockTransaction
from apps.workrecords.services import sync_spreading_work_record from apps.workrecords.services import sync_spreading_work_record
from .models import FertilizationEntry, FertilizationPlan, SpreadingSessionItem 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): def sync_actual_bags_for_pairs(year, field_fertilizer_pairs):
pairs = { pairs = {
(int(field_id), int(fertilizer_id)) (int(field_id), int(fertilizer_id))
@@ -103,3 +113,84 @@ def move_fertilization_entries_for_variety_change(change):
moved_count += len(entries_to_move) moved_count += len(entries_to_move)
return moved_count 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, SpreadingSessionSerializer,
SpreadingSessionWriteSerializer, 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): class FertilizerViewSet(viewsets.ModelViewSet):
@@ -123,6 +128,55 @@ class FertilizationPlanViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"' response['Content-Disposition'] = f'attachment; filename="fertilization_{plan.year}_{plan.id}.pdf"'
return response 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): class CandidateFieldsView(APIView):
"""作付け計画から圃場候補を返す""" """作付け計画から圃場候補を返す"""
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { FileDown, NotebookText, Pencil, Plus, Sprout, Trash2, Truck } from 'lucide-react'; import { FileDown, GitMerge, NotebookText, Pencil, Plus, Sprout, Trash2, Truck, X } from 'lucide-react';
import Navbar from '@/components/Navbar'; import Navbar from '@/components/Navbar';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -36,6 +36,14 @@ export default function FertilizerPage() {
const [plans, setPlans] = useState<FertilizationPlan[]>([]); const [plans, setPlans] = useState<FertilizationPlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [mergeSourcePlan, setMergeSourcePlan] = useState<FertilizationPlan | null>(null);
const [mergeTargets, setMergeTargets] = useState<
{ id: number; name: string; field_count: number; planned_total_bags: string; is_confirmed: boolean }[]
>([]);
const [mergeTargetId, setMergeTargetId] = useState<number | ''>('');
const [mergeLoading, setMergeLoading] = useState(false);
const [mergeSubmitting, setMergeSubmitting] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
localStorage.setItem('fertilizerYear', String(year)); localStorage.setItem('fertilizerYear', String(year));
@@ -83,6 +91,68 @@ export default function FertilizerPage() {
} }
}; };
const openMergeDialog = async (plan: FertilizationPlan) => {
setMergeSourcePlan(plan);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(true);
try {
const res = await api.get(`/fertilizer/plans/${plan.id}/merge_targets/`);
setMergeTargets(res.data);
} catch (e) {
console.error(e);
setMergeError('マージ先候補の読み込みに失敗しました。');
} finally {
setMergeLoading(false);
}
};
const closeMergeDialog = () => {
if (mergeSubmitting) return;
setMergeSourcePlan(null);
setMergeTargets([]);
setMergeTargetId('');
setMergeError(null);
setMergeLoading(false);
};
const handleMerge = async () => {
if (!mergeSourcePlan || !mergeTargetId) {
setMergeError('マージ先の施肥計画を選択してください。');
return;
}
setMergeSubmitting(true);
setMergeError(null);
try {
await api.post(`/fertilizer/plans/${mergeSourcePlan.id}/merge_into/`, {
target_plan_id: mergeTargetId,
});
closeMergeDialog();
await fetchPlans();
} catch (e: unknown) {
const err = e as {
response?: {
data?: {
error?: string;
conflicts?: { field_name: string; fertilizer_name: string }[];
};
};
};
const conflicts = err.response?.data?.conflicts ?? [];
if (conflicts.length > 0) {
const details = conflicts
.map((conflict) => `${conflict.field_name} × ${conflict.fertilizer_name}`)
.join('、');
setMergeError(`${err.response?.data?.error ?? '競合があるためマージできません。'} ${details}`);
} else {
setMergeError(err.response?.data?.error ?? 'マージに失敗しました。');
}
} finally {
setMergeSubmitting(false);
}
};
const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i); const years = Array.from({ length: 5 }, (_, i) => currentYear + 1 - i);
return ( return (
@@ -208,6 +278,16 @@ export default function FertilizerPage() {
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</button> </button>
{plan.is_variety_change_plan && (
<button
onClick={() => openMergeDialog(plan)}
className="flex items-center gap-1 rounded border border-emerald-300 px-2.5 py-1.5 text-xs text-emerald-700 hover:bg-emerald-50"
title="既存計画へマージ"
>
<GitMerge className="h-3.5 w-3.5" />
</button>
)}
<button <button
onClick={() => handleDelete(plan.id, plan.name)} onClick={() => handleDelete(plan.id, plan.name)}
className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50" className="flex items-center gap-1 rounded border border-red-300 px-2.5 py-1.5 text-xs text-red-600 hover:bg-red-50"
@@ -225,6 +305,85 @@ export default function FertilizerPage() {
</div> </div>
)} )}
</div> </div>
{mergeSourcePlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-xl rounded-lg bg-white shadow-xl">
<div className="flex items-center justify-between border-b px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-gray-800"></h2>
<p className="mt-1 text-sm text-gray-500">{mergeSourcePlan.name}</p>
</div>
<button onClick={closeMergeDialog} className="text-gray-400 hover:text-gray-600">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-4 px-5 py-4">
<p className="text-sm text-gray-600">
×
</p>
{mergeError && (
<div className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-700">
{mergeError}
</div>
)}
{mergeLoading ? (
<p className="text-sm text-gray-500">...</p>
) : mergeTargets.length === 0 ? (
<p className="text-sm text-gray-500"></p>
) : (
<div className="space-y-2">
{mergeTargets.map((target) => (
<label
key={target.id}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 ${
target.is_confirmed ? 'border-gray-200 bg-gray-50 text-gray-400' : 'border-gray-300'
}`}
>
<input
type="radio"
name="merge-target"
value={target.id}
checked={mergeTargetId === target.id}
onChange={() => setMergeTargetId(target.id)}
disabled={target.is_confirmed}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="font-medium text-gray-800">{target.name}</div>
<div className="mt-1 text-xs text-gray-500">
{target.field_count} / {target.planned_total_bags}
{target.is_confirmed ? ' / 散布確定済みのため選択不可' : ''}
</div>
</div>
</label>
))}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
<button
onClick={closeMergeDialog}
disabled={mergeSubmitting}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-50"
>
</button>
<button
onClick={handleMerge}
disabled={mergeSubmitting || mergeLoading || !mergeTargetId}
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white hover:bg-emerald-700 disabled:opacity-50"
>
{mergeSubmitting ? 'マージ中...' : 'マージ実行'}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -176,6 +176,7 @@ export interface FertilizationPlan {
spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied'; spread_status: 'unspread' | 'partial' | 'completed' | 'over_applied';
is_confirmed: boolean; is_confirmed: boolean;
confirmed_at: string | null; confirmed_at: string | null;
is_variety_change_plan: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

File diff suppressed because one or more lines are too long