コンテンツにスキップ

Vendure カスタムプラグイン - 概要と設計方針

概要

Ritsubi B2B ECサイトのビジネス要件を実現するため、8つのカスタムVendureプラグインを開発しています。これらのプラグインは、モノレポ構成の /packages/plugins/ ディレクトリで管理され、モジュール化された再利用可能なコンポーネントとして設計されています。

プラグイン開発の基礎: プラグインの実装手順はプラグイン開発の基礎を参照してください。VendureのアーキテクチャやコアコンセプトはVendure開発ハンドブックで確認できます。

互換性レンジ(全プラグイン共通)

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

プラグイン一覧

一元管理テーブル(Single View)

カテゴリ プラグイン コード配置 主な責務 稼働状況(2025-11-19時点) 関連ドキュメント
コンプライアンス 同意システム(Consent System) packages/plugins/consent-system 医薬部外品・高濃度成分の購入同意取得、署名保管、7年保持 initializeRitsubiPlugins()で常時ロード済み 同意システム
在庫 在庫管理(Inventory Management) packages/plugins/src/inventory-management.ts 在庫予約、バックオーダー、購入単位制御 initializeRitsubiPlugins()で常時ロード済み 在庫管理
アカウント運用 Customer Password Admin packages/plugins/src/customer-password-admin.ts 管理者によるsetCustomerPasswordByAdminミューテーション、履歴記録 RitsubiPlugins.CustomerPasswordAdmin経由で手動読み込み -
UX お気に入り(Wishlist) packages/plugins/wishlist 商品のお気に入り登録・取得API、Storefront同期 RitsubiPlugins.Wishlistで提供(Storefront側で呼び出し) お気に入り
決済 SB Payment Link packages/plugins/src/sb-payment-link.ts SB Paymentリンク型決済、XML生成、コールバック処理 RitsubiPlugins.SbPaymentLinkに登録、Vendure設定から有効化 SB Paymentリンク型決済
決済 決済方法ハンドラー packages/plugins/src/payment-handlers/ 売掛決済、代引き決済、銀行振込/前入金のハンドラー実装 vendure-config.shared.tsに登録済み、Dashboardで設定可能 決済方法ハンドラー
顧客管理 顧客管理(Customer Management) packages/plugins/src/customer-management.ts 15種類の顧客ステータス・33カテゴリアクセス制御 index.tsでコメントアウト中(TypeScriptエラー対処待ち) 顧客管理
価格 価格システム(Pricing System) packages/plugins/src/pricing-system.ts 掛率・割戻金・段階割引ロジック index.tsでコメントアウト中(TypeScriptエラー対処待ち) 価格システム
配送 配送計算(Shipping Calculator) packages/plugins/src/shipping-calculator.ts ブランド別送料、直送+10%サーチャージ、無料閾値 index.tsでコメントアウト中(TypeScriptエラー対処待ち) 配送計算
プロモーション キャンペーンエンジン(Campaign Engine) packages/plugins/src/campaign-engine.ts 6種類の複合キャンペーン判定 index.tsでコメントアウト中(TypeScriptエラー対処待ち) キャンペーンエンジン
連携 SMILE連携(Smile Integration) packages/plugins/src/smile-integration.ts SMILE会計システム向けCSV出力、納品先管理 index.tsでコメントアウト中(TypeScriptエラー対処待ち) SMILE連携

更新ルール: packages/plugins/src/index.tsのエクスポート状況がSingle Sourceです。プラグインの有効化/無効化やパス変更を行った際は、この表と関連リンクを同時に更新してください。

コアビジネスロジック(4プラグイン)

プラグイン 責務 主要機能
顧客管理 顧客ステータスとアクセス制御 15種類のステータス管理、33カテゴリアクセス制御
価格システム B2B価格制御 顧客別掛率、月次割戻金計算、段階的割引
配送計算 配送料金計算 ブランド別配送料、直送+10%サーチャージ
キャンペーンエンジン プロモーション管理 6種類のキャンペーンタイプ

サポート機能(4プラグイン)

プラグイン 責務 主要機能
在庫管理 在庫制御 在庫予約、バックオーダー、購入数量制御
同意システム 法的同意管理 グリコール酸等の同意取得、7年間保存
SMILE連携 会計システム連携 CSV出力、納品先管理、バッチ処理
お気に入り ユーザー体験向上 商品お気に入り機能

