価格計算ロジック (実装正本)¶
Ritsubi における 商品価格の決定パイプライン をコード視点で 1 本にまとめた実装ガイド。
ドメイン仕様(業務ルール)の正本は docs/specifications/ 配下のため、本書はそこを参照しつつ
「どのファイルが何を計算するか」「経路はどこか」 を確定させる位置づけとする。
SSoT 参照¶
- 業務ルール正本:
docs/specifications/2026-03-dashboard-commercial-rule-price-flow.md - 商流ルールターゲティング:
docs/specifications/2026-05-commercial-rule-facet-targeting.md - 直送モード定義:
docs/glossary.md直送モード節 - 配送ゾーン / Carrier:
docs/specifications/2026-05-shipping-zone-and-carrier-rules.md
本書では業務ルールの再定義はしない。値の意味、優先度、フローチャートはここを正本にする。
1. 概要¶
5 軸¶
価格は次の 5 軸の組み合わせで決まる:
- 定価 (
listUnitPrice): 商品マスタの希望小売価格(上代) - 掛率 (rate): 顧客 × 商品の組で持つ。商品単独軸では持たず、
isDefaultRateフラグで default を表現 - 数量別掛率: 数量レンジごとの掛率
- 顧客別数量掛率:
(customer, product, quantityUpperBound)の単価行 - 売上掛率分類 (
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 2 は kind === "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 = false、isDirectShippingOnly不整合 line。 - 加算式は
targetDirectPrice = listPrice × 1.1、surcharge = 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.ts の
findBestPriceForVariant(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/domainのnormalizeOrderQuantity(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) の振り分け¶
evaluateCommercialRules は rule.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)¶
calculateCatalogPrice が calculation_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.tsのcalculate(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 箇所で読み取れるよう、LineResultsSection と OrderEffectsSection
の間に配置している。直送加算は 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.tspackages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.tspackages/plugins/src/rule-engine/commercial/services/commercial-rule.service.tspackages/plugins/src/rule-engine/commercial/services/smile-base-resolver.tspackages/plugins/src/rule-engine/commercial/strategies/ritsubi-price-calculation.strategy.tspackages/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.tspackages/plugins/src/shared/shipping.tspackages/domain/src/rules/shipping.ts
9.1 SMILE import 履歴の breakdown 表示¶
SMILE 単価マスタ取り込み (SmileImportLogEntity の importType='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.directShippingSurchargecustom field を削除し、per-line 設計に統一。 対応 migration:1780100500000_drop_order_direct_shipping_surcharge.ts。- 直送組み合わせ regression を
commercial-rule-simulation.direct-shipping.spec.tsに 13 件追加。