コンテンツにスキップ

在庫管理プラグイン (Inventory Management Plugin)

概要

Ritsubiの複雑な在庫管理要件に対応するVendureカスタムプラグイン。在庫予約システム、バックオーダー対応、購入数量制御、在庫有り/無し商品の混在注文対応を実現します。

互換性レンジ

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

パッケージ情報

  • パッケージ名: @ritsubi/inventory-management-plugin
  • パス: /packages/plugins/inventory-management
  • エントリーポイント: src/index.ts

実装機能

1. 在庫予約システム

カート投入時に在庫を一時的に予約し、他の顧客が同時に購入できないように制御。

予約の有効期限

  • デフォルト有効期限: 30分
  • 自動解放: 期限切れの予約は自動的に解放
  • 期限延長: カート操作時に自動延長
// 在庫予約の作成
await inventoryService.createReservation({
  customerId: 'customer123',
  productVariantId: 'variant456',
  quantity: 5,
  expiresAt: new Date(Date.now() + 30 * 60 * 1000), // 30分後
});

2. バックオーダー対応

在庫切れ商品の予約注文を受け付け、入荷後に自動的に出荷予定に組み込む。

バックオーダーの条件

  • 商品が allowBackorder: true に設定されている
  • 顧客が在庫切れを承知で注文
  • 出荷予定日が表示される
// バックオーダー商品の注文
{
  productVariantId: 'out-of-stock-item',
  quantity: 10,
  isBackorder: true,
  expectedShippingDate: '2025-12-15'
}

3. 複雑な数量制御ルール

最小/最大購入数量

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

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

購入単位制限

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

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

検証例:

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

4. 期間別購入制限(Phase 2予定)

  • 日次制限: 1日あたりの最大購入数量
  • 月次制限: 1ヶ月あたりの最大購入数量

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

同一注文内に在庫あり商品と在庫なし商品が混在する場合の特別な処理。

混在注文の処理フロー

1. 在庫あり商品を即座に出荷準備
2. 在庫なし商品はバックオーダーとして記録
3. 注文ステータスを「一部出荷可能」に設定
4. 顧客に出荷スケジュールを通知

技術仕様

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

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

  /**
   * 在庫予約機能を有効にする
   */
  enableReservation?: boolean;

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

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

  /**
   * デフォルトの予約有効期限(分)
   */
  defaultReservationExpirationMinutes?: number;

  /**
   * 予約クリーンアップ間隔(分)
   */
  cleanupIntervalMinutes?: number;
}

プラグイン初期化

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

export const config: VendureConfig = {
  plugins: [
    InventoryManagementPlugin.init({
      enableBackorder: true,
      enableReservation: true,
      enableMixedOrders: true,
      enableComplexQuantityControl: false, // Phase2で実装予定
      defaultReservationExpirationMinutes: 30,
      cleanupIntervalMinutes: 60, // 1時間毎にクリーンアップ
    }),
  ],
};

エンティティ

InventoryReservationEntity

在庫予約を管理するエンティティ。

@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

在庫管理のコアロジックを担当するサービス。

主要メソッド:

  • createReservation(input: CreateReservationInput): Promise<InventoryReservationEntity>
  • 在庫予約を作成
  • releaseReservation(reservationId: ID): Promise<void>
  • 在庫予約を解放
  • cleanupExpiredReservations(): Promise<number>
  • 期限切れ予約をクリーンアップ
  • checkAvailability(productVariantId: ID, quantity: number): Promise<boolean>
  • 在庫の利用可能性をチェック
  • createBackorder(input: CreateBackorderInput): Promise<OrderLine>
  • バックオーダーを作成
  • handleMixedInventoryOrder(order: Order): Promise<void>
  • 在庫混在注文を処理

カスタムフィールド

ProductVariant

  • allowBackorder (boolean): バックオーダー許可
  • reservationExpirationMinutes (int): 予約有効期限(分)
  • minPurchaseQuantity (int): 最小購入数量
  • maxPurchaseQuantity (int): 最大購入数量
  • purchaseUnit (int): 購入単位

OrderLine

  • reservationId (string): 予約ID
  • isBackorder (boolean): バックオーダー
  • expectedShippingDate (datetime): 出荷予定日