設計原則

1. 関心の分離(Separation of Concerns)

各プラグインは明確に定義された単一の責務を持ち、他のプラグインと疎結合で連携します。

// ✅ 良い例: 明確な責務分離
CustomerManagementPlugin; // 顧客ステータス管理のみ
PricingSystemPlugin; // 価格計算のみ
ShippingCalculatorPlugin; // 配送料計算のみ

// ❌ 悪い例: 複数の責務を持つ
CustomerAndPricingPlugin; // 顧客管理 + 価格計算(密結合)

2. プラグイン間の連携

プラグイン間の連携は、Vendureの標準的な依存性注入(DI)とGraphQL APIを通じて行います。

// PricingSystemPluginがCustomerManagementPluginのサービスを利用
@Injectable()
export class PriceCalculationService {
  constructor(
    private customerStatusService: CustomerStatusService, // DI
  ) {}

  async calculatePrice(customerId: ID, productId: ID): Promise<number> {
    // 顧客ステータスを取得
    const status =
      await this.customerStatusService.getCustomerStatus(customerId);

    // ステータスに基づいて価格を計算
    return this.applyDiscount(productId, status.baseDiscountRate);
  }
}

3. 設定可能性(Configurability)

すべてのプラグインは、初期化時にオプションで動作をカスタマイズ可能。

// プラグインの柔軟な設定
PricingSystemPlugin.init({
  enableCustomerPricing: true,
  enableMonthlyRebate: true,
  rebateCalculationDay: 'last',
  lowPriceCustomerThreshold: 0.7,
});

4. 拡張性(Extensibility)

将来の機能追加に対応できるよう、拡張ポイントを設計。

// Phase 2での拡張を考慮
export interface InventoryManagementPluginOptions {
  enableBackorder: boolean;
  enableReservation: boolean;
  enableComplexQuantityControl?: boolean; // Phase 2で実装予定
}

5. データの整合性

エンティティとカスタムフィールドを適切に使い分け、データの整合性を保証。

// ✅ 良い例: 専用エンティティで複雑なデータを管理
@Entity()
export class MonthlyRebateEntity {
  @Column()
  customerId: string;

  @Column('decimal')
  rebateAmount: number;

  @Column()
  calculationMonth: string;
}

// ✅ 良い例: カスタムフィールドでシンプルなデータを管理
Customer.customFields.favoriteProductIds: string[]

アーキテクチャパターン

レイヤー構造

各プラグインは以下のレイヤー構造を持ちます:

プラグイン
├── API Layer (GraphQL)
│   ├── Resolvers
│   └── Schema
├── Service Layer
│   ├── Business Logic
│   └── Data Access
├── Entity Layer
│   ├── Database Entities
│   └── Custom Fields
└── Strategy/Calculator Layer (オプション)
    └── Vendure組み込み拡張

例: 価格システムプラグイン

pricing-system/
├── src/
│   ├── index.ts                          # プラグイン定義
│   ├── entities/
│   │   ├── customer-pricing.entity.ts    # Entity Layer
│   │   └── monthly-rebate.entity.ts
│   ├── services/
│   │   ├── customer-pricing.service.ts   # Service Layer
│   │   └── price-calculation.service.ts
│   ├── resolvers/
│   │   ├── customer-pricing.resolver.ts  # API Layer
│   │   └── monthly-rebate.resolver.ts
│   ├── strategies/
│   │   └── ritsubi-price-calculation.strategy.ts  # Strategy Layer
│   └── jobs/
│       └── monthly-rebate-calculation.job.ts

サービス指向アーキテクチャ(SOA)

各プラグインのサービスは、明確なインターフェースを持つ独立したコンポーネント。

// Service Interface
export interface IPriceCalculationService {
  calculatePrice(productId: ID, customerId: ID, quantity: number): Promise<number>;
  calculateTieredDiscount(customerId: ID, items: OrderItem[]): Promise<number>;
}

// Implementation
@Injectable()
export class PriceCalculationService implements IPriceCalculationService {
  async calculatePrice(...): Promise<number> {
    // 実装
  }
}

データモデル設計

エンティティ vs カスタムフィールド

使用ケース 推奨 理由
複雑なリレーション Entity 外部キー、結合クエリが必要
履歴管理が必要 Entity タイムスタンプ、ステータス管理
大量のデータ Entity インデックス、パフォーマンス最適化
シンプルな値 CustomField 軽量、高速アクセス
既存モデルの拡張 CustomField 既存のVendureモデルを拡張

