「注文受付」後の注文を、袋詰 → 精米 → 箱詰め(自動引当ボード)→ 発送まで進める一連の機能の仕様を、開発者向けにまとめます。本システムで最も複雑な領域です。
本書は
specs/35_管理者向け出荷ワークフロー機能 実装手順書.md(Ver.3.1) とspecs/06_パッケージング管理画面 手動操作要件定義書.mdを土台に、現行コード(mg_workflow)と照合して再構成したものです。挙動は実装を正とします。
関連: 注文管理(受付まで) / 送料計算 / design/データベース設計(Bag/Allocation/Package/PackageItem/PackagePlanItem)
| 画面 / 機能 | URL 名 | 実装 |
|---|---|---|
| 袋詰管理 | dashboard:mg_workflow:bagging_list |
mg_workflow/views.py(bagging_list_view) |
| 精米管理 | dashboard:mg_workflow:milling_management |
mg_workflow/views.py(milling_management_view) |
| 自動引当・パッケージングボード | dashboard:mg_workflow:packaging_board |
views.py(薄いテンプレ)+ api_packaging.py(API 群)+ JS packaging_board.js |
| 発送管理 | dashboard:mg_workflow:shipping_list |
views.py(薄いテンプレ)+ api_shipping.py(API 群)+ JS shipping_list.js |
全ビュー・API はスタッフ限定(ビュー @user_passes_test(is_staff_user)、API staff_member_required、更新系 csrf_protect)。パッケージングボードと発送管理は、JS が API を非同期で叩き操作のたびに全状態を再取得・再描画する設計(アーキテクチャ §5.1)。Issue #23以降、袋詰・梱包・発送の表示と操作は管理画面の共通販売年度だけを対象にし、JSは全APIへseason_idを渡す。APIはセッション選択との一致を検証する。
関連設定値: PACKAGE_MAX_WEIGHT_KG=20.0(箱の重量上限)、DEFAULT_BOX_WEIGHT_KG=0.5(空箱重量)、SEIMAI_WEIGHT_COEFFICIENT=1.1、送料 NORMAL_SHIPPING_FEE=1200/REMOTE_SHIPPING_FEE=1800。
bagging_list_view)URL: dashboard:mg_workflow:bagging_list(画面 SCR-AD-040、GET 表示/POST 登録)。品種・容量で絞り込み可。
ACCEPTED)」の注文明細から商品別に必要数を集計し、準備済みの袋(Bag.prepared=True)数を差し引いた残り必要袋数を表示。EmptyBagStock)/追加購入要数(need_to_buy = max(0, 不足−空袋在庫))」を集計。need_to_buy が 0 なら ok、それ以外は warning。@transaction.atomic 内で Bag(product, prepared=True) を bulk_create(物理的な袋在庫として登録)。milling_management_view)URL: dashboard:mg_workflow:milling_management(GET 表示/POST 登録、@transaction.atomic)。品種ごとに精米の要否を示す。
品種ごとに次を集計(玄米/精米の換算は扱わず、精米商品の容量ベース)。
MillingRecord.output_seimai_kg 合計(精米した分だけ増える。袋詰しても減らさない=袋詰は A 側を減らす)。max(0, A − B)。C ≤ 0 なら completed、それ以外は needed。POST で品種・精米量(>0)・メモを受け取り MillingRecord を作成。最近 20 件の作業履歴も表示。
api_packaging.py)画面 SCR-AD-050。袋(Bag)を箱(Package)に詰め、注文明細(OrderItem)へ Allocation(引当)する。ボード本体は JS(packaging_board.js)で、以下の API を呼び出す。
| API | URL 名 | 内容 |
|---|---|---|
| ボード全状態 | dashboard:mg_workflow:api_packaging_board_state |
prepared_groups(通常の「袋を追加」対象=prepared=True かつ allocation__isnull=True かつ packageitem__isnull=True)+allocated_unpacked_groups(引当済みだが未梱包の確認対象)+宛先ごとの箱・予約・未引当商品(unallocated_items)を返す |
| 未引当の準備済み袋 | dashboard:mg_workflow:api_packaging_prepared_bags |
未引当・未梱包の準備済み袋(prepared=True かつ allocation__isnull=True かつ packageitem__isnull=True)を商品別に |
| 引当可能アイテム | dashboard:mg_workflow:api_allocatable_items |
指定宛先の「注文数−引当−予約」が正の商品と利用可能在庫 |
| サイドバー在庫サマリー | dashboard:mg_workflow:api_sidebar_stock_summary |
商品別の注文数・未袋詰・袋済未梱包・梱包済 |
| API | URL 名 | 内容 |
|---|---|---|
| 箱作成 | dashboard:mg_workflow:api_packages_create |
指定宛先に、選択中のアクティブ年度で PACKAGING の Package を作成 |
| 箱削除 | dashboard:mg_workflow:api_packages_delete(DELETE) |
中身(PackageItem)・予約(PackagePlanItem)が無い箱のみ削除可 |
| 袋を箱詰め | dashboard:mg_workflow:api_package_items_create |
後述 |
| 箱から取り出し | dashboard:mg_workflow:api_package_items_delete(DELETE) |
PackageItem を削除。袋に紐づく Allocation も解除する |
| 箱間移動 | dashboard:mg_workflow:api_package_items_move(PATCH) |
同一宛先内・同一Package.season内のみ(Issue #17 Phase 4-F)。移動先の重量上限も検証 |
| ラベル設定 | dashboard:mg_workflow:api_packages_label_update(PATCH) |
50 文字以内。空文字は NULL |
| ロック状態変更 | dashboard:mg_workflow:api_packages_lock_status_update(PATCH) |
UNLOCKED/APPEND_ONLY/LOCKED |
「袋を追加」(袋の箱詰め、PackageItemCreateView)の流れ(@transaction.atomic):
seasonと箱のseasonの一致(Issue #17 Phase 4-F。code: "season_mismatch"で拒否)、箱の重量上限(予約込み+追加分が PACKAGE_MAX_WEIGHT_KG 以内)を検証。prepared=True かつ allocation__isnull=True かつ packageitem__isnull=True)を select_for_update(skip_locked=True) で確保する。Allocation を作成しつつ、同じ袋を PackageItem として箱に入れる。Allocation 済みだが未梱包の袋は、通常の「袋を追加」対象に混ぜない。発生している場合は、復旧・確認対象として別途扱う。finalize_shipping_fee_if_all_packed() を呼び、確定送料を再評価する(Issue #8、Phase 4-Fで(destination, season)単位化)。箱作成・箱削除・箱から取り出し・箱間移動も、操作後に対象宛先・seasonの finalize_shipping_fee_if_all_packed() を呼ぶ(Issue #8)。宛先・seasonに準備中の箱(空箱を含む)が残っていれば、そのseasonの final_shipping_fee は None に戻る。
箱作成時のseason決定ロジック(Issue #23): JSが共通選択年度をseason_idとして必ず送る。サーバーはセッション選択との一致、その年度がアクティブであること、対象宛先の受付済み注文年度と矛盾しないことを検証してから箱へ固定する。暗黙のactive fallbackや複数年度からの画面内選択は行わない。
PackagePlanItem)物理的な袋がまだ無くても「この箱にこの商品を N 個入れる予定」を確保する仕組み。準備済み未引当袋がある分は「袋を追加」で処理し、それでも満たせない注文残だけを予約対象にする。
dashboard:mg_workflow:api_plan_items_upsert): 商品のseasonと箱のseasonの一致(Phase 4-F)、宛先の注文枠(引当+予約+増分 ≤ 注文数)、準備済み未引当袋で満たせない残数、箱の重量上限を検証して予約数を設定(0 で削除)。dashboard:mg_workflow:api_plan_items_consume): 商品のseasonと箱のseasonの一致(Phase 4-F。upsert側のガードを迂回した想定外状態への防御)を検証したうえで、予約数の範囲で準備済み袋を確保して Allocation+PackageItem を作成し、予約を減らす(実際の袋詰へ変換)。消費後、対象宛先・箱のseasonの finalize_shipping_fee_if_all_packed() を呼ぶ(Issue #8)。「箱数最小化・同一品種優先」で袋を箱に割り付ける提案を行う。コアは _pack_bags()(容量降順に、同一season×同一品種が入る箱→同一seasonの任意の空き箱→新規箱、の順で詰める First-Fit 系アルゴリズム。Issue #17 Phase 4-Fでグルーピングキーにseasonを追加し、異なるseasonの袋が同じ箱に混ざらないようにした)。新規箱のseasonは、最初にその箱へ入れる袋の商品seasonから決まる。
| 範囲 | 提案 | 適用 |
|---|---|---|
| 宛先単位 | dashboard:mg_workflow:api_propose_packing(GET) |
dashboard:mg_workflow:api_apply_packing_proposal(POST) |
| 全体一括 | dashboard:mg_workflow:api_propose_global_packing(GET) |
dashboard:mg_workflow:api_apply_global_packing_proposal(POST) |
適用後、宛先単位の適用は対象宛先1件、全体一括の適用は影響を受けた全宛先(新規/更新された箱がある宛先、および未ロック・準備中の箱が後継なしで削除された宛先)に対して finalize_shipping_fee_if_all_packed() を呼ぶ(Issue #8)。
LOCKED の箱、または PACKAGING 以外(発送準備完了以降)の箱は固定(中身を動かさない)。APPEND_ONLY の箱は既存の袋を維持しつつ追加のみ許可。UNLOCKED かつ PACKAGING の箱だけが再梱包の対象(適用時は一度削除して組み直す)。ProposePackingView)はトランザクションを使わない純粋な読み取り計算。一括(ProposeGlobalPackingView)は内部で実際に組み直したうえで transaction.set_rollback(True) によりロールバックしてシミュレーションする。適用(POST)はいずれも実際に Allocation/Package/PackageItem を作り直す。api_shipping.py)画面 SCR-AD-060。shipping_list.js が以下 API を呼ぶ。
| API | URL 名 | 内容 |
|---|---|---|
| 発送データ取得 | dashboard:mg_workflow:api_shipping_data |
選択年度のPACKAGING以外のパッケージ一覧(年度・宛先・中身サマリー・ステータス・CSV出力日時・伝票番号) |
| ステータス更新 | dashboard:mg_workflow:api_package_status_update |
後述。送料確定/リセットを伴う |
| B2 CSV 出力 | dashboard:mg_workflow:api_download_b2_csv |
選択パッケージからヤマト B2 用 CSV を生成。csv_exported_at を記録 |
| CSV 出力状態リセット | dashboard:mg_workflow:api_reset_csv_status |
csv_exported_at を NULL に戻す |
| 伝票番号取込 | dashboard:mg_workflow:api_import_tracking_numbers |
B2 発行済み CSV をアップロードし伝票番号を紐付け |
PackageStatusUpdateView)season_id・package_ids・status を受け取り、指定した全箱が選択年度に属することを更新前に検証してから Package を select_for_update() でロックし、一括更新する(@transaction.atomic)。別年度または存在しないIDが1件でも混ざれば全件拒否する。status は PackageStatus の値で検証。LABEL_PRINTED(伝票印刷済み)への更新は、CSV 出力済み(csv_exported_at あり)のパッケージのみ対象。(destination, season)の組ごとに finalize_shipping_fee_if_all_packed(destination, season)(mg_workflow/services/shipping_fee.py)を呼ぶ。Issue #23以降、1回の一括更新は単一の選択年度内に限定される。これが送料確定/リセットの本体:
PACKAGING)の Package.season が一致する箱が1つでも残るなら、中身の有無に関わらず、その宛先・seasonの受付済み(ACCEPTED)注文の final_shipping_fee を None にリセットする(Issue #8: 空箱も対象。Package.seasonを直接クエリするため、空箱もseason限定集合から漏れずに未完了箱として判定される)。Package(過去に発送済み/手渡し済みとなった注文の箱は含めない)が1つもなければ、同様に None にリセットする。Package(中身入りのもの)がすべて「発送準備完了」以上(READY_TO_SHIP/LABEL_PRINTED/SHIPPED/DELIVERED)なら、対象箱数 × 送料単価(手渡し=0、遠地=REMOTE_SHIPPING_FEE、通常=NORMAL_SHIPPING_FEE)を計算する。final_shipping_fee=0 として確定済みにする。Issue #8 対応済み(Issue #17 Phase 4-Fでseason単位化): 確定送料の再評価は発送管理のステータス更新だけでなく、パッケージングボード側の箱作成・箱削除・箱内商品の追加/削除/移動・予約消費・梱包提案適用(宛先単位/全体一括)でも
finalize_shipping_fee_if_all_packed(destination, season)経由で行われる(詳細は上記「箱と袋の操作」「予約を追加」「自動梱包提案」の各節)。空の準備中箱が宛先・seasonに残っている場合も未確定として扱われる。
送料モデルの全体像は 送料計算 を参照。
DownloadB2CsvView): Shift_JIS(cp932)の B2 取込用 CSV を生成。お客様管理番号 は PKG{package.id} 形式(取込時のキー)。品名1 は箱の中身を「品種頭2文字+精/玄+容量数字×個数」で連結し 25 文字以内に丸める。ご請求先顧客コード/運賃管理番号 は SystemSetting.b2_customer_code を - で分割。お届け先電話番号が依頼主電話と一致する場合のみ枝番に destination.id を出す。出力後 csv_exported_at を記録。ImportTrackingNumbersView): B2 発行済み CSV(Shift_JIS)を読み、1 列目 お客様管理番号(PKG{id})をキーに 4 列目の伝票番号を該当 Package.tracking_number へ反映(select_for_update())。
Package.season導入はIssue #17 Phase 4-Fで実装済み。Bag/Allocation/PackagePlanItem自体にはスキーマ変更を加えず、年度はProduct/OrderItem経由で導出可能なまま(OrderItem.productの年度とOrder.seasonの一致はPhase 4-Dで保証済み)。Packageにはseason(SalesSeasonへの必須FK、on_delete=PROTECT、箱作成時点で確定)を追加した。当初は「箱はスキーマ変更不要、中身から年度を推測するサービス層ガードのみ」という案だったが、推測方式では空箱の年度を判定できずIssue #8対応(未完了箱が1つでも残れば送料を未確定に戻す。空箱も対象)の安全性が後退するため、Package自体に年度を確定して持たせる設計へ改訂した(Issue #17 コメント#3258)。PackagePlanItem.product/PackageItem.bag.productのseasonが箱のseasonと一致することをサービス層のガード(code: "season_mismatch")で検証し、「袋を追加」「予約を追加」「予約の消費」「箱間移動」の4箇所で年度混在を拒否する。自動梱包提案(_pack_bags)のグルーピングキーも「宛先」から「宛先×season」へ変更済み。送料確定(finalize_shipping_fee_if_all_packed)も(destination, season)単位に変更済み(Issue #17 コメント#3256)。既存98件のPackageの移行は、中身(PackageItem/PackagePlanItem)の商品seasonから機械的に導出してバックフィルする(orders.0018_populate_package_season)。season混在箱は自動判定せず移行を中断する一方、空箱(中身が一切ない箱)は出荷対象を何も持たず削除する(伝票番号・CSV出力履歴が設定済みでも区別しない。伝票番号自体はヤマト運輸側の識別子でseasonを参照しないため判定の手がかりにはならない)。削除対象・混在箱は読み取り専用のcheck_package_seasons管理コマンドで事前検査できる。年度締めの途中箱チェック(_check_in_progress_packages)もPackage.season__year=closing_yearで絞り込むよう変更済み。詳細は 検討用/17_..._実装案 §Phase 4詳細設計 2.6(Phase 4-F) を参照。
PACKAGING(準備中)→ READY_TO_SHIP(発送準備完了)→ LABEL_PRINTED(伝票印刷済み)→ SHIPPED(発送済み)→ DELIVERED(手渡し済み)。前のステータスへの差し戻しも api_package_status_update で可能。確定送料は「発送準備完了以上」になった宛先で確定する(上記)。
元資料: specs/35_管理者向け出荷ワークフロー機能 実装手順書.md (Ver.3.1), specs/06_パッケージング管理画面 手動操作要件定義書.md を現行コード(mg_workflow/views.py, mg_workflow/api_packaging.py, mg_workflow/api_shipping.py, config/settings.py)と照合して再構成
関連: README, specs棚卸し表, Issue #1