Move all fertilization entries on variety change

This commit is contained in:
akira
2026-04-05 18:42:09 +09:00
parent ae0249be69
commit c675b7b7ae
4 changed files with 53 additions and 68 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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単位` へ変更する大規模リファクタ(中長期)