WOaky / 設計メモ

倉庫移動の再設計

受注時の個別在庫移動を廃止し、リクエスト → 承認 → 出庫 → 入庫 の業務フローに載せ替える。あわせて、出荷ペースから「あと何日もつか」を機械的に出し、在庫が薄くなった倉庫へ人の判断で在庫を寄せられる状態を目指す。

背景

1. 個別の在庫移動が何のためか分からない

受注を打つタイミングで個別に倉庫移動ができるが、なぜ個別に行うのかが不明。

調査結果: 受注画面の在庫移動は、伝票を draft で作った直後に出庫確定と入庫完了を同一リクエスト内で連続実行していた。in_transit(移動中)を一切経由せず完了まで飛ぶ。

$transfer = WarehouseTransfer::create([...'status' => 'draft']);
WarehouseTransferItem::create([...]);
$transfer->confirmToInTransit($userId);   // 即
$transfer->confirmToCompleted($userId);   // 即

承認も現場の出庫作業も介在しない、正式フローの裏口になっている。倉庫移動というより即時の在庫付け替え。「元に戻す」も取消ではなく逆方向の移動を新規作成する実装で、伝票が2件残る。

2. 在庫が薄くなったことに気づけない

一括受注確定画面に出るのは、その受注の出荷倉庫の有効残数のみ。他倉庫から寄せられる可能性があるかが分からない。

ただし本質は「今回の受注が足りるか」ではない。今日の受注は捌けたが、有効残数が 1000 → 200 になった。明日も受注は来る。この 200 では耐えられない ——この判断をして、あらかじめ在庫を寄せておきたい。

つまり不足が 0 でもリクエストは発生する。むしろ出荷できた商品ほど在庫が減っており、補充判断の対象になる。

現状の実装

移動フロー
draftin_transitcompleted は実装済み。shipped_by / received_by も記録される
在庫の動き
出庫時に from から減算、入庫時に to へ加算
有効残数
Stock::effectiveQuantity() = 実在庫 − 未消込outbound予約 + 未消込inbound予約。単一倉庫のみを対象
予約
倉庫移動は InventoryReservation を一切作らない
集約画面
total-summary が既存。選択受注を商品×倉庫で集約し、過不足まで算出済み
テスト
倉庫移動の自動テストは0件。ステータス定数も無く生文字列で判定

設計上の穴: 移動中の在庫が宙に浮く

出庫で from から減り、入庫で to に入る。その間、在庫はどの倉庫にも存在しない。会社の総在庫が一時的に減る。

受け皿として「移動中」という倉庫が作られていたが、これは倉庫マスタに物理拠点と状態が混在する構造で、しかも中身がどこ行きなのか辿れない。今回これを廃止する。

判断のフロー

  1. 受注担当 一括受注確定を実行する 在庫がある分は確定して出荷へ回す。足りない分は分納となり、残受注(draft)として残る。
  2. 画面 トータル受注数の集約画面へ遷移する 選択した受注がすべて並ぶ。確定できたものも、分納で残ったものも。商品 × 倉庫で集約する。
  3. システム 「あと何日もつか」を機械的に出す 出荷実績から日次平均を算出し、有効残数が何日分に相当するかを表示。カバー日数を切っていれば警告する。
  4. 受注担当 耐えられないと判断したらリクエストする 行を展開すると他倉庫の在庫が見える。どこから何個寄せるかを指定して依頼を飛ばす。不足の有無に関わらず全行から依頼できる。

危険判定

「200 個が薄いかどうか」は商品ごとに違うため、数量では判断できない。出荷ペースで日数に変換する。

日次平均出荷 = 直近N日の shipment_out 合計 ÷ N     ← 商品 × 倉庫ごと
必要在庫     = 日次平均出荷 × カバー日数
残り日数     = 有効残数 ÷ 日次平均出荷

危ない = 残り日数 < カバー日数

Nカバー日数 は環境変数で持つ(閾値は環境変数で管理する既存方針に揃える)。

なぜ安全在庫マスタを使わないか

products.safety_stock_months というカラムは存在するが、本番 9,241 商品すべて NULL(充足率 0%)。lead_time_days / lot_size も同様に 0%。

入れ忘れではない。移行元の WO 旧システム(販売大臣)の商品マスタに、安全在庫・発注点・リードタイムに当たる項目が存在しないため、埋めようがなかった。9,241 商品へ手入力する運用も現実的ではない。

出荷実績なら stock_ledgers に商品 × 倉庫 × 日付で自動的に積まれるため、マスタ入力ゼロで立ち上がる。これが唯一の現実解。

制約: 実績が貯まるまで判定は動かない

現在の本番にある出荷実績はテストデータのみで、意味のある日次平均が出せない。運用開始直後は全商品が「実績なし」表示となり、数週間動かして初めて日数が出る。

それでもマスタ入力を待つより確実に立ち上がる。マスタを 9,241 件埋める運用は続かない。

集約画面のイメージ

商品A × 会津
  合計受注 1000 / 出荷処理 800 / 有効残数 200
  日次平均 40個 → 残り 5日分  ⚠  (14日分を切っています)
  └ 他倉庫: 中部 500 / 東京 300 / 会津工場 120
     └ 中部から 300 リクエスト →

