価格・送料計算マトリクス (SSOT)¶
注文ライン単価と注文合計を構成する全ての計算ステージを 1 箇所で管理する SSOT。実装が複数箇所に散らばっているため、ここを唯一の正本とし、各ファイルはこのマトリクスに従う。
関連 doc は pricing-system.md (商流ルール) と shipping-calculator.md (送料) だが、計算順序とどこに何を加算するかの正本はこのファイルとする。
計算順序とステージ¶
価格決定は 「商品単価ステージ → 注文合計ステージ → 税ステージ」 の 3 層で進む。早いステージで決まった値が後段の入力になる。
商品単価ステージは U1 → U3 → U2 → U4 の順:
- U1 基準価格 —
ProductVariant.price(上代) - U3 SMILE 単価マスタ (顧客 base) — 顧客の
customerCode/salesRateClassCode× SKU × 数量 で specificity 最高の 1 行を引き、finalUnitPriceの初期値とする - U2 Commercial Rule — U3 の結果に対して
multiply_unit_price/add_unit_amount/set_unit_priceを実行 - U4 直送加算 —
finalUnitPrice = max(finalUnitPrice, U1 上代 × 1.10)
originalUnitPrice (上代) は U1 のまま保持され、U4 の reference に使われる。finalUnitPrice だけが U3 → U2 → U4 を通過して変化する。
flowchart TD
subgraph UnitPriceStage["商品単価ステージ (Variant × 顧客 × 数量 × shippingMode)"]
direction TB
U1["U1 基準価格<br/>ProductVariant.price (上代)"]
U3["U3 SMILE 単価マスタ (顧客 base)<br/>得意先軸×数量軸で 1 行解決"]
U2["U2 Commercial Rule<br/>multiply / add / set on SMILE base"]
U4["U4 直送加算<br/>finalUnitPrice = max(finalUnitPrice, 上代×1.10)"]
U1 --> U3 --> U2 --> U4
end
subgraph OrderStage["注文合計ステージ"]
direction TB
O1["O1 ライン小計<br/>finalUnitPrice × quantity"]
O2["O2 注文小計<br/>Σ ライン小計"]
O3["O3 配送料<br/>都道府県別 + 送料無料閾値判定"]
O4["O4 注文調整 (add_order_amount)"]
O1 --> O2 --> O3 --> O4
end
subgraph TaxStage["税ステージ"]
direction TB
T1["T1 税率 10%<br/>tax_included または tax_excluded で適用"]
end
UnitPriceStage --> OrderStage --> TaxStage
ステージ別 SSOT 表¶
「実装 SSOT」列に書かれたファイルが唯一の実装場所。他で同じ計算をしている場合は重複/ドリフトとして列挙し、解消すべき。
U: 商品単価ステージ¶
| ID | ステージ | 入力 | 出力 | 計算式 | 適用条件 | 実装 SSOT | 関連テスト |
|---|---|---|---|---|---|---|---|
| U1 | 基準価格 | ProductVariant.price |
originalUnitPrice |
そのまま | 常時 | @vendure/core (Vendure 標準) |
— |
| U3 | SMILE 単価マスタ (顧客 base) | customer.customerCode, customer.salesRateClassCode, variant.sku, quantity |
finalUnitPrice 初期値, appliedActions[smile_price_master] |
specificity 順で 1 行決定 (詳細: pricing-system.md 「SMILE 単価マスタの検索順」)。originalUnitPrice (上代) は保持、finalUnitPrice だけが SMILE 価格に置換される |
SMILE master にヒットしたとき (PriceCalculationService.buildSmileResolver 経由) |
packages/plugins/src/system-integration/smile/services/smile-price-master.service.ts (findBestPriceForVariant) + commercial-rule-simulation.ts:buildSimulatedLines で適用 |
smile-price-master.priority.test.ts (unit) + commercial-rule-simulation.spec.ts (U3 base override) + scripts/ops/verify-smile-price-master-priority.sql (実 Postgres + 合成 fixture) + scripts/ops/verify-smile-price-master-real-csv.mjs (実 SMILE CSV 4 種 84,775 行を実 parser 経由で実 Postgres へ取り込み検証) |
| U2 | Commercial Rule 評価 | finalUnitPrice (U3 後), customer, cartLines, shippingMode |
finalUnitPrice, appliedActions[] |
multiply_unit_price / add_unit_amount / set_unit_price を tier / resolution に従って合成。掛率は SMILE base に掛かる |
pricing rule または campaign がマッチしたとき |
packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts |
commercial-rule-simulation.spec.ts |
| U4 | 直送加算 | originalUnitPrice (上代), finalUnitPrice (U3+U2 後), order.customFields.shippingMode |
finalUnitPrice |
targetDirectPrice = round(originalUnitPrice × (1 + 0.10)); finalUnitPrice = max(finalUnitPrice, targetDirectPrice)。U4 reference は 上代 (SMILE base ではない) |
shippingMode === "DIRECT" かつ variant が直送対応 |
packages/plugins/src/rule-engine/commercial/services/commercial-rule-simulation.ts:739-755 |
commercial-rule-simulation.spec.ts:637 |
U4 の重要原則:
- 加算先は 商品単価 であり、配送料でも注文小計でもない
- 顧客掛率 (U2) で割引された結果より
上代 × 1.10の方が高ければ、その差額をラインに加算する (max演算) - つまり直送時は必ず「上代 × 1.10」以上になる(顧客掛率・SMILE base より優先)
- U4 の reference は U1 (上代) のまま固定。SMILE が顧客 base を下げた場合でも、直送時はそこから上代×1.10 まで戻る
simulator と checkout の整合性:
- Dashboard
/commercial-rules/simulator経由のsimulateCommercialRulesGraphQL はCommercialRuleService.evaluateCommercialStateを呼び、U3 → U2 → U4 の完全パスを通る - Storefront / checkout 経路の
PriceCalculationService.calculateChargedPriceも同じパスなので、simulator 表示と実 checkout の価格は一致する
catalog (表示) と charged (実請求) の semantics 分離:
PriceCalculationService は目的別に 2 method を提供する。同じ U3 → U2 のロジックを共有しつつ、failure mode と shippingMode の扱いを型レベルで分離する。
| method | 経路 | shippingMode (U4) | 失敗時の挙動 | 戻り値 |
|---|---|---|---|---|
calculateChargedPrice |
order-line (実請求) | 入力に含める (U4 適用) | throw (silent fallback 禁止) | ChargedPriceResult (確定的な 3 段価格) |
calculateCatalogPrice |
catalog (PDP / 商品一覧) | 意図的に省略。CatalogPriceInput に shippingMode field 自体が存在しない |
status: "calculation_failed" を返す。silent fallback はしない |
CatalogPriceResult (computed / fallback_list_price / calculation_failed discriminator) |
calculateCatalogPrice が shippingMode を省略する理由: ProductVariantPriceCalculationStrategy.calculate は applyChannelPriceAndTax 経由で OrderService.findOne の最中にも呼ばれる。ここで shippingMode を解決するために getActiveOrderForUser を await すると findOne が再帰的に await されて async Promise deadlock になる。実請求側は calculateUnitPrice の order 引数から shippingMode を直接読めるので問題にならない。catalog の finalUnitPrice が「直送加算を含まない」のはこの設計判断の必然的な帰結。
CatalogPriceResult.status の使い分け:
computed: SMILE base または commercial rule のいずれかが効いた customer 別価格 (表示価値あり)fallback_list_price: 計算は成功したが SMILE miss + rule miss で finalUnitPrice == listPrice。区別したい caller は status で分岐calculation_failed: 計算が throw した。error: SerializedErrorが必ず添付され、RitsubiPriceCalculationStrategy.logCatalogCalculationFailureがpricing.catalog.calculation_failedカテゴリの構造化 warn ログ (Sentry breadcrumb 互換 JSON) を出す。Strategy 側は inputPrice (上代) に fallback する
O: 注文合計ステージ¶
| ID | ステージ | 入力 | 出力 | 計算式 | 適用条件 | 実装 SSOT |
|---|---|---|---|---|---|---|
| O1 | ライン小計 | finalUnitPrice, quantity |
lineTotal |
finalUnitPrice × quantity |
常時 | @vendure/core |
| O2 | 注文小計 (subTotal) |
lineTotal[] |
subTotal |
Σ lineTotal |
常時 | @vendure/core |
| O3 | 配送料 | subTotal, shippingAddress.province, shippingMode, customerStatuses |
shippingFee |
(1) 都道府県別配送料を解決 (2) subTotal >= freeShippingThreshold なら 0 |
常時 | packages/plugins/src/rule-engine/shipping/services/shipping-calculator.service.ts |
| O4 | 注文調整 | commercial rule の add_order_amount |
orderAdjustment |
rule 単位の固定額。productCode 指定で仮想商品行として SMILE 注文 CSV に出力 |
rule がマッチしたとき | packages/plugins/src/rule-engine/commercial/ |
O3 の重要原則:
- 直送加算は O3 (配送料) には乗らない。直送は U4 で商品単価に反映済み
shipping-calculator.service.tsはshippingModeを参照しない。送料は都道府県別配送料と送料無料閾値判定のみで決まる
T: 税ステージ¶
| ID | ステージ | 入力 | 出力 | 計算式 | 実装 SSOT |
|---|---|---|---|---|---|
| T1 | 消費税 | lineTotal[], shippingFee, orderAdjustment |
tax, total |
10% (taxRate: 10) |
@vendure/core + shipping-calculator.service.ts:71 |
既知の実装ドリフト¶
このマトリクスを正本とした時点での実装の食い違いを列挙する。解消されたら行を削除する。
現在、未解消のドリフトは無い(2026-05-26 D1 解消、D2 は誤診で取り下げ、D3/D4 は doc 修正済み)。新しいドリフトが見つかったらここに追記する。
解消履歴¶
- D1 (2026-05-26 解消):
shipping-calculator.service.ts:51-55のshippingFee += shippingFee × 0.10二重加算を削除。直送加算は U4 のみで反映される。テストshipping-calculator.service.spec.tsに「DIRECT モードでも shippingFee は加算されない」回帰テストを追加。 - D2 (誤診で取り下げ):
shared/shipping.tsのsurchargeRateフィールドは storefront cart-summary 表示用 GraphQL 契約として必要であり、業務ロジックの重複ではない。SHIPPING_RATES.DIRECT_SURCHARGEという単一定数を U4 計算と表示が共に read しているだけ。 - D3 / D4 (doc 修正済み):
shipping-calculator.mdの「注文小計 × 加算率」記述を本マトリクスへのリンクに置換、pricing-system.md関連資料の先頭に本マトリクスを追加。
入力依存マトリクス¶
各ステージがどの入力に依存するかの早見表。仕様変更時の影響範囲確認に使う。
| 入力 | U1 | U2 | U3 | U4 | O3 | O4 |
|---|---|---|---|---|---|---|
ProductVariant.price |
● | ● | ● (fallback 起点) | ● | — | — |
ProductVariant.sku |
— | — | ● | — | — | — |
ProductVariant.customFields.isDirectShippingOnly |
— | — | — | ● | — | — |
customer.customFields.customerCode |
— | ● | ● | — | — | — |
customer.customFields.salesRateClassCode |
— | ● | ● | — | — | — |
customer.customFields.customerStatus |
— | ● | — | — | ● (送料無料閾値) | — |
order.customFields.shippingMode |
— | ● (rule 条件で使う場合あり) | — | ● | — | — |
order.shippingAddress.province |
— | — | — | — | ● | — |
quantity |
— | ● (数量条件) | ● (quantityUpperBound) |
— | — | — |
マッチした commercial_rule / campaign |
— | ● | — | — | — | ● |
参照規約¶
- 新しい価格・送料関連の計算を追加する際は、まず本マトリクスに ID と SSOT 実装位置を登録する
- 既存ステージの計算式を変更する際は、SSOT ファイルだけを編集し、テスト (上表「関連テスト」列) を更新する
- 既知ドリフト D1〜D4 を解消したら、その行を削除する (DON'T 「修正済み」とだけ書いて残さない)
関連 doc¶
- 商流ルール (pricing-system) — SMILE 単価マスタの検索順、Commercial Rule の action 一覧
- 送料計算 (shipping-calculator) — 都道府県別配送料、送料無料閾値
- SMILE 連携 (smile-integration) — 単価マスタの取り込み・スコープ