管理者向けのマスタ管理(品種・商品・在庫・空袋)の仕様を、開発者向けにまとめます。
本書は
specs/31_商品・在庫・品種マスタ管理機能 実装手順書.md(Ver.3.0) を土台に、現行コード(products/mg_masters)と照合して再構成したものです。挙動は実装を正とし、差分は本文中で明示します。
関連: design/データベース設計(モデル定義) / design/アーキテクチャ
| 機能 | 主なファイル |
|---|---|
| マスタのデータ定義 | products/models.py(ProductVariety / Product / Stock) |
| 品種・商品・在庫・空袋の管理 UI | mg_masters/views.py |
| 動的価格提案 API | mg_masters/api.py(CalculatePriceAPIView) |
| 商品マスタの年度デフォルト値 | system_settings/models.py(SystemSetting.active_season。Issue #17 Phase 3a、Phase 4-B で SalesSeason へのFKへ型変更) |
| 年度の商品を一括複製 | mg_masters/services/product_copy.py、mg_masters/forms.py(ProductCopyForm) |
すべての画面は /management/masters/ 配下(dashboard:mg_masters:*)。各ビューは @login_required + @user_passes_test(is_staff_user) でスタッフ限定。
ProductVariety)URL: 一覧 dashboard:mg_masters:variety_list、作成 dashboard:mg_masters:variety_create、編集 dashboard:mg_masters:variety_edit、削除 dashboard:mg_masters:variety_delete。
name)、基準重量(base_weight_kg)、基準価格(base_price)。基準重量・価格は価格提案の基礎になる。ValueError/InvalidOperation 等)ならエラーメッセージを出してフォームを再表示。is_deletable): 一覧ビューは各品種に is_deletable を annotate する。配下の商品が注文(OrderItem.product)または袋(Bag.product)から参照されている品種、もしくは品種を直接参照する精米作業記録(MillingRecord.variety は on_delete=PROTECT)がある品種は削除不可(~Exists(OrderItem.filter(product__variety=…)) & ~Exists(Bag.filter(product__variety=…)) & ~Exists(MillingRecord.filter(variety=…)))。テンプレートはこれで削除ボタンの活性/非活性を制御し、非活性ボタンにはツールチップで理由を表示する(Bootstrap のツールチップは disabled ボタン上では発火しにくいため、<span data-bs-toggle="tooltip"> で包む)。あわせて配下の商品件数 product_count(Count('product', distinct=True))も annotate する。Product.variety は on_delete=CASCADE(データベース設計)のため、品種を削除すると配下の商品も一緒に削除される。variety_delete_view は削除前に注文・袋からの使用有無に加え精米作業記録(MillingRecord)の有無を再判定し、使用中なら「注文・袋詰めに利用されている商品、または精米作業記録があるため削除できません」と表示して中断する(テンプレートの非活性化に依存しないサーバ側ガード)。使用されていない品種のみ削除され、配下の未使用商品もカスケードで削除される。保険として IntegrityError / ProtectedError も捕捉する。
Product)URL: 一覧 dashboard:mg_masters:product_list、作成 dashboard:mg_masters:product_create、編集 dashboard:mg_masters:product_edit、削除 dashboard:mg_masters:product_delete、一括操作 dashboard:mg_masters:product_bulk_action。
variety / type / weight / status)。season=all と画面固有の年度フィルタは廃止した。一覧テーブルの「販売年度」列は誤認防止のため残す。is_deletable を annotate する。OrderItem と Bag のいずれにも使われていない商品のみ削除可能(~Exists(OrderItem) & ~Exists(Bag))。テンプレートはこれで削除ボタンの活性/非活性を制御する。season)、種類(玄米/精米)、容量(weight_kg)、価格係数(price_coefficient)、価格(price)、販売ステータス(未公開/販売開始/販売停止中)。errors)にフォームへ返す。name)は保存時に「品種 種類 容量kg」形式へ自動生成(Product.save())。年度は商品名に含めない(下記「販売年度」参照)。(season, variety, type, weight_kg) の重複は IntegrityError を捕捉し「同じ販売年度・品種・種類・容量の組み合わせの商品が既に存在します」と表示する。同じ品種・種類・容量でも season が異なれば登録できる。Product.season、Issue #17 Phase 4-C で sales_year(int) から SalesSeason へのFKへ移行済み)ProductVariety)は年度を跨いで使う恒久マスタなので、年度フィールドを持たせない。Product)は season(sales_seasons.SalesSeason への必須FK、on_delete=PROTECT)を持つ。存在しない年度の商品はDB制約(FK)そのものにより作成できない。既存商品は sales_year の値に対応する SalesSeason へデータマイグレーションでバックフィル済み(products/migrations/0007〜0010)。(season, variety, type, weight_kg)。同じ品種・種類・容量の商品を年度ごとに登録できる。Product.sales_year(return self.season.year)を残しているが、属性の読み取り専用でありORMクエリ(filter/values 等)には使えない。年度で絞り込む場合は season または season__year を使う。Product.name には販売年度を含めない。 販売年度は商品名の一部ではなく独立した属性として扱う(Issue #17 コメント #3238)。そのため、同一品種・種類・容量で年度違いの商品を作ると name は完全に同一の文字列になる。年度の識別は次の画面側の表示で行う。
mg_orders)の商品選択肢: Product.name を変更せず、選択肢の表示のみ [2025年度] 商品名 のように年度を付加する(注文管理 参照)。商品作成・編集フォームの「販売年度」はドロップダウンで選ぶ。選択肢は登録済みの SalesSeason.objects.all() のみ(mg_masters.views.product_create_view / product_edit_view)。
product.season(既存値のため必ず選択肢に含まれる)。OrderItemまたはBagに一度でも使われた商品の年度は変更できない。未使用商品はCLOSED以外の年度へ変更できる。SalesSeason に限られる。新しい年度(例: 2027年度)の商品を登録したい場合は、先に販売年度管理画面(/management/seasons/create/)で年度を作成してから商品作成フォームへ進む(フォームからも同画面へのリンクを表示する)。season が存在しない・削除済みのIDの場合、SalesSeason.objects.get(pk=season_id) が失敗しフォームエラーとして拒否する(mg_masters.views._resolve_season)。SystemSetting.active_season(Issue #17 Phase 3a/3b、Phase 4-B で active_sales_year(int) から SalesSeason へのFKへ型変更)商品マスタの年度デフォルト値計算に加え(Phase 3a)、購入者向け(products/cart/orders)の購入可否検証にも使う(Phase 3b。購入者向け機能 参照)。「アクティブな年度はどれか」を表す正データはこのポインタのみであり、SalesSeason.status(OPEN/CLOSED)はアクティブ性とは独立した「締め済みかどうか」だけを表す(データベース設計 §2.5)。
2025 に対応する SalesSeason を Phase 4-A・4-B のデータマイグレーションで作成・紐付け済み(sales_seasons/migrations/0002、system_settings/migrations/0009)。モデルには恒久的な default を設定していない。SystemSetting.load() は、レコードが存在しない新規環境でのみ、作成時点の年(timezone.localdate().year)に対応する SalesSeason(OPEN、無ければ新規作成)を初期値にする(get_or_create の defaults にコールバックを渡し、実際に新規作成する場合のみ評価する)。既存レコードがある場合は一切書き換えない。システム日付が変わっても自動更新されない。2025 から 2026 への切替は、年度締め実行(管理者向け補助機能 §2.2)でのみ行う。管理画面から SystemSetting を直接編集して切り替える運用は想定していない。/admin/)で他の SystemSetting フィールドと同様に編集できる(直接編集した場合、購入者向けの年度検証にも即座に反映される点に注意)。product_bulk_action)チェックした複数商品に対し、次の一括アクションを実行(フィルタ条件は維持してリダイレクト)。
is_deletable(注文・袋に未使用)な商品のみ削除。削除できなかった件数は警告表示。mg_masters/services/pricing.py の calculate_proposed_price を共用)。年度複製(上記)は価格をコピー元からそのまま引き継ぐため、基準価格を改定した後に新年度の価格を作り直す用途を想定する。対象は選択年度の未公開(PREPARATION)商品のみで、販売開始・販売停止中の商品は変更せず警告を出す。締め済み年度では実行不可。基準重量が0以下など計算できない商品はその商品だけスキップして警告し、他の商品は更新する。既に同額の商品は「変わりませんでした」と通知する。確認はブラウザの confirm ダイアログのみ(確認画面なし)。product_delete(POST)。IntegrityError / ProtectedError(OrderItem/Bag からの PROTECT 参照)を捕捉し、「既存の注文や袋詰めに紐付いているため削除できません」と表示する。
商品一覧画面の「年度の商品を複製」ボタンから、コピー元年度の商品構成を後続の販売年度へ一括複製できる。年度締め(管理者向け補助機能 §2.2)実行前に、次年度分の商品を準備する運用を想定する。
URL: 選択画面 GET / 確認画面 POST dashboard:mg_masters:product_copy、実行 POST dashboard:mg_masters:product_copy_execute。サービス層は mg_masters/services/product_copy.py(build_product_copy_plan / copy_products_to_season)、フォームは mg_masters/forms.py(ProductCopyForm)。
SalesSeason。OPEN/CLOSED どちらも選べる(年度締め前にアクティブ年度から次年度へコピーする運用のため、CLOSED に限定しない)。初期値は共通販売年度。status=OPEN かつコピー元より後の年度に限る(同一年度・過去年度・CLOSED 年度は拒否)。選択肢自体を OPEN 年度に絞り込む。初期値はコピー元より後で最も近い OPEN 年度(無ければ未選択)。variety / type / weight_kg / price_coefficient / price をそのまま引き継ぐ。season はコピー先に置き換え、status はコピー元に関わらず必ず PREPARATION(未公開)にする。name は直接コピーせず Product.save() で再生成する。SeasonStock、注文・注文明細、袋・引当・精米記録、箱・箱明細・発送情報、品種マスタ。新年度在庫は従来どおり0から開始する。(season, variety, type, weight_kg) の商品が既にある場合はスキップし、既存商品の値は一切更新しない(コピー元を正として同期する処理ではない)。作成件数・スキップ件数を実行結果として表示する。同じコピーを再実行しても重複商品は作成されない。build_product_copy_plan)。この件数は参考値であり、実行時にサーバー側で再計算した結果を正とする。copy_products_to_season は単一の transaction.atomic() 内で、年度締め(dashboard.services.year_end.execute_year_end)と同じ順序で SystemSetting → SalesSeason(主キー順) → Product(主キー順)を select_for_update() する。これにより年度締めと商品複製が同時実行された場合の順序を直列化し、デッドロックを避ける。作成は Product.objects.get_or_create() を使い、既存の Product.save() を経由させることで name を正しく再生成する。ProductCopyError としてユーザー向けメッセージに変換する。予期しない例外は transaction.atomic() によりその実行で作成した商品を含め全てロールバックする。set_management_season で共通販売年度をコピー先へ切り替え、「N件の商品を作成し、M件をスキップしました」とメッセージを表示したうえでコピー先年度の商品一覧へリダイレクトする(作成0件・全件スキップも成功として扱う)。共通販売年度の切替は成功時のみ行われ、失敗時は変更されない。CalculatePriceAPIView)URL: dashboard:mg_masters:api_product_calculate_price(POST、JSON)。商品作成・編集フォームで、品種・容量・価格係数・種類の入力をトリガーに非同期で呼び出し、提案単価をリアルタイム表示する。
提案単価の計算式:
calculation_weight_kg = weight_kg (玄米の場合)
calculation_weight_kg = weight_kg × SEIMAI_WEIGHT_COEFFICIENT (精米の場合)
proposed_price = base_price × (calculation_weight_kg / base_weight_kg) × price_coefficient
ROUND_HALF_UP)し、100円単位に丸める。玄米/精米 以外、容量・価格係数が 0 以下、品種が存在しない、品種の基準重量が 0 以下、などは 400 エラー({"ok": false, "error": {...}})。SeasonStock、Issue #17 Phase 4-E で年度別へ移行)URL: dashboard:mg_masters:stock_list(GET 表示/POST 更新、@transaction.atomic)。
SeasonStock、(season, variety) 一意)。「総供給量(total_supplied_kg)」と「総注文量(total_ordered_kg)」を保持し、現在在庫量は available_kg(=供給−注文)プロパティで動的計算する。SystemSetting.active_season)。旧?season=<id>は互換用に共通選択へ同期して正規URLへリダイレクトする。SeasonStock が無ければ get_or_create で用意する(新規年度は供給量・注文量とも0から開始)。adjustment_kg_<variety_id>)。選択中seasonの SeasonStock を select_for_update() でロックし、total_supplied_kg += adjustment_kg する。0 はスキップ。transaction.set_rollback(True) でロールバックする(売り越し防止)。status=CLOSEDの場合、供給量調整を一切受け付けずエラーにする(画面上も調整欄を非活性表示にする)。締め済み年度分の訂正はDjango管理サイト(SeasonStockAdmin)を経由する(訂正用の抜け道として維持)。Stock(品種単位、年度非対応)は削除せず残すが、Phase 4-E以降は更新対象から外れる(移行結果の比較・監査用。詳細はデータベース設計 §2.2参照)。spec との差分: 「総供給量を直接編集」ではなく 調整量(増減)で加算更新する方式が実装の正。要件(4.2.5)の記述と一致する。
EmptyBagStock)URL: dashboard:mg_masters:empty_bag_stock_list(GET 表示/POST 更新、@transaction.atomic)。
EmptyBagStock.weight_kg は unique)。Product.weight_kg の distinct)から構成する。quantity_<weight_kg>)を受け取り、get_or_create で新規登録または数量更新する。spec との差分: 空袋在庫管理(
EmptyBagStock/empty_bag_stock_list)は31の画面一覧に明示がなく、実装で追加されている(画面遷移 の差分注記とも整合)。
年度締めドライラン(管理者向け補助機能 §2)は Product.status=FOR_SALE, season__year=closing_year の商品だけをチェック対象にする。next_year 向けに先行登録された商品は混入しない。年度締め実行(管理者向け補助機能 §2.2)は、season=setting.active_season(実行時にロック済みの SalesSeason インスタンス)かつ status=FOR_SALE の商品だけを一括で SUSPENDED に変更し、SystemSetting.active_season を next_year に対応する SalesSeason へ切り替える。next_year の商品はサーバー側でも更新対象から除外される(自動で販売開始にはならない)。
購入者向け(products/cart/orders)は status=FOR_SALE かつ product.season_id=SystemSetting.active_season_id の商品だけを購入可能とする(products.services.is_product_purchasable/purchasable_products_queryset。Issue #17 Phase 4-C で年度int比較からFK id比較へ統一。購入者向け機能 参照)。管理者向けの商品マスタ・注文管理は、引き続き全年度の商品を扱える(年度制限は購入者向けのみ)。
年度別在庫(SeasonStock)はIssue #17 Phase 4-Eで実装済み。 詳細は上記「4. 在庫マスタ」節を参照。
SeasonStockがget_or_createで作成される時点で供給量・注文量とも0から開始する。SeasonStockの過去season分)は削除・上書きせず、年度別の履歴として一覧参照できる(stock_listのseason選択UI)。ProductVarietyは年度を持たない恒久マスタのまま維持する。total_supplied_kg増減)は不可。ただし注文キャンセル・数量減少によるtotal_ordered_kgの戻しは締め済み年度でも許可する(購入者・管理者どちらの経路も対象年度=order.seasonのSeasonStockを更新するため、締め済み年度の注文の後始末を塞がない)。Stock(品種単位)は移行時点のactive seasonへ1:1複製した(products.0012_populate_season_stock)うえで、削除せず移行結果の比較・監査用として残す(Issue #17 コメント#3260の指摘により、「ロールバック安全網」という位置づけは撤回済み。SeasonStock切替後Stockは更新されず実態と乖離するため、ロールバックが必要な場合は切替直前のDBバックアップ復元を原則とする)。Product.seasonのFK化はIssue #17 Phase 4-Cで実装済み。 詳細は上記「販売年度(Product.season)」節を参照。
元資料: specs/31_商品・在庫・品種マスタ管理機能 実装手順書.md (Ver.3.0) を現行コード(products/models.py, mg_masters/views.py, mg_masters/api.py, config/settings.py)と照合して再構成
関連: README, specs棚卸し表, Issue #1