コンテンツにスキップ

価格システムプラグイン (Pricing System Plugin)

概要

Ritsubi B2B ECサイトの複雑な価格制御要件に対応するVendureカスタムプラグイン。顧客別掛率(割引率)管理、月次割戻金計算、段階的特別掛率適用など、B2B特有の価格システムを実現します。

互換性レンジ

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

パッケージ情報

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

実装機能

現在の価格計算ロジック(実装ベース)

packages/plugins/pricing-system/src/services/price-calculation.service.ts の実装に基づく現在の価格計算フロー。旧システムでは価格制御フラグがONの場合のみ特別価格フローを適用していたため、その分岐を図に反映する。

価格の基準値(商品マスタ)

  • 標準売上単価: 通常の顧客に対する卸価格(ベース価格)
  • 上代単価: 一般販売価格(希望小売価格に相当)
  • 適用方針: ベースは商品マスタの価格だが、単価マスタ設定や数量別単価で上書きされる

全体の価格計算フロー(概要)

  1. カート内の各明細に対して「商品単体計算」を実行
  2. 明細ごとの結果を合算してキャンペーン価格(合計)を算出
  3. その合計をカート合計として採用(詳細は未定義)
flowchart TD
  Start([開始]) --> Lines[カート内の各明細]
  Lines --> ItemCalc["$(商品単体計算)を実行"]
  ItemCalc --> Sum[明細結果を合算]
  Sum --> Campaign["$(キャンペーン価格計算)"]
  Campaign --> CartTotal[カート合計を確定]

注記: 数量別単価の判定は共通ロジックとしておまけ込み数量を使用する(商品単体・得意先単体・得意先分類のいずれも対象)。

全体の価格計算処理ロジック

function main():
  価格合計 = fn_カート内処理()
  return 価格合計

function fn_商品単体計算():
  if is_特別価格フラグ == false:
    return 商品価格
  if is_顧客別価格マスタ設定済み:
    数量別結果 = fn_顧客別価格処理()
  elif is_顧客グループ別価格マスタ設定済み:
    数量別結果 = fn_顧客グループ別価格処理()
  else:
    数量別結果 = fn_数量別計算(商品マスタ価格)  # おまけ込み数量を使用
  RCODE結果 = fn_RCODE按分計算(数量別結果)
  if is_直送モード == true:
    return fn_直送サーチャージ(RCODE結果)
  return RCODE結果

function fn_直送サーチャージ(価格):
  if is_直送モード == true:
    return 価格 + (販売価格 / 10)
  return 価格

function fn_RCODE按分計算(価格): # 単体商品に対するRCODE処理
  if is_RCODE商品 == true and 数量 >= 12:
    return 商品マスタ価格
  return 価格

function fn_顧客別価格処理():
  価格結果 = fn_顧客別価格マスタ適用()   # ここで価格を上書き
  return fn_数量別計算(価格結果)        # 前の価格に依存せず上書き(おまけ込み数量)

function fn_顧客グループ別価格処理():
  価格結果 = fn_顧客グループ別価格マスタ適用() # ここで価格を上書き
  return fn_数量別計算(価格結果)             # 前の価格に依存せず上書き(おまけ込み数量)

function fn_商品セット処理():
  合計 = 0
  for 各明細:
    合計 += fn_商品単体計算()
  return 合計

function fn_カート内処理():
  # 例: カート内での処理
  #   - fn_カート汎用処理()
  #   - fn_クーポン処理()
  #   - fn_ポイント利用処理()
  return カート合計

function fn_カート汎用処理():
  # TODO: ロジック未定義
  # 例: カート補正の種類
  #   - fn_RCODEカート補正()
  #   - fn_キャンペーン処理()
  return カート合計

function fn_RCODEカート補正(): # アソート処理の一つ
  if not is_RCODE対象グループ:
    return カート合計
  if is_直送モード == true:
    return カート合計
  if is_1個から特別価格適用 == true:
    return カート合計

  if 数量 >= 1 and 数量 < 24:
    対象商品価格 = 上代単価 # 商品マスタの定価で上書き
  elif 数量 >= 24:
    対象商品価格 = 商品単体計算価格
  return カート合計


function fn_キャンペーン処理(): # 期限付きの処理
  # TODO: ロジック未定義
  return カート合計

1. 顧客別掛率(割引率)管理

各顧客に個別の掛率(割引率)を設定し、商品価格を動的に計算。

