コンテンツにスキップ

商流ルール

CommercialRulesPlugin は、業務上の pricing rule(恒常価格)と campaign(期間販促・特典)を 共通エンジンで実行するための実装概念です。業務上の責務分離そのものは docs/specifications/キャンペーン-価格制御責務分離.md を正本とします。

実装配置

  • プラグイン: packages/plugins/src/rule-engine/commercial/
  • Vendure設定: apps/vendure-server/src/vendure-config.shared.ts

実装層の構造 (4 層モデル)

価格制御の実装は 4 層に分かれている。下層ほど pure で、上層が下層に依存する一方向の依存関係を保つ。

flowchart TD
  subgraph L4["L4: Vendure 統合層 (adapter)"]
    Strategy["RitsubiPriceCalculationStrategy<br/>calculate / calculateUnitPrice"]
    Resolver["CommercialRuleAdminResolver / ShopResolver<br/>GraphQL endpoint"]
  end
  subgraph L3["L3: Application 層 (orchestrator)"]
    PriceCalc["PriceCalculationService<br/>calculateChargedPrice / calculateCatalogPrice"]
    CommercialSvc["CommercialRuleService<br/>evaluateCommercialState / CRUD facade"]
  end
  subgraph L2["L2: Adapter 層 (Vendure entity ↔ domain DTO)"]
    LineAdapter["commercial-line-adapter.ts<br/>toCommercialBaseLine"]
    InputBuilder["commercial-evaluation-input-builder.ts<br/>buildCommercialEvaluationInputFromCtx"]
    SmileResolver["smile-base-resolver.ts<br/>buildSmileBaseUnitPriceResolver"]
  end
  subgraph L1["L1: Engine 層 (pure simulation)"]
    Engine["commercial-rule-simulation.ts<br/>evaluateCommercialRules(input)<br/>applyPricingPhase / applyCampaignPhase"]
    Float["float-price-chain.ts<br/>FloatPriceChain"]
  end
  subgraph L0["L0: Domain 層 (@ritsubi/domain)"]
    Domain["models/commercial.ts<br/>CommercialBaseLine / CommercialEvaluationInput<br/>CommercialPricingRule / CommercialCampaignRule<br/>errors/commercial-rule.ts<br/>InvalidCommercialRuleInputError"]
  end
  L4 --> L3 --> L2 --> L1 --> L0
  • L0 (Domain): @ritsubi/domain パッケージ内の純粋な型・型ガード・エラー定義。Vendure 依存なし。
  • L1 (Engine): 価格決定の核アルゴリズム。CommercialEvaluationInput (DTO) を受け取り CommercialSimulationState を返す。pure 関数群 (内部に SimulatedLine 中間表現を持つが Vendure 型は使わない)。
  • L2 (Adapter): Vendure entity (ProductVariant / Customer / RequestContext) を L0 の DTO に変換。SMILE override 解決、subjectSet prefilter、resourceSet 展開、variant hydration を実行。
  • L3 (Application): 業務ユースケース別の orchestrator。L2 の adapter を呼んで L1 の engine を実行し、結果を Vendure API 層に渡す。
  • L4 (Vendure 統合): Vendure の Strategy / Resolver interface 実装。L3 を呼ぶだけの薄い adapter。

価格決定の data flow

sequenceDiagram
  participant Caller as L4 caller<br/>(Strategy / Resolver)
  participant App as L3 PriceCalc /<br/>CommercialRuleService
  participant Adapter as L2 input-builder<br/>+ line-adapter
  participant Engine as L1 commercial-rule-simulation
  participant SMILE as L2 smile-base-resolver

  Caller->>App: calculateChargedPrice(ctx, input)<br/>or evaluateCommercialState(ctx, input)
  App->>Adapter: buildCommercialEvaluationInputFromCtx(ctx, ports, args)
  Adapter->>SMILE: resolveSmileBaseUnitPrice(variant, qty)
  SMILE-->>Adapter: SmileBaseOverride | null
  Adapter->>Adapter: toCommercialBaseLine(variant, { listUnitPrice, baseUnitPrice, smileBaseOverride })
  Adapter-->>App: CommercialEvaluationInput<br/>{ lines, rules, context }
  App->>Engine: evaluateCommercialRules(input)
  Engine->>Engine: sortRules → applyPricingPhase → applyCampaignPhase<br/>→ applyDirectShippingSurcharge → buildSimulationState
  Engine-->>App: CommercialSimulationState
  App-->>Caller: PriceCalculationResult / CommercialSimulationState

cart 表示 / 実請求 / Dashboard simulator / benefit 確定の 4 use-case はすべてこのフローを共有する (SMILE base resolver は L2 で必ず 1 度実行され、L1 engine には baseUnitPrice 解決済みの DTO だけが渡る)。詳細は「表示価格と実請求価格の整合性」節を参照。

主な責務

  • set_unit_price
  • multiply_unit_price
  • add_unit_amount
  • add_order_amount
  • add_gift_items

