商流ルール¶
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_pricemultiply_unit_priceadd_unit_amountadd_order_amountadd_gift_items
業務概念との対応¶
pricing rule: 恒常価格・得意先別価格・常設の価格置換を担当するcampaign: 期間販促、ギフト付与、仮想商品を使った注文調整を担当する- React Dashboard では
/commercial-rulesを「価格・販促 > 価格・販促ルール」として表示する access policy系の管理は/policies(ポリシー管理)に分離する
条件評価¶
order.cartSubtotalNetorder.cartQuantityorder.matchedSubtotalNetorder.matchedQuantityorder.targetPresencetargets.productVariantIdstargets.collectionIds
金額判定は税抜商品小計を基準とし、送料・手数料・ポイント・クーポン・無償特典商品は含めません。
Tier / 解決モード¶
tierModehighest_onlycumulativeresolutionModecombineexclusive
公開 API¶
- Admin Query
commercialRulescommercialRulesimulateCommercialRules- Admin Mutation
createCommercialRuleupdateCommercialRuledeleteCommercialRule- 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.calculatePrice は Phase 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 で
@deprecatedsymbol への参照を増やさないこと。warn を// eslint-disable-next-lineで握り潰すのは禁止。
React Dashboard シミュレーション表示¶
- 対象画面は React Dashboard の
/commercial-rules/simulator simulateCommercialRulesのfocusLineResultを使って、価格決定ロジックを表示する- 価格決定ロジックのプレビューはツリーではなく、価格遷移フローとして表示する
- フローは
baseUnitPriceを起点に、appliedActionsの実行順で価格変化を並べ、最後にfinalUnitPriceを表示する - 各ステップでは次を表示する
- 変化前価格と変化後価格
- action 種別
- ルール名 / ルールコード / tier 名
- 適用値または金額
- 判定理由
actionDecisionsのうちstatus === "skipped_priority"は、不採用になった単価固定候補として補助表示する- 採用された
set_unit_priceがある場合は、そのステップ直下に不採用候補を寄せて表示する - 不採用候補があっても価格自体は変化させず、判定の背景説明として扱う
3 段価格モデル (listPrice / baseUnitPrice / finalUnitPrice)¶
価格制御では「上代」「SMILE base」「rule 適用後 final」の 3 段を型で分離して扱う。PriceCalculationResult と CommercialLineResult の両方に下記 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/domainのCommercialPricingAction/CommercialCampaignActiondiscriminated 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_priceselection は pure 関数として独立:resolvePricingSelection(pricingEvaluations, skipPricingForLineIndexes) → Map<lineIndex, SetUnitPriceSelection>を export し、「どの set_unit_price 候補を採用するか」だけを副作用なく決定できるようにした。これは phase 1 内部 selection logic の単体テスト面でもある。- 既存 spec / DB schema は維持:
CommercialRuleentity / 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[].actionsをCommercialPricingAction[]/CommercialCampaignAction[]に narrow した tier。CommercialPricingRule/CommercialCampaignRule:kinddiscriminator で 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 は実質isDefaultRate1 個のみで、tiersjsonb 内に 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[*].baseUnitPriceは SMILE 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 除去」節。
内部処理順:
- commercial rule を load
ensureVariantRelationsで line variant を hydrate- SMILE resolver があれば gift 行を除いて並列解決
toCommercialBaseLineでCommercialBaseLineDTO 化CommercialEvaluationContextを customer / channel / shipping / focus / benefit selection / now から組み立てて返す
時間窓 / enabled filter は engine 側の責務 (旧 path と同じ役割分担)。
表示価格と実請求価格の整合性 (SMILE base resolver の共有)¶
cart 表示経路 (CommercialRuleService.simulateActiveOrderState、Shop API の activeOrderCommercialSimulation など) と、実請求経路 (RitsubiPriceCalculationStrategy.calculateUnitPrice → PriceCalculationService.calculatePrice) は、いずれも simulateCommercialState に 同じ SMILE base resolver (packages/plugins/src/rule-engine/commercial/services/smile-base-resolver.ts の buildSmileBaseUnitPriceResolver) を注入する。
これにより、
- 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 ソートキー、数量軸(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 > 注文数量<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_amountaction には任意でproductCodeを指定できる。productCodeを指定した注文調整は、SMILE 注文 CSV でその商品コードの仮想商品行として出力される。productCodeには、事前に有効化したダミー商品 SKU(例:80000088)を設定する。
掛率設計¶
掛率(set_unit_price 系)は 顧客 × 商品 の組で表現する。商品単独軸の掛率ルールは作らず、default 掛率は isDefaultRate=true のルールで表現する。
優先評価ロジック:
- まず
isDefaultRate=falseの顧客個別ルールを優先評価。 - 同一注文行に対して
isDefaultRate=falseのヒットがあれば、その行についてはisDefaultRate=trueのルール評価をスキップする。 - ヒットしなかった行のみ
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 になる。
設定手順¶
- 価格・販促ルール の条件に、対象顧客または顧客グループを inline 条件として指定する
- 商品軸は
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_price
は cumulative 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 のままupdate、commitで 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:
roundMoneyはMath.round(half-away-from-+Infinity) を採用する。整数円 (JPY) 前提の業務要件であり、bankers rounding (half-to-even) には現状切り替えない。境界例:0.5 → 1、1.5 → 2、2.5 → 3、-0.5 → -0、-1.5 → -1、-2.5 → -2。
bankers rounding 切替を検討する判断材料¶
roundMoney を Math.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.ts の roundMoney
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 表示、activeOrderCommercialStateshop GraphQL 経由)
integration test caller:
apps/vendure-server/tests/integration/commercial-admin.integration-spec.tsapps/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 段階。
- shop GraphQL の
CommercialLineResulttype と admin GraphQL のCommercialSimulationLineResulttype にlistUnitPrice/baseUnitPrice/finalUnitPrice/smileBaseDiscount/ruleDiscountを expose - Storefront / Dashboard simulator の consumer を新 field 参照に書き換え
- integration test を新 field に書き換え
- 旧 field を resolver / type 定義から削除し、
@deprecated注記も外す - Phase A で warn にしている
@typescript-eslint/no-deprecatedを error に昇格
GraphQL schema 拡張時には changeset を "ritsubi-vendure-server": minor
で発行し、Storefront 側にも対応 changeset を追加する。
関連資料¶
- 価格・送料計算マトリクス (SSOT) — 単価/送料/税の全ステージを横断的に管理する正本
- 商流ルール Facet ターゲティング仕様
- 述語ベース CustomerGroup 仕様
- 商流ルール移行メモ
- 配送計算
- 顧客可視性
実装ファイル早見表¶
価格制御の実装ファイルを層ごとに整理する。
| 層 | ファイル | 役割 |
|---|---|---|
| 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.ts、commercial-rule.shop.resolver.ts |
GraphQL endpoint |