商品B × 中部
  合計受注 50 / 出荷処理 50 / 有効残数 1800
  日次平均 3個 → 残り 600日分

有効残数の引き算は自動で効く。確定時に outbound 予約が張られるため、Stock::effectiveQuantity() を普通に呼ぶだけで出荷処理分が差し引かれた値が返る。特別なロジックは不要。

既存の total-summary は既に商品 × 倉庫で集約して過不足を出しているため、骨格はそのまま使える。追加するのは「出荷処理分」の列、日数判定、他倉庫の展開、リクエストボタン。

移動のフロー

  1. 受注担当 在庫移動をリクエストする 集約画面から依頼を飛ばす。リクエストは溜まっていく。
  2. 調達チーム リクエストを見て承認する 承認すると移動伝票が draft で作られる。同じ from / to のリクエストはまとめて1伝票にできる。却下も履歴に残る。
  3. 出荷現場 出庫処理を行う 伝票に対して出庫を確定し、実出庫数を記録する。
  4. システム 移動中になり、有効残数から引かれる from に outbound 予約、to に inbound 予約が張られる。実在庫は動かさない。
  5. 入庫者 受け取って入庫状況を入力する 出庫数に対してブレがないかを実入庫数で入力。完了すると実在庫が動き、予約が消し込まれて有効残数が復帰する。

在庫の持ち方

移動中の在庫は from 倉庫に物理的に残したまま、予約で有効残数を動かす。「移動中」倉庫は使わない。

状態stocks(実在庫)予約有効残数への影響
draft動かないなしなし
in_transit動かない(from に置いたまま)from に outbound
to に inbound
from が減り、to が増える
completedfrom −出庫数
to +入庫数
両方とも消込実在庫に置き換わる

Stock::effectiveQuantity() は既に 実在庫 − 未消込outbound + 未消込inbound なので、計算式は無改修source_typewarehouse_transfer_item を足すだけで乗る。発注の入荷予約と全く同じ構造。

結果として、from の有効残数は出庫時点で即引かれ、to には「間もなく着く分」が見え、会社の総在庫は移動中も正しいままになる。

スキーマ

transfer_requests 新規

id
company_id
product_id
from_warehouse_id      移動元
to_warehouse_id        移動先
quantity               依頼数
requested_by           依頼者
sales_order_id         任意 — どの受注発の依頼か
status                 pending | approved | rejected
warehouse_transfer_id  承認後に紐づく(複数リクエスト → 1伝票)
reject_reason
timestamps

warehouse_transfer_items カラム追加

quantity           予定数(承認時に確定)
quantity_shipped   実出庫数    ← 新規
quantity_received  実入庫数    ← 新規

ブレ = quantity_shipped − quantity_received

この3カラム構成は、発注の quantity / quantity_received と同型。UIも実装も揃う。

出庫伝票と入庫伝票は分けない

現行は既に warehouse_transfers の1レコードをステータスで進める形になっており、伝票が2つ生成されているわけではない。ブレの記録は上記の明細カラムで足りるため、1伝票のままとする。

実装の分割

A
スキーマ
transfer_requests 新設、明細に出庫数 / 入庫数を追加、ステータスを定数化(現在は生文字列)
B
在庫ロジックを予約方式へ変更
最も慎重に進める箇所。confirmToInTransit() の意味が「在庫を減らす」から「予約を張る」に変わる。この領域は現在テストが1件も無いため、テストを先に置いてから着手する
C
集約画面に判定とリクエストを載せる
total-summary に「出荷処理分」の列、出荷ペースからの日数判定、他倉庫の展開、リクエスト送信を追加。あわせて受注画面の個別即時移動を撤去。一括確定モーダル自体の改修は不要。
D
調達チームの承認画面
pending 一覧、まとめて承認して1伝票化、却下。
E
倉庫移動画面の入庫入力
実入庫数の入力とブレ表示。

決めたこと

論点結論理由
リクエストの目的 不足の穴埋めではなく在庫水準の維持 今日捌けても明日の受注が来る。不足 0 でもリクエストは発生する
判断の場 集約画面(total-summary)に一本化 商品 × 倉庫の集約単位で見れば依頼が1本にまとまる。受注ごとだと細切れの依頼が調達チームに大量に届く
危険判定 出荷ペースから「残り日数」を出す 数量では商品ごとの事情を判断できない。日数なら誰でも分かる。機械が出して人が決める
判定のデータ源 出荷実績(安全在庫マスタは使わない) WO 旧システムに安全在庫の項目が無く、9,241 商品への手入力は続かない。実績なら自動で貯まる
移動中の表現 伝票ステータス 倉庫マスタに物理拠点と状態が混在するのを避ける。from / to が伝票に載るので「どこからどこへ、いくつ」が辿れる
移動中の在庫 from に残して予約で引く 会社の総在庫が移動中も正しい。既存の有効残数の式を変えずに済む
リクエストの持ち方 別テーブル 複数リクエストを1伝票にまとめられる。却下も履歴に残る
出庫 / 入庫の伝票 1伝票のまま 現行が既に1レコード。ブレは明細カラムで表現できる
個別即時移動 撤去 承認も出庫作業も経ない裏口。集約画面のリクエスト導線に置き換える