業務概念との対応

  • pricing rule: 恒常価格・得意先別価格・常設の価格置換を担当する
  • campaign: 期間販促、ギフト付与、仮想商品を使った注文調整を担当する
  • React Dashboard では /commercial-rules を「価格・販促 > 価格・販促ルール」として表示する
  • access policy 系の管理は /policies(ポリシー管理)に分離する

条件評価

  • order.cartSubtotalNet
  • order.cartQuantity
  • order.matchedSubtotalNet
  • order.matchedQuantity
  • order.targetPresence
  • targets.productVariantIds
  • targets.collectionIds

金額判定は税抜商品小計を基準とし、送料・手数料・ポイント・クーポン・無償特典商品は含めません。

Tier / 解決モード

  • tierMode
  • highest_only
  • cumulative
  • resolutionMode
  • combine
  • exclusive

公開 API

  • Admin Query
  • commercialRules
  • commercialRule
  • simulateCommercialRules
  • Admin Mutation
  • createCommercialRule
  • updateCommercialRule
  • deleteCommercialRule
  • Shop Query
  • activeOrderCommercialState

内部 service method の正本 (L3)

GraphQL public API は変わらないが、L3 内部の service method は以下を正本とする:

用途 正本 method 経路
実請求 (order-line) PriceCalculationService.calculateChargedPrice(ctx, in) L2 adapter → L1 engine、失敗時 throw
catalog (PDP / 一覧) PriceCalculationService.calculateCatalogPrice(ctx, in) shippingMode 省略、失敗時 calculation_failed status
cart / 表示一般 CommercialRuleService.evaluateCommercialState(ctx, in) L2 adapter helper 経由で L1 を呼ぶ
Dashboard simulator CommercialRuleService.evaluateCommercialState admin resolver から SMILE resolver を組んで直接呼ぶ
benefit 確定 CommercialRuleService.setOrderCampaignBenefitSelections 内部で evaluateCommercialState を呼ぶ

CommercialRuleService.simulateCommercialState(ctx, in) (variant ベース、callback で SMILE resolver を注入する旧 signature) は Phase A で撤去済みcommercial-order-line.listener.ts を含む全 caller は新 evaluateCommercialState に移行済みで、旧 method は削除済み。なお PriceCalculationResult / CommercialLineResult 側に残る @deprecated 3 段価格旧 field (originalPrice / finalPrice / discountAmount / originalUnitPrice) と PriceCalculationService.calculatePricePhase C3 で棚卸予定であり本 Phase では touch しない。lint で旧 symbol の復活を防ぐ運用は下節 "deprecated lint 運用方針" を参照。

deprecated lint 運用方針

  • eslint.config.mjs の型対応 lint block で @typescript-eslint/no-deprecated: "warn" を有効化している (typescript-eslint v8 系の built-in rule)。
  • 対象 glob は **/*.ts / **/*.tsx / **/*.mts / **/*.cts (config/test ファイルは既存の disable レイヤーで除外)。
  • 段階運用: 現状は warn。Phase C3 で 3 段価格旧 field を棚卸し全 caller を新 field に揃えた後、error に昇格する。
  • 新規 PR で @deprecated symbol への参照を増やさないこと。warn を // eslint-disable-next-line で握り潰すのは禁止。

React Dashboard シミュレーション表示

  • 対象画面は React Dashboard の /commercial-rules/simulator
  • simulateCommercialRulesfocusLineResult を使って、価格決定ロジックを表示する
  • 価格決定ロジックのプレビューはツリーではなく、価格遷移フローとして表示する
  • フローは baseUnitPrice を起点に、appliedActions の実行順で価格変化を並べ、最後に finalUnitPrice を表示する
  • 各ステップでは次を表示する
  • 変化前価格と変化後価格
  • action 種別
  • ルール名 / ルールコード / tier 名
  • 適用値または金額
  • 判定理由
  • actionDecisions のうち status === "skipped_priority" は、不採用になった単価固定候補として補助表示する
  • 採用された set_unit_price がある場合は、そのステップ直下に不採用候補を寄せて表示する
  • 不採用候補があっても価格自体は変化させず、判定の背景説明として扱う

3 段価格モデル (listPrice / baseUnitPrice / finalUnitPrice)

価格制御では「上代」「SMILE base」「rule 適用後 final」の 3 段を型で分離して扱う。PriceCalculationResultCommercialLineResult の両方に下記 3 段と 2 つの delta が field として存在する。

field 定義
第 1 段 listPrice / listUnitPrice 上代 = ProductVariant.price。SMILE / rule の影響を受けない。価格制御の正本。
第 2 段 baseUnitPrice SMILE 単価マスタ適用後の base 単価。SMILE にヒットしない場合は listPrice と同値。
第 3 段 finalUnitPrice Commercial rule (pricing rule / campaign / 直送加算) を適用した最終単価。

delta は別 field として独立に持たせる:

  • smileBaseDiscount = max(0, listPrice − baseUnitPrice) — SMILE base 適用による下げ幅 (常に 0 以上)
  • ruleDiscount = baseUnitPrice − finalUnitPrice — rule 適用による下げ幅。直送加算など surcharge では負値もあり得る
