Add fertilization plan merge workflow
This commit is contained in:
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
156
backend/apps/fertilizer/tests.py
Normal file
156
backend/apps/fertilizer/tests.py
Normal 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())
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user