コンテンツにスキップ

価格計算ロジック (実装正本)

Ritsubi における 商品価格の決定パイプライン をコード視点で 1 本にまとめた実装ガイド。 ドメイン仕様(業務ルール)の正本は docs/specifications/ 配下のため、本書はそこを参照しつつ 「どのファイルが何を計算するか」「経路はどこか」 を確定させる位置づけとする。

SSoT 参照

本書では業務ルールの再定義はしない。値の意味、優先度、フローチャートはここを正本にする。

1. 概要

5 軸

価格は次の 5 軸の組み合わせで決まる:

  1. 定価 (listUnitPrice): 商品マスタの希望小売価格(上代)
  2. 掛率 (rate): 顧客 × 商品の組で持つ。商品単独軸では持たず、isDefaultRate フラグで default を表現
  3. 数量別掛率: 数量レンジごとの掛率
  4. 顧客別数量掛率: (customer, product, quantityUpperBound) の単価行
  5. 売上掛率分類 (salesRateClassCode): SMILE の得意先分類0コード = SMILE 単価マスタの分類キー

3 段モデル + 後段 (Phase 2 / U4)

listUnitPrice  ── 商品マスタの定価(上代)
     ↓ U3: SMILE 単価マスタ specificity sort
baseUnitPrice  ── SMILE が決定する基準単価
     ↓ U2 Phase 1 (pricing): set_unit_price / multiply_unit_price / add_unit_amount
finalUnitPrice ── 1 line の最終単価
     ↓ U2 Phase 2 (campaign): add_order_amount / add_gift_items / select_gift_items
order レベル orderAdjustments[] と plannedGiftItems[] を蓄積
     ↓ U4: per-line 直送加算 (DIRECT mode のみ)
line total に surcharge を加算

U2 Phase 2kind === "campaign" の rule だけが対象。Phase 1 で line 単価が確定した 後に order-level 調整 (add_order_amount) と gift line 計画 (add_gift_items / select_gift_items) を行い、Order.surcharges と order line への反映は commercial-order-line.listener.ts (syncOrderAdjustments / syncGiftLines / syncGiftCompensationSurcharges) が cart 更新後に同期する。U4 はそのあとに走るので 直送加算は Phase 2 の order-level 調整に影響されない。

直送モード加算

  • per-line 設計Order.directShippingSurcharge 系の order 集約 custom field は持たない(per-line に統一済み)。
  • 加算は 税抜き 10%finalUnitPrice が確定したあとの後段ステップ。
  • 加算対象外: gift line、directShippingEligible = falseisDirectShippingOnly 不整合 line。
  • 加算式は targetDirectPrice = listPrice × 1.1surcharge = max(0, target - finalUnitPrice)
  • 定数 SHIPPING_RATES.DIRECT_SURCHARGE = 0.1 を SSoT とする。

2. パイプライン

flowchart TD
  input([CommercialEvaluationInput]) --> evalEntry[evaluateCommercialRules]

  evalEntry --> u3{U3: SMILE 単価マスタ}
  u3 -->|findBestPriceForVariant| baseUnit[baseUnitPrice 決定]
  baseUnit -->|hit なし| fallback[listUnitPrice を採用]
  fallback --> u2p1
  baseUnit --> u2p1

  u2p1{"U2 Phase 1: applyPricingPhase<br/>(kind != campaign)"}
  u2p1 -->|sortRulesForEvaluation<br/>isDefaultRate=false 優先<br/>priority asc, updatedAt| finalUnit[finalUnitPrice 決定<br/>set_unit_price / multiply / add_unit_amount]
  finalUnit --> u2p2

  u2p2{"U2 Phase 2: applyCampaignPhase<br/>(kind == campaign)"}
  u2p2 -->|"add_order_amount → orderAdjustments[]"| orderAdj[order レベル surcharge 計画]
  u2p2 -->|"add_gift_items / select_gift_items → plannedGiftItems[]"| giftPlan[gift line 計画]
  orderAdj --> u4
  giftPlan --> u4

  u4{U4: applyDirectShippingSurcharge}
  u4 -->|shippingMode == DIRECT| surcharge["surcharge = max(0, target - finalUnitPrice)"]
  u4 -->|NORMAL| done([CommercialSimulationState])
  surcharge --> done

  done --> listener["commercial-order-line.listener<br/>syncOrderAdjustments / syncGiftLines"]
  listener --> vendure([Order.surcharges + gift OrderLines])


  subgraph 呼び出し経路
    catalog[catalog: RitsubiPriceCalculationStrategy.calculate]
    charged[charged: calculateUnitPrice]
    sim[simulation: CommercialRuleService.evaluateCommercialState]
    cart[cart: simulateActiveOrderState]
  end
  catalog --> evalEntry
  charged --> evalEntry
  sim --> evalEntry
  cart --> evalEntry

