11_管理者向けキャンセル機能を強化し状態表示と注文作り直し導線を整備する_実装案.md(要件・UI設計)を土台に、実装の段取りを具体化する。本書は「どのファイルに何を作るか」「どの順で進めるか」を定める作業手順書である。
実コード照合(Issue #11 コメント #2585)で確認済み。実装はこれを前提にする。
mg_orders には order_list / order_accept / order_edit しかない(mg_orders/urls.py)。既存 orders/views.py:order_cancel は顧客向け(user=request.user 制限・status==NEW のみ)で流用不可。order_edit(templates/mg_orders/order_edit.html)。キャンセル導線はここと order_list に置く。total_ordered_kg は注文確定時にのみ予約され(orders/views.py:166)、引当・梱包では在庫を動かさない。キャンセル時は注文重量分を減算するだけ(orders/views.py:219-236 のロジックを流用)。引当・箱詰めとの二重計上は起きない。Allocation.order_item は CASCADE、Allocation.bag は OneToOne CASCADE、PackageItem.bag は OneToOne PROTECT。先に OrderItem/Order を消すと、箱に残った PackageItem がどの注文由来か辿れなくなる。順序は次で固定する(指摘 #1 反映)。
PackageItem / Package を先に特定し、確認画面用にスナップショットを取る。Allocation と PackageItem を外す(袋除去)。Order.status=CANCELED。Package だけ削除/無効化。残る Package は再確認対象としてフラグ。OrderItem/Order 自体は削除しない(キャンセル履歴として残す。後述の履歴保持方針を参照)。mg_workflow/api_packaging.py:145-152(PackageItemDeleteView)が「allocation があれば削除 → PackageItem 削除」を行っている。キャンセル時の袋除去ロジック(上記手順2)はこれを共通関数 unpack_bag(package_item) に切り出して流用する。Package 判定は「該当注文の袋だけ外し、残 PackageItem が 0 なら削除」。 箱は宛先単位で他注文と同梱され得る。orders.views._calculate_provisional_shipping_fee を流用できる(mg_orders/views.py で import 済み)。payment_status は UNPAID/PAID のみ(orders/models.py:16-18)。返金状態はモデル拡張が必要。api.PaymentStatusUpdateView が既存。Order.recreated_from(self-FK, null可)等の追加が必要。Order/OrderItem に備考欄も無い。orders/views.py:151 のみ)。「キャンセルして作り直す」は新規に作成導線を構築する必要があるが、これは大きいため別Issueに切り出す(後述)。Order.status=CANCELED で残し、OrderItem は削除しない。ただし既存の order_edit には「全商品数量0で order.delete()」(mg_orders/views.py:199-200)が残っており、これは注文の物理削除で本Issueのキャンセル(論理)とは別系統。本Issueでは履歴保持を優先し、キャンセル経路では delete() を使わない。既存の物理削除挙動を変更するかは本Issueの対象外(必要なら別Issue)。PackagePlanItem(箱の予約行)は触らない(確認点2への回答)。 PackagePlanItem は宛先×商品の梱包予約で、_allocatable_remaining などの容量計算に使われる(mg_workflow/api_packaging.py)。キャンセルは「対象注文由来の PackageItem(=実際に箱に入った袋)だけ外す」方針なので、既存の予約行は変更しない。対象注文を外した結果、予約と実体がずれる箱は再梱包・再確認対象として残箱フラグに含めて表示する。mg_orders 配下のサービス層に切り出して一元化する。order_edit の allocation_exists 判定もここへ統合し、Issue #4/#12 と同じ基盤を共有する。@transaction.atomic + select_for_update(在庫・袋)で行う。| Phase | 内容 | モデル変更 | リスク |
|---|---|---|---|
| 1 | 状態判定サービス + キャンセル確認UI(読み取りのみ) | なし | 低 |
| 2 | 未引当〜引当済みの自動キャンセル実行 | なし | 低 |
| 3 | 箱詰め済みキャンセル(同梱・複数箱、空箱削除、送料再確認) | なし | 中 |
| 4 | 発送準備完了・伝票印刷済みの扱い(許可範囲の制御) | なし | 中〜高(業務判断依存) |
| 5 | 入金済み対応 + 作り直しのキャンセル側(入口・引き継ぎ仕様) | 入金済みキャンセル処理履歴の追加 | 高 |
| 別 | 作り直しの作成本体(管理者注文作成・recreated_from) |
Order.recreated_from 追加 |
高(別Issue) |
mg_orders に合わせ、キャンセル実行 view は以下を必ず付ける。@login_required
@user_passes_test(is_staff_user, login_url='/accounts/login/')
@require_POST
@transaction.atomic
読み取り専用の確認画面 view は @require_POST を外す。Order.objects.select_for_update() で対象注文をロックする。order.status == CANCELED なら何もせずメッセージを返す(在庫を二重に戻さない)。total_ordered_kg 減算)は CANCELED へ遷移するこの1回だけ実行する。POST 再送・競合で重複減算しないことをテストで担保する。Stock.objects.select_for_update() でロックして更新する(既存 order_cancel と同様)。mg_orders/services/cancellation.py(または mg_orders/cancellation.py)。get_cancellation_state(order) -> CancellationState
自動実行 / 要確認 / 不可)、入金状態、関連する箱のスナップショット一覧。Allocation / PackageItem の有無、Package.status / lock_status / tracking_number、Order.status / payment_status。get_affected_packages(order) -> list[PackageSnapshot]
Order → OrderItem → Allocation → Bag → PackageItem → Package を辿り、箱ごとに「この注文分の袋数 / 箱全体の袋数 / 同梱有無 / キャンセル後に空になるか」を集計。order_edit の allocation_exists 判定をこのサービス呼び出しに置き換える(重複排除)。templates/mg_orders/order_edit.html と order_list.html に「キャンセル」導線を追加。mg_orders/views.py:order_cancel_confirm(GET)。確認画面 or モーダルを描画。mg_orders/urls.py に cancel/<int:order_id>/confirm/。templates/mg_orders/order_cancel_confirm.html。実装案「表3 管理者確認項目マトリクス」「表4」の表示ブロック(注文・明細・在庫・引当・袋・箱・同梱・送料・伝票・入金)をこの画面に対応させる。箱一覧は部分テンプレート化(_cancel_package_list.html)。mg_orders/tests.py に状態判定のユニットテスト(各状態区分・許可レベルの分岐、get_affected_packages の同梱/複数箱集計)。Phase 1 完了条件: 管理者が任意の注文でキャンセル確認画面を開き、進行状態・箱一覧・許可レベル・確認事項を確認できる(実行はまだ)。
mg_orders/views.py:order_cancel(decorator は 3.0 の統一形を適用、二重キャンセル防止のロック・CANCELED ガードを含む)。cancel/<int:order_id>/。Allocation を解除(引当済みの場合)。袋は物理在庫として残す(Allocation 削除で袋は解放)。orders/views.py:219-236 のロジックを共通関数化して流用(Stock.select_for_update → total_ordered_kg -= 注文重量)。Order.status = CANCELED、save()。要確認 ケースを実行不可にしてメッセージ表示)。Allocation が消え、袋が解放されること。完了条件: 未引当・引当済み未梱包の注文を、在庫・引当を正しく戻してキャンセルできる。
cancel_order(order) に箱詰め済み分岐を追加。get_affected_packages(order) で箱スナップショットを先取り。PackageItem を除去(PackageItemDeleteView のロジックを共通関数 unpack_bag(package_item) に切り出して流用 = allocation 削除 → PackageItem 削除)。Order.status = CANCELED。Package について items.count() == 0 なら削除/無効化。残る箱は送料・発送準備の再確認対象としてフラグ。final_shipping_fee を None に戻して再確定させるか保持するかは業務判断(未決)。final_shipping_fee = None に戻し、必ず再確定させる。READY_TO_SHIP 以降への遷移)を進められないようにブロックする。None リセット)とし、業務判断確定後に切替可能にしておく。mg_workflow の発送準備/送料確定画面へ)を表示。完了条件: 箱詰め済み注文を、同梱・複数箱を含めて安全にキャンセルでき、残箱が再確認対象として提示される。
READY_TO_SHIP / LABEL_PRINTED / tracking_number ありの箱を含む場合の可否。不可(別運用へ案内)にするか。完了条件: 発送準備完了・伝票印刷済みの注文で、許可範囲と必要な手作業案内が画面に明示される。
Order.payment_status は「その注文が入金済みか」を表す既存の状態として残し、返金・繰越・寄付などのキャンセル後のお金の扱いは別履歴として保持する。
payment_status に REFUNDED / OFFSET / DONATED のような状態を直接足すと、「注文が入金済みだった事実」と「入金をどう処理したか」が混ざるため避ける。Order.status=CANCELED として残し、入金済みだった注文には処理履歴を紐づける。PaymentAdjustment(orders/models.py に配置)。
order: 対象注文(Order、PROTECT)user: 顧客(AUTH_USER_MODEL、PROTECT)action: REFUND(返金) / CARRY_OVER(次回支払いへ繰越) / DONATION(寄付扱い)amount: 対象金額。Phase 5 では注文請求額(order.total_price)の全額固定(管理者編集なし)。部分返金・部分繰越は将来対応。note: 管理者メモcreated_at / handled_at: 記録日時・実処理日時PaymentAdjustment を作成し、「このキャンセル注文のお金がどう処理されたか」を管理者画面で確認できるようにする。「管理者向け注文作成フロー」自体が大きい機能のため、#11 に含めるのはキャンセル側までとし、作成本体は別Issueに切り出す。
#11 で行う範囲:
別Issueへ切り出す範囲(Issue #14 として起票済み):
orders/views.py:151 周辺を参考)。Order.recreated_from = models.ForeignKey('self', null=True, blank=True, on_delete=SET_NULL) とマイグレーション。完了条件(#11): 入金済み注文の扱いが明文化・実装され、後工程注文に「キャンセルして作り直す」入口と引き継ぎ仕様が定義される(作成本体の実装は別Issueに委譲)。
| 変更 | 対象 | フェーズ |
|---|---|---|
PaymentAdjustment(返金・繰越・寄付の処理履歴)追加 |
orders/models.py |
5 |
recreated_from(self-FK, null可)追加 |
orders/models.py:Order |
別Issue #14(作り直し作成本体) |
Phase 1〜4 はモデル変更なしで実装できる。recreated_from は作り直しの作成本体とともに別Issueで追加する。
@transaction.atomic のロールバック。mg_orders/tests.py(order_edit の引当ガード)が壊れないこと。final_shipping_fee)を残箱でクリアして再確定させるか、保持するか(Phase 3)。