コンテンツにスキップ

在庫・予約受注プラグイン (Inventory Management Plugin)

概要

Ritsubi の在庫・予約受注まわりの拡張を扱う Vendure カスタムプラグイン。現行実装では、Vendure 標準の在庫検証・在庫引当を正本にしつつ、PurchaseLimitRule による月間購入数制限・購入単位・最小/最大数量、予約商品と通常商品の split checkout を提供します。

カート投入時点では、独自の在庫予約レコード作成や stockOnHand / stockAllocated の変更は行いません。addItemToOrder は Vendure 標準の saleable stock を検証し、在庫不足時は InsufficientStockError を返します。標準の DefaultStockAllocationStrategy では、注文が ArrangingPayment から PaymentAuthorized または PaymentSettled へ遷移した時点で stockAllocated が増え、Fulfillment 作成時に stockOnHand が減ります。

互換性レンジ

  • Vendure: 3.5.x(apps/vendure-server は ^3.5.2 を使用)
  • Node.js: >=24.7.0(apps/vendure-server/package.json の engines に準拠)

パッケージ情報

  • パッケージ名: @ritsubi/plugins(在庫管理は packages/plugins に統合)
  • パス: packages/plugins/src/standard-extensions/inventory/
  • エントリーポイント: packages/plugins/src/index.ts

実装機能

1. Vendure 標準の在庫検証・在庫引当

現行のカート投入は、Vendure 標準の active order (AddingItems) に OrderLine を追加する処理です。カート投入時点では実在庫を減らさず、在庫引当も作成しません。

状態別の在庫への影響

タイミング 主な処理 stockAllocated stockOnHand
addItemToOrder / adjustOrderLine saleable stock を検証し、カートの OrderLine を更新 変化なし 変化なし
ArrangingPaymentPaymentAuthorized / PaymentSettled Vendure 標準の在庫引当 増える 変化なし
Fulfillment 作成 引当済み在庫を販売として確定 減る 減る
引当済み注文の cancel Release を作成 減る 変化なし

ProductVariant.stockLevel / outOfStockThreshold は Storefront の購入可否表示の正本です。UI は stockLevel = OUT_OF_STOCK を購入不可として扱います。

stockLevelOUT_OF_STOCK/LOW_STOCK/IN_STOCK)の粗いラベルでは「あと何個買えるか」が分からないため、Shop API は ProductVariant.saleableStockLevel(予約差引後の販売可能在庫数, Int!)も返します。正本は packages/plugins/src/standard-extensions/inventory/saleable-stock.shop-resolver.ts@Resolver("ProductVariant")@ResolveFieldProductVariantService.getSaleableStockLevel に委譲、StandardExtensionsPlugin.shopApiExtensions で登録)です。stockOnHand(予約済み分を含む生の手持ち在庫)は顧客視点で過大になるため使いません。

Storefront はこの実在庫数を AddToCartButton の数量ステッパー上限(カート内既存数量を差引いた残数)に反映し、要求数量が実在庫を超える「カートに入れられる表示なのに入れられない」ズレを事前に防ぎます。Shop API が InsufficientStockError を返した場合は、サーバが返す quantityAvailable で数量上限を実在庫に丸めて再試行可能にします(残数 0 のときのみ在庫切れとして無効化)。quantityAvailable を欠くレスポンスや他の拒否は同じ不可理由へ変換して再クリックを抑止します(安全側 fallback)。仕様の正本は docs/specifications/2026-04-product-variant-out-of-stock-threshold.md です。

2. 予約注文・バックオーダー対応

在庫切れ商品の欠品予約と、新商品等の予約注文を同じ予約受注モデルで扱います。これは「カート投入時に標準在庫を確保する」機能ではなく、注文レーン・配送日・送料・自動キャンセル条件を予約商品として管理するための受注モデルです。

バックオーダーの条件

  • 商品が予約受注種別を持つ
  • 予約受付期間内である
  • 出荷予定日・release group 等の予約商品メタデータが設定されている
  • 注文確定時にも予約受付期間・数量制限を再評価する