すべての経路は evaluateCommercialRules (engine pure) に収束する。

3. SMILE specificity (U3)

flowchart TD
  start(["variant + customer + quantity"]) --> filterScope{"priceKindCode 候補抽出"}
  filterScope -->|候補| sortKind["ORDER BY priceKindCode"]
  sortKind --> sortQty["次に quantityUpperBound asc<br/>NULLS LAST"]
  sortQty --> candidates["先頭 N 件を候補として取得"]
  candidates --> typeBranch{"unitTypeCode 分岐"}
  typeBranch -->|"0 / NULL"| useOverride["COALESCE quantityUnitPrice unitPrice<br/>0 円も valid"]
  typeBranch -->|"1"| useStd["標準売上単価 ProductVariant.price * unitRate / 100"]
  typeBranch -->|"2"| skip2["標準仕入単価 x 掛率<br/>販売価格計算では除外"]
  typeBranch -->|"3"| useList["上代単価 smileListPrice * unitRate / 100"]
  useOverride --> firstRow["base 価格が解決した先頭候補を採用"]
  useStd --> firstRow
  useList --> firstRow

  subgraph priceKindCode_Priority ["priceKindCode 優先順位"]
    p2["'2' 顧客 x 商品 (最狭)"]
    p3["'3' 売上掛率分類 x 商品"]
    p1["'1' 商品のみ (最広)"]
    p2 --> p3 --> p1
  end

  firstRow --> result(["baseUnitPrice"])

TODO (V-1, 発注者確認待ち): kind=2 (顧客 × 商品 base 単価) は kind=3 (得意先分類 × 商品 × 数量) より常に優先する現仕様で良いか、発注者確認待ち。顧客個別契約 (kind=2) が登録されている場合、数量階段割引 (kind=3) が一切効かない挙動になる。実装上の determinism / guest guard は #865 で解消済み。業務仕様の確認は #965 で追跡する。

「単価種別」(unitTypeCode) の意味

SMILE 単価マスタの header 単価種別 で、その行の単価をどう解釈するかを決める識別子 (SMILE 列位置は M、補足)。 正本: docs/03-implementation/smile/smile-master-xlsx-field-catalog.md 「単価種別」項。

code 意味 計算式 base 価格の取得元
0 単価 (override) COALESCE(quantityUnitPrice, unitPrice) をそのまま採用 row の 単価 / 数量別単価 (0 円も valid、SMILE 仕様)
1 標準売上単価×掛率 ProductVariant.price × unitRate / 100 ProductVariant.price (= 標準売上単価, sen)
2 標準仕入単価×掛率 (販売価格計算では使わない) import では SMILE code を保存し、販売価格計算では除外
3 上代単価×掛率 smileListPrice × unitRate / 100 ProductVariant.customFields.smileListPrice
未指定 legacy (旧 fixture) 0 と同じ扱い (suryou-tanka* 系には 単価種別 header が無い) row の 単価 / 数量別単価

'1' / '3' で base 価格が解決できない (掛率欠落・listPrice 不明など) 行は価格計算時に不採用とし、 specificity sort 上の次候補を評価する。import 時点では単価種別の業務解釈や販売用途判断を行わず、 SMILE code を正規化列へ保存する。

実装: packages/plugins/src/system-integration/smile/services/smile-price-master.service.tsfindBestPriceForVariant(SQL の ORDER BY 句に specificity が表現されている)。

詳細テスト: packages/plugins/src/system-integration/smile/services/__tests__/smile-price-master.priority.test.ts