flowchart LR
  L["listPrice (上代)<br/>ProductVariant.price"] -- "smileBaseDiscount<br/>= listPrice − baseUnitPrice<br/>(0 以上)" --> B["baseUnitPrice<br/>SMILE適用後 base"]
  B -- "ruleDiscount<br/>= baseUnitPrice − finalUnitPrice<br/>(surcharge 時は負値)" --> F["finalUnitPrice<br/>rule/直送加算後の最終単価"]

PriceCalculationResult.originalPrice / finalPrice / discountAmount (および CommercialLineResult.originalUnitPrice / discountAmount) は後方互換のため当面残しているが @deprecated。新コードは 3 段 + delta を使うこと。GraphQL の表面化は後続タスクで対応する。

4 つの組み合わせ

ケース listPrice baseUnitPrice finalUnitPrice smileBaseDiscount ruleDiscount
SMILE hit + rule hit 1000 800 700 200 100
SMILE miss + rule hit 1000 1000 850 0 150
SMILE hit + rule miss 1000 800 800 200 0
SMILE miss + rule miss 1000 1000 1000 0 0
直送加算 (rule surcharge) 1000 1000 1100 0 -100

simulation engine (commercial-rule-simulation.ts) では SimulatedLine.baseUnitPrice を SMILE 解決直後に確定し、set_unit_price / multiply_unit_price / add_unit_amount / 直送加算はその上に積み上げて finalUnitPrice を構築する。

価格計算の全体フロー

価格計算は SMILE 単価マスタを base(下敷き)として扱い、その上に商流ルール側の pricing rule / campaign / 直送加算を override する。SMILE 単価マスタは「顧客 × 商品」で確定している恒常価格として常に評価され、商流ルール側で価格アクションが何も適用されなければそのまま採用される。

flowchart TD
  Start["価格計算開始<br/>customer / productVariant / quantity / shippingMode"] --> Base["基準価格<br/>ProductVariant.price"]
  Base --> SmileLookup["SMILE単価マスタを base として検索<br/>ritsubi_smile_price_master"]
  SmileLookup --> SmileHit{"単価マスタにヒット?"}
  SmileHit -->|Yes| SmileBase["baseUnitPrice = SMILE単価<br/>(下敷き価格として採用)"]
  SmileHit -->|No| KeepBase["baseUnitPrice = ProductVariant.price"]
  SmileBase --> Commercial["CommercialRulesPlugin<br/>baseUnitPrice の上に<br/>pricing rule / campaign / 直送加算を評価"]
  KeepBase --> Commercial
  Commercial --> HasCommercial{"価格アクションあり?"}
  HasCommercial -->|Yes| Override["商流ルールの finalPrice を採用<br/>(SMILE base を上書き)"]
  HasCommercial -->|No| PassThrough["baseUnitPrice をそのまま採用"]
  Override --> Result["finalPrice"]
  PassThrough --> Result

この順番にしている理由は、SMILE 単価マスタを「顧客との取り決めとして確定している base 単価」として常に効かせ、React Dashboard で明示的に設定した商流ルール(販促・直送加算等)を 上書き として扱うため。商流ルール側に該当ルールが無ければ、SMILE base 単価がそのまま finalUnitPrice になる。

商流ルール側には、恒常的な pricing rule だけでなく、期間販促としての campaign と直送時の価格加算も含まれる。実装は L2 adapter (buildCommercialEvaluationInputFromCtx) が buildSmileBaseUnitPriceResolver を経由して各 line の baseUnitPrice を SMILE 単価で差し替えた DTO を組み立て、それを L1 engine (evaluateCommercialRules) へ渡す形になっている。L3 service (evaluateCommercialState / calculateChargedPrice / calculateCatalogPrice) は薄い orchestrator。

評価 pipeline の 2 phase 構造 (PricingRule / CampaignRule aggregate 分離 Phase 1)

CommercialRuleService.simulateCommercialState は内部で評価 pipeline を 2 phase に分けて実行する。CommercialRule.kind"pricing" / "campaign" のどちらかで rule 集合を分割し、phase 1 で line 単価を決定論的に確定したあと、その結果を input にして phase 2 で order 調整 / gift 配布などの campaign action を適用する。

flowchart TD
  Input["入力 lines + sortedRules"] --> Partition["sortRules → kind で分割<br/>pricingRules / campaignRules"]
  Partition --> PricingPhase["applyPricingPhase<br/>kind=='pricing' のみ評価<br/>set_unit_price / multiply_unit_price / add_unit_amount を line に適用"]
  PricingPhase --> PricingOut["boundary state:<br/>priceOverriddenLineIndexes<br/>setUnitPriceSelections<br/>exclusiveAborted<br/>matchedRules(phase1)"]
  PricingOut --> Abort{"exclusiveAborted?"}
  Abort -->|Yes| Surcharge
  Abort -->|No| CampaignPhase["applyCampaignPhase<br/>kind=='campaign' のみ評価<br/>add_gift_items / select_gift_items / add_order_amount を order に適用"]
  CampaignPhase --> Surcharge["applyDirectShippingSurcharge<br/>(直送加算)"]
  Surcharge --> Build["buildSimulationState<br/>matchedRules は元の sortRules 順に再構築"]

