購入者が利用する機能(商品閲覧・カート・注文・履歴・請求書)の仕様を、開発者向けにまとめます。
本書は
specs/32_購入者向け機能 実装手順書.md(Ver.3.2) を土台に、現行コード(products/cart/orders)と照合して再構成したものです。spec のコード断片は一部古いため、挙動は実装を正とし、差分は本文中で明示します。
関連: overview/システム概要 / design/アーキテクチャ / 送料計算
| 機能 | 主なファイル |
|---|---|
| 商品一覧・在庫表示 | products/views.py(product_list) |
| カート(セッション) | cart/cart.py(Cart クラス)、cart/views.py(update_cart / remove_from_cart) |
| 注文確認・確定・履歴・詳細・変更・キャンセル・請求書 | orders/views.py |
| サイドバー在庫サマリー | dashboard/context_processors.py(stock_summary)、cart/context_processors.py(cart_context) |
主要な設定値(config/settings.py): NORMAL_SHIPPING_FEE=1200、REMOTE_SHIPPING_FEE=1800、SEIMAI_WEIGHT_COEFFICIENT=1.1(精米→玄米換算係数)、PACKAGE_MAX_WEIGHT_KG=20.0、DEFAULT_BOX_WEIGHT_KG=0.5、CART_SESSION_ID='cart'。
Phase 4-B実装済み: 本節の
active_sales_yearはSystemSetting.active_season.year(sales_seasons.SalesSeasonへのFK経由)に読み替わった。以下は未実装(Phase 4以降)。在庫チェック(商品一覧の残り個数、カートのリアルタイム在庫チェック、注文確認・確定の事前/最終在庫確認)は、品種単位の現行Stockから年度・品種単位のSeasonStockへ切り替わる。新規注文作成時はactive seasonのSeasonStockを、既存注文の変更・キャンセルは対象注文が属するseasonのSeasonStock(activeとは限らない)を参照する。注文確定時は全明細の商品年度がOrder.seasonと一致することをサーバー側で追加検証する。詳細は 検討用/17_..._実装案 §Phase 4詳細設計 を参照。
products.views.product_list(URL products:product_list、/)。
status == 販売開始(FOR_SALE)かつ sales_year == SystemSetting.active_season.year(アクティブ販売年度)の商品のみ表示する(products.services.purchasable_products_queryset、Issue #17 Phase 3b、Phase 4-B で active_season.year へ型変更)。商品詳細の専用画面は存在せず、この一覧が実質的な詳細表示を兼ねる。Destination)を 1 つ選択する。選択値は request.session['active_destination_id'] に保持され、POST で切り替える。お届け先未選択時は先頭のお届け先を既定にする。Stock.available_kg(=総供給量−総注文量)から、選択中のお届け先カートで既に消費している玄米換算量を差し引いたうえで、商品 1 個あたりの玄米換算重量で割り、購入可能個数を算出する(product_stocks)。SEIMAI_WEIGHT_COEFFICIENT(×1.1)で玄米換算する。cart.cart.Cart(セッション保存。CART_SESSION_ID='cart')。DB モデルは持たない。
{ destination_id: { items: { product_id: {quantity, price} } } } 構造で、お届け先単位に商品をまとめる。「自宅用」「実家用」など複数の配送グループを同時に保持できる。__iter__)で配送グループごとに小計(items_subtotal)と送料(get_shipping_fee)、グループ合計を返す。get_shipping_fee(カート段階の簡易送料): 手渡し(TEWATASHI)は 0、遠地(ENCHI)は REMOTE_SHIPPING_FEE、それ以外は NORMAL_SHIPPING_FEE。補足: カートクラスの
get_shipping_feeは箱数を考慮しない簡易計算。注文確認・確定で表示/保存する「参考送料」は、後述のとおり梱包シミュレーションを通じて別途算出される(実装が spec より進んでいる点)。
cart.views.update_cart)URL cart:update_cart(/cart/update/、POST)。商品一覧からの追加・変更・削除を非同期で処理する。
mode(add/remove/set)と数量から新数量を算出(下限 0)。products.services.is_product_purchasable(status=FOR_SALE かつ sales_year=active_sales_year。active_sales_yearはSystemSetting.active_season.yearから取得。Phase 4-B で型変更)を満たすか検証する。満たさない場合はエラー JSON(400)を返し、カートを変更しない。画面に表示されない商品IDを直接POSTする改ざんリクエストもここで拒否される。数量を減らす・削除する操作は、対象商品が現在購入不可(年度切替済み・販売停止)であっても常に許可する(年度切替前に入れた商品をカートから出せなくなることを防ぐため)。Stock を select_for_update() で排他ロックし、available_kg を商品 1 個あたりの玄米換算重量で割った購入可能個数と比較。超過時はエラー JSON(残り{n}点です)を返して操作をブロックする。_create_cart_json_response)で返し、サイドバー等を再描画する。cart.views.remove_from_cart(/cart/remove/、POST・XHR 限定)はサイドバーからの完全削除用。
orders.views.order_confirm(URL orders:order_confirm、/orders/confirm/)。
is_product_purchasable(status=FOR_SALE かつ sales_year=active_sales_year。active_sales_yearはSystemSetting.active_season.yearから取得)を満たすか検証する。年度切替前にカートへ入れたまま残っている商品(旧年度商品・販売停止済み商品)があれば、黙って除外せず対象商品名を明示したエラーメッセージとともに確認画面への遷移を拒否し、商品一覧へリダイレクトする。Stock.available_kg と比較。不足があればエラーメッセージと共に商品一覧へリダイレクトする(確認画面に進ませない)。_calculate_provisional_shipping_fee() を呼ぶ。これは mg_workflow.api_packaging._pack_bags を使って梱包をシミュレーションし、必要箱数 × 送料単価(手渡し=0/遠地=REMOTE_SHIPPING_FEE/通常=NORMAL_SHIPPING_FEE)で「参考送料」を求める。グループ合計(group_total_price_provisional)と総合計(grand_total_provisional)をテンプレートに渡す。spec との差分: spec 32 の
order_confirmは在庫チェックのみで参考送料を計算していないが、実装は梱包シミュレーションによる参考送料計算を行う。これが要件「カート内の商品を梱包した場合の見込み箱数から計算した参考送料」を満たす本体。
orders.views.order_create(URL orders:order_create、/orders/create/、POST、@transaction.atomic)。
Phase 4-D実装済み: 作成する注文をアクティブな販売年度へ必ず紐付け、全明細の商品年度が注文年度と一致することをサーバー側と
OrderItem.save()で検証する。異なる販売年度の商品を1注文へ混在させない。在庫の年度分離はPhase 4-Eで行う。
order_confirm)を経由しない直接POSTや、確認画面表示後の年度切替にも対応するため、確定処理でも is_product_purchasable によるサーバー側再検証を行う。対象外の商品があれば注文を作成せず商品一覧へリダイレクトする。SystemSetting(pk=1)を select_for_update() でロックしてから active_season.year を読み、ロックは注文作成完了まで保持する(年度締め実行 execute_year_end と同じ行をロックすることで、年度締めとの同時実行時に旧年度商品の注文が残る競合を防ぐ。詳細は管理者向け補助機能.mdの「二重実行・同時実行対策」を参照)。Stock を select_for_update() で排他ロックして最終在庫確認。不足時は商品一覧へリダイレクト。Order を 1 件ずつ作成(1 注文=1 お届け先)。Order.seasonにはロック済みのSystemSetting.active_seasonを保存し、_calculate_provisional_shipping_fee() の結果を Order.provisional_shipping_fee に保存する。OrderItem を作成し、Stock.total_ordered_kg に玄米換算重量を加算(update_fields で保存)。spec との差分: spec は
Order.objects.create(..., shipping_fee=...)としているが、実フィールドはprovisional_shipping_fee(shipping_feeは読み取り専用プロパティ)。実装どおりprovisional_shipping_fee=で保存する。
order_history_list(/orders/history/): 自分の注文を新しい順に表示。show_all=true でキャンセル済みも含める(既定は除外)。items__allocations__bag__packageitem__package を prefetch。
season パラメータで絞り込む。未指定・不正値は SystemSetting.active_season をデフォルトにする(過去の注文で埋まって直近の注文を見失うのを防ぐため)。season=all を明示した場合のみ全年度を表示する。選択肢(available_seasons)は、本人が実際に注文したことのある年度とアクティブ年度の和集合(_resolve_history_season())。order.season.year の年度バッジを表示する({{ order.season.year|unlocalize }}。USE_THOUSAND_SEPARATOR の影響でカンマ区切りにならないよう unlocalize が必須)。all を含む)を維持したまま遷移する。order_detail(/orders/history/<id>/): 注文内容・ステータス・送料状態を表示。Order.is_shipping_fee_finalized で参考/確定を判定。関連パッケージの伝票番号と追跡 URL(Package.tracking_url、ヤマト配送かつ伝票番号ありの場合)から配送状況を表示。いずれもステータスが「新規注文」(is_cancelable)の間のみ可能。管理者が「注文受付」に進めた後は顧客側からの変更・キャンセルはできない(出荷フローの安全制限)。変更できる項目は各明細の数量とお届け先(本人に紐づく有効なお届け先のみ)。
order_update(/orders/history/update/<id>/、POST、@transaction.atomic): 各明細の数量と destination を受け取る。数量を増やす場合のみ購入可否に加えて商品年度がorder.seasonと一致することを検証する。数量を減らす・0にして削除する操作は、対象商品が現在購入不可でも許可する。以降の在庫差分、送料再計算、全明細削除時の扱いは従来どおり。order_cancel(/orders/history/cancel/<id>/、POST、@transaction.atomic): 注文明細の玄米換算重量を Stock.total_ordered_kg から戻し(select_for_update())、ステータスを CANCELED に更新。補足(Issue #2 対応済み): 在庫充足チェックは注文全体の重量差ではなく品種ごとの増加分を基準に行う。これにより、ある品種を大きく減らして別品種を増やし全体重量が増えないケースでも、増えた品種の在庫超過は正しく拒否される。回帰テストは
orders/tests.py(OrderUpdateStockCheckTests)。
orders.views.generate_invoice_pdf(URL orders:order_invoice_pdf、/orders/history/invoice/)。
show_all=true で全件)を作成日時順に取得し、xhtml2pdf でその場で PDF を生成・ダウンロード。_resolve_history_season()、GET season)を適用する。ボタン名「表示中の全注文で請求書作成」の通り、履歴一覧で選択中の年度をそのまま引き継ぐ(一覧側テンプレートが season をリンクへ付与する)。total_price(items_subtotal + shipping_fee)、繰越充当額、お支払残額を表示する。お支払残額は total_price - 繰越充当額。final_shipping_fee が未設定なら any_fee_not_finalized=True をテンプレートに渡し、「送料(未確定)」「合計(暫定)」を明記する。全送料確定時のみ最終合計を表示。SystemSetting(依頼主情報・振込先口座)をテンプレートに渡す。dashboard.context_processors.stock_summary(購入者=非スタッフのログインユーザーにのみ提供)。品種ごとに available_kg(現在在庫)と、カート内消費量(玄米換算)を併記する。cart.context_processors.cart_context がカート本体と表示しきい値(CART_HYBRID_THRESHOLD)を全ページに渡す。
元資料: specs/32_購入者向け機能 実装手順書.md (Ver.3.2) を現行コード(products/views.py, cart/cart.py, cart/views.py, orders/views.py, dashboard/context_processors.py, config/settings.py)と照合して再構成
関連: README, specs棚卸し表, Issue #1