Move all fertilization entries on variety change
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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単位` へ変更する大規模リファクタ(中長期)
|
||||
|
||||
Reference in New Issue
Block a user