予約受注レーン分離(2026-05-14 更新)

  • 設定の正本: ProductVariant.customFields
  • reservationOrderType
  • reservationShippingPolicy
  • reservationShippingChargeEnabled
  • reservationReleaseGroupKey
  • reservationExpectedShipDate
  • reservationAvailableFrom
  • reservationAvailableTo
  • reservationLimitQuantity
  • reservationAutoCancelAfterDays
  • 注文側メタデータ:
  • Order.customFields.orderLaneTypeNORMAL / RESERVATION
  • Order.customFields.containsReservationItems
  • Order.customFields.orderGroupId(同一 checkout split で生成した注文群の共通 ID)
  • Order.customFields.childOrderIndex(子注文の並び順)
  • Order.customFields.splitStatussplit / confirmed / cancelled
  • Order.customFields.reservationParentOrderCode(子注文 → aggregate 参照用、旧フローとの互換)
  • OrderLine.customFields.isReservationOrder
  • OrderLine.customFields.reservationOrderType
  • OrderLine.customFields.reservationExpectedShipDate
  • OrderLine.customFields.reservationSourceOrderCode
  • 受注分離モデル(Split Checkout):
  • カートは 1 つのまま保持する。通常商品・予約商品・欠品予約商品を混在させてカートに入れてよい。
  • 「注文手続きへ」ボタンで startSplitCheckout mutation を呼び出す。
  • Plugin 側が OrderLine を注文レーン(releaseGroupKey / orderType)で分類し、子注文を生成する。
  • 通常商品のみのカート: 分割なし。そのまま /checkout へ進む(orderGroupCode: null を返す)。
  • 混在カート: aggregate 親注文 + 子注文群を生成。親注文は type: "Aggregate" / active: false になる。
  • 子注文ページ (/account/orders/<aggregateOrderCode>) で各子注文の送料・合計を確認する。
  • 各子注文ごとに activateSplitCheckoutOrder/checkout で個別に配送・決済を完結させる。
  • 送料境界:
  • 欠品予約は原則送料無料。
  • 新商品予約は商品設定に応じて送料有無を切り替える。
  • 送料は子注文単位で再計算する。
  • Storefront 連動:
  • 予約商品を含む子注文では配送日指定を無効化し、配送時間帯のみ許可する。
  • 注文詳細ページ (order-detail-page-content) の「この注文の決済へ進む」ボタンは、 AddingItems または ArrangingPayment 状態の Seller 注文で表示される。
  • 注文確認、注文完了、注文詳細、注文履歴では通常商品と予約商品を分離表示する。
  • 自動テスト:
  • packages/plugins/src/standard-extensions/inventory/reservation-order.service.spec.ts (cart 段階の lane 混在許可・confirm 段階の split 要求判定)
  • packages/plugins/src/standard-extensions/inventory/reservation-order-seller.strategy.spec.ts (seller order 生成時の customFields / 送料境界)
  • packages/plugins/src/standard-extensions/inventory/reservation-split-checkout.service.spec.tsstartSplitCheckout / activateSplitCheckoutOrder の guard clause:空カート、状態不一致、進行中フラグ、Aggregate parent、終了済み、session 不在、他人の注文、存在しない注文コード、splitStatus="split" の冪等パス)
  • apps/vendure-server/tests/integration/reservation-order-split.integration-spec.ts (seller order 分割と混在 add-to-cart 拒否の integration 検証)

3. 購入制限ルール

購入制限の正本は PurchaseLimitRule 系 entity です。ProductVariant custom field の minimumQuantity / purchaseUnit / maxPerOrder / periodPurchaseLimit* は旧データの migration/backfill 入力としてのみ扱い、現行 schema・Storefront 表示・backend guard の判定には残しません。

最小/最大購入数量

商品ごとに購入可能な最小・最大数量を設定。

{
  minimumQuantity: 5, // 最低5個から購入可能
  maxPerOrder: 100, // 1回の注文で最大100個まで
}

購入単位制限

特定の単位でのみ購入可能に制限(例: 3個単位、6個単位)。

{
  purchaseUnit: 3, // 3の倍数でのみ購入可能
}

検証例:

  • 3個 ✅ OK
  • 5個 ❌ NG(3の倍数ではない)
  • 6個 ✅ OK
  • 9個 ✅ OK

