コンテンツにスキップ

価格・送料計算マトリクス (SSOT)

注文ライン単価と注文合計を構成する全ての計算ステージを 1 箇所で管理する SSOT。実装が複数箇所に散らばっているため、ここを唯一の正本とし、各ファイルはこのマトリクスに従う。

関連 doc は pricing-system.md (商流ルール) と shipping-calculator.md (送料) だが、計算順序とどこに何を加算するかの正本はこのファイルとする。

計算順序とステージ

価格決定は 「商品単価ステージ → 注文合計ステージ → 税ステージ」 の 3 層で進む。早いステージで決まった値が後段の入力になる。

商品単価ステージは U1 → U3 → U2 → U4 の順:

  1. U1 基準価格ProductVariant.price (上代)
  2. U3 SMILE 単価マスタ (顧客 base) — 顧客の customerCode / salesRateClassCode × SKU × 数量 で specificity 最高の 1 行を引き、finalUnitPrice の初期値とする
  3. U2 Commercial Rule — U3 の結果に対して multiply_unit_price / add_unit_amount / set_unit_price を実行
  4. 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 経由の simulateCommercialRules GraphQL は 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 / 商品一覧) 意図的に省略CatalogPriceInputshippingMode field 自体が存在しない status: "calculation_failed" を返す。silent fallback はしない CatalogPriceResult (computed / fallback_list_price / calculation_failed discriminator)

calculateCatalogPriceshippingMode を省略する理由: ProductVariantPriceCalculationStrategy.calculateapplyChannelPriceAndTax 経由で OrderService.findOne の最中にも呼ばれる。ここで shippingMode を解決するために getActiveOrderForUser を await すると findOne が再帰的に await されて async Promise deadlock になる。実請求側は calculateUnitPriceorder 引数から 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.logCatalogCalculationFailurepricing.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.tsshippingMode を参照しない。送料は都道府県別配送料と送料無料閾値判定のみで決まる

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-55shippingFee += shippingFee × 0.10 二重加算を削除。直送加算は U4 のみで反映される。テスト shipping-calculator.service.spec.ts に「DIRECT モードでも shippingFee は加算されない」回帰テストを追加。
  • D2 (誤診で取り下げ): shared/shipping.tssurchargeRate フィールドは 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