Order

  • hasMixedInventoryItems (boolean): 在庫混在注文
  • inventoryReservationSummary (text): 在庫予約サマリー(JSON形式)

対応する要件

要件定義書との対応

  • 在庫予約システム: カート投入時の在庫一時確保
  • バックオーダー対応: 在庫切れ商品の予約注文
  • 購入単位制御: 最小/最大数量、購入単位制限
  • 在庫混在注文: 在庫有り/無し商品の同時注文対応

使用例

フロントエンド(Next.js)での使用

// 在庫予約を作成(カートに追加)
const { data } = await apolloClient.mutate({
  mutation: ADD_TO_CART_WITH_RESERVATION,
  variables: {
    productVariantId: 'variant123',
    quantity: 5,
  },
});

if (data.addToCart.reservationCreated) {
  console.log(`在庫予約完了: ${data.addToCart.reservationId}`);
  console.log(`有効期限: ${data.addToCart.expiresAt}`);
}

バックオーダーの確認

// 在庫切れ商品をバックオーダーで注文
const { data } = await apolloClient.mutate({
  mutation: ADD_TO_CART_BACKORDER,
  variables: {
    productVariantId: 'out-of-stock-item',
    quantity: 10,
    acceptBackorder: true,
  },
});

console.log(`バックオーダー受付: ${data.orderLine.isBackorder}`);
console.log(`出荷予定日: ${data.orderLine.expectedShippingDate}`);

購入数量の検証

// 購入数量が有効かチェック
const { data } = await apolloClient.query({
  query: VALIDATE_QUANTITY,
  variables: {
    productVariantId: 'variant123',
    quantity: 7,
  },
});

if (!data.validateQuantity.isValid) {
  console.error(`エラー: ${data.validateQuantity.errorMessage}`);
  // 例: "この商品は3個単位で購入してください"
}

管理画面での使用

// 期限切れ予約をクリーンアップ
const count = await inventoryService.cleanupExpiredReservations();
console.log(`${count}件の予約を解放しました`);

// バックオーダーの出荷予定を更新
await inventoryService.updateBackorderShippingDate(
  'backorder123',
  new Date('2025-12-20'),
);

// 在庫混在注文の処理状況を確認
const order = await orderService.findOne('order456');
if (order.customFields.hasMixedInventoryItems) {
  const summary = JSON.parse(order.customFields.inventoryReservationSummary);
  console.log(`即座出荷可能: ${summary.availableItems.length}件`);
  console.log(`バックオーダー: ${summary.backorderItems.length}件`);
}

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

自動クリーンアップ

期限切れの予約を定期的に自動解放。

@Cron('0 * * * *') // 毎時0分に実行
async cleanupExpiredReservations() {
  const expiredCount = await this.inventoryService.cleanupExpiredReservations();
  this.logger.log(`期限切れ予約を${expiredCount}件解放しました`);
}

データモデル

在庫予約のライフサイクル

ACTIVE (予約中)
  ├─ 期限内に注文確定 → FULFILLED (完了)
  ├─ 期限切れ → EXPIRED (期限切れ)
  └─ ユーザーがキャンセル → RELEASED (解放)

セキュリティ考慮事項

  1. 予約の検証: 顧客本人の予約のみ操作可能
  2. 在庫の二重予約防止: トランザクションでアトミックに処理
  3. バックオーダーの承認: バックオーダーは顧客の明示的な同意が必要
  4. 予約履歴の記録: すべての予約操作を監査ログに記録

パフォーマンス最適化

  • 予約情報のキャッシング: アクティブな予約をRedisにキャッシュ
  • バッチクリーンアップ: 大量の期限切れ予約を効率的に処理
  • インデックス最適化: productVariantIdstatus の複合インデックス

トラブルシューティング

よくある問題

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

  • 原因: 予約が解放されていない
  • 解決: クリーンアップジョブを手動実行、または予約を手動解放

問題: カート有効期限が短すぎる

  • 原因: reservationExpirationMinutes が短く設定されている
  • 解決: デフォルト値を30分から60分に延長

関連ドキュメント

今後の拡張予定

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