4. 月間購入数制限(RTM-069 実装済み)

  • 対象単位: PurchaseLimitRule × ProductVariant。顧客ターゲットなしは商品共通、顧客または顧客グループターゲットありは個別制限。
  • 設定フィールド:
  • limitQuantity
  • periodStart / periodEnd(rule 自体の有効期間)
  • 判定導線:
  • 商品追加
  • カート内数量変更
  • 注文確定直前
  • 集計基準:
  • 顧客ごとの過去注文数量 + 現在のアクティブ注文数量
  • JST 暦月(当月1日 00:00 以上、翌月1日 00:00 未満)
  • セット商品は親 SKU ではなく構成品 OrderLine を集計
  • React Dashboard 入力:
  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/purchase-limit-rules.tsx
  • ルール名、対象商品、対象顧客/顧客グループ、月間上限、最小数量、購入単位、1注文上限を同じ画面で管理する。
  • 自動テスト:
  • packages/plugins/src/standard-extensions/inventory/purchase-limit-rule.service.spec.ts
  • packages/plugins/src/standard-extensions/set-products/index.spec.ts
  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/purchase-limit-rule-form-utils.spec.ts

5. 顧客・顧客グループ別購入制限

顧客または顧客グループごとに異なる上限を設定する場合も、商品共通制限と同じ PurchaseLimitRule 系 entity を正本にします。

  • 対象単位: Customer / CustomerGroup × ProductVariant
  • ルール正本:
  • ritsubi_purchase_limit_rule
  • ritsubi_purchase_limit_rule_customer
  • ritsubi_purchase_limit_rule_customer_group
  • ritsubi_purchase_limit_rule_product_variant
  • 判定導線:
  • 商品追加
  • カート内数量変更
  • 注文確定直前
  • Storefront の事前表示(ProductVariant.purchaseLimitRuleStates
  • 適用方針:
  • 顧客グループ制限は、グループ全体の共有枠ではなく、所属する各顧客ごとの上限として扱う。
  • 購入履歴の集計期間は JST の暦月(当月1日 00:00 以上、翌月1日 00:00 未満)に固定する。 任意の30日間や rule 設定日から1か月ではなく、1日から月末までの月間購入数制限として扱う。
  • 複数 rule が一致した場合は、残追加可能数が最も小さい rule を購入可否の上限にする。
  • 対象 rule があるのに顧客を解決できない場合は、残数を楽観表示せず購入判定を fail-closed にする。

6. 在庫有り/無し商品の混在注文対応

同一 checkout 内に通常商品と予約商品が混在する場合は、表示上の分離だけで終わらせず seller order として分割する。

混在注文の処理フロー

1. カートには通常商品と予約商品を混在させてよい
2. 「注文手続きへ」で split checkout を開始する
3. aggregate 親注文と seller order 群を生成する
4. 通常商品は通常受注 seller order へ分離する
5. 予約商品は releaseGroupKey と出荷予定日ごとに予約受注 seller order へ分離する
6. 各 seller order で送料を独立計算し、親子リンクを保持する

技術仕様

プラグイン設定オプション

export interface InventoryManagementPluginOptions {
  /**
   * バックオーダー機能を有効にする
   */
  enableBackorder?: boolean;

  /**
   * 将来の独自在庫予約用。現行 add-to-cart 経路では未接続。
   */
  enableReservation?: boolean;

  /**
   * 在庫混在注文を有効にする
   */
  enableMixedOrders?: boolean;

  /**
   * 複雑な数量制御を有効にする(Phase 2)
   */
  enableComplexQuantityControl?: boolean;

  /**
   * 将来の独自在庫予約用。現行 add-to-cart 経路では未接続。
   */
  defaultReservationExpirationMinutes?: number;

  /**
   * 将来の独自在庫予約用。現行 add-to-cart 経路では未接続。
   */
  cleanupIntervalMinutes?: number;
}

プラグイン初期化

import { InventoryManagementPlugin } from "@ritsubi/inventory-management-plugin";

export const config: VendureConfig = {
  plugins: [
    InventoryManagementPlugin.init({
      enableBackorder: true,
      enableReservation: true, // 現行 add-to-cart 経路では未接続
      enableMixedOrders: true,
      enableComplexQuantityControl: false, // Phase2で実装予定
      defaultReservationExpirationMinutes: 30, // 現行 add-to-cart 経路では未接続
      cleanupIntervalMinutes: 60, // 現行 add-to-cart 経路では未接続
    }),
  ],
};

エンティティ

InventoryReservationEntity

独自在庫予約を管理するためのエンティティ。現行の addItemToOrder 経路では作成されません。現時点では将来拡張用の entity として存在し、Vendure 標準の StockMovement / StockLevel が実際の在庫検証・引当の正本です。

@Entity()
export class InventoryReservationEntity extends VendureEntity {
  @Column()
  customerId: string;

  @Column()
  productVariantId: string;

  @Column("int")
  quantity: number;

  @Column()
  orderId: string;

  @Column("enum", { enum: ["ACTIVE", "EXPIRED", "RELEASED", "FULFILLED"] })
  status: ReservationStatus;

  @Column()
  reservedAt: Date;

  @Column()
  expiresAt: Date;

  @Column({ nullable: true })
  releasedAt: Date;
}

QuantityControlRuleEntity

数量制御ルールを定義するエンティティ(Phase 2)。

@Entity()
export class QuantityControlRuleEntity extends VendureEntity {
  @Column()
  productVariantId: string;

  @Column("int", { nullable: true })
  minPurchaseQuantity: number;

  @Column("int", { nullable: true })
  maxPurchaseQuantity: number;

  @Column("int", { nullable: true })
  purchaseUnit: number; // 購入単位(例: 3個単位)

  @Column("int", { nullable: true })
  dailyLimit: number; // 日次制限

  @Column("int", { nullable: true })
  monthlyLimit: number; // 月次制限

  @Column()
  effectiveFrom: Date;

  @Column({ nullable: true })
  effectiveTo: Date;
}

サービス

InventoryService

独自在庫予約の将来拡張用サービス。現行実装は簡易版で、カート投入時の DB 保存・在庫引当・期限切れ解放には接続されていません。

主要メソッド:

  • createReservation(ctx, productVariantId, quantity, options): Promise<InventoryReservationEntity>
  • メモリ上の予約 entity を組み立てる。DB 保存は行わない
  • cleanupExpiredReservations(): Promise<number>
  • 現行実装では 0 を返す
  • checkAvailability(productVariantId: ID, quantity: number): Promise<boolean>
  • 現行実装では true を返す。実際の在庫可否は Vendure 標準の addItemToOrder / adjustOrderLine で検証する

カスタムフィールド

PurchaseLimitRule

  • name: ルール名
  • enabled: 有効フラグ
  • periodStart / periodEnd: rule 自体の有効期間。月間集計範囲ではない
  • limitQuantity: 月間購入上限
  • minimumQuantity: 最小購入数量
  • purchaseUnit: 購入単位
  • maxPerOrder: 1注文あたり上限
  • priority: 並び順・表示優先度
  • description: 運用メモ

対象顧客・対象顧客グループ・対象 ProductVariant は JSON ではなく relation table で保持します。

OrderLine

  • isReservationOrder (boolean): 予約商品 line
  • reservationOrderType (string): 予約種別
  • reservationExpectedShipDate (datetime): 出荷予定日
  • reservationSourceOrderCode (string): 分割元注文コード

Order

  • orderLaneType (string): NORMAL / RESERVATION
  • containsReservationItems (boolean): 予約商品を含むか
  • reservationParentOrderCode (string): aggregate 参照用
  • reservationGroupSummary (text): 予約 line サマリー(JSON 形式)

対応する要件

要件定義書との対応

  • 在庫検証・引当: Vendure 標準の saleable stock 検証、決済段階の stockAllocated、Fulfillment 時の stockOnHand 減算
  • 予約受注対応: 予約商品のレーン分離、受付期間・出荷予定日の管理
  • 購入制限ルール: PurchaseLimitRule による最小/最大数量、購入単位制限、JST 暦月の月間購入数制限
  • 在庫混在注文: 在庫有り/無し商品の同時注文対応

使用例

フロントエンド(Storefront)での使用

import { useMutation } from "@tanstack/react-query";
import { graphql } from "@/__generated__/gql";
import { vendureQueryClient } from "@/lib/vendure-fetch-client";

const ADD_ITEM_TO_ORDER = graphql(`
  mutation AddItemToOrder($productVariantId: ID!, $quantity: Int!) {
    addItemToOrder(productVariantId: $productVariantId, quantity: $quantity) {
      ... on Order {
        id
        totalQuantity
      }
      ... on InsufficientStockError {
        errorCode
        message
        quantityAvailable
      }
    }
  }
`);

// カートに追加する。実在庫は減らず、saleable stock 不足時だけ拒否される。
const { mutateAsync: addItemToOrder } = useMutation({
  mutationFn: (variables: { productVariantId: string; quantity: number }) =>
    vendureQueryClient(ADD_ITEM_TO_ORDER, variables),
});

const data = await addItemToOrder({
  productVariantId: "variant123",
  quantity: 5,
});

バックオーダーの確認

予約商品・欠品予約商品は ProductVariant の予約受注 custom fields を正本にします。通常の addItemToOrder でカートへ入れ、split checkout 開始時に注文レーンごとの seller order へ分割します。

購入数量の検証

Storefront は apps/storefront/src/lib/purchase-limit.tsapps/storefront/src/lib/cart-eligibility.ts を共通入口にします。商品詳細、商品カード、クイックオーダー、カート、checkout は同じ helper で PurchaseLimitRule 由来の最小数量、購入単位、1注文あたり上限、月間購入数制限、在庫状態を評価します。

管理画面での使用

Vendure Dashboard では ProductVariant の stockOnHand / outOfStockThreshold / trackInventory と、予約受注 custom fields を編集します。独自の期限切れ在庫予約 cleanup 画面や backorder 更新 API は現行実装にはありません。

独自在庫予約クリーンアップジョブ

自動クリーンアップ

現行実装では独自在庫予約を作成しないため、期限切れ予約の自動解放ジョブは未接続です。将来 InventoryReservationEntity を実際の DB 保存に接続する場合は、作成・延長・解放・監査ログを同じ変更で設計します。

データモデル

Vendure 標準在庫のライフサイクル

AddingItems (カート)
  └─ addItemToOrder / adjustOrderLine: saleable stock を検証
ArrangingPayment
  └─ PaymentAuthorized / PaymentSettled へ遷移: Allocation を作成し stockAllocated を増やす
Fulfillment 作成
  └─ Sale を作成し stockAllocated と stockOnHand を減らす
引当後の cancel
  └─ Release を作成し stockAllocated を減らす

セキュリティ考慮事項

  1. 在庫検証の正本: Storefront の事前判定だけに頼らず、Shop API の addItemToOrder / adjustOrderLine で最終検証する
  2. fail-closed UX: InsufficientStockError を受けた場合は CTA を再有効化せず、同じ不可理由として表示する
  3. 予約受付期間の再評価: stale cart を通さないよう注文確定時にも予約商品条件を検証する
  4. 将来の独自在庫予約: 導入する場合は二重予約防止、期限切れ解放、監査ログ、注文 cancel 時の release を同時に実装する

パフォーマンス最適化

  • 在庫判定の共通化: Storefront の購入可否は cart-eligibility.ts / purchase-limit.ts を共通入口にし、surface ごとの重複判定を避ける
  • Vendure 標準在庫の利用: StockLevel / StockMovement を正本にし、独自在庫予約の二重管理を増やさない
  • 将来拡張時のインデックス: InventoryReservationEntity を実運用に接続する場合は productVariantIdstatus の複合インデックスを設計する

トラブルシューティング

よくある問題

問題: 在庫があるのに「在庫切れ」と表示される

  • 原因: outOfStockThreshold 到達、trackInventory 設定、stockAllocated の残存、または search index の在庫状態未反映
  • 解決: ProductVariant の stockLevelsstockMovementsoutOfStockThreshold、search index 更新状態を確認する

問題: カート投入で在庫が減らない

  • 原因: 現行仕様。カート投入時点では stockOnHand / stockAllocated を変更しない
  • 解決: 在庫引当は PaymentAuthorized / PaymentSettled への遷移、実在庫減算は Fulfillment 作成で確認する

注文明細スナップショット(OrderLineSnapshotListener)

概要

注文確定時(OrderPlacedEvent)に OrderLine の商品名・SKU をスナップショット保存するリスナー。後続の商品マスタ変更(商品名変更・SKU変更)が受注履歴・帳票・SMILE CSV 出力に影響しないようにします。

実装

packages/plugins/src/standard-extensions/inventory/order-line-snapshot.listener.ts

保存フィールド

OrderLine.customFields フィールド 内容
snapshotSku 注文確定時の productVariant.sku
snapshotProductName 注文確定時の product.name(または productVariant.name

動作

  • OrderPlacedEvent をサブスクライブし、注文内の全 OrderLine を処理
  • すでに値が設定されている場合は上書きしない(冪等)
  • 保存に失敗してもエラーログを出力するのみ(注文フローはブロックしない)

SMILE CSV との連携

SMILE 受注 CSV の商品コード列(商品コード)は snapshotSku を優先参照します。snapshotSku が未設定の場合は productVariant.sku にフォールバックします(smile-order-columns.ts)。

関連ドキュメント

今後の拡張予定

  • リアルタイム在庫通知機能
  • 在庫アラート自動通知
  • 予測在庫管理(AIベース)
  • マルチ倉庫在庫管理