例: 在庫予約

// ✅ Entity: 複雑なリレーションと履歴管理
@Entity()
export class InventoryReservationEntity {
  @Column()
  customerId: string;

  @Column()
  productVariantId: string;

  @Column()
  quantity: number;

  @Column()
  reservedAt: Date;

  @Column()
  expiresAt: Date;

  @Column('enum')
  status: ReservationStatus;
}

例: お気に入り商品

// ✅ CustomField: シンプルなID配列
Customer.customFields.favoriteProductIds: string[]

// 理由:
// - リレーション不要
// - 履歴管理不要
// - データ量が少ない(最大100件)

統合パターン

Vendure標準機能との統合

1. ShippingCalculator統合

const RitsubiShippingCalculator = new ShippingCalculator({
  code: 'ritsubi-shipping-calculator',
  calculate: (ctx, order) => {
    return calculatorService.calculate(ctx, order);
  },
});

// プラグイン設定で自動登録
config.shippingOptions.shippingCalculators.push(RitsubiShippingCalculator);

2. PromotionAction統合(キャンペーン)

const campaignPromotionAction = new PromotionItemAction({
  code: 'ritsubi-campaign-discount',
  execute: (ctx, orderLine, args) => {
    return campaignEngineService.applyDiscount(ctx, orderLine);
  },
});

3. CustomFields統合

// プラグイン初期化時にカスタムフィールドを追加
@VendurePlugin({
  configuration: config => {
    config.customFields.Customer.push({
      name: 'customerStatus',
      type: 'string',
      label: [{ languageCode: LanguageCode.ja, value: '顧客ステータス' }],
    });
    return config;
  },
})

パフォーマンス最適化戦略

1. キャッシング

Redis を使用して頻繁にアクセスされるデータをキャッシュ。

// 顧客ステータスのキャッシング(5分TTL)
const cacheKey = `customer-status:${customerId}`;
let status = await redis.get(cacheKey);

if (!status) {
  status = await this.getCustomerStatusFromDB(customerId);
  await redis.set(cacheKey, JSON.stringify(status), 'EX', 300);
}

2. バッチ処理

重い処理はバックグラウンドで実行。

// 月次割戻金計算(毎月1日午前2時実行)
@Cron('0 2 1 * *')
async calculateMonthlyRebates() {
  const customers = await this.customerService.findAll();

  for (const customer of customers) {
    await this.jobQueue.add({
      type: 'calculate-rebate',
      data: { customerId: customer.id },
    });
  }
}

3. インデックス最適化

頻繁に検索されるカラムにインデックスを作成。

@Entity()
export class ConsentRecordEntity {
  @Index()
  @Column()
  customerId: string;

  @Index()
  @Column()
  productId: string;

  @Index(['customerId', 'consentType'])
  @Column()
  consentType: string;
}

エラーハンドリング戦略

1. カスタムエラークラス

export class InsufficientInventoryError extends Error {
  constructor(productId: string, requested: number, available: number) {
    super(
      `在庫不足: 商品${productId} (要求: ${requested}, 利用可能: ${available})`,
    );
    this.name = 'InsufficientInventoryError';
  }
}

2. グレースフルデグラデーション

async calculateShipping(order: Order): Promise<number> {
  try {
    // 外部API呼び出し
    return await this.externalShippingApi.getRate(order);
  } catch (error) {
    // フォールバック: デフォルト料金
    this.logger.warn(`外部API失敗、デフォルト料金を使用: ${error.message}`);
    return this.getDefaultShippingRate(order);
  }
}

3. リトライメカニズム

@Retry({ maxAttempts: 3, backoff: 1000 })
async exportToSmile(order: Order): Promise<void> {
  await this.smileCsvExportService.exportOrder(order);
}

セキュリティベストプラクティス

1. 認証・認可

@Mutation()
@Allow(Permission.UpdateCustomer) // 管理者のみ
async updateCustomerStatus(
  @Ctx() ctx: RequestContext,
  @Args() args: UpdateCustomerStatusInput
): Promise<Customer> {
  // 処理
}

2. データ検証