3.x 丸めと量子化 (#864 H-2 / H-3 / V-3)

数量 (quantity) と SMILE rate 計算の丸めは以下を SSoT とする。

数量量子化 (H-2 / V-3)

  • 入口の正本: @ritsubi/domainnormalizeOrderQuantity (packages/domain/src/rules/quantity.ts)。
  • 仕様: Math.trunc で整数化し、最低数量 1 を保証する。SMILE 単価マスタは数量を整数前提で扱うため、 小数を Math.round でラウンドする旧実装 (commercial-rule-simulation.ts:1147) と Math.trunc で切り捨てる旧実装 (smile-price-master.service.ts:298) の二重実装を統一した。
  • 負数 / NaN / Infinity は silent に 1 化せず throw する (safety.md 準拠)。
  • allowFractional: true は将来の小数数量サポート用のフラグだが、現状の SMILE 連携では使わない。

SMILE rate 計算の丸め (H-3, TODO)

  • 現状: SMILE rate 計算 (unitTypeCode='1'/'3'base × unitRate / 100) は Math.round を採用している (smile-price-master.service.ts 内 2 箇所)。
  • TODO: SMILE 本体 (KIMONO/SMILE 業務 package) 側の丸め仕様 (truncate / round / banker's rounding のどれか) が 公式ドキュメントとして手元に揃っていない。将来 SMILE 出力との突合で差異が判明した時点で、 Math.round を SMILE 本体の挙動 (おそらく Math.trunc 系) に合わせる修正を行う。
  • それまでは現状の Math.round を維持する (1 円以内の差異であれば実害は限定的)。
  • 突合タスクは issue #864 H-3 を参照。

4. CommercialRule の優先度 (U2)

優先順 備考
isDefaultRate false 優先 specific rule が default rule より先
priority asc(小さい方が先) 同一 default 区分内
updatedAt desc tiebreaker

共通 sort 関数 sortRulesForEvaluation を SSoT とする (packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts)。 旧 sortRules は本セッションで sortRulesForEvaluation に統合済み。

4.1 Phase 1 (pricing) と Phase 2 (campaign) の振り分け

evaluateCommercialRulesrule.kind で 2 phase に分ける:

Phase 対象 rule 適用される action 反映先
Phase 1 kind !== "campaign" set_unit_price / multiply_unit_price / add_unit_amount line の finalUnitPrice を更新
Phase 2 kind === "campaign" add_order_amount / add_gift_items / select_gift_items orderAdjustments[] / plannedGiftItems[]

Phase 1 で resolutionMode === "exclusive" が打ち切った場合、Phase 2 は丸ごとスキップする (exclusiveAbortedFromPricing)。

4.2 action 種別の意味と適用先

action 適用される値 用途 フロア / 上限
set_unit_price floatChain を rounded 値で再初期化 tier 跨ぎの単価上書き -
multiply_unit_price current × multiplier 掛率乗算(例: 0.65 = 35% 引き) floatChain で誤差累積なし
add_unit_amount current + value 単価固定値加減算 Math.max(0, ...) で 0 円フロア
add_order_amount roundMoney(value) order 全体への調整 (cart 全体への割引 / 加算)。Order.surcharges に変換 -
add_gift_items productCode + quantity ギフト品の自動追加。syncGiftLines で order line として add line price は強制 0
select_gift_items benefit 選択 顧客が候補から選んだ gift を採用 line price は強制 0

実装: applyPricingPhase / applyCampaignPhase / evaluateAndDispatch / applySimulationRule (packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts)。

Vendure への反映は commercial-order-line.listener.ts:

engine 出力 Vendure 側 sync method
orderAdjustments[] Order.surcharges(commercial 用 SKU prefix で識別) syncOrderAdjustments
plannedGiftItems[] gift OrderLine (campaignMarker customField で識別) syncGiftLines
gift 補填 gift の元 line に対する compensation surcharge syncGiftCompensationSurcharges

5. 直送モード (U4)

項目 値 / 場所
フラグ Order.shippingMode (NORMAL / DIRECT)
商品側可否 ProductVariant.directShippingEligible
直送限定 ProductVariant.isDirectShippingOnly
算術 targetDirectPrice = listPrice * 1.1, surcharge = max(0, target - finalUnitPrice)
加算率定数 SHIPPING_RATES.DIRECT_SURCHARGE = 0.1(fallback。実運用値は Settings Store key shipping.directShippingSurchargeRate。SSoT: packages/domain/src/rules/shipping.ts + Settings Store)
編集 UI React Dashboard > 設定 > ◇ 配送設定/shipping-settings)。手数料率・通常 / 直送の最低送料・直送注意文言の 4 値を Settings Store に保存する(apps/vendure-server/src/config/custom-fields.ts の policy コメント通り GlobalSettings.customFields は使わない)
加算先 per-line の line total(Order レベル合計 field は持たない)
Storefront 表示 subtotal に内包
除外 gift line / directShippingEligible=false / 不整合 line

appliedActions の順序は smile_price_master → pricing actions → direct_shipping_surcharge で固定。

6. 呼び出し経路

経路 入口 用途
catalog RitsubiPriceCalculationStrategy.calculate 商品一覧 / 詳細表示
charged RitsubiPriceCalculationStrategy.calculateUnitPrice 受注ライン確定単価
simulation CommercialRuleService.evaluateCommercialState Dashboard Simulator
cart simulateActiveOrderState Storefront cart preview

経路差は 入力 builder と評価コンテキスト のみ。価格決定本体は engine pure な evaluateCommercialRules に集約しており、4 経路で同一結果が出ることをテストで担保している。

6.0 計算失敗時の方針 (catalog fallback / charged fail-closed)

calculateCatalogPricecalculation_failed(計算が throw)や missing_focus_line (engine invariant 違反, issue #866 M-5)を返したとき、経路ごとに方針を変える。 判断基準は 「誤った価格を表示する害」と「商品が表示されない害」のどちらが大きいか

  • catalog 経路(入口 RitsubiPriceCalculationStrategy.calculate。商品一覧 / 詳細 / 注文履歴 hydrate で共用): inputPrice(標準売上単価)へ fallback し、ERROR ログで観測する。calculate は throw すると GetFavorites / 商品一覧 / 注文詳細が丸ごと 500 になり、1 商品の価格失敗が画面全体を落とすため。
  • charged 経路(入口 RitsubiPriceCalculationStrategy.calculateUnitPrice。受注ライン確定単価): fail-closed (throw)。base price へ退避しない。誤単価で課金すると財務事故になるため、 refusing to use base price の structured log を残し運用検知する。
  • catalog 経路で fallback した場合も silent にはせず ERROR ログ(pricing.catalog.calculation_failed / pricing.catalog.missing_focus_line)で必ず観測する。恒常的に出るなら pricing rule / SMILE 単価の欠落を疑う。
  • 実装: ritsubi-price-calculation.strategy.tscalculate(catalog)と calculateUnitPrice(charged)。
  • 注: DefaultSearchPlugin の search 経路(visibility browse)は calculate を介さないため、 ここで価格計算 throw が起きても browse 自体は別系統。

6.1 Dashboard Simulator の表示カバレッジ

/commercial-rules/simulator (SimulationPage.tsx) は evaluateCommercialRules の出力を全段階で可視化する。詳細仕様は docs/specifications/2026-03-dashboard-commercial-rule-price-flow.md を参照。

表示セクション 表示内容 engine field 対応
入力コントロール 顧客 / line (variant + quantity) / shippingMode (NORMAL / DIRECT) CommercialEvaluationInput 構築
価格結果 (focus line) 上代 / SMILE 後 / 最終単価 / SMILE 下げ / rule 下げ / 行合計 focusLineResult.{listUnitPrice, baseUnitPrice, finalUnitPrice, smileBaseDiscount, ruleDiscount, lineFinalTotal}
価格遷移タイムライン 基準価格 → action 単位の前後単価変化 → 不採用候補 appliedActions[] + actionDecisions[]
適用 action テーブル 8 種 (smile_price_master / set / multiply / add_unit / add_order / 2 gift / direct_shipping_surcharge) appliedActions[]
ルール / tier 結果 成立 tier / 次 tier / tierMode (累積 or 最上位のみ) matchedRules[].{appliedTiers, nextTier, tierMode}
行別結果テーブル line ごとの上代 / SMILE 後 / 最終 / 数量 / 値引 lineResults[]
カート合計 値引前合計 / 行値引合計 / 注文調整合計 / 最終合計 (4-card) cartOriginalTotal / lineDiscountTotal / orderAdjustmentTotal / cartFinalTotal
注文調整 add_order_amount で生成された order-level 調整一覧 orderAdjustments[]
ギフト計画 add_gift_items / select_gift_items で計画された gift 一覧 plannedGiftItems[]

カート合計セクションは multi-line + order-level 調整 + 直送加算が混在するケースで 最終金額を 1 箇所で読み取れるよう、LineResultsSectionOrderEffectsSection の間に配置している。直送加算は per-line で finalUnitPrice に組み込まれるため、 cartFinalTotal に自動内包される (別カードにはしない)。

7. 型 (3 段モデル抜粋)

type PriceCalculationResult =
  | {
      status: "computed";
      listUnitPrice: number; // U0: 定価(上代)
      baseUnitPrice: number; // U3 出力: SMILE 単価マスタ結果
      finalUnitPrice: number; // U2 Phase 1 出力: CommercialRule pricing actions 適用後
      directShippingSurcharge?: number; // U4 出力: per-line 加算(DIRECT のみ)
      appliedActions: AppliedAction[];
    }
  | {
      status: "fallback_list_price";
      listUnitPrice: number;
      finalUnitPrice: number;
    }
  | { status: "calculation_failed"; reason: string };

engine 全体 (cart 単位) の出力は CommercialSimulationState で、上記 line 結果に加えて:

type CommercialSimulationState = {
  cartOriginalTotal: number; // Σ lineOriginalTotal (preview line を除く)
  cartFinalTotal: number; // lineFinalTotal + orderAdjustmentTotal
  lineDiscountTotal: number; // max(0, cartOriginalTotal - lineFinalTotal)
  orderAdjustmentTotal: number; // Σ orderAdjustments[].amount (U2 Phase 2 結果)
  lineResults: CommercialLineResult[];
  matchedRules: CommercialRuleMatch[];
  plannedGiftItems: PlannedGiftItem[]; // U2 Phase 2 結果: gift line 計画
  orderAdjustments: CommercialOrderAdjustment[]; // U2 Phase 2 結果: order レベル調整
};
  • ruleDiscount = baseUnitPrice - finalUnitPrice
  • 負値になり得るケース: 直送 surcharge は finalUnitPrice ではなく line total に加算するため、 finalUnitPrice 自体が listUnitPrice を超えることは通常起きない。ただし pricing action が 値上げ系であれば理論上 ruleDiscount < 0 も成立する(仕様としては許容)。

8. テスト構造

ファイル
engine pure packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.spec.ts
DTO parity packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.dto-entrypoint.spec.ts
直送組み合わせ packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.direct-shipping.spec.ts(本セッションで 13 件追加)
service 層 packages/plugins/src/rule-engine/commercial/services/price-calculation.service.spec.ts
strategy packages/plugins/src/rule-engine/commercial/strategies/ritsubi-price-calculation.strategy.spec.ts
SMILE specificity packages/plugins/src/system-integration/smile/services/__tests__/smile-price-master.priority.test.ts
Dashboard E2E apps/vendure-server/tests/dashboard-e2e/commercial-rules.spec.ts (simulator の cart 合計 / focus / actions 表示確認)

9. 関連ファイル

  • packages/plugins/src/rule-engine/commercial/services/price-calculation.service.ts
  • packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts
  • packages/plugins/src/rule-engine/commercial/services/commercial-rule.service.ts
  • packages/plugins/src/rule-engine/commercial/services/smile-base-resolver.ts
  • packages/plugins/src/rule-engine/commercial/strategies/ritsubi-price-calculation.strategy.ts
  • packages/plugins/src/rule-engine/commercial/listeners/commercial-order-line.listener.ts (Phase 2 結果の Vendure 反映)
  • packages/plugins/src/system-integration/smile/services/smile-price-master.service.ts
  • packages/plugins/src/shared/shipping.ts
  • packages/domain/src/rules/shipping.ts

9.1 SMILE import 履歴の breakdown 表示

SMILE 単価マスタ取り込み (SmileImportLogEntityimportType='PRICE' log) は tanka.txt / suryou-tanka.txt / suryou-tanka-bunrui.txt / suryou-tanka-tokuisaki.txt の 4 fixture を扱うが、Dashboard 履歴 UI 上では一律「単価」と表示されていて、中身の priceKindCode / unitTypeCode 分布が不可視だった。

SmileImportLogEntity に以下 2 つの jsonb フィールドを追加し、processor からは upsert 成功行のみ集計する:

  • priceKindBreakdown: Record<string, number> | null — 例: { "1": 100, "2": 50, "3": 200 }
  • unitTypeBreakdown: Record<string, number> | null — 例: { "0": 200, "1": 50, "3": 100 }

unitTypeCode は parser 側で必ず '0'/'1'/'2'/'3' のいずれかに正規化される (M列ヘッダが無い legacy fixture は '0' (固定単価) に default する)。skip / error 行は集計対象外。breakdown が NULL の旧 log は Dashboard 上で明示的に (未集計) と表示し、データ欠落を可視化する。

migration: apps/vendure-server/src/migrations/20260530004000_add_smile_import_log_breakdown.ts

10. 変更履歴メモ (本セッション)

  • sortRules の重複実装を sortRulesForEvaluation に統合し、commercial-rule-simulation.ts を SSoT 化。
  • SMILE base unit price resolver factory を CommercialRuleService.buildSmileBaseUnitPriceResolver に集約。
  • Order.directShippingSurcharge custom field を削除し、per-line 設計に統一。 対応 migration: 1780100500000_drop_order_direct_shipping_surcharge.ts
  • 直送組み合わせ regression を commercial-rule-simulation.direct-shipping.spec.ts に 13 件追加。