掛率の種類

  • 基本掛率: 顧客ステータスに基づく標準割引率(例: 0.70 = 30%割引)
  • 商品別掛率: 特定商品カテゴリに対する個別掛率
  • 期間限定掛率: キャンペーン期間中の特別掛率
  • 数量別掛率: 購入数量に応じた段階的掛率

価格計算式

最終価格 = 定価 × 基本掛率 × (1 - 追加割引率)

2. 月次割戻金計算(エクスビアンス商品)

エクスビアンス商品の月次購入実績に基づく割戻金を自動計算。

割戻金計算ロジック

月次購入金額 = 当月のエクスビアンス商品購入合計
割戻金率 = 段階的に決定購入金額に応じて変動
割戻金額 = 月次購入金額 × 割戻金率

割戻金の段階設定例

月次購入金額 割戻金率
〜50万円 0%
50〜100万円 2%
100〜200万円 3%
200万円〜 5%

3. 段階的特別掛率適用

複数商品群の数量に応じて、段階的に特別掛率を適用。

適用条件

  • 商品群A: 10個以上購入で追加5%割引
  • 商品群B: 20個以上購入で追加10%割引
  • 組み合わせ: 複数商品群の合計数量でも計算可能

4. 「特に安価な顧客」判定ロジック

すでに低価格で購入している顧客(掛率70%以下)は、追加割引の対象外とする制御。

if (顧客の基本掛率 <= 0.7) {
  // 追加割引キャンペーン適用不可
  return false;
}

5. 年次割戻金サマリー

月次割戻金の年間合計を集計し、年次報告書として出力。

技術仕様

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

export interface PricingSystemPluginOptions {
  /**
   * 顧客別価格制御を有効にする
   */
  enableCustomerPricing?: boolean;

  /**
   * 月次割戻金計算を有効にする
   */
  enableMonthlyRebate?: boolean;

  /**
   * 段階的割引制御を有効にする
   */
  enableTieredDiscount?: boolean;

  /**
   * 割戻金計算日
   * 'last' = 月末, 数値 = 特定日
   */
  rebateCalculationDay?: 'last' | number;

  /**
   * 割戻金支払日
   */
  rebatePaymentDay?: number;

  /**
   * 「特に安価な顧客」判定閾値
   * この掛率以下の顧客は追加割引対象外
   */
  lowPriceCustomerThreshold?: number;
}

プラグイン初期化

import { PricingSystemPlugin } from '@ritsubi/pricing-system-plugin';

export const config: VendureConfig = {
  plugins: [
    PricingSystemPlugin.init({
      enableCustomerPricing: true,
      enableMonthlyRebate: true,
      enableTieredDiscount: true,
      rebateCalculationDay: 'last', // 月末計算
      rebatePaymentDay: 15, // 翌月15日支払い
      lowPriceCustomerThreshold: 0.7, // 70%以下は「特に安価な顧客」
    }),
  ],
};

エンティティ

CustomerPricingEntity

顧客別価格設定を管理するエンティティ。

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

  @Column('decimal', { precision: 5, scale: 2 })
  baseDiscountRate: number; // 基本掛率

  @Column('jsonb', { nullable: true })
  categorySpecificRates: Record<string, number>; // カテゴリ別掛率

  @Column('jsonb', { nullable: true })
  tieredDiscounts: TieredDiscount[]; // 段階的割引設定

  @Column()
  effectiveFrom: Date;

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

MonthlyRebateEntity

月次割戻金の計算結果を記録するエンティティ。

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

  @Column()
  calculationMonth: string; // YYYY-MM形式

  @Column('decimal', { precision: 12, scale: 2 })
  totalPurchaseAmount: number; // 月次購入金額

  @Column('decimal', { precision: 5, scale: 2 })
  rebateRate: number; // 適用された割戻金率

  @Column('decimal', { precision: 12, scale: 2 })
  rebateAmount: number; // 割戻金額

  @Column('enum', { enum: ['CALCULATED', 'APPROVED', 'PAID'] })
  status: string;

  @Column()
  calculatedAt: Date;

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

サービス

CustomerPricingService

顧客別価格設定の管理サービス。

主要メソッド:

  • getCustomerPricing(customerId: ID): Promise<CustomerPricingEntity>
  • setBaseDiscountRate(customerId: ID, rate: number): Promise<void>
  • setCategorySpecificRate(customerId: ID, categoryId: ID, rate: number): Promise<void>
  • getEffectiveDiscountRate(customerId: ID, productId: ID, quantity: number): Promise<number>

