#13 のコメント群(#2699 レビュー〜#2713 まで)で確定した設計を土台に、実装の段取りを具体化する。本書は「どのファイルに何を作るか」「どの順で進めるか」を定める作業手順書である。要件・スコープの確定版は #2704(最終スコープ)+ #2706 / #2709 / #2713(追加整理)を正とする。
実コード照合で確認済み。実装はこれを前提にする。
PaymentAdjustment は実装済み。 orders/models.py:263 に action(REFUND / CARRY_OVER / DONATION)・amount(IntegerField, 円)・order(PROTECT)・user(PROTECT)・note / created_at / handled_at を持つ。created_by(操作管理者)は持たない。mg_orders/services/cancellation.py:489 record_payment_adjustment() が PaymentAdjustment(action=CARRY_OVER, amount=order.total_price) を作る。mg_orders/views.py:182-184 から呼ばれる。繰越残高の台帳化・充当は未実装(本 Issue の範囲)。Order.payment_status は UNPAID / PAID の二値のみ(orders/models.py:16-18)。入金額・請求残額・部分入金状態は持たない。→ 本 Issue の「請求残額」は #2704 で 請求残額 = total_price − 繰越充当額 と定義し直し済み。現金/振込の入金台帳は持たない。Order.total_price は動的(orders/models.py:69 = items_subtotal + shipping_fee)。shipping_fee は final_shipping_fee 確定で変わり(orders/models.py:56-59)、items_subtotal は明細編集で変わる。→ 充当は 送料確定済み(is_shipping_fee_finalized == True)注文のみ(#2704)、かつ 充当後は金額が変わる編集を禁止(#2706-B)。Order.is_editable は SHIPPED / DELIVERED / CANCELED 以外で True(orders/models.py:77-78)。送料確定済みでも明細編集が通るため、編集制限は別途ガードが必要。mg_orders/api.py:34 PaymentStatusUpdateView が payment_status を自由に更新できる。→ 「繰越充当」と「現金/振込 PAID」の混在を作れてしまうため、#2713 の案1(両方向ガード)で塞ぐ。order.is_paid で #11 の3択を起動する。 mg_orders/views.py:162 が if order.is_paid: で返金/繰越/寄付を必須選択させ、payment_amount = order.total_price を全額固定で record_payment_adjustment に渡す(views.py:168, 184)。→ 繰越充当で PAID になった注文では二重処理になるため、#2709 の責務分離(has_credit_applied で分岐)が必要。mg_customers/views.py:70 customer_detail_view(テンプレート templates/mg_customers/customer_detail.html、URL dashboard:mg_customers:customer_detail)。繰越残高・履歴の表示はここに足す。mg_orders/views.py order_edit(templates/mg_orders/order_edit.html)。充当導線・残額表示はここと order_list に置く。orders/views.py:253 generate_invoice_pdf(テンプレート templates/orders/invoice_template.html)が顧客横断で grand_total = Σ order.total_price を出力。入金・充当の概念は持たない。領収書 PDF は存在しない。| # | 決定 | 根拠コメント |
|---|---|---|
| D1 | 残高は履歴テーブル CustomerCreditTransaction の合計で算出。残高テーブルは持たない。 |
当初案 / #2699 |
| D2 | 充当は管理者手動のみ。顧客カート自動充当はやらない。 | #2704 |
| D3 | 充当は 送料確定済み注文のみ。送料未確定への充当・送料確定後の自動再調整はやらない。 | #2704 / #2706-B |
| D4 | 部分充当を許可。請求残額 = total_price − 繰越充当額。残額 0 で PAID、残額ありは UNPAID のまま。 |
#2704 |
| D5 | 充当後の注文は 金額が変わる編集を禁止(明細・数量・単価)。変更はキャンセルして作り直しへ。 | #2706-B |
| D6 | 充当先注文をキャンセルしたら、APPLIED_TO_ORDER 分を残高へ戻す逆仕訳を作る(二重戻し防止)。 |
#2706-A |
| D7 | キャンセル時のお金の扱いは支払い原資で分岐。繰越充当履歴あり → #11 の3択スキップ+残高戻し一本化。 | #2709 |
| D8 | 案1: 繰越と現金の混在を初期スコープで禁止。繰越充当履歴がある注文は PaymentStatusUpdateView からの手動 payment_status 変更(PAID/UNPAID 両方向)を拒否/手動 PAID 済み注文は繰越充当禁止。 |
#2713 / #2714-2 |
| D9 | 請求書 PDF に繰越充当額・支払残額を反映。領収書 PDF は #13 では新設しない(将来実装時のみ繰越充当分を二重計上しない方針を残す)。 | #2706-C / #2714-3 |
| D10 | 残高変更(充当・戻し)は transaction.atomic 内で、対象 order 行 → 顧客(user)行の順に select_for_update() でロックし、そのロック内で残高集計・履歴作成・payment_status 更新を行う(同一顧客の同時充当を直列化)。ロック順は order→user に統一する(キャンセル処理が先に order をロックするため、逆順にすると循環待ち=デッドロックになる・#2722)。 |
#2705-5 / #2714-1 / #2722 |
CustomerCreditTransaction のマネージャ/クラスメソッドに置く。残高を変更する操作(充当・繰越加算・戻し)は mg_orders/services/credit.py(新規)に一元化し、cancellation.py と同じサービス層の流儀に合わせる。@transaction.atomic + 残高の確定読み(D10)で行う。| Phase | 内容 | モデル変更 | リスク |
|---|---|---|---|
| 1 | 繰越残高台帳モデル + 繰越加算(#11 接続)+ 顧客詳細表示 | CustomerCreditTransaction 追加 |
中 |
| 2 | 確定請求への繰越充当(部分充当 / 充当判断に必要な残額表示 / 原資混在ガード D8 / 充当先キャンセルの戻し D6 / #11 責務分離 D7 / 編集制限 D5) | なし | 高 |
| 3 | 入金状態表示の拡張(注文一覧の充当済みバッジ・一覧性改善) | なし | 低〜中 |
| 4 | 請求書 PDF への反映(領収書 PDF は新設しない) | なし | 中 |
CustomerCreditTransaction(orders/models.py に配置)PaymentAdjustment と同じ orders アプリに置く。残高は本テーブルの amount 合計で算出する(D1)。
| フィールド | 型 | 備考 |
|---|---|---|
user |
FK AUTH_USER_MODEL, PROTECT |
顧客 |
transaction_type |
CharField(choices) | 下記 enum |
amount |
IntegerField(円) |
残高を増やす=正 / 充当・戻し等で減らす=負。PaymentAdjustment.amount と型を揃える(#2705-3) |
source_order |
FK Order, PROTECT, null |
繰越発生元の注文 |
target_order |
FK Order, PROTECT, null |
充当先の注文 |
payment_adjustment |
FK PaymentAdjustment, PROTECT, null |
#11 の繰越記録への参照 |
note |
TextField(blank) | 管理者メモ |
created_by |
FK AUTH_USER_MODEL, SET_NULL, null |
操作管理者。移行・自動反映分は null(#2705-4) |
created_at |
DateTimeField(auto_now_add) |
注文参照はすべて PROTECT(監査ログ性、#2705-6)。
transaction_type(enum):
| 値 | amount 符号 | 初期実装で生成するか |
|---|---|---|
CARRY_OVER_FROM_CANCELED_ORDER |
+ | する(Phase 1) |
APPLIED_TO_ORDER |
− | する(Phase 2) |
RETURNED_FROM_CANCELED_APPLIED_ORDER |
+ | する(Phase 2 / D6 の逆仕訳) |
REFUNDED_FROM_CREDIT |
− | しない(将来用・生成経路なし、#2705-7) |
ADJUSTMENT |
± | しない(将来用・生成経路なし) |
# CustomerCreditTransaction のクラスメソッド/マネージャ
@classmethod
def balance_for(cls, user) -> int:
return cls.objects.filter(user=user).aggregate(s=Sum('amount'))['s'] or 0
注文単位の繰越充当額(請求残額計算と編集制限・戻しで共用):
def applied_credit_total(order) -> int:
# target_order=order の APPLIED_TO_ORDER 合計(負値)の絶対値から、
# 同 order への戻し(RETURNED_...)を差し引いた、現在有効な充当額
CustomerCreditTransaction 追加(3.1)。balance_for / 集計サービスの追加。mg_orders/services/cancellation.py:489 record_payment_adjustment() で action == CARRY_OVER の場合、同一トランザクション内で CustomerCreditTransaction(CARRY_OVER_FROM_CANCELED_ORDER, amount=+adjustment.amount, source_order=order, payment_adjustment=adjustment, user=order.user) を作る。これにより #11 の記録と #13 の台帳が二重管理にならず、リンクで辿れる。PaymentAdjustment(action=CARRY_OVER) を台帳へ反映する管理コマンド(management/commands/backfill_credit_from_carryover.py)またはデータマイグレーションを用意。created_by=null。冪等(同じ payment_adjustment から二重生成しない)にする。mg_customers/views.py:70 customer_detail_view のコンテキストに credit_balance(balance_for)と credit_transactions(履歴一覧)を追加。templates/mg_customers/customer_detail.html に「繰越残高」と履歴一覧(発生日 / 種別 / 金額 / 元注文 / 充当先注文 / メモ)を表示。PaymentAdjustment の REFUND / DONATION)は台帳に入れないため、残高に混ざらないことをテストで担保。完了条件: 入金済みキャンセルで繰越を選んだ金額が顧客別残高として確認でき、顧客詳細で残高と履歴を辿れる。返金・寄付は残高に混ざらない。
本フェーズが最大の山。充当・原資混在ガード・充当後キャンセルの戻し・#11 責務分離・編集制限を含む。
mg_orders/services/credit.py 新規)@transaction.atomic
def apply_credit_to_order(order, amount, staff):
# 同一顧客の同時充当を直列化(D10): 注文行 → 顧客行の順でロック(order→user 統一・#2722)
order = Order.objects.select_for_update().get(pk=order.pk)
User.objects.select_for_update().get(pk=order.user_id)
# 前提検証(D3/D4/D8)
# - order.is_shipping_fee_finalized == True (D3 送料確定済み限定)
# - order.payment_status == UNPAID (D8: 既に PAID の注文には充当しない)
# - 0 < amount <= min(balance_for(order.user), 請求残額) (D4 上限)
# 請求残額 = order.total_price - applied_credit_total(order)
CustomerCreditTransaction.objects.create(
user=order.user, transaction_type=APPLIED_TO_ORDER,
amount=-amount, target_order=order, created_by=staff,
)
# 残額0なら PAID(D4)
if order.total_price - applied_credit_total(order) == 0:
order.payment_status = PAID
order.save(update_fields=['payment_status'])
min(顧客残高, 請求残額)(D4)。部分充当を許可。order 行だけでなく 顧客(user)行も select_for_update() でロックし、そのロック内で残高集計・履歴作成・payment_status 更新まで行って直列化する。payment_status == UNPAID 検証で足り、支払い原資の厳密判定(_is_manually_marked_paid)は初期スコープでは不要(#2714 軽微補足)。order_edit(templates/mg_orders/order_edit.html)に、充当を判断するための最小表示を Phase 2 時点で必須実装する:
PAID になるか UNPAID のまま残額ありか。min(残高, 請求残額)、確認)を表示。@require_POST / staff / @transaction.atomic)。PAID 済み・残高不足・請求額超過は理由を出して拒否(direct POST も拒否)。payment_status 変更を全面禁止: mg_orders/api.py PaymentStatusUpdateView で、繰越充当履歴がある(applied_credit_total(order) > 0)注文は PAID/UNPAID いずれへの手動変更も拒否する。全額繰越で PAID の注文を手動 UNPAID に戻すのも不整合になるため、PAID 化禁止だけでなく変更自体を止める。apply_credit_to_order は payment_status == PAID の注文への充当を拒否(初期スコープでは「既に PAID なら充当しない」の単純判定で足りる)。PAID/UNPAID 更新は従来どおり許可。has_credit_applied(order)(= 有効な APPLIED_TO_ORDER 履歴がある)を共通化し、5.4 / 5.5 でも使う。mg_orders/services/cancellation.py のキャンセル実行に、戻し処理を組み込む。has_credit_applied(order) なら、applied_credit_total(order) 分を CustomerCreditTransaction(RETURNED_FROM_CANCELED_APPLIED_ORDER, amount=+戻し額, target_order=order, created_by=staff) として残高へ戻す。RETURNED_... がある/order.status == CANCELED の再実行では戻しを作らない(select_for_update + CANCELED ガード。#11 の二重キャンセル防止と同じ流儀)。mg_orders/views.py:162 の if order.is_paid: を if order.is_paid and not has_credit_applied(order): に変更。
PAID → 従来どおり #11 の3択。UNPAID)のキャンセルは is_paid == False で元々3択が起動しないため、5.4 の APPLIED_TO_ORDER 履歴ベースの戻しで正しくカバーされる(#2711 で確認済み)。applied_credit_total(order) > 0 の注文では、items_subtotal が変わる編集(商品・数量・単価)を禁止。
order_edit 側の保存処理でガードし、direct POST も拒否。完了条件: 管理者が送料確定済み・未充当/部分充当の注文へ繰越を全額/部分充当でき、履歴が残り元注文・充当先注文を辿れる。残高不足・請求額超過・送料未確定・原資混在は拒否。充当先キャンセルで充当額が残高へ戻り、二重戻し・現金返金漏れが起きない。充当済み注文は金額が変わる編集ができない。
充当判断に必要な表示(注文総額/残高/充当額/請求残額/充当後の PAID・UNPAID)は Phase 2 の操作画面で実装済み とする。Phase 3 は周辺画面への表示拡張・一覧性改善に絞る。
order_list(注文一覧)に「繰越充当済み/残額あり」のバッジ・列を追加し、一覧で充当状況が分かるようにする。PAID、残額あり → UNPAID のまま。UNPAID でも「繰越充当済み・残額あり」と区別できる表示を一覧へ展開。完了条件: 注文一覧で繰越充当状況(充当済み/残額あり)が一覧でき、操作画面(Phase 2)の表示と整合する。
orders/views.py:253 generate_invoice_pdf / templates/orders/invoice_template.html を改修。grand_total(支払残額)と整合させる。完了条件: 請求書 PDF に繰越充当額・支払残額が反映され、画面表示と一致する。領収書 PDF は #13 では新設しない。
| 変更 | 対象 | フェーズ |
|---|---|---|
CustomerCreditTransaction 追加 |
orders/models.py |
1 |
既存 PaymentAdjustment(CARRY_OVER) の台帳バックフィル(管理コマンド/データ移行、冪等) |
orders/management or migration |
1 |
Phase 2〜4 はモデル変更なし。PaymentStatusUpdateView / order_edit / cancellation.py / generate_invoice_pdf の挙動変更のみ。
balance_for 集計。バックフィルの冪等性。PAID。部分充当→UNPAID のまま残額が残る。min(残高, 請求残額))。payment_status 変更(PAID/UNPAID 両方向)を拒否/PAID 済み注文への充当を拒否/繰越充当なし注文の手動更新は従来どおり許可。total_price − 繰越充当額 と一致。mg_orders/tests.py のキャンセル・編集ガード、orders の請求書)が壊れないこと。#13 は以下をすべて満たすまでクローズしない。
PAID・残額ありを UNPAID(充当済み表示)として扱える。PAID になった注文のキャンセルで #11 の3択が起動せず、繰越充当なし PAID では従来どおり起動する。payment_status 変更を全面拒否)。PaymentAdjustment 記録と矛盾しない。UNPAID で作成され、送料確定後に残高が十分あれば #13 の充当で PAID にできる。REFUNDED_FROM_CREDIT / ADJUSTMENT の操作導線(enum 定義のみ、将来用)。mg_orders/services/credit.py とするか、orders 側に置くか(読み取りはモデル、変更はサービスで分離する前提)。order_edit に統合するか、入金管理用の別操作にするか。