設計意図:

  • action 種別を型レベルで分離: @ritsubi/domainCommercialPricingAction / CommercialCampaignAction discriminated union と type guard (isCommercialPricingAction / isCommercialCampaignAction) を使い、各 phase が処理できる action 種別を型で示す。
  • phase 間 boundary を明示: priceOverriddenLineIndexes (非 default pricing rule が触った line) を PricingPhaseOutput として export し、phase 2 はそれを input として受け取る。従来 phase をまたいで漂っていた cross-cutting state が phase 1 → phase 2 の明示的な値渡しに整理された。
  • set_unit_price selection は pure 関数として独立: resolvePricingSelection(pricingEvaluations, skipPricingForLineIndexes) → Map<lineIndex, SetUnitPriceSelection> を export し、「どの set_unit_price 候補を採用するか」だけを副作用なく決定できるようにした。これは phase 1 内部 selection logic の単体テスト面でもある。
  • 既存 spec / DB schema は維持: CommercialRule entity / GraphQL schema / Dashboard UI は本 phase では変えない。後続 Phase 2 / 3 で entity・table・編集 UI を分離する想定。

実装位置:

  • 型: packages/domain/src/rules/commercial-rule-kind.ts
  • 2 phase pipeline 本体: packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts (applyPricingPhase / applyCampaignPhase / resolvePricingSelection)
  • orchestrator: packages/plugins/src/rule-engine/commercial/services/commercial-rule.service.ts (simulateCommercialState)

Phase 2a: 型レベルの kind 分離 (DB schema は据え置き)

Phase 1 で確立した「rule.kind で 2 phase に振り分ける」境界を、型レベルで discriminated union として表現する強化版。commercial-rule-simulation.ts:790 付近の Phase 1 コメント「DB schema は変えず rule.kind で入力 rule 集合を pricing / campaign に振り分ける」と整合する。

追加した型 (packages/domain/src/rules/commercial-rule-kind.ts):

  • CommercialPricingTier / CommercialCampaignTier: tiers[].actionsCommercialPricingAction[] / CommercialCampaignAction[] に narrow した tier。
  • CommercialPricingRule / CommercialCampaignRule: kind discriminator で narrow した rule。tiers も対応する narrowed tier 型。
  • ClassifiedCommercialRule = CommercialPricingRule | CommercialCampaignRule: discriminated union。
  • isPricingRule(rule) / isCampaignRule(rule): kind discriminator を信頼する type guard。kind 未指定の legacy record は pricing 扱い (既存仕様維持)。

サービス側 (commercial-rule.service.ts) に additive:

  • findAllPricingRules(ctx): Promise<PricingCommercialRuleRecord[]>
  • findAllCampaignRules(ctx): Promise<CampaignCommercialRuleRecord[]>
  • 既存 listCommercialRules は無変更で残し、外部 caller は破壊しない。simulateCommercialState / listActiveCampaignCodes 内の rule.kind !== "campaign" / rule.kind === "campaign" 比較は type guard 経由に置換 (振る舞いは等価)。

設計意図:

  • DB schema (commercial_rule_entity) / entity / migration は触らない。物理的に分離可能な column は実質 isDefaultRate 1 個のみで、tiers jsonb 内に action 種別が集中しているため、テーブル分離の便益が小さい。
  • Tier 内 action と kind の整合は 保存時の正規化 (normalizeCommercialRuleKind / inferCommercialRuleKind) と Dashboard UI 側の編集制約で維持する。type guard は runtime で action 種別を再検証しない。
  • 後続 Phase 2b で「pricing と campaign で column が大きく分岐し始めた」「Dashboard 編集経路が完全に分かれた」のいずれかが起きたら DB 物理分離を再検討する。

DTO 境界と engine entrypoint

価格決定の核アルゴリズム (L1) は plain DTO だけを入力に取る 設計に統一されている。Vendure entity (ProductVariant / Customer / RequestContext) は L2 adapter 層で DTO に変換される。

Input DTO 一覧 (@ritsubi/domain/models/commercial.ts)

役割
CommercialBaseLine 1 注文 line 分の DTO。variant 由来の productVariantId / sku / name / collectionIds / facetValueIds、価格 (listUnitPrice / baseUnitPrice)、行属性 (quantity / previewOnly / directShippingEligible / isGift)、SMILE 由来の smileBaseOverride? (priceKindCode / quantityUpperBound / scopeKey) を持つ
CommercialEvaluationContext 顧客と評価コンテキスト。customerId? / customerGroupIds / customerCustomFields / channelId / shippingMode? / focusProductVariantId? / selectedBenefitSelections? / now?
CommercialBenefitSelectionInput benefit 選択結果 (rule / tier / selection / item ids)
CommercialEvaluationInput { lines: CommercialBaseLine[], rules: CommercialRule[], context: CommercialEvaluationContext }