PriceCalculationService

価格計算ロジックを担当するサービス。

主要メソッド:

  • calculatePrice(productId: ID, customerId: ID, quantity: number): Promise<number>
  • calculateTieredDiscount(customerId: ID, items: OrderItem[]): Promise<number>
  • applyLowPriceCustomerRule(customerId: ID, additionalDiscount: number): Promise<number>

MonthlyRebateService

月次割戻金の計算と管理を行うサービス。

主要メソッド:

  • calculateMonthlyRebate(customerId: ID, month: string): Promise<MonthlyRebateEntity>
  • approveRebate(rebateId: ID): Promise<void>
  • markAsPaid(rebateId: ID): Promise<void>
  • getAnnualRebateSummary(customerId: ID, year: number): Promise<RebateSummary>

対応する要件

要件定義書との対応

  • 3.4.1 顧客別価格制御・月次割戻金システム: 掛率管理と割戻金計算
  • 複数商品群の数量ベース割引制御: 段階的特別掛率適用
  • 年次割戻金サマリー: 年間集計機能

使用例

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

// 顧客別価格を取得
const { data } = await apolloClient.query({
  query: GET_PRODUCT_PRICE,
  variables: {
    productId: 'exuviance-moisturizer',
    customerId: currentUser.id,
    quantity: 10,
  },
});

console.log(`定価: ${data.listPrice}`);
console.log(`掛率適用後: ${data.customerPrice}`);
console.log(`段階的割引: ${data.tieredDiscount}`);
console.log(`最終価格: ${data.finalPrice}`);

月次割戻金の確認

// 顧客の月次割戻金履歴を取得
const { data } = await apolloClient.query({
  query: GET_MONTHLY_REBATES,
  variables: {
    customerId: currentUser.id,
    year: 2025,
  },
});

data.monthlyRebates.forEach(rebate => {
  console.log(`${rebate.month}: ${rebate.rebateAmount}円`);
});

管理画面での使用

// 顧客の掛率を設定
await customerPricingService.setBaseDiscountRate('customer123', 0.75);

// 月次割戻金バッチ計算
await monthlyRebateService.calculateAllMonthlyRebates('2025-10');

// 割戻金承認
await monthlyRebateService.approveRebate('rebate456');

バッチ処理

月次割戻金計算ジョブ

毎月末に自動実行されるバッチ処理。

@Cron('0 2 1 * *') // 毎月1日 午前2時実行
async calculateMonthlyRebates() {
  const lastMonth = moment().subtract(1, 'month').format('YYYY-MM');
  const customers = await this.customerService.findAll();

  for (const customer of customers) {
    await this.monthlyRebateService.calculateMonthlyRebate(
      customer.id,
      lastMonth
    );
  }
}

データモデル

カスタムフィールド

  • Customer.baseDiscountRate - 基本掛率
  • Customer.specialPricingTier - 特別価格ティア
  • ProductVariant.listPrice - 定価
  • OrderLine.appliedDiscountRate - 適用された掛率
  • OrderLine.rebateEligible - 割戻金対象フラグ

セキュリティ考慮事項

  1. 価格情報の秘匿: 顧客別価格は他の顧客には非公開
  2. 掛率変更権限: 掛率変更は管理者のみ可能
  3. 割戻金承認フロー: 自動計算後、管理者承認が必要
  4. 価格履歴の保存: すべての価格変更履歴を記録

パフォーマンス最適化

  • 価格キャッシング: 計算済み価格をRedisにキャッシュ(5分TTL)
  • バッチ計算: 大量の価格計算はバックグラウンドで処理
  • インデックス最適化: 顧客IDと商品IDの複合インデックス作成

トラブルシューティング

よくある問題

問題: 価格が正しく計算されない

  • 原因: 掛率設定が有効期間外
  • 解決: effectiveFromeffectiveTo を確認

問題: 割戻金が計算されない

  • 原因: エクスビアンス商品のタグ付けが不足
  • 解決: 商品に exuviance タグを追加

関連ドキュメント

今後の拡張予定

  • AI予測による最適掛率提案
  • リアルタイム割戻金シミュレーション
  • 動的な割戻金率調整機能
  • 複数ティアの自動昇格システム