Issue #17 は、2025年分の受注・出荷・入金・在庫・商品公開状態を安全に締め、2026年の受注開始へ切り替えるための専用機能を作る Issue である。
このドキュメントでは、年度切替を単なる手作業チェックではなく、管理者向けの「年度締め・年度切替」機能として実装するための方針を整理する。
重要なのは、最初から本番データを大きく書き換える一括処理を作ることではない。まずは締め前チェックをドライランとして実装し、何が残っているかを管理者が一画面で判断できる状態にする。そのうえで、商品販売停止・2026年在庫準備・年度切替ログなどの実行処理を段階的に追加する。
実コードとドキュメントから、現時点では次を前提にする。
Order.created_at がある。sales_year / season / crop_year のような明示的な年度フィールドはない。Product.status=販売開始 の商品を対象にする。Product.sales_year を導入する。Stock.total_supplied_kg - Stock.total_ordered_kg で購入可能量を出す。Order.status / Package.status などの状態から未処理を集計する。CustomerCreditTransaction の合計で算出する。したがって、年度切替機能は「年度モデルを入れるかどうか」と「まず本番で使える締め前チェックを出すこと」を分けて考える。
管理者が年度締め画面を開くと、次のことができる。
画面の目的は「管理者が内部データ構造を知らなくても、締め漏れを見つけられる」ことである。
初期実装は、次の段階構成にする。
| Phase | 内容 | DB変更 | リスク |
|---|---|---|---|
| 1 | 年度締めドライラン画面。未処理件数・対象一覧を表示する | なし | 低 |
| 2 | 年度切替ログモデルと、商品・在庫を年度切替画面から扱う土台を作る | あり | 中 |
| 3a | Product.sales_year 導入・SystemSetting.active_sales_year 導入・商品マスタの年度UI |
あり | 中 |
| 3b | 購入者向け年度検証・2025年締め実行(対象年度の販売中商品の停止、active_sales_year 切替、実行ログ記録) |
あり | 中〜高 |
| 4 | 注文・在庫の年度別管理の本格導入を検討 | あり | 高 |
Phase 1 は必ず最初に入れる。これにより、本番データを変更せずに「どのくらい未処理が残っているか」を把握できる。
Phase 2 以降は、Phase 1 の結果を見てから詳細を詰める。特に年度モデルをどこまで入れるかは、2025年データの実態を見て判断する。
管理者向け補助機能として dashboard 配下に置く案を推奨する。
/management/year-end/dashboard:year_enddashboard.views.year_end_view または専用アプリ mg_year_endtemplates/dashboard/year_end.html最初は dashboard に置いてよい。年度締めは注文・商品・在庫・出荷を横断する入口機能であり、既存のダッシュボード補助機能の性格に近い。
ただし、Phase 2 以降でモデルやサービスが増える場合は mg_year_end アプリを切る選択肢もある。
対象年度
締め前チェック
判定結果
OK: 締め実行可能WARN: 確認が必要だが、管理者判断で進められるBLOCK: 先に処理しないと締め実行不可次にすること
締め実行エリア
画面 view に集計ロジックを直書きせず、サービス関数へ切り出す。
候補:
# dashboard/services/year_end.py
def build_year_end_preview(closing_year: int, next_year: int) -> YearEndPreview:
...
戻り値は dataclass または単純な dict でよい。Phase 1 ではテンプレートに渡しやすい dict で始めてもよいが、テストしやすさを考えると dataclass が望ましい。
例:
@dataclass
class YearEndPreview:
closing_year: int
next_year: int
blockers: list[YearEndCheckItem]
warnings: list[YearEndCheckItem]
summaries: dict[str, Any]
can_close: bool
現状 dashboard/ 配下には services/ ディレクトリがないため、Phase 1 実装時に dashboard/services/__init__.py と合わせて新設する。サービス層の前例は mg_orders/services/ と mg_workflow/services/ にある。
Order.objects.filter(status=Order.OrderStatus.NEW)
年度フィールドがないため、Phase 1 では全件を対象にする。日付で絞る場合は created_at__year=closing_year を使えるが、2025年以前から残った注文も見落としたくないため、最初は「閉じていない注文は年度に関わらず表示」が安全。
Order.objects.exclude(
status__in=[
Order.OrderStatus.NEW,
Order.OrderStatus.SHIPPED,
Order.OrderStatus.DELIVERED,
Order.OrderStatus.CANCELED,
]
)
NEW は未受付注文として別枠で扱うため、対応中注文のクエリからも除外する。対応中注文は ACCEPTED / PACKED など、後工程が進んでいる注文を中心に出す。
Order.objects.exclude(status=Order.OrderStatus.CANCELED).filter(final_shipping_fee__isnull=True)
ただし NEW 注文は参考送料だけでよい状態なので、締め判定では ACCEPTED 以降を強く見る。
Order.objects.exclude(status=Order.OrderStatus.CANCELED).filter(
payment_status=Order.PaymentStatus.UNPAID
)
繰越充当で残額ありの注文も UNPAID のままになるため、可能なら mg_orders.services.credit.remaining_billed_amount(order) と合わせて表示する。remaining_billed_amount は mg_orders/services/credit.py の関数であり、mg_workflow 配下ではない点に注意する。
Package.objects.filter(
status__in=[
Package.PackageStatus.PACKAGING,
Package.PackageStatus.READY_TO_SHIP,
Package.PackageStatus.LABEL_PRINTED,
]
)
READY_TO_SHIP / LABEL_PRINTED は発送前の状態なので、年度締め前に確認対象にする。DELIVERED は手渡し済み、SHIPPED は発送済みとして基本的に閉じた扱いにする。
CustomerCreditTransaction.balance_for(user) を顧客単位で一覧化する。件数が多くない想定なら Python 側集計でもよいが、将来は DB 集計へ寄せる。
Phase 1 では、まず CustomerCreditTransaction.objects.values("user_id").annotate(balance=Sum("amount")).exclude(balance=0) 相当で「残高が0ではない顧客」を抽出し、顧客情報と合わせて表示する方針にする。balance_for(user) は単一顧客の残高確認には便利だが、全顧客一覧で N+1 にしないよう注意する。
Product.objects.filter(status=Product.ProductStatus.FOR_SALE)
年度フィールドがないため、Phase 1 では販売中商品すべてを表示する。商品名に年度が含まれない場合は、2025年向けか2026年向けか画面だけでは判断できない点も警告として出す。
Stock.objects.select_related("variety").order_by("variety__name")
total_supplied_kg / total_ordered_kg / available_kg を表示する。年度締め時に total_ordered_kg をゼロにするかどうかは、現行モデルでは危険なので Phase 1 では変更しない。
初期ルールは保守的にする。
| 項目 | 判定 | 理由 |
|---|---|---|
ACCEPTED 以降で未発送・未手渡しの注文あり |
BLOCK | 出荷・送料・請求が閉じていない可能性が高い |
NEW 注文あり |
WARN | キャンセルまたは受付判断が必要 |
ACCEPTED 以降で final_shipping_fee is NULL |
BLOCK | 請求額が確定していない |
| 未入金注文あり | WARN または BLOCK | 運用判断。締め前に必ず回収するなら BLOCK |
PACKAGING の箱あり |
BLOCK | パッケージング途中 |
READY_TO_SHIP / LABEL_PRINTED の箱あり |
WARN | 発送前かステータス更新漏れの可能性 |
| 繰越残高あり | WARN | 2026年へ持ち越す残高として確認が必要 |
| 販売中商品あり | WARN | 締め実行で停止対象か確認が必要 |
判定ルールはコードにベタ書きせず、少なくともサービス関数内で項目ごとにまとめる。将来、設定化したくなる可能性が高い。
一括更新を行う前に、確認結果を残すモデルを追加する。Phase 2 は「確認結果の保存」のみを行い、商品停止・在庫変更・年度切替の実行は行わない。
実装したモデル(year_end/models.py):
class YearEndRun(models.Model):
class Status(models.TextChoices):
DRY_RUN_SAVED = 'DRY_RUN_SAVED', 'ドライラン結果保存'
closing_year = models.PositiveIntegerField()
next_year = models.PositiveIntegerField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRY_RUN_SAVED)
dry_run_snapshot = models.JSONField(default=dict)
executed_actions = models.JSONField(default=dict)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='year_end_runs')
created_at = models.DateTimeField(auto_now_add=True)
executed_at = models.DateTimeField(null=True, blank=True)
status は Phase 2 時点で意味が曖昧にならないよう DRY_RUN_SAVED(ドライラン結果保存)の1値のみを定義した。Phase 3b で締め実行を追加する際、実行系の状態(実行中・実行完了・失敗等)を choices に追加する想定であり、現時点では先取りしない。
実装案の当初案は配置候補として dashboard.models または新アプリ mg_year_end.models を挙げ、mg_year_end を推奨していた。しかし実装時に現行コードを確認したところ、docs/design/アーキテクチャ.md に明記された既存の責務分割(「mg_* および dashboard は独自モデルを持たず、accounts/products/orders/system_settings のようなデータ所有アプリのモデルを操作するビュー・API層として機能する」)と両案とも矛盾することが分かった(mg_orders/mg_customers/mg_masters/mg_workflow/dashboard はいずれも models.py が空でマイグレーション0件)。
そこで、system_settings(専用の管理UI・URLを持たずデータのみを所有する小規模アプリ)を precedent として、プレフィックスなしの新規アプリ year_end を作成し YearEndRun モデルのみを持たせる方針に変更した。画面・URL・集計/保存サービス(dashboard/services/year_end.py)は引き続き dashboard が持ち、year_end.models.YearEndRun を操作する(mg_orders が orders.models.Order を操作する関係と同じ)。この判断は Issue #17 に実装前コメントとして記録している。
closing_year / next_year)dry_run_snapshot。形式は 管理者向け補助機能 §2.1 を参照)executed_actions。Phase 2 では常に {})created_by。request.user から設定し、POST値は信用しない)created_at)・実行日時(executed_at。Phase 2 では常に None)「実行時に管理者が確認したチェック項目」は、dry_run_snapshot.checks の各要素(key/label/status/count/detail/items)としてすべて記録される。
YearEndPreview はモデルインスタンス・Decimal・datetime を含むため、そのまま JSONField へは保存できない。dashboard.services.year_end.serialize_year_end_preview() がプリミティブ値のみの dict に変換してから保存する。version フィールド(現在 1)を持たせ、将来チェック項目やシリアライズ形式が変わっても、過去に保存済みのスナップショットを形式ごとに区別して解釈できるようにした。個人情報は表示に必要な username のみを複製し、メールアドレス等は複製しない。
年度締めは本番データを変える操作(Phase 3 以降)につながるため、あとから「何を確認したか」を見えるようにする、という Phase 2 の目的は達成している。
Phase 3 で対象年度の商品だけを安全に停止するには、商品自体が販売年度を持つ必要がある。品種(ProductVariety)は年度を跨いで使うマスタなので年度を持たせず、Product.sales_year を必須フィールドとする。
レビュー(Issue #17 コメント #3237)とその回答(コメント #3238)を踏まえ、Phase 3 は次の2段階に分割する。
| サブフェーズ | 内容 | 依存する未決事項 |
|---|---|---|
| Phase 3a | Product.sales_year 導入・一意制約変更・商品マスタの年度UI・SystemSetting.active_sales_year 導入(用途は管理画面の年度デフォルト値計算に限定) |
なし。単独で着手・完了できる |
| Phase 3b | 購入者向け表示・カート・注文作成での年度検証、締め実行(BLOCK制御・確認チェックボックス・実行ログ・再ドライラン・active_sales_year の切替) |
購入者向け年度検証の境界、YearEndRun.Status の実行系状態 |
Phase 3a は購入者向け機能を一切変更せず、管理者向け画面(商品マスタ・注文管理)だけを対象にする。Phase 3b は Phase 3a のスキーマを前提に、購入者向けの年度切替本体と実際の締め実行を実装する。
Product.sales_year(実装済み)Product.sales_year(PositiveIntegerField、verbose_name='販売年度')を追加した。モデルには恒久的な default を設定していない(テストの都合で実仕様を弱めない。Issue #17 コメント #3238)。products/migrations/0004_product_sales_year.py — sales_year を null=True で追加。products/migrations/0005_populate_product_sales_year.py — データマイグレーションで既存商品全件に sales_year=2025 を設定。products/migrations/0006_product_sales_year_not_null.py — sales_year を null=False 化し、unique_together を (sales_year, variety, type, weight_kg) へ変更。Product.name は変更していない。 Product.save() の名前生成規則("{variety.name} {type} {weight_kg}kg")に販売年度は含めない。販売年度は商品名の一部ではなく独立した属性として扱う(Issue #17 コメント #3238)。年度違いの商品を区別する必要がある画面では、sales_year を商品名とは別の項目・表示ラベルとして扱う(Phase 3a-3 参照)。Product.objects.create(...) の全16箇所(本番: mg_masters/views.py 1箇所、テスト: mg_orders/tests.py 8箇所・orders/tests.py 4箇所・dashboard/tests.py 2箇所・mg_workflow/tests.py 1箇所)を、モデル・マイグレーションの追加と同一コミットで更新し、明示的に sales_year を渡すようにした。SystemSetting.active_sales_year(実装済み)商品マスタの作成フォームで「今どの年度の商品を登録すべきか」を毎回手動選択させないため、現在の運用対象年度を SystemSetting.active_sales_year として保持する。この用途は Phase 3a では「管理画面の年度候補・デフォルト値の計算」に限定し、購入者向け表示・カート・注文作成の年度検証(Phase 3b)には使わない。
SystemSetting.active_sales_year(PositiveIntegerField、verbose_name='アクティブ販売年度')を追加した。既存の SystemSetting フィールドと同様、Django 管理サイト(/admin/)で編集できる。SystemSetting(本番同期済み開発DBを含む)には、Product.sales_year と同様に3段階のマイグレーション(system_settings/migrations/0005_active_sales_year.py で null=True 追加 → 0006_populate_active_sales_year.py で active_sales_year=2025 を設定 → 0007_active_sales_year_not_null.py で null=False 化)で対応した。default=2025 を設定していない。SystemSetting.load() が初めてレコードを作成する場合のみ、作成時点の年(timezone.localdate().year)を初期値にする(get_or_create(pk=1, defaults={'active_sales_year': timezone.localdate().year}))。defaults は新規作成時のみ適用され、既存レコードがあれば無視される。active_sales_year は、システム日付が変わっても自動更新しない(load() は既存レコードをそのまま返すだけで、値を書き換えない)。2025 から 2026 への切替は、Phase 3b の明示的な「年度切替操作」でのみ行う(Phase 3a では切替処理を実装していない)。product_list_view): 初期表示は active_sales_year でフィルタする。GET パラメータ sales_year=all で「すべての年度」に切り替えられる。一覧に「販売年度」列を追加した。sales_year の初期選択値を active_sales_year にした。sales_year の初期選択値を、保存済みの product.sales_year にした。{active_sales_year - 1, active_sales_year, active_sales_year + 1} ∪ Product.objects.values_list('sales_year', flat=True).distinct() の和集合をドロップダウンの選択肢にした(mg_masters.views._get_sales_year_choices)。mg_masters.views._resolve_sales_year、フォームエラーとし登録しない)。mg_orders)の商品選択肢(order_create/order_edit)では、Product.name は変更せず、表示のみ [{{ product.sales_year|unlocalize }}年度] {{ product.name }} のように年度を付加した。保存データ・検索キーには影響しない。|unlocalize は USE_THOUSAND_SEPARATOR=True により年度が 2,025 のようにカンマ区切り表示されるのを防ぐために必要(実装中に発見した表示バグ。同種のバグが Phase 1/2 の templates/dashboard/year_end.html の年度表示にも既存していたため、あわせて修正した)。Phase 3b(初期の締め実行)で自動化する処理は絞る。
管理者が選択した商品を 販売停止中 または 未公開 に変更する。
既存の mg_masters.product_bulk_action_view に販売ステータス一括変更があるため、処理自体は既存仕様と近い。
Product.sales_year=closing_year かつ status=FOR_SALE の商品のみを締め実行の対象候補にする。next_year の商品は画面で選択できないだけでなく、サーバ側でも更新対象から拒否する。これにより、2026年向け商品の誤停止を管理者の目視だけに依存させない。
在庫は Stock.total_supplied_kg と Stock.total_ordered_kg の差分で購入可能量を出す。現行モデルのまま total_ordered_kg を一括でゼロに戻すと、過去注文との整合性が壊れる可能性がある。
そのため、初期実装では在庫の一括初期化は自動化しない。やる場合は、年度モデル導入または年度別在庫モデルを検討してからにする。
短期案:
total_ordered_kg は過去注文の集計として残す。中長期案:
Stock を年度別にする。SeasonStock(season, variety, total_supplied_kg, total_ordered_kg)SystemSetting.active_sales_year は Phase 3a で追加済み(用途は商品マスタの年度デフォルト値計算に限定)。Phase 3b では、年度締め実行時にこの値を closing_year から next_year へ更新する処理を追加し、購入者向け商品表示・カート・注文作成が active_sales_year を見て年度を検証するようにする。これにより、2025年の履歴を残したまま2026年受注へ切り替えられる。
SalesSeason のような専用モデルへの本格移行(注文・在庫の年度別管理)は、業務要件として必要であるとPhase 4方針で確定した。注文は必ず1年度に属し、食品在庫は年度をまたいで持ち越さず、新年度を0から開始する。
SalesSeason を追加するclass SalesSeason(models.Model):
year = models.PositiveIntegerField(unique=True)
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=False)
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
関連付け候補:
Product.seasonOrder.seasonStock を SeasonStock へ移行メリット:
懸念:
Product.sales_year を追加する(Phase 3a で採用)品種は年度を持たせず、商品に必須の販売年度を持たせる。注文は注文明細に紐づく商品の年度から追跡できるが、注文自体への年度保持は Phase 4 で検討する。
メリット:
懸念:
Phase 1 の方針。現時点の推奨スタート地点。
メリット:
懸念:
dashboard/services/year_end.py を追加する。build_year_end_preview(closing_year, next_year) を実装する。dashboard.views.year_end_view を追加する。dashboard/urls.py に /year-end/ を追加する。templates/dashboard/year_end.html を追加する。mg_year_end アプリを追加するか、dashboard.models.YearEndRun を追加する。mg_*/dashboard はモデルを持たない)との整合性を確認し、新規アプリ year_end(プレフィックスなし、system_settings 相当のデータ所有アプリ)を追加した。判断理由は「Phase 2: 年度切替ログモデル」節を参照。YearEndRun モデルと migration(year_end/migrations/0001_initial.py)を作った。dashboard.services.year_end.serialize_year_end_preview() でドライラン結果を JSON snapshot に変換し、save_year_end_run() で保存できるようにした。year_end_save_view(POST、スタッフ限定、PRGパターン)を追加し、過去の年度締め確認結果を year_end_view の画面下部に表示するようにした。Product.sales_year を追加した(3段階マイグレーション: null=True で追加 → データマイグレーションで 2025 を付与 → null=False 化)。(sales_year, variety, type, weight_kg) へ変更した。Product.objects.create(...) 呼び出し16箇所を、モデル・マイグレーションの追加と同一コミットで更新した。SystemSetting.active_sales_year を追加した(3段階マイグレーション: null=True で追加 → データマイグレーションで 2025 を付与 → null=False 化)。SystemSetting.load() を、新規レコード作成時のみ現在年を初期値にするよう更新した。active_sales_year 初期選択)・編集フォーム(保存済み年度初期選択)・年度候補生成(active±1 ∪ 既存値)・「その他の年度を入力」(ドロップダウンとの同時指定は拒否)を実装した。Product.name は変更しない)。products / system_settings / mg_masters / mg_orders)。docs/design/データベース設計.md を更新した。USE_THOUSAND_SEPARATOR=True により年度が 2,025 のようにカンマ区切りで表示される)を修正した。Phase 1/2 で実装済みの templates/dashboard/year_end.html にも同種のバグがあったため、あわせて修正した。購入者向け年度検証の境界(実装済み):
products.views.product_list): status=FOR_SALE かつ sales_year=active_sales_year の商品だけを表示する(products.services.purchasable_products_queryset)。商品詳細の専用画面は存在せず、一覧が実質的な詳細表示を兼ねるため別対応は不要。cart.views.update_cart): 数量が増える操作のみ products.services.is_product_purchasable で検証する。減量・削除は年度切替前のカート内容を出せなくなることを防ぐため常に許可する。この経路は元々 Product.status の検証すら行っていなかった既存ギャップでもあった。orders.views.order_confirm / order_create): カート内の全商品を検証し、対象外があれば黙って除外せず、商品名を明示したエラーメッセージとともに商品一覧へリダイレクトする。order_confirm を経由しない直接POSTにも対応するため、order_create でも同じ検証を独立して行う(防御的多層化)。
order_create は SystemSetting.load() でロックなしに active_sales_year を読んでおり、年度締め実行(execute_year_end)と同時に走ると、年度締めが SystemSetting をロックして再集計している間に order_create が旧年度の active_sales_year を読んで購入可能と誤判定し、年度締め完了後に旧年度商品の未受付注文が残る競合があった。order_create も execute_year_end と同じ SystemSetting 行(pk=1)を select_for_update() でロックしてから active_sales_year を読み、注文作成完了までロックを保持するよう修正した(order_confirm は表示のみで業務データを変更しないためロック不要のまま)。両者とも「SystemSetting → (年度締めは Product、注文確定は Stock)」の順でロックするため、Product/Stock 間で相互待ちにならずデッドロックは起きない。詳細は 管理者向け補助機能 §2.2 の「二重実行・同時実行対策」を参照。orders.views.order_update): 数量を増やす明細のみ検証する。減量・全削除(実質キャンセル)は常に許可する。管理者側(mg_orders.views.order_create/order_edit)には年度制限を適用しない。Phase 3a どおり全年度の FOR_SALE 商品を選択肢に含める(理由は Issue #17 コメント #3241、注文管理 を参照)。
締め実行(dashboard.services.year_end.execute_year_end()、単一の transaction.atomic()):
SystemSetting を select_for_update() でロックし、active_sales_year が closing_year と一致するか再確認する(二重実行・同時実行対策の主ガード)。sales_year=next_year かつ status=FOR_SALE の商品が1件以上あるか確認する(YearEndPreview.next_year_ready_count)。sales_year=closing_year かつ status=FOR_SALE の商品を select_for_update() でロックし、SUSPENDED へ一括変更する(next_year の商品には触れない)。SystemSetting.active_sales_year を next_year へ更新する。YearEndRun を status=EXECUTED で作成し、実行直前のプレビューを dry_run_snapshot、変更内容を executed_actions に保存する。1回目の成功で active_sales_year が next_year へ変わるため、同じ closing_year での2回目の実行はステップ1の再確認で自然に拒否される。実行後は PRG パターンで年度締め画面へ戻り、再ドライランで残件を確認できる。詳細は 管理者向け補助機能 §2.2。
業務要件として次を確定した(Issue #17 コメント #3254)。
SalesSeason または同等の年度モデルを導入し、年度の準備中・受付中・締め済み等の状態を管理する。Orderを必ず1つの販売年度へ紐付ける。OrderItem.productの年度を注文年度と一致させ、購入者・管理者の両経路で年度混在注文を禁止する。Package・Allocation・Bagを注文・商品年度と整合させ、年度混在箱を禁止する。具体的なモデル構造・状態遷移・段階的移行手順・フェーズ分割・決定事項は、本ドキュメント末尾の 「Phase 4 詳細設計」 を参照。この節はコード実装・マイグレーション作成の前段階の設計である。Phase 4着手をブロックしていた判断事項はすべて確定済み(Issue #17、2026-07-14)である。
ACCEPTED 注文があると BLOCK になる。ACCEPTED 以降で final_shipping_fee is NULL の注文があると BLOCK になる。PACKAGING の箱があると BLOCK になる。year_end/tests.py)。YearEndRun.dry_run_snapshot に保存でき、created_by が保持される。Decimal・datetime が残らない(json.dumps() が成功することで検証)。count)・items の長さが元の YearEndPreview と一致する。dashboard:year_end へ redirect される(PRG)。closing_year >= next_year)を拒否し、YearEndRun を作成しない。created_by は無視され、常に request.user が使われる。Order / Package / Product / Stock 等の業務データが変更されない。products.tests / system_settings.tests / mg_masters.tests / mg_orders.tests に対応)sales_year を指定せずに Product.objects.create(...) を呼ぶと失敗する(必須であることの確認)。sales_year=2025 が設定されている。sales_year が異なれば登録できる。sales_year・品種・種類・容量の組み合わせは重複登録できない(IntegrityError)。SystemSetting.load() は、レコードが存在しない場合のみ現在年を active_sales_year の初期値にする。SystemSetting.load() は、既存レコードがあれば active_sales_year を書き換えない(何度呼んでも不変)。sales_year 初期選択値が active_sales_year になっている。sales_year 初期選択値が保存済みの値になっている。{active-1, active, active+1} ∪ 既存 sales_year 値と一致する。active_sales_year でフィルタされている。Product.name 自体は変更されていない。products.tests / cart.tests / orders.tests / dashboard.tests / year_end.tests に対応)購入者側:
order_confirm を経由しない直接POSTでも order_create が同じ検証で拒否する。ドライラン:
products_for_sale チェックが closing_year の商品だけを対象にし、next_year の商品を含まない。sales_year が保存される。next_year_ready_count が next_year 向け FOR_SALE 商品数と一致する。締め実行(execute_year_end / year_end_execute_view):
active_sales_year が closing_year と不一致・next_year 側の販売中商品が0件・WARN未確認・バックアップ未確認・締める年度の再入力不一致・不正な年度値、のいずれでも実行不可(業務データ・YearEndRun とも一切変更されない)。closing_year の販売中商品だけが SUSPENDED になり、next_year の商品は変更されない。active_sales_year が next_year に切り替わる。YearEndRun が status=EXECUTED で作成され、dry_run_snapshot / executed_actions / executed_at が保存される。Order / Package / Stock 等の業務データは変更されない。active_sales_year・YearEndRun すべてがロールバックされる(YearEndRun.objects.create を強制的に失敗させて確認)。closing_year での2回目の実行は拒否される(二重実行対策)。TransactionTestCase + threading)。年度締め対注文確定の同時実行(レビュー指摘対応、orders.tests.OrderCreateYearEndConcurrencyTests):
threading.Event で実際のロック取得順を制御し、開始順だけに依存しない(偶然成功する構造にしない)。SystemSetting をロックした場合: 注文は正常に作成され、後続の年度締めは新規注文(payment_status=UNPAID)による BLOCK を再集計で検出して拒否される。active_sales_year・旧年度商品の状態は変わらず、EXECUTED の YearEndRun は作成されない。SystemSetting をロックした場合: 年度締めは正常に完了し(YearEndRun が1件だけ EXECUTED で作成され、active_sales_year が next_year になる)、後続の注文確定は更新後の active_sales_year を読んで旧年度商品を購入不可と判定し拒否する(注文は作成されない)。実装時は次のドキュメントも更新する。
docs/features/管理者向け補助機能.md
docs/design/アーキテクチャ.md
year_end を追加したため、アプリ一覧表・責務分割節を更新する。(Phase 2 実装済み。Phase 3b で dashboard が system_settings も操作する旨を明記済み)docs/features/商品在庫マスタ.md
docs/features/注文管理.md
docs/overview/要件定義.md
docs/design/データベース設計.md
Product.sales_year の追加・一意制約の変更、SystemSetting.active_sales_year の追加を反映する。(Phase 3a 実装済み。Phase 3b で active_sales_year の用途拡大を反映済み)docs/features/購入者向け機能.md
SalesSeasonモデル、注文の年度必須化、年度別在庫はPhase 4で導入する方針に決定した。CustomerCreditTransaction、CreditApplicationAllocation、reversal_ofで監査可能にする設計へ確定した。「未入金注文がある場合に年度締めを BLOCK にするか WARN にするか」は Phase 1 で解決済み(BLOCK を採用。理由は 管理者向け補助機能 §2 を参照)。
「active sales year を
SystemSettingに持たせるか」は Phase 3a で解決済み(SystemSetting.active_sales_yearを採用)。Phase 3b で購入者向けの年度検証・年度締め実行時の切替の両方に利用する形で決着した。「年度締め実行前のバックアップ確認を画面上で必須にするか」は Phase 3b で解決済み(必須のチェックボックスとして実装。管理者向け補助機能 §2.2 参照)。
「
YearEndRun.Statusへどんな choices を追加するか」は Phase 3b で解決済み(EXECUTEDの1値を追加。同期・短時間のトランザクションのためRUNNING/FAILEDは先取りしない。失敗時はtransaction.atomic()によりYearEndRun自体を作成しない設計とした)。「購入者向け商品表示・カート・注文作成で
active_sales_yearを検証する具体的な境界」は Phase 3b で解決済み(商品一覧・カート追加/数量変更・注文確認/確定・購入者側注文変更の5経路。詳細は Issue #17 コメント #3241、購入者向け機能 を参照)。
初回は Phase 1 に絞る。
OK / WARN / BLOCK。このスコープなら、年度切替専用機能としての価値をすぐ出しつつ、本番データを壊すリスクを抑えられる。
この節は Issue #17 コメント #3254 で確定した業務要件(前掲9項目)を、実装可能な粒度まで具体化したものである。この節自体はコード実装・マイグレーション作成を含まない。 「6. 決定事項」のうち着手をブロックしていた項目は、2026-07-14のユーザー判断ですべて確定した。
現時点(2026-07-14)で Order は全64件が単一の販売年度(2025年)に属する(Issue #17 コメント #3253 で確認済み)。Product.sales_year は2025・2026の2値のみ存在し、Stock は品種単位で3件のみ、Package は98件(コメント#3235参照)。年度をまたぐ「混在」の実例は存在しない。この単純さは移行の安全性を大きく高めるため、以下の設計はこの前提(既存注文・既存箱は全件単一年度)に強く依存している。移行時はこの前提を管理コマンドで機械的に検証し、崩れていれば移行を中断する(4節、Phase 4-D・4-F参照)。
年度・在庫に関わる経路と、参照/更新するモデルを全経路で洗い出した。
| # | 経路 | ファイル | 現状参照 | Phase 4 での変更 |
|---|---|---|---|---|
| 1 | 商品一覧 | products/views.py::product_list |
Product.status/sales_year、Stock(全品種) |
SeasonStock(active season) |
| 2 | 購入可否判定 | products/services.py::is_product_purchasable |
SystemSetting.active_sales_year |
SystemSetting.active_season.year |
| 3 | サイドバー在庫サマリー | dashboard/context_processors.py::stock_summary |
Stock(全品種) |
SeasonStock(active season) |
| 4 | カート追加・数量変更 | cart/views.py::update_cart、cart/cart.py |
Stock.select_for_update()(品種)、SystemSetting.load() |
SeasonStock.select_for_update()(active season, 品種) |
| 5 | 注文確認 | orders/views.py::order_confirm |
Stock(読取のみ)、SystemSetting.load() |
SeasonStock(active season, 読取のみ) |
| 6 | 注文確定(購入者) | orders/views.py::order_create |
SystemSetting.select_for_update()、Stock.select_for_update() |
SystemSetting(.active_season経由でロック対象は同じ行)、SeasonStock.select_for_update()(active season)。Order.seasonをactive seasonで設定。全明細のproduct年度検証を追加 |
| 7 | 注文変更(購入者) | orders/views.py::order_update |
Stock.select_for_update()、SystemSetting.load() |
SeasonStock.select_for_update()(その注文が属するseason。activeとは限らない)。増量時のみproduct年度=order.season検証 |
| 8 | 注文キャンセル(購入者) | orders/views.py::order_cancel |
Stock.select_for_update() |
SeasonStock.select_for_update()(その注文が属するseason) |
| 9 | 請求書PDF | orders/views.py::generate_invoice_pdf |
Order/CustomerCreditTransaction |
変更なし(Order.seasonは表示に使わないが将来の年度別内訳表示に利用可) |
| 10 | 管理者注文作成 | mg_orders/views.py::order_create |
Stock.select_for_update()、商品選択肢は全年度 |
SeasonStock.select_for_update()(選択したseason)。season選択UIを追加。商品選択肢を選択seasonでフィルタ |
| 11 | 管理者注文編集 | mg_orders/views.py::order_edit |
Stock.select_for_update()、新規商品選択肢は全年度 |
SeasonStock.select_for_update()(order.season)。新規商品選択肢をorder.seasonでフィルタ。order.season自体は変更不可(決定事項3) |
| 12 | 管理者キャンセル | mg_orders/services/cancellation.py::restore_stock_reservation |
Stock.select_for_update()(品種) |
SeasonStock.select_for_update()(order.season, 品種) |
| 13 | 繰越充当・キャンセル戻し | mg_orders/services/credit.py |
Order/CustomerCreditTransaction(年度非依存) |
CustomerCreditTransaction.recorded_in_seasonを作成時に付与 |
| 14 | 商品マスタCRUD | mg_masters/views.py(product系) |
Product.sales_year(int、自由入力可) |
Product.seasonFK化(2.3節、Phase 4-C)。年度候補はSalesSeason.objects.all()からの選択のみとなり、自由入力欄は撤廃。DB制約(FK)そのものが孤立年度を防ぐ |
| 15 | 在庫マスタ | mg_masters/views.py::stock_list |
Stock(品種単位、全年度共通) |
SeasonStock(season選択UI追加、CLOSED seasonは供給量調整不可) |
| 16 | 袋詰管理 | mg_workflow/views.py::bagging_list_view |
Bag.product(ACCEPTED注文明細から集計) |
変更なし(Bagの年度はproductから常に導出されるため、Phase 4での複数年度混在時も自然に品種・容量別集計に混ざらないことをテストで確認) |
| 17 | 精米管理 | mg_workflow/views.py::milling_management_view |
MillingRecord.variety(品種単位、年度非依存) |
変更なし(精米は品種単位の恒久マスタに対する作業記録であり年度概念がそもそも無い) |
| 18 | 自動引当・パッケージングボード | mg_workflow/api_packaging.py |
Bag/Package/PackageItem/PackagePlanItem/Allocation(年度非考慮) |
Package.seasonを箱作成時点で必須確定(2.6節、Phase 4-F。当初の推測ベース_infer_package_season案はコメント#3258指摘1により撤回)。PackagePlanItem/PackageItem/Allocation追加時にseason_id一致をガード。_pack_bagsのグルーピングキーに season を追加 |
| 19 | 発送管理・送料確定 | mg_workflow/api_shipping.py、mg_workflow/services/shipping_fee.py::finalize_shipping_fee_if_all_packed |
Package/Order(宛先単位、年度非考慮) |
宛先×season単位へ変更(finalize_shipping_fee_if_all_packed(destination, season)。Package.seasonを直接クエリするため空箱も正しく未完了箱判定に含まれる。旧season未完了箱が新season送料確定を妨げず、箱数・送料も年度間で合算されないようにする。2.6参照。Issue #17 コメント#3256指摘3で「変更なし」の判断を撤回、コメント#3258指摘1で推測ベースの判定をPackage.season直接クエリへさらに修正) |
| 20 | 年度締めドライラン | dashboard/services/year_end.py::build_year_end_preview 他 |
注文系4チェックは年度非絞り込み、商品チェックのみsales_year=closing_year |
注文系チェックをOrder.season=closing_seasonで絞り込み。途中箱チェックをPackage.season=closing_seasonで絞り込み。商品チェックをProduct.season=closing_season(FK)で絞り込み。next_year_ready判定をSalesSeason存在チェックに変更 |
| 21 | 年度締め実行 | dashboard/services/year_end.py::execute_year_end |
SystemSettingロック、Product一括SUSPENDED化 |
Productの対象抽出をseason=closing_season(FK)へ変更。closingseason のstatusをOPEN→CLOSEDへ変更し、SystemSetting.active_seasonポインタをclosing→nextへ付け替える処理を同一トランザクションに追加 |
| 22 | Django管理サイト | products/admin.py |
Stockを登録 |
SeasonStockも登録(訂正用の抜け道として維持。未決事項9) |
SalesSeason(新規アプリ sales_seasons)既存の year_end(プレフィックスなし、モデルのみ所有)と同じ配置方針を踏襲する。sales_seasons アプリは SalesSeason モデルのみを持ち、専用の画面・URLは持たない。画面は dashboard(新規 /management/seasons/、または既存の年度締め画面の拡張)が持ち、year_end アプリを dashboard が操作するのと同じ関係にする。
# sales_seasons/models.py
class SalesSeason(models.Model):
class Status(models.TextChoices):
OPEN = 'OPEN', '未締め'
CLOSED = 'CLOSED', '締め済み'
year = models.PositiveIntegerField(unique=True, verbose_name='年度')
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name='状態')
closed_at = models.DateTimeField(null=True, blank=True, verbose_name='締め日時')
closed_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT, related_name='closed_sales_seasons', verbose_name='締め実行者')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
(Issue #17 コメント #3256 指摘1への対応)ACTIVEという状態値は持たない。 「今どの年度がアクティブ(受付中)か」はSalesSeason自身には一切保持させず、SystemSetting.active_season(2.2節)という単一のポインタのみで表す。SalesSeason.statusは「締め済みかどうか」だけを表すOPEN/CLOSEDの2値に限定する。
修正前の問題: 当初案はSystemSetting.active_season(FK)とSalesSeason.status==ACTIVEの両方を同時に持たせていた。これは単なる参照と実体の関係ではなく、両方が「この年度は現在アクティブか」という同じ業務事実を表しており、書き込み漏れや競合で両者が食い違う(例: SystemSetting.active_seasonは2026年を指しているのに、どのSalesSeason行もstatus=ACTIVEになっていない、または2つの行が同時にACTIVEになる)余地がある二重の正データだった。
修正後: 「アクティブな年度はどれか」を判定するコードは、プロジェクト全体でSystemSetting.load().active_season(またはそのロック済みインスタンス)を参照する一箇所に統一する。「このSalesSeasonは現在アクティブか」を表示したい場合は、SystemSetting.active_season_id == season.idというその場で導出する比較として扱い、SalesSeason自身にその結果を保存しない。
この変更に伴い、started_at(受付開始日時)フィールドは削除した。当初案では「準備中→受付中」の遷移時刻を記録する想定だったが、その遷移自体をSalesSeason側の状態として持たなくなったため、記録すべき時刻がなくなった(いつアクティブに切り替わったかはYearEndRun.executed_atから追跡できる)。
なお、あるSalesSeasonがOPENのまま「まだ一度もアクティブになっていない準備中の年度」なのか「現在アクティブな年度」なのかは、statusだけでは区別できない(区別が必要な場面では常にSystemSetting.active_seasonとの比較で判定する。3節参照)。
SystemSetting.active_sales_year の扱い廃止せず、active_season = FK(SalesSeason) へ型変更して残す。 「二重の正データを持たない」原則には、値(年度・状態)の重複を避けることで従う。SystemSetting.active_seasonは年度の実体データを一切持たず、SalesSeasonへの参照(ポインタ)のみを持つ。Order.destinationがDestinationの内容を複製せず参照のみ持つのと同じ関係であり、値の二重管理には当たらない。
ポインタを残す理由はロック対象の安定性である。既存のexecute_year_end()/order_create()の同時実行対策は「SystemSetting(pk=1固定の1行)をselect_for_update()でロックしてから読む」という単純な形に依存している。もしSystemSettingからこのフィールドを完全に削除し、"現在アクティブなSalesSeason行"をSalesSeason.objects.filter(status=ACTIVE)のようなステータス条件で直接ロックしようとすると、MySQL(本プロジェクトの実DB)ではセカンダリインデックス条件に対するSELECT ... FOR UPDATEのロック挙動(ネクストキーロック・ギャップロック)が絡み、「ロック取得直後にactive行が別トランザクションでCLOSEDへ変わった直後の再評価」が期待通りに機能する保証がない。本プロジェクトは既に「条件付きUniqueConstraint等のMySQLで効かない仕組みには依存しない」という方針をPhase 3bで明示している(コメント#3242/#3243)ため、同じ理由でこの手法は避ける。
ロックの実体はSystemSetting(pk=1)のままで、ロック後に読む値がactive_sales_year(int)からactive_season(FK)に変わるだけであり、既存コードの形はほぼ変わらない。
(Issue #17 コメント #3256 指摘1への対応) 2.1節の修正(SalesSeason.statusからACTIVEを削除)と合わせて、「アクティブな年度はどれか」を表す正データはSystemSetting.active_seasonの1箇所のみになった。SalesSeason側はOPEN/CLOSED(締め済みかどうか)という、アクティブ性とは独立した情報のみを保持する。ロック戦略も上記のとおりSystemSetting(pk=1)のままであり、正データの一本化とロック対象の変更は不要という結論になる。
Product.sales_year の扱い(Issue #17 コメント #3258 指摘3により判断を撤回)当初「intのまま維持」としていたが、Product.season = FK(SalesSeason, on_delete=PROTECT)への段階的移行を採用する。
「既存16箇所の生成コード・fixture修正量を抑える」ことを理由にintのまま残す案(旧案A)を採用していたが、これは存在しない年度の商品をDBが許容するという参照整合性の欠落を、実装コストの都合で許してしまっていた。mg_mastersのフォーム/サービス層検証だけでは、Django管理サイトや将来のコード追加など、フォームを経由しない生成経路を防げない。Issue #17 コメント #3238で確定した「テスト都合で実仕様を弱めない」という方針と矛盾するため、この判断を撤回する。
# products/models.py の Product に追加
season = models.ForeignKey('sales_seasons.SalesSeason', on_delete=models.PROTECT, verbose_name='販売年度')
一意制約: ('sales_year', 'variety', 'type', 'weight_kg') → ('season', 'variety', 'type', 'weight_kg')へ変更。
移行手順(nullable追加→データ移行→整合性検証→アプリコード切替→NOT NULL化→旧列整理、の標準パターン):
Product.seasonをnullableで追加。sales_yearごとに対応するSalesSeason(year=sales_year)が存在することを確認し(4-AでProduct.sales_yearの全distinct値をカバーするSalesSeasonをバックフィル済みのため、通常は既に揃っている)、Product.season = SalesSeason.objects.get(year=product.sales_year)で一括割当する。Productのseason.year == sales_yearが一致すること、孤立年度(対応するSalesSeasonが存在しないsales_year値)が0件であることを確認する。不一致・孤立が見つかった場合は移行を中断する。unique_together・fixtureをseasonベースへ切替(本番コード・テストfixtureのProduct.objects.create(...)全16箇所超を、Phase 3aと同じ要領で同一コミットで更新。sales_year=<int>ではなくseason=<SalesSeasonインスタンス>を渡す)。seasonをNOT NULL化。sales_year列(int)を削除する。削除タイミングは、既存Stock同様「実運用で問題ないことを確認してから」でよい(この列だけ残しても実害は小さいため、Phase内で即削除するかは実装時判断でよい。未決事項に追加せず、実装時に決定する軽微事項とする)。読み取り専用の互換プロパティ: Product.nameのテンプレート表示・年度ラベル表示(mg_ordersの[2025年度] 商品名等)など、属性としての読み取りが多数の箇所にあるため、移行期間中の書き換え量を抑える目的で次のプロパティを用意してよい。
@property
def sales_year(self):
return self.season.year
ただし、これは属性読み取り専用の利便性プロパティであり、ORMクエリ(Product.objects.filter(sales_year=...)、.values_list('sales_year', ...)等)はPythonプロパティ経由では動作しないため、クエリ箇所は必ずseason=season_objまたはseason__year=yearへ書き換える。以下は主な書き換え対象。
mg_masters/views.py::_get_sales_year_choices(int候補生成 → SalesSeason.objects.all()の選択肢生成へ全面変更。「その他の年度を入力」自由入力欄は撤廃し、存在しない年度を選びたい場合は「新しい年度(SalesSeason)を先に登録する」導線に置き換える。これによりDBのFK制約そのものが年度の孤立を防ぐため、旧未決事項10は解消される)。mg_masters商品一覧の年度フィルタ(GETパラメータsales_year → seasonのid、またはseason__year)。dashboard/services/year_end.py::_check_products_for_sale / execute_year_end()の対象商品クエリ(sales_year=closing_year → season=closing_season)。products/services.py::is_product_purchasable / purchasable_products_queryset(product.sales_year == active_sales_yearという年度int比較 → product.season_id == active_season.idというFK id比較へ統一。Issue #17 コメント#3258「Order/Package/SeasonStock等の年度比較をFK IDベースへ統一する」を反映)。Product.nameへ年度を含めない既決定(Issue #17 コメント #3238)は維持する。ProductVarietyへ年度を持たせない既決定も維持する。
旧未決事項2(「FKに変えるか」)はこの改訂により解消(FK化することが確定事項になったため、もはや選択の余地がある未決事項ではない)。
Order.season# orders/models.py に追加
season = models.ForeignKey('sales_seasons.SalesSeason', on_delete=models.PROTECT, verbose_name='販売年度')
mg_orders/views.py::order_editに年度選択UIは設けない。products/services.pyにvalidate_items_single_season(season, items) -> list[str](不一致商品名のリストを返す)を新設し、以下4経路すべてから呼ぶ。
orders.views.order_create(新規作成)、orders.views.order_update(数量増加時のみ、既存のis_product_purchasable検証と同じ位置)mg_orders.views.order_create(新規作成)、mg_orders.views.order_edit(新規商品追加・数量増加時のみ)OrderItem.save()をオーバーライドし、self.product.season_id != self.order.season_idならValueErrorを送出する軽量な最終防衛ラインを追加する(full_clean()は使わない。既存コードがfull_clean()を呼ぶ設計になっていないため、呼び出し漏れの心配がないsave()側に置く)。(Issue #17 コメント#3258の指摘により、年度の一致比較はsales_year(int)ではなくseason_id(FK)同士の比較に統一する。 2.3節でProduct.seasonをFK化したため、Order.seasonとの比較もFK id同士で行える)。order.season」でフィルタする(購入者側は既存のis_product_purchasableによりactive season以外がそもそも選べない)。mg_orders.views.order_createにseason選択ドロップダウン(初期値=active season)を追加する。選択後に商品選択肢を絞り込む(JSでの絞り込みは既存のproduct_pricesマップと同様の仕組みで実現できる)。order.season.status == CLOSEDの場合、購入者・管理者どちらのorder_createも拒否する(Phase 4-H。コメント#3260指摘4で4-G表記を修正)。SeasonStock、productsアプリに追加)# products/models.py に追加
class SeasonStock(models.Model):
season = models.ForeignKey('sales_seasons.SalesSeason', on_delete=models.PROTECT, related_name='stocks', verbose_name='販売年度')
variety = models.ForeignKey(ProductVariety, on_delete=models.PROTECT, related_name='season_stocks', verbose_name='品種')
total_supplied_kg = models.DecimalField(max_digits=8, decimal_places=3, default=0, verbose_name='総供給量(kg)')
total_ordered_kg = models.DecimalField(max_digits=8, decimal_places=3, default=0, verbose_name='総注文量(kg)')
updated_at = models.DateTimeField(auto_now=True, verbose_name='最終更新日時')
class Meta:
unique_together = ('season', 'variety')
@property
def available_kg(self):
return self.total_supplied_kg - self.total_ordered_kg
(season, variety)。品種単位の現行Stock(varietyがPK)から、年度が主キーの一部に加わる形へ拡張する。get_or_createによるレコード作成時のデフォルト値のまま)。バルク作成のシグナルは設けず、現行stock_listと同じ「表示・更新時にget_or_create」方式を(season, variety)キーに拡張するだけにする。mg_masters::stock_listにseason選択UI(既定=active season)を追加し、過去seasonのSeasonStockは一覧表示のみ(供給量調整フォームは非活性)にする。season.status == CLOSEDのSeasonStockは、供給量増加(管理者調整)を拒否する。注文量の減少(キャンセル・数量減少による在庫戻し)は締め済み年度でも許可する(決定事項8。溜まった旧年度注文の後始末を塞がないため)。SystemSetting→Product(年度締めのみ)/SystemSetting→Stock」の順序を踏襲し、StockをSeasonStockに置き換える。新規: SystemSetting(.active_season経由でロック対象は変わらず同一行)→ SeasonStock。年度締め実行は SystemSetting → SalesSeason(closing/next両方、.order_by('id')でロック順を固定しデッドロックを避ける)→ Product → (在庫はロックしない、締め実行では在庫を変更しないため現行通り)。order.season。年度切替後もその注文が属していた season のSeasonStockを戻す。activeとは限らない点に注意(切替直後、旧season注文の変更・キャンセルは引き続き起こり得る)。Stock(3件)の移行: 0節の前提(既存注文は全件単一season)により、Stockのtotal_ordered_kgは100%そのseasonの累積であることが保証される。データマイグレーションでStockの各行からSeasonStock(season=<移行時点のactive season>, variety=variety, total_supplied_kg=..., total_ordered_kg=...)を1:1で作成する。曖昧な按分は発生しない。Stockモデルの扱い(Issue #17 コメント #3260 指摘1により訂正): Phase 4完了時点では削除しない(未決事項6)が、「ロールバック安全網」ではない。 SeasonStockへの切替後、Stockは更新対象から外れるため、運用開始直後から実際の在庫状態(SeasonStock側)と乖離していく。「コード側の参照をStockへ戻せば復旧できる」という主張は成立しない。旧Stockを残す目的は、移行結果の比較・監査用(切替直後にSeasonStockの値が旧Stockの値と一致することを確認する基準値)に限定する。ロールバックが必要な場合は、切替直前のDBバックアップ復元を原則とする。旧Stockをロールバック用途に使う案(二重書き込み・整合性検証を伴う)は、明確な互換期間の設計が必要になり複雑化するため採用しない。少なくとも1回の年度サイクルをSeasonStockで実運用したのち、監査目的も終えたと判断できれば別途削除フェーズを起こす。Bag/Allocation/Package/PackageItem/PackagePlanItem)(Issue #17 コメント #3258 指摘1により判断を撤回)当初「Packageはスキーマ変更不要、サービス層の推測ガードのみ」としていたが、Package.seasonを必須FKとして追加する設計へ改訂する。
_infer_package_season(package)(箱の中身から年度を推測し、空箱はNone扱い)という推測ベースの設計には、次の安全上の欠陥があった。
_infer_package_season()がNoneを返すためseason限定集合から除外する」という当初案は、この安全側の仕様を後退させてしまう(作業中の空箱が残っているにもかかわらず、season限定によって送料が確定されてしまう回帰)。_check_in_progress_packages)も、推測ベースでは空箱の年度を判定できない。推測に頼らず、Package自体に年度を確定させて持たせることでこの2点を解消する。
# orders/models.py の Package に追加
season = models.ForeignKey('sales_seasons.SalesSeason', on_delete=models.PROTECT, verbose_name='販売年度')
PackagesCreateView/api_packages_create)にseasonを必須で確定させる。空箱の段階から所属seasonが決まる。SystemSetting.active_seasonとする。対象宛先に複数season(例: 旧season・新seasonの両方にACCEPTED注文がある移行期間中)のACCEPTED注文が存在する場合のみ、明示的なseason選択UIを表示する(曖昧でない通常時は選択UIを出さず自動でactive seasonを採用し、操作を煩雑にしない)。Bag/Allocation/PackagePlanItem自体へのスキーマ変更は不要のまま。 これらの年度は引き続きProduct/OrderItem経由で導出可能であり(2.3/2.4節)、Package.seasonという確定済みの値と一致するかどうかを検証する対象になる。Package.seasonとの直接比較へ変更)| 対象 | 検証内容 |
|---|---|
PackagePlanItem(予約を追加・更新、api_plan_items_upsert) |
product.season_id == package.season_id(FK idの直接比較。2.3節のProduct.seasonにより実現) |
PackageItem(袋を追加、PackageItemCreateView) |
bag.product.season_id == package.season_id |
Allocation(引当。袋を箱に追加する際に同時作成) |
order_item.order.season_id == package.season_id(OrderItem.productとorder.seasonは2.4節のガードで既に一致しているため、実質的に上記2条件と同値だが、防御的に明示検証する) |
箱間移動(api_package_items_move) |
移動元・移動先のPackage.seasonが一致すること(現行の「同一宛先内のみ」に「同一season内のみ」を追加) |
梱包提案の適用(api_apply_packing_proposal/api_apply_global_packing_proposal) |
新規作成する箱には対象となる袋のseasonを設定する。既存箱への追加は上記表の各条件に従う |
いずれもDBレイヤーではなくサービス層での検証になる(MySQLはテーブルを跨るCHECK制約を持たないため、2.4節のOrder.season/OrderItem.productガードと同じ方針)。
_pack_bags)のグルーピングキー: 「宛先」単位から「宛先 × season」単位へ変更する(変更なし、旧案から維持)。Package.seasonは箱の状態によらず作成時点で確定済みのため、空箱・計画のみの箱・実詰め済みの箱のいずれであっても年度は常に明確である(推測不要になったことで、箱の状態で扱いを変える必要がなくなった)。Package.seasonの注文同士のみ。異なるseasonの注文が同じ宛先に同時にACCEPTEDである場合、箱は宛先ごとにseason別で複数できる(既存の「宛先ごとに複数の箱を持てる」仕組みをそのまま使うだけで済み、新しい概念の導入は不要)。finalize_shipping_fee_if_all_packed)(Issue #17 コメント #3256 指摘3で「変更しない」判断を撤回し宛先×season単位へ変更。コメント #3258 指摘1により、season判定を推測ではなくPackage.seasonの直接クエリへさらに修正)
finalize_shipping_fee_if_all_packed(destination, season)。related_orders = Order.objects.filter(destination=destination, season=season, status=ACCEPTED)。all_destination_packages相当の未完了箱判定はPackage.objects.filter(destination=destination, season=season)で直接絞り込む(_infer_package_seasonは不要になったため廃止。空箱もPackage.seasonを持つため、season限定集合から漏れずに未完了箱として正しく判定される、コメント#3258指摘1で懸念された回帰を解消)。以降のtarget_packages/target_ordersの計算は現行ロジックのまま、絞り込み済みのseason限定集合に対して行う。_check_in_progress_packages(closing_season)をPackage.objects.filter(season=closing_season, status__in=[...])で絞り込む(推測ではなくPackage.seasonを直接クエリする)。(destination, season)の組をすべて列挙し、組ごとに1回ずつ呼ぶ」形に変える。影響箇所: PackageItemCreateView/PackageItemDeleteView/箱間移動/予約消費(単一season)、梱包提案の適用・パッケージ削除・発送ステータス一括更新(複数の(destination, season)にまたがりうる)。ACCEPTED注文がある状態で、(1) 旧seasonの未完了箱(空箱を含む)が新season注文の送料確定を妨げないこと、(2) 新旧seasonの箱数・送料が合算されないこと、(3) 各seasonの代表注文(送料を記録する1件)がそのseason内の注文であること、を確認する。Package(98件、Issue #17 コメント #3235 で確認済みの参考値)の移行(Phase 4-F実装時にユーザー判断により最終確定。コメント#3260時点の案から変更) 各Packageについて、紐づくPackageItem.bag.product.season・PackagePlanItem.product.seasonのdistinct値を集計する。
seasonとして設定する(商品から機械的に導出できるため推測ではない)。Order.seasonバックフィルの中断条件と同じ方針)。Package.season導入後は空箱に中身(異なるseasonの商品)を追加しようとしてもseason不一致ガードで拒否されるため、既存の空箱をそのまま次年度に持ち越しても実用上の価値がない。伝票番号(tracking_number)・CSV出力履歴(csv_exported_at)が設定済みの空箱(かつて中身が入り発送処理が進んだ後、中身だけ取り出された履歴がある箱)も含め、区別なく削除する(伝票番号自体はヤマト運輸側が発行する識別子でありProduct/SalesSeasonを参照しないため、season判定の手がかりにはならない)。事前に読み取り専用のcheck_package_seasons管理コマンドで、削除対象となる空箱(件数・id・宛先・作成日時・伝票番号の有無)を確認できる。CustomerCreditTransaction)(Issue #17 コメント #3256 指摘2により全面改訂) 当初案はrecorded_in_season(取引の記録時点のactive season)1本で「年度監査」を賄おうとしたが、これは「いつ記録されたか」しか表さず、確定要件である繰越元年度・繰越先年度・金額そのものを直接記録できていなかった(source_order/target_order経由の間接的な導出に頼っており、注文を伴わない調整・返金では導出すらできない)。
| 案 | 内容 | 評価 |
|---|---|---|
| A(不採用・当初案) | recorded_in_seasonのみ追加 |
「繰越元年度」「繰越先年度」を直接表すフィールドがなく、注文を伴わない取引種別(ADJUSTMENT/REFUNDED_FROM_CREDIT)では年度の追跡手段がそもそもない |
| B(推奨) | 既存のCustomerCreditTransactionにsource_season/target_season(いずれもSalesSeasonへのFK、nullable)を追加し、取引種別ごとに設定ルールを定義する。recorded_in_seasonも残し「取引時点のactive season」の汎用スタンプとして併用する |
既存の「1行=1仕訳」という台帳設計を維持したまま、繰越元・繰越先を直接(推測なしで)記録できる。source_order/target_orderが既にそれぞれのOrder.seasonを持つため、新モデルを新設せずとも整合する |
| C(不採用) | 年度間振替専用の新規モデル(例: CreditSeasonTransfer)を設け、CustomerCreditTransactionとは別に繰越の年度遷移だけを記録する |
繰越の発生(CARRY_OVER_FROM_CANCELED_ORDER)と充当(APPLIED_TO_ORDER)は既にsource_order/target_orderで1行ずつ紐づいており、これと同じ情報を別モデルに複製することになる。台帳が2箇所に分かれ、整合性維持のコストが増すだけで案Bに対する追加の利点がない |
案Bを採用する。
# orders/models.py の CustomerCreditTransaction に追加
source_season = models.ForeignKey(
'sales_seasons.SalesSeason', on_delete=models.PROTECT, null=True, blank=True,
related_name='credit_source_transactions', verbose_name='繰越元年度',
)
target_season = models.ForeignKey(
'sales_seasons.SalesSeason', on_delete=models.PROTECT, null=True, blank=True,
related_name='credit_target_transactions', verbose_name='充当先年度',
)
recorded_in_season = models.ForeignKey(
'sales_seasons.SalesSeason', on_delete=models.PROTECT,
related_name='credit_recorded_transactions', verbose_name='記録時点の年度',
)
reversal_of = models.OneToOneField(
'self', on_delete=models.PROTECT, null=True, blank=True,
related_name='reversal', verbose_name='取消対象の充当取引',
)
reversal_ofはIssue #17 コメント #3260 指摘3で追加した(後述)。
transaction_type |
source_order |
target_order |
source_season |
target_season |
recorded_in_season |
reversal_of |
|---|---|---|---|---|---|---|
CARRY_OVER_FROM_CANCELED_ORDER(繰越加算, 正) |
必須(既存) | null | 必須。source_order.seasonと一致すること |
null(充当先はまだ未定のため) | 必須(作成時点のactive season) | null |
APPLIED_TO_ORDER(充当, 負) |
null | 必須(既存) | null(残高プールは特定の年度に紐付かない fungible な預かり金のため) | 必須。target_order.seasonと一致すること |
必須 | null |
RETURNED_FROM_CANCELED_APPLIED_ORDER(充当取消による戻し, 正) |
null | 必須(既存、取消対象の充当先注文) | null | 必須。target_order.seasonと一致すること(取り消された充当がどの年度向けだったかを示す) |
必須 | 必須。取消対象のAPPLIED_TO_ORDER行を直接参照する(Issue #17 コメント#3260指摘3) |
ADJUSTMENT(管理者調整, 将来用) |
任意 | 任意 | 設定したsource_orderがあればそのseasonと一致必須、なければnull可 |
設定したtarget_orderがあればそのseasonと一致必須、なければnull可 |
必須 | null |
REFUNDED_FROM_CREDIT(残高からの返金, 将来用) |
null | null | null(プールからの払い出しであり特定年度に紐付かない) | null(注文に充当されるものではない) | 必須 | null |
source_order/target_orderが設定されている場合、対応するsource_season/target_seasonは必ずその注文のseasonと一致しなければならない。DB層(MySQL)ではテーブルを跨るCHECK制約を書けないため、この一致検証はサービス層(mg_orders/services/credit.pyの各生成関数内)で行い、CustomerCreditTransaction.save()にも防御的な整合性チェック(ValueError送出)を追加する(Order.season/OrderItem.productの整合検証と同じ二重化の方針、2.4節参照)。record_payment_adjustment(CARRY_OVER_FROM_CANCELED_ORDER生成時、source_season=order.season)、apply_credit_to_order(APPLIED_TO_ORDER生成時、target_season=order.season)、return_credit_on_cancel(RETURNED_FROM_CANCELED_APPLIED_ORDER生成時、target_season=order.season・reversal_of=<取消対象のAPPLIED_TO_ORDER行>)の3箇所。いずれもrecorded_in_seasonには生成時点のSystemSetting.load().active_seasonを設定する。target_orderだけでは、同じ注文へ複数回APPLIED_TO_ORDER(部分充当)が行われている場合に、「今回の戻しがどの充当を取り消しているのか」を一意に特定できず、戻し取引から元のCreditApplicationAllocation(元取引別の配賦明細)まで監査経路がつながらなかった。CustomerCreditTransaction.reversal_of(自己OneToOneField)を追加し、RETURNED_FROM_CANCELED_APPLIED_ORDER行が取消対象の特定のAPPLIED_TO_ORDER行を直接参照するようにする。OneToOneFieldにより、1つのAPPLIED_TO_ORDER行に対する取消は高々1回に制限される(二重取消の防止をDB制約で保証)。return_credit_on_cancelの挙動変更: 従来は「有効な充当合計額」1件分のRETURNED_FROM_CANCELED_APPLIED_ORDER行を1行だけ作成していたが、これではreversal_ofが単一のFKである以上、複数回の部分充当を1行で表せない。改訂後は、対象注文に紐づくまだ取消(reversal)されていないAPPLIED_TO_ORDER行を全件取得し、行ごとに1件ずつRETURNED_FROM_CANCELED_APPLIED_ORDERを作成する(amountはその元APPLIED_TO_ORDER行の絶対値、reversal_ofにその元行を設定)。複数の部分充当があった注文をキャンセルすると、複数の戻し行が作成される。CustomerCreditTransaction.applied_credit_total(order)(APPLIED_TO_ORDERとRETURNED_FROM_CANCELED_APPLIED_ORDERの合算)は、行数が変わっても合計金額は変わらないため、既存の集計ロジックは変更不要。reversal_of.transaction_type == APPLIED_TO_ORDERであること、reversal_of.user == self.userであること、reversal_of.target_order == self.target_orderであることをサービス層とCustomerCreditTransaction.save()の防御的ガードの両方で検証する。returned_transaction.reversal_of.allocations_consumed(CreditApplicationAllocationのrelated_name)を辿ることで、「この戻しが取り消した充当は、具体的にどの元繰越取引からいくら配賦されたものか」まで一意に追跡できる。CreditApplicationAllocation)— Issue #17 コメント #3258 指摘2により追加課題: 上記のsource_season/target_seasonだけでは、「1回の充当がどの元繰越取引から、いくらずつ発生したか」という配賦の内訳までは表せない。顧客残高はfungible(区別のないプール)なため、複数年度由来の残高が混在している状態で1回の充当が発生すると、単一のsource_seasonスカラー値では複数元年度にまたがる内訳を表現できない。
設計方針: 消費側の取引(APPLIED_TO_ORDER、将来のREFUNDED_FROM_CREDIT、負のADJUSTMENT)が、供給側の取引(CARRY_OVER_FROM_CANCELED_ORDER、RETURNED_FROM_CANCELED_APPLIED_ORDER、正のADJUSTMENT)からいくら配賦されたかを表す明細モデルを追加する。
# orders/models.py に追加
class CreditApplicationAllocation(models.Model):
"""繰越残高の消費(充当・払出)取引が、どの供給(元残高)取引から
いくら配賦されたかを表す明細(Issue #17 コメント#3258 指摘2)。"""
consuming_transaction = models.ForeignKey(
CustomerCreditTransaction, on_delete=models.PROTECT,
related_name='allocations_consumed', verbose_name='充当・払出取引',
)
source_transaction = models.ForeignKey(
CustomerCreditTransaction, on_delete=models.PROTECT,
related_name='allocations_supplied', verbose_name='元残高取引',
)
amount = models.PositiveIntegerField(verbose_name='配賦額')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='作成日時')
class Meta:
unique_together = ('consuming_transaction', 'source_transaction')
役割分担の整理(source_season/target_season/recorded_in_seasonとの重複はない):
source_season(供給側の行が持つ): その行自身がどの年度から発生した残高かを表す(その行単独の属性)。target_season(消費側の行が持つ): その行がどの年度の注文へ充当・関連したかを表す(その行単独の属性)。recorded_in_season(全行が持つ): 記録時点のactive seasonの汎用スタンプ。CreditApplicationAllocation(今回追加): 上記3フィールドのいずれとも役割が重複しない。 「1つの消費取引が、複数の供給取引からどう配賦されたか」という取引間の関係を表すのは、この明細モデルだけである。3フィールドはいずれも「1行単独の属性」であり、複数行にまたがる配賦の内訳は表現できないため、削除すべき重複フィールドは無い(コメント#3258の「不要な重複フィールドは削る」を検討した結果、削るべきものはないと判断した)。配賦順序(FIFO): apply_credit_to_orderが充当額を確定する際、その顧客の供給側取引のうち未配賦残額が残っているものをcreated_at昇順(古い順)で取得し、必要額に達するまで順に配賦する。未配賦残額 = source_transaction.amount - Sum(そのsource_transactionへの既存allocation.amount)。1回の充当が複数の元取引にまたがる場合、その分だけCreditApplicationAllocation行を複数作成する(consuming_transactionは同一、source_transactionとamountが行ごとに異なる)。
部分充当: 既存どおり1回のapply_credit_to_orderで顧客残高の一部だけを充当できる(上限min(残高, 請求残額))。配賦もその充当額分だけ行われ、供給側取引の残額は次回以降の充当のために残る。
キャンセルによる戻し・返金時の扱い(逆仕訳の設計判断):
| 案 | 内容 | 評価 |
|---|---|---|
| 採用: 新規の供給取引として復元 | RETURNED_FROM_CANCELED_APPLIED_ORDERは、取り消されたAPPLIED_TO_ORDERの配賦明細(CreditApplicationAllocation)を遡って変更・削除せず、それ自体を新しい供給側取引(戻し時点をcreated_atとする新たなFIFOソース)として扱う |
台帳を追記のみ(immutable)に保てる。取り消された充当の配賦明細は「その充当が実際にどの元取引から発生したか」という過去の事実としてそのまま残る。既に別の充当が後続の元取引を消費済みの場合でも、過去の配賦順序を遡って組み替える必要がない |
| 不採用: 元の配賦明細を巻き戻して復元 | 取り消された充当が使った元取引の残額を、その元取引自身に復元する | 直感的だが、その元取引の「復元後の残り」は本来の時系列上では既に別の後続充当が先に消費している可能性があり、FIFO順序をその時点まで遡って再計算する必要が生じる。台帳の不変性(追記のみ)という設計方針と衝突するため不採用 |
CreditApplicationAllocation行自体は追記のみで、既存行の削除・更新は行わない(他の残高テーブルと同じ、1行=1仕訳の不変ログという設計方針を踏襲)。consuming_transactionに対するCreditApplicationAllocation.amountの合計は、必ずabs(consuming_transaction.amount)と一致しなければならない。apply_credit_to_orderおよび将来の返金・払出処理は、消費取引本体と配賦明細群を同一のtransaction.atomic()内で作成し、合計が一致することをその場でアサートする。return_credit_on_cancelが作る戻し行は新しい供給側取引であり、消費側の配賦明細は作成しない。戻し行については、amount == abs(reversal_of.amount)を同一トランザクション内で検証する。apply_credit_to_order/return_credit_on_cancelは既に「Order→User」の順でselect_for_update()しており(D10、同一顧客の残高変更を直列化)、CreditApplicationAllocationの計算(未配賦残額の集計)はこの既存ロックの内側で行う純粋な読み取り+追記のため、新たなロック対象は不要。CustomerCreditTransaction行が追記専用(更新されない)である限り、既存のロック順序のままで安全にFIFO計算できる。CustomerCreditTransactionは現状0件のため、CreditApplicationAllocationのバックフィルは実質的に不要(移行対象なし)。将来的に既存のAPPLIED_TO_ORDER行が存在する状態でこの機能を追加する場合は、created_at順に供給側取引を再構成してFIFO配賦を後付けする移行スクリプトが必要になる点を留意事項として残す。SalesSeason の状態遷移(Issue #17 コメント #3256 指摘1を反映し全面改訂) SalesSeason.status自体は次の一方向のみの遷移とする。
OPEN → CLOSED
「アクティブ(受付中)かどうか」はstatusとは別の軸であり、SystemSetting.active_seasonというポインタが「現在どのSalesSeasonを指しているか」で決まる(2.1/2.2節)。したがって年度の状態は次の2軸の組み合わせで表現する。
| 軸 | 取り得る値 | 何を表すか | 変更される箇所 |
|---|---|---|---|
SalesSeason.status |
OPEN / CLOSED |
この年度がまだ締められていないか、締め済みか | execute_year_end()が締める対象のseasonにのみOPEN→CLOSEDを適用 |
SystemSetting.active_season |
いずれか1つのSalesSeasonへのFK |
購入者向け表示・在庫チェック等が今どの年度を対象にするか | execute_year_end()が締め対象から次年度へポインタを付け替え |
| 遷移 | 誰が | 条件 | 実行箇所 |
|---|---|---|---|
(作成)→ OPEN |
スタッフ | 年度管理画面で新規season作成(yearが未使用であること) |
新規 dashboard(or mg_seasons)の年度管理画面。作成しただけではアクティブにはならない(active_seasonポインタは動かない) |
active_seasonポインタの付け替え(closing→next) |
スタッフ | execute_year_end()実行時、next_yearに対応するSalesSeasonがOPENで存在し、かつ当該seasonにFOR_SALE商品が1件以上あること。付け替え前のactive_seasonがclosing_yearと一致すること |
execute_year_end()(既存の締め実行と同一トランザクション) |
closingseason: OPEN → CLOSED |
スタッフ | 既存の締め実行前提条件(BLOCKなし・WARN確認・バックアップ確認・年度再入力一致)をすべて満たすこと | execute_year_end()(同上、ポインタ付け替えと同一トランザクション) |
各状態で許可する操作はstatus(OPEN/CLOSED)だけで決まる。「購入者が実際に購入できるか」はこれに加えて「SystemSetting.active_seasonと一致するか」がさらに絞り込む(既存のis_product_purchasableと同じ構造)。
SalesSeason.status |
新規注文(購入者、active_season一致時のみ) |
新規注文(管理者、決定事項7) | 既存注文の増量・商品追加 | 既存注文の減量・キャンセル | 在庫供給量調整 | 商品season指定 |
|---|---|---|---|---|---|---|
OPEN |
可(active_seasonと一致する場合のみ) |
可(CLOSED以外は選択可) |
可 | 可 | 可 | 可 |
CLOSED |
不可 | 不可 | 不可 | 可 | 不可 | 不可(新規商品作成の選択肢からは外す。2.3節のFK制約により、そもそもSalesSeason一覧から選ぶ形になる) |
transaction.atomic()内で完結させる。途中で例外が発生した場合、商品のSUSPENDED化・active_seasonポインタ付け替え・closingseasonのstatus変更・YearEndRun作成のすべてがロールバックされる(現行execute_year_end()の設計を維持・拡張するのみで、新たなロールバック機構は不要)。SystemSetting(pk=1)のまま維持する(2.2参照)。1回目の成功でactive_seasonが次年度へ変わるため、同じclosing_yearでの2回目のPOSTは既存と同様に自然に拒否される。CLOSINGを永続状態として持つか: 持たない。 そもそもACTIVEという状態自体をSalesSeasonから排除したため、その「処理中」に相当する中間状態を論じる前提がなくなった。execute_year_end()は現行どおり単一の同期的トランザクション(DBロック時間は短い)で完結しており、「処理中」を外部から観測する必要がない。将来、締め実行が重い非同期処理になった場合にのみ再検討する(決定事項4)。CLOSED → OPENのような逆遷移、およびactive_seasonを過去のseasonへ戻す操作は用意しない。理由: 逆遷移は「商品のSUSPENDED解除」「その間にactive_seasonだった別seasonの状態」「経過した注文・在庫変更の巻き戻し」まで扱う必要があり、既存の他の状態機械(発送済みの取り消し不可、キャンセル済みの復元不可)と同様に「前方のみ・やり直しはキャンセル&作り直し」という本システム全体の設計方針に合わせる。真に必要な訂正は手動のDB操作(Django管理サイト等)で対応する運用とする(決定事項5)。(Issue #17 コメント #3258 を反映し全面改訂。Product.season・Package.seasonの移行を追加)
対象: 商品22件(sales_year→seasonのFK化)、注文64件、在庫3件、SystemSetting.active_sales_year=2025、YearEndRun(0件、変更なし)、既存の袋・引当(スキーマ変更なし、移行対象外)、既存の箱98件(seasonのFK付与)、CustomerCreditTransaction(現状0件)。
段階的マイグレーションは以下の順序で行い、各境界でテストスイートを緑に保つ(フェーズ分割は5節を参照。ここでは移行そのものの内部順序を示す)。Product.seasonはOrder.seasonより前に、Package.seasonはOrder.seasonより後に行う(Packageの既存データバックフィルはAllocation経由でOrder.seasonとも突き合わせて検証できるため)。
SystemSetting.active_season・Product.season・Order.season・Package.season・CustomerCreditTransaction.source_season/target_season/recorded_in_season(すべてnull許容)を追加。SeasonStock・CreditApplicationAllocationは新規モデルのため最初から必須フィールドのみで作成可能(既存行がないため段階化不要)。SalesSeason行を作成: 既存のProduct.sales_yearの distinct 値(2025・2026)とSystemSetting.active_sales_year(2025)の和集合について作成する。statusは「その年度が過去に締められた形跡があるか」だけで決める(ACTIVEは存在しないため判定不要)。具体的には、active_sales_year未満の値→CLOSED(既に締められたとみなす)、active_sales_year以上の値→OPEN(今回の実データでは2025・2026ともOPENになる)。SystemSetting.active_season に、active_sales_yearと一致するSalesSeason行をセット(アクティブ性はこのポインタのみで表現する。2.1/2.2節)。Product.seasonのバックフィル。 各Product.sales_yearに対応するSalesSeason(year=sales_year)が存在することを確認し(前段2-1で全distinct値をカバー済みのため通常は揃っている)、一括でseasonを割り当てる。孤立年度(対応するSalesSeasonが無い値)が見つかった場合は移行を中断する(2.3節)。Order.seasonのバックフィル前に、管理コマンドで「全注文が単一年度に属する」という前提を検証する。 具体的には、各Orderのitems__product__seasonのdistinct値が1件以下であることを確認する。1件を超える注文が1件でも見つかった場合は移行を中断し、手動判断(どの年度に寄せるか)を仰ぐ。前提が崩れていなければ、全64件へSalesSeason(year=2025)を設定する。Stockの3行からSeasonStock(season=2025のSalesSeason, variety=..., total_supplied_kg=..., total_ordered_kg=...)を1:1で作成する(0節の前提により曖昧さなし)。Package.seasonのバックフィル(Issue #17 コメント #3260 指摘2により訂正)。 各Packageについて、紐づくPackageItem.bag.product.season・PackagePlanItem.product.seasonのdistinct値を集計する。1件ならその値を設定、2件以上(年度混在箱)なら移行を中断する。0件(空箱)は「active seasonを自動設定」しない。 事前の読み取り専用検査で空箱を一覧化し、今回の実データについて「空箱は2025年度」という明示的な移行判断を確認したうえでその値を設定する。この判断が確認できない環境では移行を中断し、手動マッピングを要求する(2.6節)。CustomerCreditTransaction(現状0件)は該当があればsource_order/target_orderのseasonからsource_season/target_seasonを導出し、recorded_in_seasonは移行時点のactive seasonを設定。CreditApplicationAllocationは現状0件のため移行対象なし。TestCaseではなく移行検証用のアサーション、またはmanage.py check相当の検証コマンド)で確認する。少なくとも次を確認する。
Product.season.yearが全件sales_yearと一致する(孤立年度0件)。SeasonStockのtotal_supplied_kg/total_ordered_kg合計が旧Stockの値と一致する。Order.seasonが設定された注文数が全注文数と一致する(NULL残りがないこと)。Package.seasonが設定された箱数が全箱数と一致し、年度混在箱が0件であること。SalesSeasonのyearがProduct.sales_yearの全distinct値をカバーしている。SystemSetting.active_season・Product.season・Order.season・Package.season・CustomerCreditTransaction.recorded_in_seasonをNOT NULL化し、SystemSetting.active_sales_year(int)列を削除する。Product.sales_year(int)列も削除する(互換用の読み取り専用プロパティとして名前を再利用する。2.3節)。Stock(品種単位の旧在庫モデル)は削除しない(未決事項6、2.5参照)。(Issue #17 コメント #3258 を反映し、Product.season(新設 Phase 4-C)・Package.season(Phase 4-Fへ統合)の追加に伴いフェーズを7分割から8分割へ再編。旧レター参照はすべて新レターへ置き換えた。さらにコメント#3299を反映し、Phase 4-BとPhase 4-Cの間に4-B2を追加。既存フェーズのレターは変更していない)
各フェーズは独立して意味を持ち、境界でテストスイートを緑に保つ。フィールド必須化と生成コード・fixture更新など、分離すると赤くなるものは同一フェーズに含める(Phase 3aのProduct.sales_year導入と同じ方針)。
| 旧レター(コメント#3255/#3257時点) | 新レター | 変更内容 |
|---|---|---|
| 4-A | 4-A | 変更なし(SalesSeason) |
| 4-B | 4-B | 変更なし(SystemSetting.active_season) |
| (新設) | 4-B2 | 管理者向け年度作成画面(Issue #17 コメント#3299指摘2により追加。SalesSeasonの新規作成のみを許可する最小画面) |
| (新設) | 4-C | Product.season導入(コメント#3258指摘3) |
| 4-C | 4-D | Order.season導入(内容変更なし、レターのみ変更) |
| 4-D | 4-E | SeasonStock導入(内容変更なし、レターのみ変更) |
| 4-E | 4-F | 出荷の年度整合(Package.seasonを追加、コメント#3258指摘1)+送料確定のseason化 |
| 4-F | 4-G | 顧客残高の年度監査(CreditApplicationAllocationを追加、コメント#3258指摘2) |
| 4-G | 4-H | 締め済み年度ガード・execute_year_end()統合(内容変更なし、レターのみ変更) |
SalesSeasonモデル導入【実装済み。10節参照】sales_seasons.SalesSeason。SalesSeasonを参照しない)。sales_seasons/migrations/0001_initial.py+データマイグレーション(4節の「SalesSeason行を作成」部分)。sales_seasons.tests(フィールド・year一意制約・__str__)。既存229テストは無変更で成功。SalesSeasonテーブルを追加。SalesSeasonテーブルがstatus(OPEN/CLOSEDの2値のみ、ACTIVEは存在しない)で作成され、既存の年度状況(2025・2026ともOPEN。どちらがアクティブかはSalesSeason側では表現しない)を正しく反映してバックフィルされている。全既存テストが成功する。SystemSetting.active_seasonへの切替【実装済み。11節参照】system_settings.SystemSetting(active_season追加、active_sales_year削除)。SystemSetting.load().active_sales_yearを.active_season.yearへ置換。SystemSetting.load()のブートストラップ(新規環境で最初のレコードを作る際、対応するSalesSeason(year=現在年, status=OPEN)をget_or_createし、active_seasonにセットする。SalesSeason.statusにACTIVEは存在しないため、アクティブ性はactive_seasonポインタの設定のみで表現される)。active_seasonをnullable追加→active_sales_yearをnullable化→データ移行→NOT NULL化+active_sales_year削除(4段階、同一フェーズ内。Phase 3aのProduct.sales_year導入と同じ理由で、コード切替も同フェーズに同梱しないと境界でテストが赤くなる)。(Issue #17 コメント#3299指摘1により訂正) 当初は3段階(nullable追加→データ移行→NOT NULL化+削除)としていたが、active_sales_yearをNOT NULL・defaultなしのまま最終段階でRemoveFieldすると、その逆マイグレーション(AddField)が「NOT NULLかつdefaultなしの列を既存行があるテーブルへ追加する」操作になりDBで失敗し得る。削除前にactive_sales_yearをAlterFieldでnullable化する段階を挟むことで、逆方向のAddFieldが常に安全な「nullable列の復元」になるようにした。データ移行段階の逆処理も、単にactive_season=Noneにするのではなく、active_season.yearからactive_sales_yearを復元してからactive_seasonをnullへ戻す順序にした。SystemSettingはDjango管理サイトでの編集のみ)。system_settings.tests(load()のブートストラップ更新)、Phase 1〜3bのactive_sales_yearを直接assertしていたテストを.active_season.yearへ更新。active_sales_year記述を更新。SystemSettingは多くの経路から読まれるため、切替漏れがないか回帰テストで確認する必要があるが、Order/Stockなど他モデルは未変更のため影響範囲は閉じている)。makemigrations --check --dry-runが変更なしを返す。背景: Phase 4-Aのレビュー対応(コメント#3256指摘)でSalesSeasonのDjango admin登録は撤回済みであり、現時点でSalesSeasonを作成する画面・API経路は存在しない(管理コマンド・データマイグレーション経由のみ)。Phase 4-CでProduct.seasonを導入すると、商品作成・編集フォームの年度選択肢がSalesSeason.objects.all()からの選択のみになるため、新しい年度(例: 2027年度)の商品を登録する前に、その年度のSalesSeasonを作成できる手段が必要になる。Phase 4-C着手前に、この専用画面を独立した実装単位として用意する。
SalesSeasonをそのまま使う)。dashboard 配下の年度管理画面(例: /management/seasons/、dashboard:season_list/dashboard:season_create)。一覧表示(year/status/closed_at/closed_by)と、新規作成フォーム(yearのみ入力)を提供する。SalesSeasonの新規作成のみ(yearが未使用であることをフォームで検証し、statusは常定OPENで作成)。status・closed_at・closed_byの直接編集、CLOSED → OPENの逆遷移、既存行の削除。これらを許可すると「状態遷移はOPEN→CLOSEDの前方のみ、締め取消・再開は提供しない」という決定事項(Phase 4-A)と矛盾するため、フォーム自体にこれらの入力項目を持たせない(Django adminのような汎用ModelAdminは使わない)。year重複時のエラー・status等の想定外パラメータが無視されること)。SalesSeasonを、status等を誤って操作できない形で作成できる。既存のSalesSeason一覧(2025・2026)が正しく表示される。全既存テストが成功する。Product.season導入(新設、Issue #17 コメント #3258 指摘3)【実装済み。13節参照】products.Product(season追加、sales_yearは最終的に削除し読み取り専用プロパティへ置き換え)。mg_masters/views.py(商品一覧・作成・編集フォーム、_get_sales_year_choicesの全面書き換え、「その他の年度を入力」欄の撤廃)、products/services.py::is_product_purchasable/purchasable_products_queryset(FK id比較へ)、dashboard/services/year_end.py(_check_products_for_sale/execute_year_end()の対象商品クエリ)、mg_ordersの商品選択肢表示ラベル。本番コード・テストfixtureのProduct.objects.create(...)全16箇所超を同一コミットでseason=渡しへ更新(Phase 3aのsales_year導入と同じ規模の一括更新が再度発生する)。seasonをnullable追加→Product.sales_yearの値に対応するSalesSeasonを割当(孤立年度があれば移行中断)→整合性検証→アプリコード・unique_together・fixture切替→NOT NULL化→旧sales_year列削除(読み取り専用プロパティへ置換)。SalesSeason一覧からの選択のみになる(自由入力欄は撤廃)。年度候補に無い年度で登録したい場合は、先に年度管理画面(Phase 4-A由来)でSalesSeasonを作成する導線に変わる。Product.objects.create()呼び出しをseason=渡しへ更新。新規: 存在しない年度(SalesSeasonが無い値)を指定した商品作成がDB制約(FK)で拒否されること、_get_sales_year_choicesがSalesSeason.objects.all()に基づくこと。既存Phase 3a/3bのテストはseason=ベースへ更新しつつ、判定結果は不変であることを確認。SalesSeasonへのseasonを保持する。存在しない年度の商品作成がDBレベルで拒否される。商品マスタの年度フィルタ・年度候補表示がSalesSeasonベースで従来と同等に動作する。全テスト成功。Order.season導入と年度混在防止【実装済み。14節参照】orders.Order(season追加)。products/services.py::validate_items_single_season新設。OrderItem.save()に防御的ガード追加(product.season_idとorder.season_idのFK id比較、2.4節)。seasonをnullable追加→(0節の前提を検証する管理コマンド実行)→全64件へseason=2025のSalesSeasonをバックフィル→NOT NULL化(同一フェーズ)。order.seasonでフィルタ。Order.objects.create()呼び出し(本番コード・fixture)にseason=を追加。新規: 4経路それぞれで異なるseason商品の混在が拒否されること、管理者商品選択肢が正しくフィルタされること。Orderは最も参照の多いモデル。ただしStock/Package側はこの時点で未変更のため、切り戻しはOrder関連コードとテストの範囲に閉じる)。season=2025を保持。4経路すべてで年度混在が拒否される。既存の「管理者は全年度商品を編集可能」という Phase 3b の方針(年度混在防止とは別軸)は維持される。全テスト成功。SeasonStock導入【実装済み。15節参照】products.SeasonStock。既存Stockは削除しない。SeasonStock新規作成+データマイグレーション(既存Stock3件からの1:1複製)。mg_masters::stock_listにseason選択UI追加(既定=active、CLOSEDは供給量調整フォーム非活性)。Stockを直接生成・assertしていた全テストをSeasonStockベースに更新。新規: 旧seasonの注文キャンセル・数量変更がその注文のseasonのSeasonStockを正しく更新すること(activeのSeasonStockに影響しないこと)。CLOSED seasonの供給量調整が拒否されること。Stock削除タイミング)、9(CLOSED season訂正の扱い)。Stockテーブル自体は無傷で残るが、SeasonStock切替後は更新されず実態と乖離するため、「コード側参照を戻せば復旧できる」という意味でのロールバック安全網ではない。ロールバックは切替直前のDBバックアップ復元を原則とする。Stockを残す目的は移行結果の比較・監査用に限定する。SystemSetting→Product→SeasonStock)がテストで確認される。全テスト成功。(Issue #17 コメント #3256 指摘3・コメント #3258 指摘1を反映。Package.seasonを必須FKとして追加する内容へ全面改訂)
orders.Package(season追加、必須FK。2.6節)。mg_workflow/api_packaging.py(箱作成時にseasonを確定・設定。PackagePlanItem/PackageItem/Allocation追加時のseason_id一致ガード4箇所。_pack_bagsのグルーピングキーに season を追加)。mg_workflow/services/shipping_fee.py::finalize_shipping_fee_if_all_packedのシグネチャを(destination, season)へ変更し、Package.objects.filter(destination=destination, season=season)を直接クエリする(推測ヘルパーは廃止)。dashboard/services/year_end.py::_check_in_progress_packagesをPackage.season=closing_seasonで絞り込む。(destination, season)の組ごとに1回ずつ呼ぶ形へ変更する。Package.seasonをnullable追加→既存98件のバックフィル(PackageItem/PackagePlanItemのproduct.seasonから導出、0件なら移行時点のactive season、2件以上の混在なら移行中断)→整合性検証→アプリコード切替→NOT NULL化。seasonが確定すること、既存98件のバックフィルが正しく行われること。ACCEPTED注文が共存する状態で、(1) 旧seasonの未完了箱(空箱を含む)が新season注文の送料確定を妨げないこと、(2) 箱数・送料が年度間で合算されないこと、(3) 各seasonの代表注文(送料を記録する1件)がそのseason内の注文であることを確認する。Package.season=closing_seasonで正しく絞り込まれること。Package.seasonと送料確定のseason単位化を追記。Packageへのスキーマ追加・バックフィルを伴うため、旧_infer_package_season案より切り戻しコストはやや高いが、Package自体の他フィールドは無傷)。seasonが確定し、空でない箱に異なるseasonの袋・予約商品を追加しようとするとエラーになる。自動梱包提案が season 別に独立して計算される。送料確定が season 単位で正しく分離される(空箱を含めた未完了箱判定を含む、上記テスト3点)。年度締めの途中箱チェックがseason単位で絞り込まれる。既存の単一season運用(現状のテストの大半)は影響を受けない。(Issue #17 コメント #3256 指摘2・コメント #3258 指摘2・コメント #3260 指摘3を反映。CreditApplicationAllocation・reversal_ofを追加する内容へ全面改訂)
orders.CustomerCreditTransaction(source_season・target_season・recorded_in_season・reversal_of(自己OneToOneField)の4フィールドを追加)。新規 orders.CreditApplicationAllocation(配賦明細)。mg_orders/services/credit.py(apply_credit_to_order→FIFO配賦計算+CreditApplicationAllocation作成+target_season設定、return_credit_on_cancel→未取消のAPPLIED_TO_ORDER行ごとに1件ずつreversal_of付きの戻し行を作成する形へ改訂)、mg_orders/services/cancellation.py::record_payment_adjustment(CARRY_OVER_FROM_CANCELED_ORDER生成時にsource_season設定)。3箇所ともrecorded_in_seasonを生成時点のactive seasonで設定。CustomerCreditTransaction.save()にsource_season/target_season一致検証、reversal_ofの種別・顧客・target_order一致検証(防御的ガード)を追加。source_season・target_season・recorded_in_season・reversal_ofをnullable追加→データ移行→整合性検証→recorded_in_seasonのみNOT NULL化(source_season/target_season/reversal_ofは取引種別により恒久的にnullable)。CreditApplicationAllocationは新規モデルのため段階化不要(現状0件)。source_season/target_season/recorded_in_season/reversal_ofが設定されること。FIFO配賦(複数の元取引にまたがる充当が正しく複数行に分割されること、配賦合計が充当額と一致すること)。複数回の部分充当がある注文をキャンセルすると、未取消のAPPLIED_TO_ORDER行ごとにreversal_of付きの戻し行が1件ずつ作成されること(既存の合計金額ベースのテストと整合すること)。同一APPLIED_TO_ORDER行に対する二重取消がreversal_ofの一意制約で拒否されること。reversal_of経由で元のCreditApplicationAllocationまで辿れること。source_order/target_orderのseasonと一致しない値、またはreversal_ofの種別・顧客・target_orderが不一致の値を保存しようとすると拒否されること。CustomerCreditTransactionがrecorded_in_seasonを持ち、取引種別に応じたsource_season/target_season/reversal_ofが2.7節の表どおりに設定される。1回の充当が複数年度の元取引にまたがる場合でも、CreditApplicationAllocationで内訳(元取引・配賦額)が推測なしに追跡できる。戻し取引からreversal_ofを経由して、取り消された充当の元配賦明細まで一意に追跡できる。同一充当の二重取消がDB制約で防止される。繰越元年度・繰越先年度・金額が、注文レコードを辿ることなくCustomerCreditTransaction単体から直接判別できる。既存繰越関連テストは無変更で成功する。dashboard/services/year_end.py(build_year_end_previewの注文系4チェックをOrder.season=closing_seasonで絞り込み、next_year_ready判定を「next_yearのSalesSeasonがOPENで存在するか」へ変更、execute_year_end()に「closingseason をOPEN→CLOSED」「active_seasonポインタをclosing→nextへ付け替え」を追加)。orders/views.py::order_create・mg_orders/views.py::order_create・order_editに「season.status == CLOSEDなら新規注文・増量・商品追加を拒否」ガードを追加。OPENなSalesSeasonであり、かつ現在のSystemSetting.active_seasonと一致することの表示、次年度SalesSeasonがOPENで存在しない場合のエラー文言)。YearEndExecuteConcurrencyTests・OrderCreateYearEndConcurrencyTestsをSalesSeason状態遷移込みで再検証。新規: 締める年度に無関係な別年度の「塩漬け」注文があっても締め実行がブロックされないこと(Issue #17 コメント #3253 で懸念された将来リスクの解消確認)。CLOSED season注文への増量・新規商品追加が拒否されること。execute_year_end()は年度締め機能の中核であり、既存の213件規模の関連テストすべてに影響しうる)。ただし Phase 4-A〜G が先に完了・検証済みであるため、このフェーズで新規に導入するモデル・スキーマはなく、リスクはクエリ・ガードロジックに閉じる。Phase 4の判断事項を一覧化する。#3・#4・#5・#7・#8は2026-07-14にユーザーが推奨案を確定したため、実装着手をブロックする未決事項は残っていない。
| # | 項目 | 選択肢 | 推奨案 | 影響 | 後から変更可能か | 今決める必要があるか |
|---|---|---|---|---|---|---|
| 1 | SystemSetting.active_sales_yearの扱い |
(a) active_seasonFKへ型変更して残し、SalesSeason.statusからはACTIVEを排除する(OPEN/CLOSEDの2値のみ) (b) 完全廃止しSalesSeason.status=ACTIVEを直接クエリ (c) 現状維持(int) |
(a)(解消済み) | Issue #17 コメント#3256の指摘により、当初案(active_seasonとSalesSeason.status=ACTIVEの併存)が二重の正データだったことが判明したため、(a)へ確定した。ロック対象はSystemSetting(pk=1)のまま安定し、アクティブ性の正データはactive_seasonのみになる(2.1/2.2節) |
― | 解消済み(本レビューで確定。もはやユーザー判断待ちではない) |
| 2 | Product.sales_yearをFKに変えるか |
(a) intのまま維持+サービス層検証 (b) SalesSeasonへのFK化 |
(b)(解消済み) | Issue #17 コメント#3258の指摘により、(a)は「存在しない年度の商品をDBが許容する」という参照整合性の欠落を実装コストの都合で残す判断であり、コメント#3238「テスト都合で実仕様を弱めない」と矛盾すると判明したため、(b)へ確定した(2.3節、Phase 4-C) | ― | 解消済み(本レビューで確定。もはやユーザー判断待ちではない) |
| 3 | Order.seasonを作成後に変更可能にするか |
(a) 不変(キャンセル&作り直しのみ) (b) 管理者のみ変更可能にする | (a)(確定) | (a)は既存の「キャンセルして作り直す」運用と一貫し、実装も単純 | (b)は後から追加機能として実装しやすい(不変を先に決めても詰まない) | 解消済み |
| 4 | CLOSINGを永続状態として持つか |
(a) 持たない(OPEN/CLOSEDの2状態のみ。アクティブ性はSystemSetting.active_seasonポインタで別途表現) (b) 持つ(実行中を可視化) |
(a)(確定) | (a)は現行の同期・短時間トランザクションと整合し状態機械が単純。(b)は将来の非同期化に備えられるが現時点で必要性がない | 後から(b)へ拡張可能(追加のみで済む) | 解消済み |
| 5 | 締め取消・再開を許可するか | (a) 許可しない(前方のみ、訂正は手動DB操作) (b) CLOSED→OPENの逆遷移+active_seasonを過去に戻す操作を用意する |
(a)(確定) | (a)は既存の「発送済みは取消不可」等の一貫した設計方針に合う。(b)は商品状態・在庫・他seasonとの整合を巻き戻す複雑な処理が必要 | (b)は後から独立機能として追加できる(今回作らなくても詰まない) | 解消済み |
| 6 | 既存Stock(品種単位)モデルの削除タイミング |
(a) Phase 4完了時点では残し、実運用1サイクル後に別フェーズで削除 (b) Phase 4-E内で削除まで完了させる | (a) | (Issue #17 コメント#3260指摘1により理由を訂正)(a)はロールバック安全網ではなく、移行結果の比較・監査用として残す(SeasonStock切替後は更新されず実態と乖離するため復旧手段にはならない。ロールバックは切替直前のDBバックアップ復元が原則)。データ量は3行程度で維持コストは無視できる |
いつでも削除フェーズを追加できる | 不要(実装時に判断すれば足りる) |
| 7 | 管理者注文作成のseason選択UI | (a) 明示的なドロップダウン(初期値=active) (b) 常にactiveへ固定し変更不可 | (a)(確定) | (a)はPhase 3bで確定した「管理者は全年度を扱える」方針と整合する。(b)は移行期間中の旧season注文の先行作成・訂正ができなくなる | 後から(b)へ制限することは可能(緩い方から狭める方が安全) | 解消済み |
| 8 | 締め済み年度の注文に対するキャンセル・数量減少の扱い | (a) 常に許可する (b) 締め済み年度は一切変更不可にする | (a)(確定) | (a)は締め後に残った旧season注文の後始末(未入金の督促断念によるキャンセル等)を可能にする。(b)は運用上詰まる可能性がある | 後から(b)へ厳格化は可能 | 解消済み |
| 9 | CLOSED seasonの在庫供給量を訂正する手段を残すか | (a) 通常UIでは完全禁止し、Django管理サイト(/admin/)経由の手動訂正のみ許可する (b) 訂正理由付きの例外操作をUIに用意する |
(a) | (a)は既存のStockと同様SeasonStockを/admin/に登録するだけで実現でき、実装コストが低い。(b)は追加のUI・監査ログ設計が必要 |
後から(b)を追加機能として実装できる | 不要(実装時に判断すれば足りる) |
| 10 | 商品マスタの年度入力とSalesSeasonの整合をどこで強制するか |
(a) mg_mastersのフォーム/サービス層で検証 (b) Product.seasonをFK化しDB層で強制する |
(b)(解消済み) | 決定事項#2でProduct.seasonをFK化することが確定したため、この整合はDB制約そのもので保証される。フォーム/サービス層での追加検証はもはや主たる保証手段ではない |
― | 解消済み(決定事項#2の帰結として自動的に確定) |
#1・#2・#10はIssue #17 コメント #3256・#3258のレビューで解消済み、#3・#4・#5・#7・#8は2026-07-14のユーザー判断で確定済み。 #6・#9は推奨案を実装時判断として採用でき、Phase 4着手をブロックする未決事項は残っていない。
Issue #17 コメント #3256 で指摘された3点への対応を、この改訂で反映した。
| # | 指摘 | 対応 | 反映箇所 |
|---|---|---|---|
| 1 | active seasonの正データが二重(SystemSetting.active_seasonとSalesSeason.status=ACTIVE) |
SalesSeason.statusからACTIVEを削除しOPEN/CLOSEDの2値のみに限定。「アクティブかどうか」はSystemSetting.active_seasonとの比較でのみ導出する、単一の正データに一本化した |
2.1, 2.2, 3節、未決事項#1 |
| 2 | recorded_in_seasonだけでは年度間繰越(繰越元年度・繰越先年度・金額)を監査できない |
CustomerCreditTransactionにsource_season/target_seasonを追加し、取引種別ごとの必須/nullable条件・整合性検証ルールを定義した。専用の年度間振替モデルは新設せず、既存の1行=1仕訳の台帳を拡張する案を採用した |
2.7節、Phase 4-G(コメント#3258反映後のレター。当時は4-F) |
| 3 | 送料確定(finalize_shipping_fee_if_all_packed)を年度非依存のままにできない |
シグネチャを(destination, season)へ変更し、宛先×season単位で送料を確定する設計へ変更した。旧season未完了箱が新season送料確定を妨げない、箱数・送料が年度間で合算されない、という2点を新規テストで担保する |
2.6節、1節#19、Phase 4-F(コメント#3258反映後のレター。当時は4-E) |
Issue #17 コメント #3258 で指摘された3点への対応を、この改訂で反映した。いずれもコメント#3256対応(7節)で示した修正版の詳細を確認した結果、さらに修正が必要と判明したものである。
| # | 指摘 | 対応 | 反映箇所 |
|---|---|---|---|
| 1 | 空箱をseason判定から除外すると、Issue #8対応(未完了箱があれば送料を未確定へ戻す)の安全性が後退する | Package.seasonを必須FKとして追加し、箱作成時点でseasonを確定する設計へ変更した(推測ベースの_infer_package_seasonは廃止)。空箱もPackage.seasonを持つため、season限定の未完了箱判定から漏れない。年度締めの途中箱チェックもPackage.seasonを直接クエリするよう修正した |
2.6節、Phase 4-F(新設のPackage.season部分) |
| 2 | 繰越元取引と充当先が結び付いておらず、複数年度由来の残高が混在する充当の内訳を追跡できない | CreditApplicationAllocation(消費側取引と供給側取引・配賦額を結ぶ明細モデル)を追加した。FIFO配賦・部分充当・複数元取引にまたがる充当・キャンセル戻し(新規の供給取引として復元、元の配賦明細は書き換えない)・配賦合計の一致制約を設計した。既存のsource_season/target_season/recorded_in_seasonとは役割が重複しないことを確認し、削除すべきフィールドはないと判断した |
2.7節、Phase 4-G |
| 3 | Product.sales_yearをintで残すと、フォーム/サービス層以外の経路(管理サイト・将来のコード追加)から孤立年度商品を作成できてしまい、年度モデルの参照整合性が完成しない |
Product.season = FK(SalesSeason, PROTECT)への段階的移行を採用した(nullable追加→バックフィル→整合性検証→アプリコード切替→NOT NULL化→旧sales_year列削除、読み取り専用プロパティへ置換)。旧未決事項2・10はこれにより解消。Order/Package/SeasonStock等の年度比較もFK idベースへ統一した |
2.3, 2.4節、Phase 4-C(新設)、未決事項#2・#10 |
これに伴い、Phase 4のフェーズ分割を7分割(4-A〜4-G)から8分割(4-A〜4-H)へ再編した(5節冒頭の対応表を参照)。Package.season・Product.seasonの追加によりPhase 4-A〜4-Hの依存関係・移行順序(4節)も見直した。
Issue #17 コメント #3260 で指摘された4点への対応を、この改訂で反映した。いずれもコメント#3258対応(8節)で示した修正版の実装可能性・監査性を確認した結果、さらに修正が必要と判明したものである。
| # | 指摘 | 対応 | 反映箇所 |
|---|---|---|---|
| 1 | 旧StockはSeasonStock切替後に実態と乖離するため、「ロールバック安全網」という位置づけは成立しない |
旧Stockを残す目的を「移行結果の比較・監査用」に訂正し、ロールバックは切替直前のDBバックアップ復元を原則とする方針へ改めた。二重書き込みによるロールバック用途は複雑化するため不採用とした |
2.5節、Phase 4-E、未決事項#6 |
| 2 | 既存の空箱へactive seasonを自動設定する一般則は、年度を機械検証できない推測に当たる | 「0件ならactive season」という一般ルールを撤回し、移行前に空箱を件数・ID・宛先・作成日時とともに一覧化する読み取り専用検査を追加。今回の実データについて「空箱は2025年度」という明示的な移行判断を記録する方式へ変更した(判断が成立しない環境は移行を中断し手動マッピングを要求する) | 2.6節「既存Packageの移行」、4節、Phase 4-F |
| 3 | 充当取消後の供給取引(RETURNED_FROM_CANCELED_APPLIED_ORDER)から、取消対象の消費取引を一意に特定できない |
CustomerCreditTransaction.reversal_of(自己OneToOneField)を追加し、取消対象の特定のAPPLIED_TO_ORDER行を直接参照する設計へ変更した。return_credit_on_cancelは未取消のAPPLIED_TO_ORDER行ごとに1件ずつ戻り行を作成する形に改訂し、OneToOneFieldにより二重取消をDB制約で防止する |
2.7節、Phase 4-G |
| 4 | フェーズ再編後、(4-G)という古いレター参照が1箇所残っていた |
2.4節「締め済み年度への新規注文禁止」の参照をPhase 4-Hへ修正した。関連ドキュメントをrgで横断検索し、他に古いレター参照が残っていないことを確認した |
2.4節 |
実DBを使った破壊的検証は行っていない(空箱の一覧化検査は読み取り専用のコマンドとして設計しただけであり、本セッション内で実DBに対して実行してはいない)。
設計(2.1節・5節)どおり sales_seasons アプリと SalesSeason モデルを実装した。
sales_seasons/models.py。year(unique)/status(OPEN/CLOSEDの2値、既定OPEN)/closed_at/closed_by(FK→User, on_delete=PROTECT)/created_at/updated_at。設計どおりACTIVEは持たず、専用URL・専用画面も作成していない。year_endと同じ「プレフィックスなし・モデルのみ所有」方針。config/settings.pyのINSTALLED_APPSへsystem_settingsの直後・year_endの直前に追加した。ModelAdminを登録していたが、通常のModelAdminはstatusを自由編集でき、CLOSED→OPENの逆遷移が可能になってしまい「状態遷移はOPEN→CLOSEDの前方のみ、締め取消・再開は提供しない」という決定事項と矛盾するため撤回した)。year_end.YearEndRunと同様、sales_seasons/admin.pyには登録しない理由をコメントで明記するのみとした。状態変更はexecute_year_end()(Phase 4-H)だけに限定する。sales_seasons/migrations/0001_initial.py: SalesSeasonテーブルの作成(manage.py makemigrationsで自動生成、手動編集なし)。sales_seasons/migrations/0002_populate_sales_seasons.py: データマイグレーション。Product.sales_yearのdistinct値とSystemSetting.active_sales_yearの和集合を対象年度とし、active_sales_year未満の年度はCLOSED、以上の年度はOPENとしてSalesSeasonを作成する。SystemSettingが存在しないのにProduct.sales_yearが存在する場合はSalesSeasonBackfillErrorを送出して移行を中断する(黙って補正しない)。SystemSettingもProductも存在しない空DB(新規環境)の場合は何も作成しない。
SalesSeasonを全件削除する片方向のデータ移行(値の復元はしない)。Phase 4-A時点では他モデルからSalesSeasonを参照するコードが存在しないため、全件削除がそのまま0001_initial適用直後の状態に戻す操作になる。Product/SystemSetting/Order等の既存モデル・既存コード経路は一切変更していない)。sales_seasons/tests.pyに11件追加。
SalesSeasonModelTests(6件): 既定status=OPEN、year一意制約(IntegrityError)、statuschoicesがOPEN/CLOSEDの2値のみであること、closed_at/closed_byの永続化、closed_byのon_delete=PROTECT(締め実行者ユーザーの削除がブロックされること)、__str__。PopulateSalesSeasonsMigrationTests(5件): django.db.migrations.executor.MigrationExecutorでマイグレーションをsales_seasons 0001_initialまで巻き戻し、履歴モデル(apps.get_model)でテストデータを作成したうえで0002を再適用し、生成結果を検証する方式(TransactionTestCase)。商品年度とactive_sales_yearからSalesSeasonが作成されること、active_sales_year未満の年度がCLOSEDになること、商品がなくてもactive_sales_yearの年度は作成されること、空DBでは何も作成されないこと、SystemSettingが存在せずProductだけが存在する想定外状態では例外を送出して移行が中断されること、をそれぞれ確認した。テストDB上でのみ実行し、開発DB本体は一切変更していない。sales_seasons: 11 tests OKmanage.py check: 問題なしmanage.py makemigrations --check --dry-run: 変更なしdocs/design/データベース設計.md(§2.5新設、§3のユニーク制約・on_delete表に追加)、docs/design/アーキテクチャ.md(アプリ構成表・データ所有アプリ一覧に追加)。migrate実行・manage.py shellでの書き込み操作は行っていない。次はPhase 4-B(SystemSetting.active_seasonへの切替)。
設計(2.2節・5節)どおりSystemSetting.active_sales_year(int)をactive_season(SalesSeasonへのFK、on_delete=PROTECT)へ型変更した。
system_settings/models.py。active_sales_yearフィールドを削除し、active_season = models.ForeignKey(SalesSeason, on_delete=models.PROTECT, verbose_name='アクティブ年度')を追加。SystemSetting.load()は、新規レコード作成時のみ_bootstrap_active_season()(現在年に対応するSalesSeasonをOPENでget_or_create)を呼ぶよう変更した。get_or_createのdefaultsにコールバックを渡すことで、既存レコードがある通常経路では余分なクエリを発生させない(Django 4.0以降の callable defaults機能を利用)。Product.sales_year導入と同じ理由でコード切替も同フェーズに同梱。Issue #17 コメント#3299指摘1により当初の3段階から訂正)。
system_settings/migrations/0008_systemsetting_active_season.py: active_seasonをnullable FKとして追加(makemigrationsで自動生成)。system_settings/migrations/0009_alter_active_sales_year_nullable.py: active_sales_yearをnull=TrueへAlterField(新設。理由は下記11.1節)。system_settings/migrations/0010_populate_active_season.py: データマイグレーション。既存のSystemSetting(pk=1)のactive_sales_yearに対応するSalesSeasonを検索しactive_seasonへセットする。SystemSettingが存在しない場合は何もしない。対応するSalesSeasonが見つからない場合(Phase 4-Aバックフィル後に手動削除された等の想定外状態)はActiveSeasonBackfillErrorを送出し、黙って補正せず移行を中断する。逆方向はactive_season.yearからactive_sales_yearを復元してからactive_seasonをnullへ戻す(単純にactive_season=Noneにするだけだった当初案から訂正)。system_settings/migrations/0011_active_season_not_null.py: active_sales_yearをRemoveField、active_seasonをNOT NULL化。products/views.py、cart/views.py、orders/views.py(order_confirm/order_create/order_update)、mg_masters/views.py(3箇所)、dashboard/views.py、dashboard/services/year_end.py::execute_year_end)でSystemSetting.load().active_sales_year(またはsetting.active_sales_year)を.active_season.yearへ置換した。テンプレートのコンテキスト変数名(active_sales_year)は変更していない(値は引き続きint)。execute_year_end()の変更: 読み取り側はsetting.active_season.year != closing_yearへ変更。書き込み側はSalesSeason.objects.get(year=next_year)を取得してsetting.active_seasonへ設定する。対応するSalesSeasonが存在しない場合はYearEndExecutionErrorで中止する(既存の他の前提条件チェックと同じ形式のエラーとして扱う。フィールドがFKになったことで新たに発生し得る失敗モードであり、Phase 4-Aの「想定外の状態は黙って補正せず安全に失敗させる」という方針を踏襲した)。エラーメッセージは「次年度のSalesSeasonを作成してから再実行してください」とし、実在しない導線(Django admin。Phase 4-Aレビュー対応で登録を撤回済み)は案内しない(Issue #17 コメント#3299指摘2により訂正。管理者向け年度作成画面はPhase 4-B2として新設。11.2節参照)。SalesSeason.statusのOPEN→CLOSED遷移・next_year_ready判定のSalesSeasonベース化はPhase 4-Hで追加する(今回は含めない)。executed_actionsのJSONキー名(active_sales_year_before/active_sales_year_after)は値の取得方法のみ変更し、キー名自体は据え置いた(監査ログ形式の不要な変更を避けるため)。system_settings.testsをactive_seasonベースへ書き換え(ブートストラップ・不変性の2件)+逆マイグレーション検証2件(11.1節)。mg_masters/dashboard/products/cart/ordersの既存テストでSystemSetting.objects.create(active_sales_year=...)としていた箇所をSystemSetting.objects.create(active_season=SalesSeason.objects.create(year=...))へ、SystemSetting.load().active_sales_yearのアサーションを.active_season.yearへ更新した。execute_year_end()を呼ぶテスト(dashboard.testsの3クラス、orders.tests.OrderCreateYearEndConcurrencyTests)は、next_yearに対応するSalesSeasonが存在しないと新たにYearEndExecutionErrorになるため、setUpでnext_year分のSalesSeasonも作成するよう更新した。次年度SalesSeason欠如時の原子性テストを新規追加(11.2節)。manage.py check問題なし。manage.py makemigrations --check --dry-run変更なし。docs/design/データベース設計.md(§2.4のフィールド表・Phase 4確定方針note、§2.5のバックフィル説明、§3のon_delete表)、docs/design/アーキテクチャ.md(sales_seasonsの被参照状況)、docs/features/管理者向け補助機能.md(§2.2、Phase 4確定方針note)、docs/features/購入者向け機能.md(Phase 4確定方針note、§1/2/3/4のactive_sales_year記述)、docs/features/商品在庫マスタ.md(SystemSetting.active_season節ほか)、docs/features/注文管理.md(管理者側年度制限の記述)。migrate実行・manage.py shellでの書き込み操作は行っていない。当初の3段階マイグレーションでは、0010(現0011)がactive_sales_year(NOT NULL・defaultなし)をRemoveFieldしていた。この逆適用(AddField)は、既存行があるテーブルへ「NOT NULLかつdefaultなしの列」を追加する操作になり、MySQLでは値の補完手段がないため失敗し得る。
対応として、RemoveFieldの前にactive_sales_yearをAlterFieldでnull=True化する段階(0009_alter_active_sales_year_nullable.py)を追加した。これにより、削除時に記録される「削除前のフィールド定義」がnullable版になり、逆適用のAddFieldは常に安全な「nullable列の復元」になる。あわせて、データ移行(0010_populate_active_season.py、旧0009)の逆処理を、単純なactive_season=Noneから「active_season.yearをactive_sales_yearへ復元してからactive_seasonをnullへ戻す」処理へ訂正した。
検証はsystem_settings.tests.ActiveSeasonMigrationReversalTests(MigrationExecutorで0011適用済みの状態から0007まで逆適用し、既存行のactive_sales_yearが正しく復元されること・例外が発生しないことを確認)で行う。テストDB上でのみ実行し、開発DB本体は変更していない。
execute_year_end()が次年度SalesSeason欠如時に案内していた「Django管理サイトでSalesSeasonを作成」という導線は、Phase 4Aレビュー対応(コメント#3256)でSalesSeasonのadmin登録を撤回済みのため実在しない。メッセージから具体的な操作経路の案内を削除し、「次年度のSalesSeasonを作成してから再実行してください」という一般的な文言へ訂正した。
恒久的な対応として、SalesSeasonの新規作成のみを許可する最小の管理者向け画面をPhase 4-B2として新設することを実装案へ明記した(5節参照)。status・closed_at・closed_byの編集やCLOSED→OPENは許可しない設計とし、Phase 4-C(Product.season導入、年度選択肢がSalesSeason一覧からの選択のみになる)着手前に用意する。Phase 4-B2自体の実装はこのセッションでは行わない(設計への明記のみ)。
次年度SalesSeasonが存在しない状態での原子性はdashboard.tests.YearEndExecuteServiceTests.test_missing_next_year_season_prevents_execution_atomicallyで検証した(YearEndExecutionErrorになる・対象商品が販売停止されない・SystemSetting.active_seasonが変わらない・YearEndRunが作成されないことを確認)。
次はPhase 4-C(Product.season導入)。Phase 4-C着手前にPhase 4-B2(年度作成画面)の実装可否をユーザーへ確認する。
Issue #17 コメント#3301で確定した仕様どおり、SalesSeasonの一覧表示・新規作成のみを許可する最小画面をdashboardアプリへ追加した。モデル・マイグレーションの変更はない(Phase 4-Aで導入済みのSalesSeasonをそのまま使う)。
/management/seasons/(dashboard:season_list)・/management/seasons/create/(dashboard:season_create)。既存のdashboard直下URL(year-end/等)と同じ命名規則(サブアプリを介さずdashboard/urls.pyに直接定義)に合わせた。season_list_view): SalesSeason.objects.order_by('-year')を表示。列は販売年度・状態・アクティブ年度かどうか・締め日時・締め実行者。アクティブ年度はSystemSetting.load().active_seasonとの比較(season.id == active_season_id)でその場で導出し、SalesSeason自身へは保存しない(2.1/2.2節と同じ設計方針)。season_create_view): dashboard.forms.SalesSeasonCreateForm(ModelForm、fields=['year']。レビュー指摘対応により手動int()変換から変更。12.1節参照)を使う。status・closed_at・closed_byはフォームが認識するフィールド自体に存在しないため、POSTに混入していても一切バインドされない。作成されるSalesSeasonのstatusは常にモデルの既定値(OPEN)。year > SystemSetting.active_season.yearはフォームのclean_year()でPOST処理時にサーバー側で再検証する(画面表示時の値は信用しない)。重複年度はModelFormの一意検証(Model.validate_unique())で拒否したうえで、SalesSeason.yearのDB一意制約を最終防衛線として維持し、事前チェックをすり抜けた同時POSTによる重複作成はIntegrityErrorを捕捉してform.add_error()で利用者向けエラーへ変換する(@transaction.atomicは使わずMySQLの自動コミットに任せる既存踏襲)。フォームの初期値はactive_season.year + 1以上で最小の未登録年度。@login_required + @user_passes_test(is_staff_user, login_url='/accounts/login/')。既存のdashboard配下の全画面と同じデコレータの組み合わせで、未認証・非スタッフともログイン画面へ302リダイレクトする(year_end_view等と同一の挙動)。dashboard:season_edit/dashboard:season_delete等は存在しない。テストでNoReverseMatchを確認)。Django adminへのSalesSeason再登録も行っていない。templates/dashboard/season_list.html・templates/dashboard/season_form.htmlを新設。既存のmg_masters(品種マスタ一覧・作成フォーム)と同じBootstrapカード・テーブル・パンくずリスト構成に合わせた。templates/base_management.htmlのサイドナビへ「販売年度管理」を追加(「年度締め」の直下)。templates/dashboard/year_end.htmlの「現在のアクティブ販売年度」表示部にも年度管理画面へのリンクを追加した。dashboard/tests.pyに3クラス22件を追加。
SalesSeasonListViewTests(5件): ログイン必須・非スタッフ拒否・スタッフ閲覧可・アクティブ年度IDがSystemSetting.active_seasonと一致・アクティブ年度切替時に表示も追随すること。SalesSeasonCreateViewTests(15件): ログイン必須・非スタッフはGET/POSTとも拒否・初期値がactive+1以上の最小未登録年度になること(既存年度をスキップする場合も含む)・正常な未来年度の作成(status=OPEN)・複数年先の作成・アクティブ年度と同値の拒否・過去年度の拒否・重複年度のフォームエラー拒否・極端に大きい整数がフォームエラーになり500にならないこと(12.1節)・非数値がフォームエラーになり500にならないこと・POSTにstatus/closed_at/closed_byを混入させても反映されないこと・作成後もactive_seasonが変わらないこと・編集/削除URLが存在しないこと。SalesSeasonCreateConcurrencyTests(1件、TransactionTestCase): 同一年度への同時POSTが500にならず、作成される行が1件だけであることを実スレッドで確認(orders.tests.OrderCreateYearEndConcurrencyTestsと同様、Clientを別スレッドから呼び出す方式)。dashboardの新規22件を含め、全体265 tests OK(Phase 4-Bレビュー対応後の243件+新規22件)。manage.py check問題なし。manage.py makemigrations --check --dry-run変更なし(モデル変更なしのため当然の結果)。migrate・manage.py shell書き込み・検証データ作成は禁止のため)。Djangoテストクライアントによる実レンダリング確認(200応答・テンプレート解決・ORMクエリ成功・リダイレクト先確認)で代替した。docs/design/アーキテクチャ.md(URLルーティング構成に/management/seasons/追加)、docs/features/管理者向け補助機能.md(§2.3新設、関連ファイルとURL表に追加)。データベース設計.mdはモデル変更がないため更新なし。Product.sales_year/Product.season・Order・Stock/SeasonStock・Package・CustomerCreditTransaction・年度締め時のSalesSeason状態遷移(Phase 4-H)には触れていない。Phase 4-Cのコードにも着手していない。初回実装はrequest.POST.get('year')を手動でint()変換し、year <= active_year・重複の有無をアプリケーションコードで個別にチェックしていた。この方式ではSalesSeason.year(PositiveIntegerField)が本来持つDB範囲チェック(MySQLのINT UNSIGNED範囲を超える極端に大きい整数など)を経由しないため、そのような値がPOSTされるとSalesSeason.objects.create()実行時にDataError等の未捕捉例外となり500になり得た(捕捉していたのは重複を示すIntegrityErrorのみ)。
対応としてdashboard/forms.pyにSalesSeasonCreateForm(django.forms.ModelForm、Meta.fields = ['year'])を新設した。
ModelFormはis_valid()内部でModel.full_clean()を呼ぶため、PositiveIntegerFieldのDB範囲チェック(MinValueValidator/MaxValueValidator、DBバックエンドのinteger_field_range()から導出)がフォームエラーとして扱われ、極端に大きい整数・非数値の入力はいずれもDBへ到達する前にフォームエラーになる。__init__(self, *args, active_year, **kwargs)でactive年度を受け取り、clean_year()でyear > active_yearを検証する(サーバー側再検証の実体)。ModelFormが自動的に行う一意検証(Model.validate_unique()、Meta.fieldsに含まれるyearのunique制約に基づく)でフォームエラーとして表示する。事前チェックのすり抜け(同時POST)に対しては、引き続きSalesSeason.objects.create()相当の保存処理(form.save())をtry/except IntegrityErrorで囲み、DB一意制約を最終防衛線として維持する。status・closed_at・closed_byはMeta.fieldsに含めていないため、POSTに混入していてもフォームが一切バインドしない(従来の「読み取らない」から「フィールド自体が存在しないため読み取りようがない」という、より強い保証に変わった)。templates/dashboard/season_form.htmlを{{ form.year }}ベースの描画へ変更し、フィールドエラー・非フィールドエラーを表示する要素を追加した。dashboard/tests.pyに極端に大きい整数(99999999999999)・非数値('abc')を送信して500にならずフォームエラーになることを確認するテストを追加した。既存の「status等がフォーム対象外である」テストはそのまま維持している(Meta.fieldsによる許可リストで同じ結果になる)。
次はPhase 4-Cレビュー待ち。Phase 4-Cへは、ユーザーの明示承認を得てから着手する。
2.3節・4節・5節で設計したProduct.season(SalesSeasonへの必須FK)への移行を実装した。ユーザーによる明示承認を受けて着手した。
products/models.pyのProductからsales_year(int)を削除し、season = models.ForeignKey(SalesSeason, on_delete=models.PROTECT, verbose_name='販売年度')を追加した。一意制約を(sales_year, variety, type, weight_kg)から(season, variety, type, weight_kg)へ変更した。読み取り専用の互換プロパティ
@property
def sales_year(self):
return self.season.year
を残し、属性としての読み取り箇所(テンプレートの年度ラベル表示等)の書き換えを抑えた。ORMクエリ(filter/values/values_list/order_by等)はこのプロパティ経由では動作しないため、該当箇所はすべてseasonまたはseason__yearへ書き換えた。
設計どおり、system_settingsのactive_season移行(Phase 4-Bレビュー対応)と同じ4段階構成にした。
products/migrations/0007_product_season.py: seasonをnullable FKとして追加(makemigrationsで自動生成)。products/migrations/0008_alter_sales_year_nullable.py: sales_yearをAlterFieldでnull=True化。削除前にnullable化しておくことで、逆マイグレーション(AddField)が常に安全な「nullable列の復元」になる(Issue #17 コメント#3299でsystem_settingsに対して行った対応と同じ理由)。products/migrations/0009_populate_product_season.py: データマイグレーション。既存Productのsales_yearのdistinct値ごとに対応するSalesSeason(year=sales_year)を検索し、一括でseasonへ割り当てる。対応するSalesSeasonが見つからない場合はProductSeasonBackfillErrorを送出して移行を中断する(黙って作成・補正しない)。逆方向はseason.yearからsales_yearを復元してからseasonをnullへ戻す(system_settingsのrestore_active_sales_yearと同じ考え方)。products/migrations/0010_product_season_not_null.py: unique_togetherを旧制約→空→新制約の順で切り替えつつ、sales_yearをRemoveField、seasonをNOT NULL化する。products.tests.ProductSeasonMigrationReversalTestsを新設し、MigrationExecutorで最終状態(0010)から0006_product_sales_year_not_null(Phase 4-B終了時点相当、season導入前の状態)まで逆適用できることを検証した。
Product行がある状態で逆適用しても例外を送出しないこと、sales_yearの値がseason.yearから正しく復元されること。Productが0件の環境でも正逆適用できること。sales_yearの一意制約)・再度リーフまで順方向適用した後(seasonの一意制約)とも、一意制約が正しく成立すること(IntegrityErrorを確認)。実DBへのmigrateは一切行わず、すべてテストDB上のMigrationExecutorで検証した。
products/services.py: is_product_purchasable(product, active_season_id) / purchasable_products_queryset(active_season_id)を、sales_year(int比較)からseason_id(FK id比較)ベースへ変更した(Issue #17 コメント#3258「年度比較はFK idベースへ統一する」を反映)。products/views.py・cart/views.py・orders/views.py(order_confirm/order_create/order_update): SystemSetting.load().active_season.year(int取得)をSystemSetting.load().active_season_idへ置き換え、上記関数への引数を統一した。dashboard/services/year_end.py: _check_products_for_sale・next_year_ready_countの商品クエリをsales_year=からseason__year=へ変更。execute_year_end()の対象商品クエリは、既にロック・検証済みのsetting.active_seasonインスタンスを直接使うseason=setting.active_seasonへ変更した(新規クエリを増やさず、かつ「トランザクション内で確認したseason」と「実際に更新するseason」を確実に一致させる)。executed_actionsのJSONキー名(sales_year/active_sales_year_before/active_sales_year_after)は、監査ログ形式の不要な変更を避けるため据え置いた(値は引き続きproduct.sales_year互換プロパティ経由で取得するため実質的な変更はない)。mg_masters/views.py・templates/mg_masters/product_form.html・product_list.htmlを全面的に書き換えた。
SalesSeasonのid(GETパラメータseason)で行う。初期値はSystemSetting.active_season、season=allで全年度表示を維持する。active_season、編集フォームの初期値は保存済み商品のseason。選択肢はSalesSeason.objects.all()のみ(year降順、SalesSeason.Meta.ordering)。SalesSeasonを作成する導線にした。seasonが存在しない・削除済みのIDの場合は_resolve_season()がフォームエラーとして拒否する(SalesSeason.objects.get(pk=season_id)のDoesNotExist/ValueErrorを捕捉)。CLOSED年度の商品作成・変更を制限する処理は、今回は追加していない(Phase 4-Hの締め済み年度ガードの範囲であり、先取り実装すると設計書の境界と矛盾するため)。products/tests.py: ProductSeasonTests(必須化・IntegrityError・PROTECT・一意制約・商品名非混入・互換プロパティ)、PopulateProductSeasonMigrationTests(正方向のバックフィル・複数年度・想定外状態での中断・空DB)、ProductSeasonMigrationReversalTests(上記逆マイグレーション検証)を追加。mg_masters/tests.py: season フィルタ・初期値・存在しない/削除済みseason IDの拒否・自由入力欄が存在しないこと・年度管理画面へのリンクを追加したテストへ全面的に書き換えた。dashboard/tests.py・orders/tests.py・cart/tests.py・mg_orders/tests.py・mg_workflow/tests.py: Product.objects.create(sales_year=...)としていたfixtureを、すべてseason=SalesSeason.objects.get_or_create(year=...)[0]へ書き換えた。dashboard.tests.YearEndExecuteServiceTests.test_missing_next_year_season_prevents_execution_atomicallyは、Product.seasonがPROTECTなFKになったことで「next_year向け商品が存在するのにSalesSeasonが存在しない」という想定外状態がFK制約により構造的に作れなくなったため、SalesSeason.objects.getを直接パッチして防御分岐の安全性(例外時に商品・active_season・YearEndRunが一切変更されないこと)を引き続き検証する形へ書き換えた。manage.py check: 問題なし。manage.py makemigrations --check --dry-run: 変更なし。開発DB本体へのmigrate実行・manage.py shellでの書き込みは行っていない。
rgでsales_year・Productの全参照を確認した。残存するsales_year表記は、(a) 読み取り専用互換プロパティの定義・docstring、(b) 過去のSalesSeason導入前を対象とするマイグレーション履歴テスト(sales_seasons/tests.py・system_settings/tests.py・本Phaseで追加したproducts.testsの逆マイグレーションテスト)が操作するHistoricalModel、(c) SystemSetting.active_sales_year・executed_actionsのJSONキー(active_sales_year_before/after、sales_year)という、Productフィールドではない正当な表記のみであることを確認した。クエリ箇所(filter/values/values_list/order_by等)・fixture・フォームPOSTフィールド・JavaScriptへ渡す年度データにsales_yearベースの実装は残っていない。
本ファイル(このセクション)、docs/design/データベース設計.md(§2.3のフィールド表・on_delete表・ユニーク制約・バックフィル説明)、docs/features/商品在庫マスタ.md(§2「販売年度」節を全面改訂)、docs/features/管理者向け補助機能.md(年度締めチェック・実行前提条件のクエリ記述)、docs/features/注文管理.md(年度締めチェックの記述)。
Order.season・SeasonStock・Package.season・CreditApplicationAllocation・CustomerCreditTransactionの年度フィールド・SalesSeasonの状態遷移(Phase 4-H)には触れていない。
Phase 4-Cはレビュー完了。Phase 4-Dの実装結果は次節に記録する。
Order.seasonをSalesSeasonへの必須FK(PROTECT)として追加した。nullable追加、既存注文明細の商品年度からのバックフィル、NOT NULL化の3段階マイグレーションとし、商品年度が0件または複数年度になる注文があれば専用例外で安全に中断する。読み取り専用の事前検査コマンドcheck_order_seasonsも追加した。
注文年度はモデル保存時に変更を拒否する。OrderItem.save()とproducts.services.validate_items_single_season()の二重ガードにより、購入者・管理者それぞれの注文作成/変更4経路で年度混在を拒否する。管理者向け新規注文画面には年度選択を追加し、初期値をactive season、商品候補を選択年度で絞り込む。既存注文編集では年度選択を設けず、追加商品候補をorder.seasonへ限定する。
Phase 4-Dでは現行Stockを変更していない。年度別在庫SeasonStockへの切替はPhase 4-E、締め済み年度への増加操作禁止と年度締めチェック統合はPhase 4-Hに残す。
新規モデルproducts.SeasonStock(season・varietyへのFK、いずれもPROTECT、(season, variety)一意)を追加した。マイグレーションは2段階(0011_seasonstock: モデル新設、0012_populate_season_stock: 既存Stock3件を移行時点のactive seasonへ1:1複製。対応するactive seasonが特定できない想定外状態はSeasonStockBackfillErrorで安全に中断する片方向のデータ複製)。旧Stockは削除せず、移行結果の比較・監査用としてそのまま残す(未決事項6の推奨案どおり)。
年度・在庫に関わる全13経路(1節の表の該当行)をStockからSeasonStockへ切り替えた。参照するseasonは経路によって異なる: 商品一覧・サイドバー在庫サマリー・カート追加/数量変更・注文確認・購入者注文確定・デバッグ画面はactive season、既存注文の数量変更・キャンセル(購入者・管理者とも)は**order.season(activeとは限らない)、管理者注文作成は選択したseason**を対象にする。年度締めドライランの在庫チェック(_check_stock)は締める年度のSeasonStockだけに絞り込むよう変更した(旧Stockは年度を持たず全件対象だったため)。
mg_masters::stock_listにseason選択UI(既定=active season、GETパラメータの非数値・存在しないIDはactive seasonへ安全にフォールバック)を追加した。season.status == CLOSEDの場合は供給量調整(total_supplied_kgの増減)を一切受け付けず、画面上も入力欄を非活性化する。締め済み年度分の訂正が必要な場合は、SeasonStockをDjango管理サイトへ登録したSeasonStockAdmin経由で行う(未決事項9の推奨案どおり、訂正用の抜け道として維持)。既存Stock用のStockAdminも無変更で残る。
テストは、Stockを直接生成・assertしていた全テスト(cart/dashboard/mg_orders/mg_workflow/orders/productsの各tests.py、orders/test_phase4d.py)をSeasonStockベースへ書き換えた。新規テストとして、SeasonStockモデル自体の一意制約・PROTECT・available_kg(products.tests.SeasonStockTests)、0012_populate_season_stockの正方向バックフィル・空DB・active season不明時の中断(PopulateSeasonStockMigrationTests)と逆マイグレーション(SeasonStockMigrationReversalTests)、旧seasonの注文キャンセルがorder.seasonのSeasonStockだけを更新しactive seasonのSeasonStockに影響しないこと(orders.tests.OrderCancelCrossSeasonStockTests)、stock_listのseason選択・締め済みseasonでの供給量調整拒否(mg_masters.tests.StockListSeasonTests)を追加した。
全311テスト成功(Phase 4-D完了時点の293件+新規18件)、manage.py check/makemigrations --check --dry-run問題なし。開発DB本体へのmigrate・書き込みは行っていない。
orders.Packageにseason(SalesSeasonへの必須FK、PROTECT)を追加した。当初案(箱はスキーマ変更不要、中身から年度を推測するサービス層ガードのみ)は、空箱の年度を判定できずIssue #8対応(未完了箱が1つでも残れば送料を未確定に戻す。空箱も対象)の安全性を後退させるため撤回し、Package自体に箱作成時点でseasonを確定して持たせる設計(コメント#3258)で実装した。マイグレーションは3段階(0017_package_season_nullable: nullable追加、0018_populate_package_season: 既存Packageの中身(PackageItem.bag.product.season/PackagePlanItem.product.season)から機械的に導出してバックフィル。distinct値が1件ならそれをseasonに設定、2件以上(season混在)はPackageSeasonBackfillErrorで安全に中断、0件(空箱)は削除、0019_package_season_not_null: NOT NULL化)。
空箱の扱いは実装当初「常に中断・手動対応」としていたが、レビューで「空箱は出荷対象の中身を持たず、season導入後は中身を追加しようとしてもseason不一致ガードで拒否されるため実用上の価値がない」という指摘を受け、削除する方針へ変更した(2.6節の該当箇所を参照)。伝票番号(tracking_number)・CSV出力履歴(csv_exported_at)が設定済みの空箱(かつて中身が入り発送処理が進んだ箱)も区別なく削除する対象とした。伝票番号自体はヤマト運輸側が発行する識別子でありProduct/SalesSeasonを参照しないため、season判定の手がかりにはならないと確認した。事前検査用の読み取り専用check_package_seasons管理コマンド(削除対象の空箱一覧化、season混在箱の検出)も追加した。
PackagePlanItem.product/PackageItem.bag.productのseasonがPackage.seasonと一致することをサービス層のガード(code: "season_mismatch")で検証し、「袋を追加」(PackageItemCreateView)・「予約を追加」(PlanItemUpsertView)・「予約の消費」(PlanItemConsumeView)・「箱間移動」(PackageItemMoveView、宛先一致に加えseason一致も検証)の4箇所で年度混在を拒否する。自動梱包提案のコア_pack_bags()のグルーピングキーにseason_idを追加し、宛先単位・全体一括のいずれの提案・適用でも異なるseasonの袋が同じ箱に混ざらないようにした(新規箱のseasonは最初に入る袋の商品seasonから決まる)。
finalize_shipping_fee_if_all_packedのシグネチャを(destination, season)へ変更し、Package.objects.filter(destination=destination, season=season)を直接クエリするよう修正した(推測ヘルパーは不要になったため廃止。空箱もseason限定集合から漏れずに未完了箱として判定される)。呼び出し元(箱作成・削除・箱内商品追加/削除/移動・予約消費・梱包提案適用[宛先単位・全体一括]・発送ステータス一括更新)はすべて、影響を受けた(destination, season)の組を列挙し組ごとに1回ずつ呼ぶ形へ変更した。年度締めの途中箱チェック(_check_in_progress_packages)もPackage.season__year=closing_yearで絞り込むよう変更した。
箱作成時のseason決定(PackageCreateView)は、対象宛先のACCEPTED注文が単一seasonなら自動採用(画面選択なし)、ACCEPTED注文が0件ならSystemSetting.active_seasonへフォールバック、複数season共存時のみcode: "ambiguous_season"(候補season一覧付き)で拒否し明示的なseason_id指定を要求する設計とした。JS側(packaging_board.js::createPackage)はambiguous_season応答を受けたらprompt()で年度を選ばせて再送信する。既存の梱包提案の商品選択UI・モーダルは変更していない(season不一致時はサーバー側ガードが返すseason_mismatchエラーメッセージを既存の汎用エラー表示に委ねる)。
Django管理サイトのPackageAdminにseason列・フィルタを追加した。パッケージングボードのJSON API(BoardStateView)にもseason_id/season_yearを追加し、箱カードに年度バッジを表示するようにした。
テストは、Package.objects.create()を呼んでいた既存テスト(mg_workflow/mg_orders/dashboardの各tests.py)全箇所にseason=を追加した。新規テストとして、Package.seasonの必須化・PROTECT、0018_populate_package_seasonの正方向バックフィル(単一season・PackagePlanItem由来・season混在での中断・空箱の削除・空箱削除が他Packageに影響しないこと・空DB)と逆マイグレーション、check_package_seasonsコマンド(orders/test_phase4f.py)、4箇所の整合性ガード拒否・同一season内移動の成功、箱作成時のseason決定(単一自動採用・複数候補時のambiguous_season・明示指定・active seasonフォールバック・不正なseason_id拒否)、自動梱包提案がseasonをまたいで袋を混在させないこと、送料確定がseason単位で正しく分離されること(mg_workflow.tests.PackageSeasonGuardTests)、年度締めの途中箱チェックがPackage.season__year=closing_yearで絞り込まれること(dashboard.tests)を追加した。
全338テスト成功(Phase 4-E完了時点の311件+新規27件)、manage.py check/makemigrations --check --dry-run問題なし。開発DB本体へのmigrate・書き込みは行っていない。
orders.CustomerCreditTransactionにsource_season・target_season(いずれもSalesSeasonへの任意FK、PROTECT、取引種別により恒久的にnullable)・recorded_in_season(同FK、必須)・reversal_of(自己OneToOneField、PROTECT、任意)の4フィールドを追加した。新規モデルorders.CreditApplicationAllocation(consuming_transaction・source_transaction・amount、(consuming_transaction, source_transaction)一意)も追加し、案B(既存台帳への年度フィールド追加)+配賦明細モデルの組み合わせ(実装案2.7節)を実装した。マイグレーションは3段階(0020: 4フィールド追加+CreditApplicationAllocation新設、0021: recorded_in_seasonのバックフィル。設計時点でCustomerCreditTransactionは0件と確認されていたため、既存行が見つかった場合は機械的な導出手段がなくCreditTransactionSeasonBackfillErrorで安全に中断する、0022: recorded_in_seasonのNOT NULL化)。
CustomerCreditTransaction.save()に防御的ガードを追加し、source_order/target_orderが設定されている行は対応するsource_season/target_seasonがその注文のseasonと一致することを検証する。reversal_ofを設定する行は、参照先がAPPLIED_TO_ORDER種別であること・userが一致すること・target_orderが一致することを検証する。reversal_ofはOneToOneFieldのため、同一のAPPLIED_TO_ORDER行への二重取消はDB制約で防止される。
生成箇所3箇所を改訂した。mg_orders/services/cancellation.py::record_payment_adjustment(CARRY_OVER_FROM_CANCELED_ORDER生成時にsource_season=order.seasonを設定)、mg_orders/services/credit.py::apply_credit_to_order(APPLIED_TO_ORDER生成時にtarget_season=order.seasonを設定し、新設の_allocate_fifo()でその顧客の供給側取引(amount > 0の行)をcreated_at昇順(FIFO)で未配賦残額まで消費しCreditApplicationAllocationを作成)、mg_orders/services/credit.py::return_credit_on_cancel(従来は「有効な充当合計額」1件分の戻し行を作成していたが、対象注文のまだ取消されていないAPPLIED_TO_ORDER行を全件取得し、行ごとに1件ずつreversal_of付きのRETURNED_FROM_CANCELED_APPLIED_ORDERを作成する形へ改訂。複数回の部分充当がある注文をキャンセルすると複数の戻し行が作成される)。いずれもrecorded_in_seasonには生成時点のSystemSetting.load().active_seasonを設定する。既存のbackfill_credit_from_carryover管理コマンドも同様に更新した。取消された充当の配賦明細は遡って変更・削除せず、戻し行自体を新しい供給側取引として扱う(台帳を追記のみに保つ設計方針、実装案2.7節の採用案)。
FIFO配賦・逆仕訳の計算は、既存のapply_credit_to_order/return_credit_on_cancelが既に行っているorder→userのselect_for_update()ロックの内側で行う読み取り+追記のみのため、新たなロック対象は追加していない。
テストは、CustomerCreditTransaction.objects.create()を呼んでいた既存テスト(orders/mg_orders/dashboardの各tests.py)全箇所にrecorded_in_season=を追加した。新規テストとして、source_season/target_seasonの整合性検証・reversal_ofの3種検証・二重取消のDB制約防止(orders/test_phase4g.py::CustomerCreditTransactionSeasonFieldTests)、CreditApplicationAllocationの一意制約、0021の正方向(空DB・既存行での中断)と逆マイグレーション、FIFO配賦が複数の元取引に正しく分割されること・登録順(金額の大小ではない)で消費されること・複数回の部分充当が個別の戻し行として取消されること・二重キャンセルで重複した戻しが作られないこと・戻し行が新しい供給取引として以降のFIFO配賦に使えること・戻し取引からreversal_ofを経由して元の配賦明細まで追跡できること(mg_orders.tests.CreditFifoAllocationTests)を追加した。
全358テスト成功(Phase 4-F完了時点の338件+新規20件)、manage.py check/makemigrations --check --dry-run問題なし。開発DB本体へのmigrate・書き込みは行っていない。Phase 4-Hには未着手。
dashboard/services/year_end.pyのbuild_year_end_previewが呼ぶ注文系4チェック(_check_unaccepted_orders・_check_in_progress_orders・_check_unfinalized_shipping_fee_orders・_check_unpaid_orders)を、いずれもclosing_year引数を受け取りOrder.objects.filter(season__year=closing_year)で絞り込む形へ変更した。これにより、締める年度に無関係な別年度の残存(「塩漬け」)注文が締めをブロックしなくなり、Issue #17 コメント #3253で懸念されていた将来リスクを解消した。next_year_ready_countの判定基準も、従来の「next_yearのFOR_SALE商品が1件以上あるか」から「next_yearのSalesSeasonがOPENで存在するか」へ変更した(商品未登録でも次年度SalesSeasonさえ用意すれば締め実行できる設計へ変更)。この2点のスキーマ意味変更に伴いSNAPSHOT_VERSIONを2→3へ、executed_actionsにclosing_season_id/next_season_idを追加したことに伴いEXECUTED_ACTIONS_VERSIONを1→2へ、それぞれ上げた。
execute_year_end()を改訂し、SystemSetting(pk=1)をselect_for_update()でロックしたうえでsetting.active_seasonを締める対象のSalesSeason(closing_season)として扱い、closing_yearと一致することを検証する処理へ変更した。年度締め実行の最終段で、closing_seasonをOPEN→CLOSEDへ遷移(status・closed_at・closed_byを設定)させたうえで、SystemSetting.active_seasonをclosing_seasonからnext_seasonへ付け替えるよう変更した(決定事項4・5により、逆遷移・締め取消は提供しない)。次年度SalesSeasonがOPENで存在しない場合はYearEndExecutionErrorで中断する。
締め済み(CLOSED)年度への新規注文・既存注文の増量を拒否するガードを4箇所に追加した。orders/views.py::order_create(購入者の新規注文。SystemSetting.active_seasonがCLOSEDを指す場合に拒否)、orders/views.py::order_update(購入者の数量変更。order.seasonがCLOSEDかつ増量の場合のみ拒否、減量・削除は常に許可)、mg_orders/views.py::_order_create_post(管理者の新規注文。選択seasonがCLOSEDの場合に拒否。_order_create_contextのseason選択肢からもCLOSEDを除外)、mg_orders/views.py::order_edit(管理者の注文編集。order.seasonがCLOSEDかつ増量・商品追加の場合のみ拒否、減量・削除は常に許可)。購入者側order_createのガードは、通常の運用フローではactive_seasonがCLOSEDを指すことはないが、SystemSettingAdmin(Django管理サイト)にactive_seasonの編集制限がなく、手動で締め済み年度へ変更され得るため、防御的に追加した。
dashboard/views.py::_year_end_execution_blockersのエラーメッセージと、templates/dashboard/year_end.htmlの実行結果アラート(締め済みになる旨・締め取消不可である旨の一文)を、上記の設計変更に合わせて更新した。
テストは、dashboard/tests.pyに、締める年度に無関係な別年度の注文(新規・未受付・未入金)が4チェックいずれにも影響しないこと(YearEndPreviewServiceTests::test_unrelated_other_year_orders_do_not_block_closing)、次年度SalesSeasonがOPENで存在しない場合に実行が拒否されること・FOR_SALE商品の有無には依存しなくなったこと(YearEndExecuteServiceTests::test_next_year_not_ready_when_season_is_closed・test_next_year_ready_does_not_depend_on_for_sale_products)、締め実行でclosing_seasonがCLOSEDへ遷移しactive_seasonが次年度へ切り替わること(test_execute_closes_the_closing_season_and_keeps_next_season_open)、例外時のロールバックでSalesSeason.statusも含め全変更が巻き戻ることの追加検証を加えた。orders/tests.pyに、SystemSetting.active_seasonがCLOSEDの場合の購入者新規注文拒否(OrderCreateClosedActiveSeasonTests)、order.seasonがCLOSEDの場合の購入者増量拒否・減量許可(OrderUpdateClosedSeasonTests)を追加した。mg_orders/tests.pyのAdminOrderCreateTestsに、管理者の新規注文におけるCLOSEDseason拒否・選択肢からの除外、order_editにおけるCLOSEDseason注文の増量拒否・減量許可のテストを追加した。
全369テスト成功(Phase 4-G完了時点の358件+新規11件)、manage.py check/makemigrations --check --dry-run問題なし(モデル・マイグレーションの変更なし)。開発DB本体へのmigrate・書き込みは行っていない。これでPhase 4(4-A〜4-H)すべてが完了した。