Engine entrypoint (evaluateCommercialRules)

// packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts
export function evaluateCommercialRules(
  input: CommercialEvaluationInput,
  options?: { directShippingMarkupRate?: number },
): CommercialSimulationState;

入力前提:

  • input.rules商流ルール内の inline 条件だけを持つ。表示制御の条件モデルには依存せず、resourceSetIds は保存・正規化時に拒否する。
  • input.lines[*].baseUnitPriceSMILE override 解決済み。SMILE master ヒット時は SMILE 単価、miss 時は listUnitPrice と同値。
  • input.lines[*]directShippingEligible / isGift を含む必要 fields がすべて埋まっている (variant hydration 済み)。
  • sortRules は engine 内部で実施するので caller は不要。

入力前提が守られない場合 (例: variant が未 hydrate、SMILE base が null) は engine が InvalidCommercialRuleInputError (@ritsubi/domain/errors) を throw する。L2 adapter / L3 application は catch して Vendure の UserInputError に変換する責務を持つ。

Adapter helper (buildCommercialEvaluationInputFromCtx)

// packages/plugins/src/rule-engine/commercial/services/commercial-evaluation-input-builder.ts
export async function buildCommercialEvaluationInputFromCtx(
  ctx: RequestContext,
  ports: CommercialEvaluationInputBuilderPorts,
  args: BuildCommercialEvaluationInputArgs,
): Promise<CommercialEvaluationInput>;

ports は structural type で以下を要求する: listCommercialRules / ensureVariantRelations / resolveSmileBaseUnitPrice?。production 経路では Vendure DI 経由で既存 service / buildSmileBaseUnitPriceResolver をそのまま渡せる。test では stub を渡す。

per-variant N+1 注意: catalog 一覧では calculate() が variant ごとに calculateCatalogPrice を呼ぶため、listCommercialRules と SMILE 単価参照 (findBestPriceForVariant) は request-scoped でキャッシュ/メモ化済み(rules は createRequestScopedCache + 5s shared、SMILE は (productCode, customerCode, salesRateClassCode, quantity) キー)。新しい ports 実装や stub 差し替え時にこのメモ化を外すと、1 ページ ~400 variant で千クエリ規模の N+1 が再発する。詳細は docs/03-implementation/infrastructure/storefront-products-performance-troubleshooting.md の「2026-05-28 per-variant pricing N+1 除去」節。

内部処理順:

  1. commercial rule を load
  2. ensureVariantRelations で line variant を hydrate
  3. SMILE resolver があれば gift 行を除いて並列解決
  4. toCommercialBaseLineCommercialBaseLine DTO 化
  5. CommercialEvaluationContext を customer / channel / shipping / focus / benefit selection / now から組み立てて返す

時間窓 / enabled filter は engine 側の責務 (旧 path と同じ役割分担)。

表示価格と実請求価格の整合性 (SMILE base resolver の共有)

cart 表示経路 (CommercialRuleService.simulateActiveOrderState、Shop API の activeOrderCommercialSimulation など) と、実請求経路 (RitsubiPriceCalculationStrategy.calculateUnitPricePriceCalculationService.calculatePrice) は、いずれも simulateCommercialState同じ SMILE base resolver (packages/plugins/src/rule-engine/commercial/services/smile-base-resolver.tsbuildSmileBaseUnitPriceResolver) を注入する。

これにより、

  • cart 上に表示される「この顧客向けの単価」
  • 注文確定時に Vendure 側で確定する unitPrice

