カタログ・在庫・引当(Square Catalog / Inventory)


1. 方針

項目 決定
マスター Square Catalog
在庫 Square Inventory(on_hand)+当方の引当
鮮度 webhookで秒、フォールバックで15分
表示(顧客) 在庫あり / 残りわずか / 売切れ(数は出さない)
表示(店員) 手元・引当・カート内・販売可能(数)
引当 注文作成時にhold。期限切れ/取消で解放、決済完了で確定
引当TTL 注文10分 / 決済待ち15分
安全在庫 店員アプリで設定(admin)、既定0、店舗単位
カート下書き heartbeat 120秒・TTL 5分・変更時2秒デバウンス・匿名
ロケーション 今は1。キーには locationId を含める
バーコード 今はUI非対応。索引は設計に含める

可用数の定義

available = on_hand - orderHolds - safetyStock
  • cartHolds は表示専用(下書きカート合計)。可用数からは差し引かない(二重計上を避ける)
  • 注文が期限切れ/取消になると orderHolds は解放され、カート下書きが残っていれば店員画面の「カート内」に再び現れる

2. データモデル

キー 内容
SHOP#{shopId}/META catalogVersion / lastCatalogSync / sync{auto,interval,lastRunBy} / safetyStock
SHOP#{shopId}/CATALOG カタログスナップショット(配信用)
CATALOG#{itemId}/META 商品名・カテゴリ・税
CATALOG#{itemId}/VAR#{variationId} SKU・barcode・価格・trackInventory・locationOverrides.soldOut
BARCODE#{barcode}/META → variationId(スキャン用)
INV#{locationId}/VAR#{variationId} onHand / reserved / soldOut / calculatedAt
RESERVE#{orderId}/META 引当明細・TTL
CART#{cartId}/META 下書きカート(shopId・items・TTL・GSI1PK=SHOP#{shopId}#CART)
WEBHOOK#{eventId}/META 冪等(TTL 30日)

3. 同期

対象 方式
Catalog 全件 SearchCatalogObjects(ITEM/ITEM_VARIATION/CATEGORY/TAX)→ スナップショット+個別項目を更新
Catalog 増分 catalog.version.updated → begin_time=lastCatalogSync で差分(latest_time 保存)
Inventory inventory.count.updated(秒)→ INV# を更新。BatchRetrieveInventoryCounts(updated_after) で再取得
フォールバック 15分間隔(自動同期ON時)
売切れ sold_out を最優先(在庫数より優先)
失敗時 前回キャッシュ保持+staleSeconds を返す
参照元 /api/bootstrap・/api/catalog・注文価格解決は snapshot を参照(未作成時のみmock)

店員アプリからの操作

  • 表示: 最終同期、カタログ版、鮮度、商品数
  • 操作(admin): 今すぐ同期 / 自動ON-OFF / 頻度(1/5/15分、既定5)
  • 手動同期は30秒のクールダウン。監査ログに記録

API

メソッド パス 内容
GET /api/catalog 顧客向け(availability)
GET /api/staff/catalog 店員向け(数・cartQty・staleSeconds)
GET/PUT /api/staff/sync 同期状態・設定(adminで更新)
POST /api/staff/sync/run 手動同期(admin)
POST /api/carts/{cartId} カート下書き(匿名・レート制限)
POST /webhooks/square 署名検証・冪等

4. 引当

注文作成時

  1. 明細の価格を**マスター(キャッシュ)**から解決
  2. RESERVE#{orderId} に明細+TTLを記録
  3. INV# を条件付き更新(楽観ロック)
    reserved = if_not_exists(reserved, 0) + n
    条件: soldOut = false かつ reserved = 読み取り時の値
    
    事前に可用数(onHand - reserved - safetyStock >= n)を確認し、競合時は失敗扱い。
  4. 失敗時は 409 OUT_OF_STOCK(該当商品の詳細付き)

解放・確定

事象 処理
取消 / 期限切れ / FAILED reserved を戻す+RESERVE削除
決済完了(PAID) reserved を戻す(Squareがon_handを減算)+RESERVE削除
カート下書きが有効 表示上「カート内」に戻る(可用数は解放済み)

整合性

  • TTL切れRESERVEは定期同期(syncHandler、既定15分)で掃除し reserved を戻す
  • 注文作成の再送(同じclientToken)は既存注文を返し、二重引当しない
  • 同時引当は条件付き更新で片方のみ成功

5. カート下書き(B)

  • 顧客PWA: カート変更を2秒デバウンスして POST /api/carts/{cartId}
  • 保存: CART#{cartId}(TTL 5分)、heartbeat 120秒、画面非表示/空カートは送信停止
  • 店員: GSI1(SHOP#{shopId}#CART) で有効カートを取得し、SKU別に合計
  • プライバシー: 匿名・TTLで消滅・レート制限(1端末 1req/2秒)

6. 安全在庫

  • 店舗単位(SHOP#{shopId}.safetyStock、既定0)。将来SKU単位
  • 店員アプリ(admin)で変更。変更は即時反映
  • available = on_hand - reserved - safetyStock

7. フェーズ

Phase 内容 状態
C-1 CatalogProvider+キャッシュ+同期+店員の同期設定 実装済み
C-2 Inventory+可用数+顧客/店員表示 実装済み
C-3 引当(hold/release/commit)+OUT_OF_STOCK 実装済み
C-4 カート下書き+店員のカート内合計 実装済み
C-5 安全在庫の店員設定 実装済み
C-6 複数ロケーション 後続
C-7 バーコード索引+スキャンUI M8

8. テスト

  • Contract: mock / cache で list・get・barcode・version
  • 同期: 全件・増分・sold_out・順不同/重複webhook・失敗時のフォールバック
  • 引当: 最後の1点を2注文同時 → 片方のみ成功
  • E2E: 売切れ商品の注文で OUT_OF_STOCK、店員画面に在庫数
  • カート下書き: TTL後に合計から消える

9. コスト・セキュリティ