From c675b7b7ae9674ce49777cea96f0b6e7ca5e07fa Mon Sep 17 00:00:00 2001 From: akira Date: Sun, 5 Apr 2026 18:42:09 +0900 Subject: [PATCH] Move all fertilization entries on variety change --- backend/apps/fertilizer/services.py | 4 +- backend/apps/plans/services.py | 4 +- backend/apps/plans/tests.py | 51 ++++++++------- 改善案/issue_3_計画始動後の作付け変更_調査.md | 62 +++++++------------ 4 files changed, 53 insertions(+), 68 deletions(-) diff --git a/backend/apps/fertilizer/services.py b/backend/apps/fertilizer/services.py index 19748f0..6855e9b 100644 --- a/backend/apps/fertilizer/services.py +++ b/backend/apps/fertilizer/services.py @@ -60,7 +60,7 @@ def sync_stock_uses_for_spreading_session(session): @transaction.atomic -def move_unspread_entries_for_variety_change(change): +def move_fertilization_entries_for_variety_change(change): moved_count = 0 old_variety_id = change.old_variety_id new_variety = change.new_variety @@ -73,7 +73,6 @@ def move_unspread_entries_for_variety_change(change): year=change.year, variety_id=old_variety_id, entries__field_id=change.field_id, - entries__actual_bags__isnull=True, ) .distinct() .prefetch_related('entries') @@ -83,7 +82,6 @@ def move_unspread_entries_for_variety_change(change): entries_to_move = list( old_plan.entries.filter( field_id=change.field_id, - actual_bags__isnull=True, ).order_by('id') ) if not entries_to_move: diff --git a/backend/apps/plans/services.py b/backend/apps/plans/services.py index 927d927..95cdcac 100644 --- a/backend/apps/plans/services.py +++ b/backend/apps/plans/services.py @@ -59,10 +59,10 @@ def handle_plan_variety_change(plan: Plan, *, old_variety, new_variety, reason: def process_plan_variety_change(change: PlanVarietyChange): - from apps.fertilizer.services import move_unspread_entries_for_variety_change + from apps.fertilizer.services import move_fertilization_entries_for_variety_change from .services_rice_transplant import move_rice_transplant_entries_for_variety_change - moved_count = move_unspread_entries_for_variety_change(change) + moved_count = move_fertilization_entries_for_variety_change(change) move_rice_transplant_entries_for_variety_change(change) if moved_count != change.fertilizer_moved_entry_count: change.fertilizer_moved_entry_count = moved_count diff --git a/backend/apps/plans/tests.py b/backend/apps/plans/tests.py index 9ad5ce3..f20716d 100644 --- a/backend/apps/plans/tests.py +++ b/backend/apps/plans/tests.py @@ -111,47 +111,47 @@ class PlanVarietyChangeTests(TestCase): self.assertEqual(change.old_variety_id, self.old_variety.id) self.assertEqual(change.new_variety_id, self.new_variety.id) - def test_serializer_update_moves_only_unspread_fertilizer_entries(self): - material_unspread = Material.objects.create( + def test_serializer_update_moves_all_fertilizer_entries_for_target_field(self): + material_target = Material.objects.create( name='高度化成14号', material_type=Material.MaterialType.FERTILIZER, ) - material_partial = Material.objects.create( + material_spread = Material.objects.create( name='分げつ一発', material_type=Material.MaterialType.FERTILIZER, ) - fertilizer_unspread = Fertilizer.objects.create( + fertilizer_target = Fertilizer.objects.create( name='高度化成14号', - material=material_unspread, + material=material_target, ) - fertilizer_partial = Fertilizer.objects.create( + fertilizer_spread = Fertilizer.objects.create( name='分げつ一発', - material=material_partial, + material=material_spread, ) old_fertilization_plan = FertilizationPlan.objects.create( name='2026年度 にこまる 元肥', year=2026, variety=self.old_variety, - calc_settings=[{'fertilizer_id': fertilizer_unspread.id, 'method': 'per_tan', 'param': '1.0'}], + calc_settings=[{'fertilizer_id': fertilizer_target.id, 'method': 'per_tan', 'param': '1.0'}], ) - unspread_entry = FertilizationEntry.objects.create( + target_entry = FertilizationEntry.objects.create( plan=old_fertilization_plan, field=self.field, - fertilizer=fertilizer_unspread, + fertilizer=fertilizer_target, bags='4.00', actual_bags=None, ) - partial_entry = FertilizationEntry.objects.create( + spread_entry = FertilizationEntry.objects.create( plan=old_fertilization_plan, field=self.field, - fertilizer=fertilizer_partial, + fertilizer=fertilizer_spread, bags='3.00', actual_bags='1.0000', ) untouched_entry = FertilizationEntry.objects.create( plan=old_fertilization_plan, field=self.other_field, - fertilizer=fertilizer_unspread, + fertilizer=fertilizer_target, bags='2.00', actual_bags=None, ) @@ -166,7 +166,7 @@ class PlanVarietyChangeTests(TestCase): serializer.save() change = PlanVarietyChange.objects.get(plan=self.plan) - self.assertEqual(change.fertilizer_moved_entry_count, 1) + self.assertEqual(change.fertilizer_moved_entry_count, 2) old_fertilization_plan.refresh_from_db() new_plan = FertilizationPlan.objects.exclude(id=old_fertilization_plan.id).get( @@ -179,12 +179,12 @@ class PlanVarietyChangeTests(TestCase): ) self.assertEqual(new_plan.calc_settings, old_fertilization_plan.calc_settings) - unspread_entry.refresh_from_db() - partial_entry.refresh_from_db() + target_entry.refresh_from_db() + spread_entry.refresh_from_db() untouched_entry.refresh_from_db() - self.assertEqual(unspread_entry.plan_id, new_plan.id) - self.assertEqual(partial_entry.plan_id, old_fertilization_plan.id) + self.assertEqual(target_entry.plan_id, new_plan.id) + self.assertEqual(spread_entry.plan_id, new_plan.id) self.assertEqual(untouched_entry.plan_id, old_fertilization_plan.id) old_reserves = list( @@ -199,16 +199,21 @@ class PlanVarietyChangeTests(TestCase): transaction_type=StockTransaction.TransactionType.RESERVE, ).order_by('material__name') ) - self.assertEqual(len(old_reserves), 2) - self.assertEqual(len(new_reserves), 1) + self.assertEqual(len(old_reserves), 1) + self.assertEqual(len(new_reserves), 2) self.assertEqual( {(reserve.material_id, reserve.quantity) for reserve in old_reserves}, { - (material_partial.id, partial_entry.bags), - (material_unspread.id, untouched_entry.bags), + (material_target.id, untouched_entry.bags), + }, + ) + self.assertEqual( + {(reserve.material_id, reserve.quantity) for reserve in new_reserves}, + { + (material_target.id, target_entry.bags), + (material_spread.id, spread_entry.bags), }, ) - self.assertEqual(new_reserves[0].quantity, unspread_entry.bags) def test_serializer_update_moves_rice_transplant_entries_for_target_field(self): old_rice_plan = RiceTransplantPlan.objects.create( diff --git a/改善案/issue_3_計画始動後の作付け変更_調査.md b/改善案/issue_3_計画始動後の作付け変更_調査.md index b704301..00395c9 100644 --- a/改善案/issue_3_計画始動後の作付け変更_調査.md +++ b/改善案/issue_3_計画始動後の作付け変更_調査.md @@ -236,8 +236,8 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ - 圃場グループは対応不要 - 田植え計画も同様に移動 -> **補足(最終確定)**: 散布済み Entry の扱いは後述 8-3 の検討を経て **(A) 旧計画に残す** に確定した。 -> その他の方針は初期提案どおり採用。 +> **補足(最終確定)**: 施肥 Entry の扱いは後述 8-3 の検討を経て **(B) 対象圃場の全件を新品種計画へ移動** に確定した。 +> 履歴スナップショットは将来必要になった時点で追加検討とする。 この提案には良い点が多い一方で、現行実装のまま採ると危険な点もある。 @@ -254,10 +254,11 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ - 変更理由 `reason` があると運用上かなり有用 - 自動移動結果の件数も履歴に残せると監査しやすい -#### b. 未散布 Entry の移動 +#### b. 対象圃場の施肥 Entry を新品種計画へ集約する -これは方向性としてかなり自然。 -「まだ実施していない将来計画」は新品種側へ寄せ、引当も付け替えるのは業務的に納得感が高い。 +現在の計画・栽培記録・将来の集計を一貫させるには、 +対象圃場の施肥 Entry を既散布/未散布で分断せず、新品種計画へ集約する方が自然。 +RESERVE もその plan 構成に合わせて再生成する。 #### c. 田植え計画も同様に扱う @@ -266,7 +267,7 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ ### 8-2. そのまま採ると危険な点 -#### a. 散布済み Entry を新品種計画へ移動する案 +#### a. 全 Entry を新品種計画へ移動する案 これはもっとも議論が必要。 @@ -285,13 +286,12 @@ issue にある「足川北上が圃場追加候補に出てこない」はこ - 施肥計画 PDF や一覧で、後から見る人が経緯を誤解しやすい - 「なぜこの新品種計画に既散布分が入っているのか」を履歴表示なしでは理解できない -したがって、散布済み Entry を移動するなら、少なくとも +したがって、全 Entry を移動するなら、少なくとも `変更前品種で発生した実績である` ことが UI で明示される必要がある。 -現時点では、実装の安全性だけで見ると -`散布済み Entry は旧計画に残す` -方が素直。 +ただし、将来の栽培記録実装では「その圃場・その年度の最終品種」に +施肥情報を集約したい要求が強いため、最終的には B案を採用する。 #### b. Entry の plan FK 付け替えだけでは履歴の意味が弱い @@ -338,26 +338,24 @@ PlanVarietyChange old_variety FK(Variety, SET_NULL, null=True) new_variety FK(Variety, SET_NULL, null=True) reason text blank - moved_entry_count int default=0 # 自動移動した未散布エントリ数(監査用) + moved_entry_count int default=0 # 自動移動した施肥エントリ数(監査用) ``` - `plan FK` だけでなく `field_id` と `year` を冗長保持した方が将来参照しやすい - `reason` があると運用上かなり有用 - `moved_entry_count` で自動移動の件数を残すことで監査ログを兼ねる -#### 散布済み Entry の扱い ✅ **(A) 旧計画に残す** 確定 +#### 施肥 Entry の扱い ✅ **(B) 対象圃場の全件を新品種計画へ移動** 確定 理由: -- 履歴解釈が明確(「にこまる用に施肥した」という文脈が旧計画に保持される) -- PDF/一覧での意味が崩れにくい -- `actual_bags` の二重反映リスクを避けられる(同 year+field+fertilizer に複数エントリを持たない) -- 将来「既散布分も移したい」要求が出た場合に後から対応できる +- 栽培記録の観点では「その圃場・その年度に最終的に作った品種」に施肥情報を集約した方が自然 +- 既散布/未散布で計画が分裂すると、将来の集計や参照で特別処理が増える +- 旧計画側に圃場が残らないため、画面上の違和感が少ない +- `actual_bags` を含めて plan ごと付け替えることで、圃場単位の施肥履歴を新品種側へ一貫して寄せられる -#### 未散布 Entry の扱い ✅ 新品種計画へ移動(RESERVE付け替えあり)確定 - -「まだ実施していない将来計画」は新品種側へ寄せる。 -RESERVE 付け替えもこの方針と整合する。 +散布済み/未散布に関係なく、**対象圃場の FertilizationEntry は全件移動**する。 +RESERVE も移動後の plan 構成に合わせて新旧 plan 単位で再生成する。 #### 圃場グループ ✅ 対応不要 確定 @@ -402,13 +400,11 @@ RESERVE 付け替えもこの方針と整合する。 1. `PlanVarietyChange` モデル追加(履歴記録のみ・既存データに触らない) 2. 品種変更トリガーのサービス追加 -3. `未散布 Entry のみ` 新品種計画(常に新規作成)へ移動を施肥計画で実装 +3. 対象圃場の施肥 Entry `全件` を新品種計画(常に新規作成)へ移動する処理を実装 4. RESERVE 付け替えと `actual_bags` 再集計を確認 5. 田植え計画へ横展開 6. allocation 画面の履歴インジケータ追加 -散布済み Entry の移動は今後の要求が出た時点で別途検討する。 - --- ## 9. 確定仕様まとめ @@ -419,8 +415,7 @@ RESERVE 付け替えもこの方針と整合する。 | 項目 | 決定内容 | |---|---| -| 散布済み Entry | **旧計画に残す(A確定)** | -| 未散布 Entry | **新品種計画へ移動 + RESERVE付け替え** | +| 施肥 Entry | **対象圃場の全件を新品種計画へ移動 + RESERVE再生成** | | 移動先計画の選び方 | **常に新規作成**(既存計画には集約しない) | | 移動先計画の命名 | `{year}年度 {品種名} 施肥計画(品種変更移動)` | | 変更履歴 | **PlanVarietyChange モデルを新設** | @@ -437,18 +432,8 @@ RESERVE 付け替えもこの方針と整合する。 2. 施肥計画エントリの移動 - 未散布の判定: - - actual_bags IS NULL → 未散布(移動対象) - - actual_bags IS NOT NULL かつ actual_bags < bags → 一部散布済み(移動不可・旧計画に残す) - - actual_bags >= bags → 散布完了(移動不可・旧計画に残す) - ※ actual_bags = 0 の扱いは明示的に決定しておく必要がある。 - 現行 services.py では SUM = 0 のとき NULL に丸めるが、 - データ補正や手動更新で 0 が直接セットされる可能性は排除できない。 - 本仕様では actual_bags IS NULL を未散布と判定し、0 は一部散布済みと同様に扱う - (移動不可・旧計画に残す)とする。 - 対象: FertilizationPlan.variety=A かつ year=変更年度 かつ - FertilizationEntry.field=変更圃場 かつ actual_bags IS NULL(未散布) + FertilizationEntry.field=変更圃場(全件) 処理: a. variety=B, year=変更年度 の新 FertilizationPlan を作成 名前: "{year}年度 {B品種名} 施肥計画(品種変更移動)" @@ -458,9 +443,6 @@ RESERVE 付け替えもこの方針と整合する。 d. 新 plan 全体の RESERVE を生成(stock_service.create_reserves_for_plan(新plan)) e. PlanVarietyChange.moved_entry_count に移動件数を記録 - 非対象(旧計画に残す): - actual_bags IS NOT NULL のエントリ(一部散布済み・散布完了) - 3. 田植え計画エントリの移動 田植え計画には施肥計画の actual_bags に相当する実績概念がまだない @@ -489,6 +471,6 @@ RESERVE 付け替えもこの方針と整合する。 ### 9-4. 未解決・将来検討 -- 散布済み Entry を新品種計画側にも見せたい要求が出た場合 → 別途設計 +- 変更履歴のスナップショットをどこまで持つか → 実装後に見直し - allocation 画面の変更履歴インジケータ(実装ステップ6) - `actual_bags` 集計を `year+field+fertilizer` から `plan単位` へ変更する大規模リファクタ(中長期)