の双方で 同一の SMILE 単価マスタヒット結果baseUnitPrice として採用される。SMILE 解決が片方の経路だけ走って表示価格と実請求が乖離する状態は構造的に発生しないようにしてある (cart 表示で SMILE base が抜ける = 構造的問題 #1 はこの共有により解消)。

新たに simulate 経路を増やすときは、必ず buildSmileBaseUnitPriceResolver を経由して resolveSmileBaseUnitPrice を渡すこと。

SMILE 単価マスタの検索順

SMILE 単価マスタは、得意先軸(誰に売るか)と数量軸(どれだけ売るか)の 2 軸で価格を持つ。価格決定は 1 回の SQL で全候補をスコアリングし、最も具体的な 1 行を採用する。

原則: 指定範囲が狭いほど優先される(specificity が高い行が勝つ)。 得意先軸では「全顧客 < 分類 < 個別顧客」、数量軸では「無制限 < 大きい upperBound < 小さい upperBound」。両軸とも「対象が絞り込まれているほど強い」という同じ向き。

優先順位の 2 軸

弱い ← → 強い 実体
得意先軸 商品別 (単価種類=1) → 得意先分類別 (単価種類=3) → 得意先別 (単価種類=2) customerCode / salesRateClassCode の絞り込み度合い
数量軸 基本単価(quantityUpperBound IS NULL) → 数量別レンジ(quantityUpperBound が小さいほど強い) 数量階段の細かさ

組み合わせ優先度(低 → 高、上書きされる順):

  1. 商品別 × 基本単価
  2. 商品別 × 数量別
  3. 得意先分類別 × 基本単価
  4. 得意先分類別 × 数量別
  5. 得意先別 × 基本単価
  6. 得意先別 × 数量別

得意先軸が 第 1 ソートキー、数量軸(quantityUpperBound 昇順)が 第 2 ソートキー。したがって「得意先別の基本単価」は「得意先分類別の数量別」より強い。

候補マトリクス

flowchart LR
  subgraph CustomerAxis["得意先軸(強い順)"]
    direction TB
    K2["単価種類=2<br/>得意先別商品<br/>customerCode 一致"]
    K3["単価種類=3<br/>得意先分類別商品<br/>salesRateClassCode 一致"]
    K1["単価種類=1<br/>商品別<br/>全顧客共通"]
  end
  subgraph QuantityAxis["数量軸(強い順)"]
    direction TB
    Q1["数量別<br/>quantityUpperBound &gt; 注文数量<br/>かつ最小の upperBound"]
    Q2["基本単価<br/>quantityUpperBound IS NULL"]
  end
  CustomerAxis -. 直積 .-> QuantityAxis

単一クエリでの解決フロー

実装 (SmilePriceMasterService.findBestPriceForVariant()) は分岐ではなく、候補を 1 回で抽出し ORDER BY で優先 1 行を選ぶ。

flowchart TD
  Start["価格計算 base 解決<br/>customer / SKU / quantity"] --> Filter["候補抽出<br/>productCode = SKU<br/>かつ COALESCE(quantityUnitPrice, unitPrice) IS NOT NULL<br/>かつ (quantityUpperBound IS NULL<br/>または quantityUpperBound > quantity)"]
  Filter --> Match["得意先軸の一致条件<br/>(種類=2 かつ customerCode 一致)<br/>OR (種類=3 かつ 分類コード一致)<br/>OR (種類=1)"]
  Match --> Sort["ORDER BY<br/>1. 単価種類 (2 → 3 → 1)<br/>2. quantityUpperBound ASC NULLS LAST<br/>3. rowNo ASC NULLS LAST<br/>4. updatedAt DESC"]
  Sort --> Top{"先頭 1 行?"}
  Top -->|あり| Pick["採用<br/>quantityUnitPrice ?? unitPrice"]
  Top -->|なし| Fallback["ヒットなし<br/>ProductVariant.price を base にする"]

採用例

customerCode=C001 / salesRateClassCode=A01 / SKU=80001 / 注文数量 10 の場合の候補と採用判定例:

flowchart TB
  subgraph Candidates["候補行"]
    direction TB
    R1["#1 種類=1 商品別<br/>upperBound=NULL<br/>単価 ¥1,000"]
    R2["#2 種類=1 商品別<br/>upperBound=20<br/>単価 ¥900"]
    R3["#3 種類=3 分類別 (A01)<br/>upperBound=NULL<br/>単価 ¥850"]
    R4["#4 種類=3 分類別 (A01)<br/>upperBound=50<br/>単価 ¥820"]
    R5["#5 種類=2 得意先別 (C001)<br/>upperBound=NULL<br/>単価 ¥800"]
    R6["#6 種類=2 得意先別 (C001)<br/>upperBound=20<br/>単価 ¥750"]
  end
  Candidates --> Rank["ORDER BY 種類(2→3→1), upperBound ASC NULLS LAST, rowNo ASC NULLS LAST, updatedAt DESC"]
  Rank --> Winner["採用: #6<br/>得意先別 × 数量別 ¥750"]
  Rank -. 不採用 .-> R5
  Rank -. 不採用 .-> R4
  Rank -. 不採用 .-> R3
  Rank -. 不採用 .-> R2
  Rank -. 不採用 .-> R1

注: quantityUpperBound ASC NULLS LAST により、同じ得意先軸内では「数量レンジに乗る最小の階段」が基本単価より優先される。quantityUpperBound は排他的上限なので、注文数量が全数量別レンジを上回る(どの quantityUpperBound > quantity も満たさない)場合は、その軸では基本単価行(upperBound IS NULL)が拾われる。最終 sentinel 99999999 以上は価格未定義として fail-closed する。

erDiagram
  CUSTOMER ||--o{ SMILE_PRICE_MASTER : "salesRateClassCode or customerCode"
  PRODUCT_VARIANT ||--o{ SMILE_PRICE_MASTER : "sku = productCode"
  SMILE_IMPORT_LOG ||--o{ SMILE_PRICE_MASTER : "sourceImportLogId"

  CUSTOMER {
    string customerCode
    string salesRateClassCode
  }

  PRODUCT_VARIANT {
    string sku
    int price
  }

  SMILE_PRICE_MASTER {
    string priceKindCode
    string customerCode
    string salesRateClassCode
    string productCode
    int quantityUpperBound
    int quantityUnitPrice
    int unitPrice
    string scopeKey
  }

代表ユースケース

  • 対象商品だけを会員価格へ変更する
  • 対象商品を含むカート税抜小計が 150,000 円以上なら B 特典
  • 対象商品を含むカート税抜小計が 200,000 円以上なら C 特典
  • 対象商品の数量到達でギフトと注文調整を同時適用する

仮想商品による割引出力

  • add_order_amount action には任意で productCode を指定できる。
  • productCode を指定した注文調整は、SMILE 注文 CSV でその商品コードの仮想商品行として出力される。
  • productCode には、事前に有効化したダミー商品 SKU(例: 80000088)を設定する。

掛率設計

掛率(set_unit_price 系)は 顧客 × 商品 の組で表現する。商品単独軸の掛率ルールは作らず、default 掛率は isDefaultRate=true のルールで表現する。

優先評価ロジック:

  1. まず isDefaultRate=false の顧客個別ルールを優先評価。
  2. 同一注文行に対して isDefaultRate=false のヒットがあれば、その行については isDefaultRate=true のルール評価をスキップする。
  3. ヒットしなかった行のみ isDefaultRate=true の default ルールが評価される。

詳細は docs/specifications/2026-05-commercial-rule-facet-targeting.md を SSOT とする。

売上掛率分類コードによる顧客ターゲット指定

SMILE 得意先インポートで取り込まれた salesRateClassCode(例: A01)を、掛率ルールの適用対象として直接使える。

SMILE 単価マスタ(tanka.csv / suryou-tanka*.txt)を取り込んだ価格は ritsubi_smile_price_master を正本とし、CustomerGroup や CommercialRule を自動生成しない。商流ルール評価の base 単価 として、単価種類=2(得意先別商品)、単価種類=3(得意先分類別商品)、単価種類=1(商品別)の順に評価し、該当する数量上限レンジの最小一致単価を base にする。商流ルールが何も適用されなければこの base 単価が finalUnitPrice になる。

設定手順

  1. 価格・販促ルール の条件に、対象顧客または顧客グループを inline 条件として指定する
  2. 商品軸は conditions.targets.productVariantIds / collectionIds / facetValueIds で指定する

動作

  • 掛率ルール評価時、ログイン顧客の ID / 顧客グループ / custom field が inline 条件を満たすと、そのルールが適用される
  • SMILE 再インポートで顧客属性が変わると、次回ルール評価から inline 条件に基づいて適用先が変わる

auto CustomerGroup との使い分け

方式 向いているケース
salesRateClassCode inline 条件 SMILE の掛率分類コードをそのまま適用したい
customerGroup 条件(auto group) 掛率分類以外の条件も含む複合ターゲットを定義したい

float chain と rounding の決定論性

Commercial rule の multiply_unit_price / add_unit_amount / set_unit_pricecumulative tierMode で多段に連鎖適用されることがあるため、評価エンジンは FloatPriceChain (packages/domain/src/rules/float-price-chain.ts が正本、 plugins 側 packages/plugins/src/rule-engine/commercial/services/float-price-chain.ts は re-export shim) を経由して以下の semantics を採用する。

  • 演算は float、書き戻し時のみ round: cumulative tier で multiply を多段に 連鎖する場合、各段で Math.round を挟むと誤差が蓄積する。FloatPriceChain は float のまま updatecommit で rounded 値を line.finalUnitPrice へ 書き戻す。次の段は引き続き float から継続する。
  • set_unit_price は chain を明示的に reset する: rounded された固定単価 を新しい起点として後続の multiply / add を連鎖させる。これは「以前の小数 演算結果を捨てる」意思決定をコード上で明示するもので、set_unit_price 直後 の multiply は rounded 固定値ベースで計算されることが保証される。
  • peek(index, fallback): chain に値が無い場合は line.finalUnitPrice (= SMILE override 後 / set 前) を fallback として使う。これにより SMILE base → multiply 連鎖、set → multiply 連鎖、ベタ multiply の 3 系統が一貫した 起点で開始する。
  • rounding policy: roundMoneyMath.round (half-away-from-+Infinity) を採用する。整数円 (JPY) 前提の業務要件であり、bankers rounding (half-to-even) には現状切り替えない。境界例: 0.5 → 11.5 → 22.5 → 3-0.5 → -0-1.5 → -1-2.5 → -2

bankers rounding 切替を検討する判断材料

roundMoneyMath.round から bankers rounding (half-to-even) に切り替える 判断材料を業務側と AI で共有する。下記いずれかが起きた場合に、業務側合意の 上で切替を検討する:

  • 会計監査要求: 監査側から half-away から half-to-even への変更要求が 発生 (税抜計算の累積誤差を中立化したい場合)
  • SMILE 側との乖離: 税抜計算で端数 0.5 円が累積し、月次/年次の請求額が SMILE 側 ledger と乖離する事象が観測された
  • 顧客からの請求書再発行要求: rounding 起因で 1 円差が発生し、顧客・ 取引先から再発行 / 訂正書発行を要求された

切替手順は packages/domain/src/rules/float-price-chain.tsroundMoney TODO コメント参照。roundMoney 単一差替で実装可能な構造を確保しているが、 既存請求書の再発行可否を業務側と合意してから merge する。

詳細は commercial-rule-simulation.spec.ts の "float chain 決定論性" 節 および float-price-chain.spec.ts を正本とする。

auto CustomerGroup との関係

掛率ルールの顧客ターゲットに customerGroup 条件を使う場合、CustomerGroup は手動メンバ・auto group(述語ベース)のどちらでも構わない。auto group は Customer custom field の条件式に基づき保存時に物理メンバが更新されるため、SMILE 属性変更が掛率ルールの適用範囲へ自動的に反映される。詳細は docs/specifications/2026-05-predicate-based-customer-group.md

@deprecated 旧 field の caller 棚卸し (Phase C3 時点)

3 段価格モデル導入時に @deprecated 化した旧 field の現存 caller を記録する (2026-05-26 棚卸し)。これらの field を削除する前に caller を新 field (listUnitPrice / baseUnitPrice / finalUnitPrice / smileBaseDiscount / ruleDiscount) に順次移行する必要がある。

CommercialLineResult.originalUnitPrice (旧) → listUnitPrice (新)

active caller (production code):

  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/simulation-flow.ts (Dashboard simulator の価格遷移フロー)
  • packages/plugins/src/standard-extensions/admin-extensions/dashboard/campaigns/SimulationPage.tsx (Dashboard simulator の表示)
  • apps/storefront/src/lib/product-pricing.ts (Storefront の price breakdown 表示、activeOrderCommercialState shop GraphQL 経由)

integration test caller:

  • apps/vendure-server/tests/integration/commercial-admin.integration-spec.ts
  • apps/vendure-server/tests/integration/commercial-apply.integration-spec.ts

PriceCalculationResult.originalPrice / finalPrice / discountAmount (旧) → listPrice / finalUnitPrice / 各 delta (新)

GraphQL schema の focusLineResult 経由で公開されているため、Storefront / Dashboard simulator から間接的に依存。直接 import している箇所は packages/ plugins/src/rule-engine/commercial/services/price-calculation.service.ts 内部のみ (interface 定義 + helper)。

削除手順 (今後の別タスク)

caller 移行は GraphQL schema 拡張 → consumer 切替 → 旧 field 撤去 の 3 段階。

  1. shop GraphQL の CommercialLineResult type と admin GraphQL の CommercialSimulationLineResult type に listUnitPrice / baseUnitPrice / finalUnitPrice / smileBaseDiscount / ruleDiscount を expose
  2. Storefront / Dashboard simulator の consumer を新 field 参照に書き換え
  3. integration test を新 field に書き換え
  4. 旧 field を resolver / type 定義から削除し、@deprecated 注記も外す
  5. Phase A で warn にしている @typescript-eslint/no-deprecated を error に昇格

GraphQL schema 拡張時には changeset を "ritsubi-vendure-server": minor で発行し、Storefront 側にも対応 changeset を追加する。

関連資料

実装ファイル早見表

価格制御の実装ファイルを層ごとに整理する。

ファイル 役割
L0 packages/domain/src/models/commercial.ts CommercialBaseLine / CommercialEvaluationInput 等の DTO
L0 packages/domain/src/rules/commercial-rule-kind.ts CommercialPricingAction / CommercialCampaignAction discriminated union、isPricingRule / isCampaignRule type guard
L0 packages/domain/src/errors/commercial-rule.ts InvalidCommercialRuleInputError
L1 packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts engine 本体 (evaluateCommercialRules / applyPricingPhase / applyCampaignPhase / resolvePricingSelection)
L1 packages/plugins/src/rule-engine/commercial/services/float-price-chain.ts FloatPriceChain interface (cumulative tier の決定論性)
L2 packages/plugins/src/rule-engine/commercial/services/commercial-line-adapter.ts toCommercialBaseLine(variant, options) — variant → DTO
L2 packages/plugins/src/rule-engine/commercial/services/commercial-evaluation-input-builder.ts buildCommercialEvaluationInputFromCtx — subjectSet / resourceSet / hydration / SMILE 解決を集約
L2 packages/plugins/src/rule-engine/commercial/services/smile-base-resolver.ts buildSmileBaseUnitPriceResolver — SMILE master ルックアップ resolver
L3 packages/plugins/src/rule-engine/commercial/services/price-calculation.service.ts calculateChargedPrice / calculateCatalogPrice
L3 packages/plugins/src/rule-engine/commercial/services/commercial-rule.service.ts evaluateCommercialState / CRUD facade / findAllPricingRules / findAllCampaignRules
L4 packages/plugins/src/rule-engine/commercial/strategies/ritsubi-price-calculation.strategy.ts Vendure OrderItemPriceCalculationStrategy 実装
L4 packages/plugins/src/rule-engine/commercial/api/commercial-rule.admin.resolver.tscommercial-rule.shop.resolver.ts GraphQL endpoint