async recordConsent(input: RecordConsentInput): Promise<ConsentRecord> {
  // 入力検証
  if (!input.consentType || !input.customerId) {
    throw new UserInputError('必須フィールドが不足しています');
  }

  // ビジネスルール検証
  const product = await this.productService.findOne(input.productId);
  if (!product.customFields.consentRequired) {
    throw new ForbiddenError('この商品に同意は不要です');
  }

  // 処理
}

3. 監査ログ

async updateCustomerStatus(customerId: ID, newStatus: string): Promise<void> {
  // ステータス更新
  await this.customerService.update({ id: customerId, status: newStatus });

  // 監査ログ記録
  await this.auditLogService.log({
    action: 'UPDATE_CUSTOMER_STATUS',
    entityType: 'Customer',
    entityId: customerId,
    oldValue: oldStatus,
    newValue: newStatus,
    userId: ctx.activeUserId,
    timestamp: new Date(),
  });
}

テスト戦略

1. ユニットテスト

サービスとビジネスロジックのテスト。

describe('PriceCalculationService', () => {
  it('should apply customer discount rate', async () => {
    const price = await priceCalculationService.calculatePrice(
      'product-123',
      'customer-456',
      10,
    );

    expect(price).toBe(7000); // 定価10,000円 × 掛率0.7
  });
});

2. 統合テスト

プラグイン間の連携をテスト。

describe('Order with Campaign', () => {
  it('should apply campaign discount and calculate final price', async () => {
    // キャンペーン作成
    const campaign = await campaignService.createCampaign({...});

    // 注文作成
    const order = await orderService.create({...});

    // キャンペーン適用
    await campaignEngineService.applyCampaign(order, campaign);

    // 最終価格を検証
    expect(order.totalWithTax).toBe(expectedTotal);
  });
});

3. E2Eテスト

GraphQL APIのエンドツーエンドテスト。

describe('Wishlist GraphQL', () => {
  it('should add product to favorites', async () => {
    const result = await shopClient.query(gql`
      mutation {
        addToFavorites(productId: "product-123")
      }
    `);

    expect(result.addToFavorites).toBe(true);
  });
});

デプロイメント戦略

開発環境

# ローカル開発
pnpm install
pnpm run dev

# Vendureサーバー: http://localhost:3000
# Dashboard: http://localhost:3000/dashboard
# Shop API: http://localhost:3000/shop-api

ステージング環境

# Docker Compose
docker-compose -f docker-compose.staging.yml up -d

# プラグインのホットリロード有効

本番環境

# ビルド
pnpm run build

# デプロイ(例: Railway)
railway up

# 環境変数設定
ENABLE_CUSTOMER_PRICING=true
ENABLE_MONTHLY_REBATE=true
REDIS_URL=redis://...

モニタリングとロギング

1. 構造化ログ

this.logger.log({
  level: 'info',
  message: 'Customer status updated',
  customerId: customer.id,
  oldStatus: oldStatus,
  newStatus: newStatus,
  timestamp: new Date().toISOString(),
});

2. パフォーマンスメトリクス

@Timed('price-calculation')
async calculatePrice(productId: ID, customerId: ID): Promise<number> {
  // 処理時間を自動記録
}

3. エラー追跡

try {
  await this.exportToSmile(order);
} catch (error) {
  // Sentry等のエラートラッキングサービスに送信
  Sentry.captureException(error, {
    tags: { plugin: 'smile-integration' },
    extra: { orderId: order.id },
  });
  throw error;
}

今後の拡張計画

Phase 2 機能

  • 在庫管理: 複雑な数量制御(日次・月次制限)
  • 価格システム: AI予測による最適掛率提案
  • キャンペーン: A/Bテスト機能
  • 同意システム: 多言語対応

Phase 3 機能

  • リアルタイムSMILE連携: API連携(CSV→API)
  • 高度な分析機能: BI連携、ダッシュボード
  • マルチテナント対応: 複数ブランドの統合管理

まとめ

Ritsubiのカスタムプラグインは、以下の原則に基づいて設計されています:

  1. モジュール化: 各プラグインは独立した責務を持つ
  2. 拡張性: 将来の機能追加に柔軟に対応
  3. パフォーマンス: キャッシング、バッチ処理による最適化
  4. セキュリティ: 認証、認可、監査ログの徹底
  5. 保守性: テスト、ログ、ドキュメントの充実

これらのプラグインにより、複雑なB2Bビジネス要件を効率的に実現し、高品質なECシステムを構築します。

関連ドキュメント