現在、確定送料 final_shipping_fee の再計算またはリセットは、発送管理のステータス更新 API でのみ実行される。
mg_workflow/api_shipping.py
_finalize_shipping_fee_if_all_packed(destination)PackageStatusUpdateView.post から呼び出しmg_workflow/api_packaging.py
そのため、発送管理で箱を READY_TO_SHIP 以上にして送料確定した後、パッケージング画面で箱構成や箱の中身を変更すると、確定送料が古いまま残る可能性がある。
パッケージング API のうち、送料確定後の箱数や対象注文と箱の対応に影響する操作では、操作後に該当宛先の確定送料を再評価する。
再評価には既存の _finalize_shipping_fee_if_all_packed(destination) 相当の処理を使う。ただし、現行実装をそのまま呼ぶだけでは不十分。
現行の target_packages は items__bag__allocation__order_item__order__in=related_orders で絞り込んでおり、PackageItem への INNER JOIN になる。そのため、中身が1つもない空箱は対象箱に含まれない。
このままだと、Issue 本文の主要シナリオである「送料確定後にパッケージング画面で空の準備中箱を追加した」ケースで、新しい箱が target_packages に現れず、final_shipping_fee が None に戻らない。したがって、再評価処理は共有化だけでなく、空箱を含む未確定化判定へ見直す必要がある。
final_shipping_fee を None に戻す。READY_TO_SHIP 以上なら、箱数に基づいて再計算する。final_shipping_fee を None に戻す。mg_workflow/api_packaging.py のうち、送料に影響する書き込み操作を対象にする。
PackageCreateView.post
PackageDeleteView.delete
target_packages には入らないため、現行ロジックをそのまま呼ぶだけでは実質 no-op になり得る。PackageItemCreateView.post
PackageItemDeleteView.delete
PackageItemMoveView.patch
PlanItemConsumeView.post
PackageItem と Allocation を追加するため、実質的に中身追加と同じ扱い。ApplyPackingProposalView.post
ApplyGlobalPackingProposalView.post
_calculate_global_proposal(simulated=False) が全宛先横断で未ロック・準備中箱を削除し、複数宛先に新しい箱や割当を作成する。destination を再評価するだけでは不十分。送料や箱数、注文と箱の対応に影響しない操作は対象外にする。
PackageLabelUpdateView.patch
PackageLockStatusUpdateView.patch
BoardStateViewPreparedBagsViewSidebarStockSummaryViewAllocatableItemsViewProposePackingViewProposeGlobalPackingViewPlanItemUpsertView.post は予約数量を変更するが、実箱数と実際の PackageItem / Allocation はまだ変わらない。確定送料の根拠は実箱とその中身なので、原則として対象外でよい。ただし、業務上「予約変更も再確定を要求したい」判断がある場合は対象に含める。
まず _finalize_shipping_fee_if_all_packed の判定を見直す。
現行ロジック:
target_packages = Package.objects.filter(
destination=destination,
items__bag__allocation__order_item__order__in=related_orders,
).distinct()
このクエリは items への INNER JOIN になるため、空箱を無視する。Issue #8 の主要シナリオである「確定後に空の準備中箱を追加する」ケースを直せない。
見直し案:
Package.objects.filter(destination=destination).exclude(status__in=ready_statuses).exists()final_shipping_fee を None に戻す。target_packages 相当。None に戻す。つまり、最低限次の2系統を分ける。
all_destination_packages = Package.objects.filter(destination=destination)
target_packages = Package.objects.filter(
destination=destination,
items__bag__allocation__order_item__order__in=related_orders,
).distinct()
all_destination_packages は未完了箱の有無を判定するために使い、target_packages は確定時の箱数と対象注文の特定に使う。
判定ロジックを見直した上で、現在 api_shipping.py にある _finalize_shipping_fee_if_all_packed(destination) を共有しやすい場所へ移す。api_packaging.py から直接 api_shipping.py を import すると API モジュール間の依存になる。
選択肢:
api_packaging.py から import する。mg_workflow/services/shipping_fee.py などへ移動し、発送管理 API とパッケージング API の双方から呼ぶ。推奨は 2。
理由:
想定:
# mg_workflow/services/shipping_fee.py
def finalize_shipping_fee_if_all_packed(destination: Destination):
...
既存の _finalize_shipping_fee_if_all_packed は移動または薄い wrapper にする。
mg_workflow/services/ が未作成の場合は、__init__.py も追加する。
各 View で、変更対象の destination を操作前または操作中に確保し、書き込み完了後に finalize_shipping_fee_if_all_packed(destination) を呼ぶ。
注意点:
package.delete() や pi.delete() の前に destination を保持しておく。@transaction.atomic の中で呼ぶことで、操作結果と送料状態を同一トランザクションに含める。PackageStatusUpdateView.post と同じく、操作後の状態を見て再評価する。例:
destination = package.destination
package.delete()
finalize_shipping_fee_if_all_packed(destination)
ApplyGlobalPackingProposalView.post は _calculate_global_proposal(simulated=False) の中で全体の箱削除と再作成を行う。
必要な影響宛先:
final_package_groups_by_dest.keys()
destination_id
final_package_groups_by_dest に含まれる想定だが、実装時に確認する。実装案:
_calculate_global_proposal(simulated=False) の非シミュレーション時に affected_destination_ids も返す。Destination.objects.filter(id__in=affected_destination_ids) を取得し、各宛先を再評価する。返り値の互換性を考えると、明示的な小さな dataclass または dict に変える選択肢もあるが、影響範囲を小さくするなら tuple に 1 要素追加する。
例:
created_count, updated_count, affected_destination_ids = _calculate_global_proposal(simulated=False)
for destination in Destination.objects.filter(id__in=affected_destination_ids):
finalize_shipping_fee_if_all_packed(destination)
simulated=True 側の返り値は既存の ProposeGlobalPackingView が利用しているため、破壊しないよう注意する。
mg_workflow/tests.py の PackagingBoardOperationSemanticsTests に回帰テストを追加する。
最低限ほしいテスト:
PackageCreateView.post で空の準備中箱を追加すると final_shipping_fee が None に戻る。
_finalize_shipping_fee_if_all_packed を単に呼ぶだけでは green にならないことを確認する、中核の回帰テスト。PackageItemDeleteView.delete で単一箱の中身を取り出すと final_shipping_fee が None に戻る。
None を期待するテストは単一箱構成にする。PlanItemConsumeView.post で予約消費により中身を追加すると final_shipping_fee が再評価される。ApplyGlobalPackingProposalView.post で複数宛先にまたがる変更が起きた場合、影響を受けた各宛先の final_shipping_fee が stale にならない。1 は Issue の主要経路を押さえる最重要回帰テスト。
2 は中身削除経路の回帰テスト。
3 はレビューコメント #2837 の漏れ対策。
4 は全宛先横断操作の特殊性対策。
mg_workflow/tests.py に #8 の失敗する回帰テストを追加する。_finalize_shipping_fee_if_all_packed の判定ロジックを、空箱を含む未完了箱の有無を見られる形へ修正する。_finalize_shipping_fee_if_all_packed を service 層へ移動する、または既存関数を import 可能に整理する。PackageStatusUpdateView.post を新しい service 関数呼び出しへ切り替える。api_packaging.py の対象 View に再評価呼び出しを追加する。_calculate_global_proposal(simulated=False) で影響宛先 ID を集め、ApplyGlobalPackingProposalView.post から各宛先を再評価する。docs/features/送料計算.md と docs/features/出荷ワークフロー.md から #8 の現行実装上の注意を削除または解消済みに更新する。docker compose -f docker-compose.dev.yml exec app python manage.py test mg_workflow
docker compose -f docker-compose.dev.yml exec app python manage.py test mg_workflow orders
docker compose -f docker-compose.dev.yml exec app python manage.py makemigrations --check --dry-run
_calculate_global_proposal の返り値を変える場合、simulated=True を使う提案表示側を壊さないようにする。PackageItemMoveView は同一宛先内の移動のみ許可しているため、再評価対象は移動元/移動先どちらも同じ宛先でよい。PackageDeleteView.delete は空箱のみ削除できる。現行の請求対象箱クエリでは空箱が無視されるため、未確定化判定と確定時カウントを分ける見直しが前提になる。PlanItemUpsertView.post を対象外にする判断は、予約を送料確定の根拠に含めない現行仕様に基づく。実装前に業務判断が変わる場合は追加対象にする。final_shipping_fee が stale にならない。