From 9f96d1f82014b40fc47bf783db18c1d6c08b8291 Mon Sep 17 00:00:00 2001 From: Akira Date: Tue, 17 Mar 2026 19:56:13 +0900 Subject: [PATCH] =?UTF-8?q?=E6=95=A3=E5=B8=83=E5=AE=9F=E7=B8=BE=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E4=BF=AE=E6=AD=A3:=20=E3=83=90?= =?UTF-8?q?=E3=82=B0=E4=BF=AE=E6=AD=A3=E3=83=BB=E4=BB=95=E6=A7=98=E9=81=A9?= =?UTF-8?q?=E5=90=88=E3=83=BB=E3=83=87=E3=83=83=E3=83=89=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 候補API: 運搬済みフィルタ(date IS NOT NULL)を追加。 delivery_plan_id指定時は全明細表示、年度全体時のみ日付フィルタ適用 - StockTransaction.spreading_item: CASCADE→SET_NULL に修正(仕様7.3準拠) - perform_destroy: SET_NULL対応でUSEを明示削除してからsession削除 - ConfirmSpreadingModal.tsx: 未使用のため削除 - FertilizerEditPage.tsx: 旧散布確定関連デッドコード全除去 (isConfirmed/confirmedAt state, handleUnconfirm, 確定取消ボタン, 確定済みバナー) - services.py: 未使用のto_decimal_or_zero削除 Co-Authored-By: Claude Opus 4.6 --- backend/apps/fertilizer/services.py | 7 - backend/apps/fertilizer/views.py | 4 + .../0004_fix_spreading_item_on_delete.py | 20 ++ backend/apps/materials/models.py | 2 +- .../_components/ConfirmSpreadingModal.tsx | 308 ------------------ .../_components/FertilizerEditPage.tsx | 105 ++---- frontend/tsconfig.tsbuildinfo | 2 +- 7 files changed, 52 insertions(+), 396 deletions(-) create mode 100644 backend/apps/materials/migrations/0004_fix_spreading_item_on_delete.py delete mode 100644 frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx diff --git a/backend/apps/fertilizer/services.py b/backend/apps/fertilizer/services.py index 7c0a3da..0b125d8 100644 --- a/backend/apps/fertilizer/services.py +++ b/backend/apps/fertilizer/services.py @@ -56,10 +56,3 @@ def sync_stock_uses_for_spreading_session(session): fertilization_plan=None, spreading_item=item, ) - - -def to_decimal_or_zero(value): - try: - return Decimal(str(value)) - except Exception: - return Decimal('0') diff --git a/backend/apps/fertilizer/views.py b/backend/apps/fertilizer/views.py index 761166f..596b893 100644 --- a/backend/apps/fertilizer/views.py +++ b/backend/apps/fertilizer/views.py @@ -381,8 +381,10 @@ class SpreadingSessionViewSet(viewsets.ModelViewSet): return SpreadingSessionSerializer def perform_destroy(self, instance): + from apps.materials.models import StockTransaction year = instance.year affected_pairs = {(item.field_id, item.fertilizer_id) for item in instance.items.all()} + StockTransaction.objects.filter(spreading_item__session=instance).delete() instance.delete() sync_actual_bags_for_pairs(year, affected_pairs) @@ -485,6 +487,8 @@ class SpreadingCandidatesView(APIView): delivery_queryset = DeliveryTripItem.objects.filter(trip__delivery_plan__year=year) if delivery_plan_id: delivery_queryset = delivery_queryset.filter(trip__delivery_plan_id=delivery_plan_id) + else: + delivery_queryset = delivery_queryset.filter(trip__date__isnull=False) delivery_rows = delivery_queryset.values( 'field_id', 'field__name', diff --git a/backend/apps/materials/migrations/0004_fix_spreading_item_on_delete.py b/backend/apps/materials/migrations/0004_fix_spreading_item_on_delete.py new file mode 100644 index 0000000..5fd38c0 --- /dev/null +++ b/backend/apps/materials/migrations/0004_fix_spreading_item_on_delete.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0 on 2026-03-17 10:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fertilizer', '0008_spreadingsession_fertilizationentry_actual_bags_and_more'), + ('materials', '0003_stocktransaction_spreading_item_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='stocktransaction', + name='spreading_item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_transactions', to='fertilizer.spreadingsessionitem', verbose_name='散布実績明細'), + ), + ] diff --git a/backend/apps/materials/models.py b/backend/apps/materials/models.py index 64ea25c..9e9ca9c 100644 --- a/backend/apps/materials/models.py +++ b/backend/apps/materials/models.py @@ -207,7 +207,7 @@ class StockTransaction(models.Model): ) spreading_item = models.ForeignKey( 'fertilizer.SpreadingSessionItem', - on_delete=models.CASCADE, + on_delete=models.SET_NULL, null=True, blank=True, related_name='stock_transactions', diff --git a/frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx b/frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx deleted file mode 100644 index 7ea4e77..0000000 --- a/frontend/src/app/fertilizer/_components/ConfirmSpreadingModal.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { Loader2, X } from 'lucide-react'; - -import { api } from '@/lib/api'; -import { FertilizationPlan } from '@/types'; - -interface ConfirmSpreadingModalProps { - plan: FertilizationPlan | null; - isOpen: boolean; - onClose: () => void; - onConfirmed: () => Promise | void; -} - -type ActualMap = Record; - -const entryKey = (fieldId: number, fertilizerId: number) => `${fieldId}-${fertilizerId}`; -type EntryMatrix = Record>; - -export default function ConfirmSpreadingModal({ - plan, - isOpen, - onClose, - onConfirmed, -}: ConfirmSpreadingModalProps) { - const [actuals, setActuals] = useState({}); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!isOpen || !plan) { - return; - } - - const nextActuals: ActualMap = {}; - plan.entries.forEach((entry) => { - nextActuals[entryKey(entry.field, entry.fertilizer)] = String(entry.bags); - }); - setActuals(nextActuals); - setError(null); - }, [isOpen, plan]); - - const layout = useMemo(() => { - if (!plan) { - return { - fields: [] as { id: number; name: string; areaTan: string | undefined }[], - fertilizers: [] as { id: number; name: string }[], - planned: {} as EntryMatrix, - }; - } - - const fieldMap = new Map(); - const fertilizerMap = new Map(); - const planned: EntryMatrix = {}; - - plan.entries.forEach((entry) => { - if (!fieldMap.has(entry.field)) { - fieldMap.set(entry.field, { - id: entry.field, - name: entry.field_name ?? `圃場ID:${entry.field}`, - areaTan: entry.field_area_tan, - }); - } - if (!fertilizerMap.has(entry.fertilizer)) { - fertilizerMap.set(entry.fertilizer, { - id: entry.fertilizer, - name: entry.fertilizer_name ?? `肥料ID:${entry.fertilizer}`, - }); - } - if (!planned[entry.field]) { - planned[entry.field] = {}; - } - planned[entry.field][entry.fertilizer] = String(entry.bags); - }); - - return { - fields: Array.from(fieldMap.values()), - fertilizers: Array.from(fertilizerMap.values()), - planned, - }; - }, [plan]); - - if (!isOpen || !plan) { - return null; - } - - const handleConfirm = async () => { - setSaving(true); - setError(null); - - try { - await api.post(`/fertilizer/plans/${plan.id}/confirm_spreading/`, { - entries: plan.entries.map((entry) => ({ - field_id: entry.field, - fertilizer_id: entry.fertilizer, - actual_bags: Number(actuals[entryKey(entry.field, entry.fertilizer)] || 0), - })), - }); - await onConfirmed(); - onClose(); - } catch (e: unknown) { - console.error(e); - const detail = - typeof e === 'object' && - e !== null && - 'response' in e && - typeof e.response === 'object' && - e.response !== null && - 'data' in e.response - ? JSON.stringify(e.response.data) - : '散布確定に失敗しました。'; - setError(detail); - } finally { - setSaving(false); - } - }; - - const numericValue = (value: string | undefined) => { - const parsed = parseFloat(value ?? '0'); - return isNaN(parsed) ? 0 : parsed; - }; - - const actualTotalByField = (fieldId: number) => - layout.fertilizers.reduce( - (sum, fertilizer) => sum + numericValue(actuals[entryKey(fieldId, fertilizer.id)]), - 0 - ); - - const actualTotalByFertilizer = (fertilizerId: number) => - layout.fields.reduce( - (sum, field) => sum + numericValue(actuals[entryKey(field.id, fertilizerId)]), - 0 - ); - - return ( -
-
-
-
-

- 散布確定: 「{plan.name}」 -

-

- 施肥計画編集と同じ並びで、各セルの計画値を確認しながら実績数量を入力します。 -

-
- -
- -
- {error && ( -
- {error} -
- )} - -
-
-
-
年度
-
{plan.year}年度
-
-
-
作物 / 品種
-
- {plan.crop_name} / {plan.variety_name} -
-
-
-
対象圃場
-
{plan.field_count}筆
-
-
-
肥料数
-
{plan.fertilizer_count}種
-
-
-
- -
- 各セルの灰色表示が計画値、入力欄が散布実績です。「0」を入力したセルは未散布として引当解除されます。 -
- -
- - - - - - {layout.fertilizers.map((fertilizer) => ( - - ))} - - - - - {layout.fields.map((field) => ( - - - - {layout.fertilizers.map((fertilizer) => { - const key = entryKey(field.id, fertilizer.id); - const planned = layout.planned[field.id]?.[fertilizer.id]; - const hasEntry = planned !== undefined; - return ( - - ); - })} - - - ))} - - - - - - {layout.fertilizers.map((fertilizer) => ( - - ))} - - - -
- 圃場名 - - 面積(反) - -
{fertilizer.name}
-
- 計画 / 実績 -
-
- 実績合計 -
- {field.name} - - {field.areaTan ?? '-'} - - {hasEntry ? ( -
- - 計画 {planned} - - - setActuals((prev) => ({ - ...prev, - [key]: e.target.value, - })) - } - className="w-20 rounded-md border border-gray-300 px-2 py-1 text-right text-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-200" - /> -
- ) : ( -
-
- )} -
- {actualTotalByField(field.id).toFixed(2)} -
合計 - {layout.fields - .reduce((sum, field) => sum + (parseFloat(field.areaTan ?? '0') || 0), 0) - .toFixed(2)} - - {actualTotalByFertilizer(fertilizer.id).toFixed(2)} - - {layout.fields - .reduce((sum, field) => sum + actualTotalByField(field.id), 0) - .toFixed(2)} -
-
-
- -
- - -
-
-
- ); -} diff --git a/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx b/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx index ecfa43f..ad70a50 100644 --- a/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx +++ b/frontend/src/app/fertilizer/_components/FertilizerEditPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Undo2, Sprout } from 'lucide-react'; +import { ChevronLeft, Plus, X, Calculator, Save, FileDown, Sprout } from 'lucide-react'; import Navbar from '@/components/Navbar'; import { api } from '@/lib/api'; import { Crop, FertilizationPlan, Fertilizer, Field, StockSummary } from '@/types'; @@ -66,8 +66,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { const [roundedColumns, setRoundedColumns] = useState>(new Set()); const [stockByMaterialId, setStockByMaterialId] = useState>({}); const [initialPlanTotals, setInitialPlanTotals] = useState>({}); - const [isConfirmed, setIsConfirmed] = useState(false); - const [confirmedAt, setConfirmedAt] = useState(null); const [loading, setLoading] = useState(!isNew); const [saving, setSaving] = useState(false); @@ -103,9 +101,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { setName(plan.name); setYear(plan.year); setVarietyId(plan.variety); - setIsConfirmed(false); - setConfirmedAt(null); - const fertIds = Array.from(new Set(plan.entries.map((e) => e.fertilizer))); const ferts = fertsRes.data.filter((f: Fertilizer) => fertIds.includes(f.id)); setPlanFertilizers(ferts); @@ -202,7 +197,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── 肥料追加・削除 const addFertilizer = (fert: Fertilizer) => { - if (isConfirmed) return; + if (planFertilizers.find((f) => f.id === fert.id)) return; setPlanFertilizers((prev) => [...prev, fert]); setCalcSettings((prev) => [...prev, { fertilizer_id: fert.id, method: 'per_tan', param: '' }]); @@ -210,7 +205,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { }; const removeFertilizer = (id: number) => { - if (isConfirmed) return; + setPlanFertilizers((prev) => prev.filter((f) => f.id !== id)); setCalcSettings((prev) => prev.filter((s) => s.fertilizer_id !== id)); const dropCol = (m: Matrix): Matrix => { @@ -229,14 +224,14 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── 圃場追加・削除 const addField = (field: Field) => { - if (isConfirmed) return; + if (selectedFields.find((f) => f.id === field.id)) return; setSelectedFields((prev) => [...prev, field]); setShowFieldPicker(false); }; const removeField = (id: number) => { - if (isConfirmed) return; + setSelectedFields((prev) => prev.filter((f) => f.id !== id)); setCalcMatrix((prev) => { const next = { ...prev }; delete next[id]; return next; }); setAdjusted((prev) => { const next = { ...prev }; delete next[id]; return next; }); @@ -246,7 +241,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── 自動計算 const runCalc = async (setting: CalcSetting) => { - if (isConfirmed) return; + if (!setting.param) { setSaveError('パラメータを入力してください'); return; @@ -305,7 +300,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── セル更新(adjusted を更新) const updateCell = (fieldId: number, fertId: number, value: string) => { - if (isConfirmed) return; + setAdjusted((prev) => { const next = { ...prev }; if (!next[fieldId]) next[fieldId] = {}; @@ -316,7 +311,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── 列単位で四捨五入 / 元に戻す(トグル) const roundColumn = (fertId: number) => { - if (isConfirmed) return; + if (roundedColumns.has(fertId)) { // 元に戻す: adjusted からこの列を削除 → calc値が再び表示される setAdjusted((prev) => { @@ -390,10 +385,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { // ─── 保存(adjusted 優先、なければ calc 値を使用) const handleSave = async () => { setSaveError(null); - if (isConfirmed) { - setSaveError('確定済みの施肥計画は編集できません。'); - return; - } if (!name.trim()) { setSaveError('計画名を入力してください'); return; } if (!varietyId) { setSaveError('品種を選択してください'); return; } if (selectedFields.length === 0) { setSaveError('圃場を1つ以上選択してください'); return; } @@ -434,31 +425,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { } }; - // ─── 確定取消 - const handleUnconfirm = async () => { - if (!planId) return; - setSaveError(null); - try { - await api.post(`/fertilizer/plans/${planId}/unconfirm/`); - setIsConfirmed(false); - setConfirmedAt(null); - // 引当が再作成されるので在庫情報を再取得 - const stockRes = await api.get('/materials/fertilizer-stock/'); - setStockByMaterialId( - stockRes.data.reduce( - (acc: Record, summary: StockSummary) => { - acc[summary.material_id] = summary; - return acc; - }, - {} - ) - ); - } catch (e) { - console.error(e); - setSaveError('確定取消に失敗しました'); - } - }; - // ─── PDF出力 const handlePdf = async () => { if (!planId) return; @@ -514,15 +480,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { この施肥計画から散布実績へ進む )} - {!isNew && isConfirmed && ( - - )} {!isNew && ( @@ -552,16 +509,6 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { )} - {isConfirmed && ( -
- i - - この施肥計画は散布確定済みです。 - {confirmedAt ? ` 確定日時: ${new Date(confirmedAt).toLocaleString('ja-JP')}` : ''} - -
- )} - {/* 基本情報 */}
@@ -571,7 +518,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { value={name} onChange={(e) => setName(e.target.value)} placeholder="例: 2025年度 コシヒカリ 元肥" - disabled={isConfirmed} + />
@@ -580,7 +527,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" value={year} onChange={(e) => setYear(parseInt(e.target.value))} - disabled={isConfirmed} + > {years.map((y) => )} @@ -591,7 +538,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) { className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500" value={varietyId} onChange={(e) => setVarietyId(e.target.value ? parseInt(e.target.value) : '')} - disabled={isConfirmed} + > {crops.map((crop) => ( @@ -617,7 +564,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {
{actualVal !== undefined && ( @@ -865,7 +812,7 @@ export default function FertilizerEditPage({ planId }: { planId?: number }) {