本文へ移動
SQL道場 更新日: 2026年5月2日 約19分で読めます

価格改定履歴をSQLで扱う方法|注文時点の正しい単価を取得するクエリ


「当時の価格」を再現できないと何が困るか

商品の価格は変わる。セール期間中は下がり、原材料高騰で値上げされ、需要に応じて動的に変化する。

問題はデータベースの設計だ。多くのシステムでは商品マスタの price カラムを直接上書き更新する。すると注文明細に「注文時の価格」が記録されていない場合、後から「あの注文の正しい売上はいくらか」を計算できなくなる。

具体的にどんな困り事が起きるか。

  • 3ヶ月前の売上を再集計しようとしたら、現在の価格で計算されていた
  • 価格改定前後の売上を比較しようとしたが、基準が揃わない
  • 顧客からの「あのとき何円で買ったか教えてほしい」という問い合わせに答えられない

正しい解決策は2つある。
注文明細に単価を記録しておく(注文時点での保存)か、
価格改定履歴テーブルを持ち、注文日と突き合わせて当時の価格を再現する(AS-OF クエリ)だ。


テーブル設計の選択肢を整理する

設計A:注文明細に単価を保存する(推奨)

-- order_items テーブルに unit_price_at_order カラムを持つ
-- order_id, product_id, quantity, unit_price_at_order(注文時点の単価)

これが最もシンプルかつ確実だ。注文が確定した瞬間に単価を書き込むため、後から価格が変わっても影響を受けない。

しかし現実には「単価カラムがなく、商品マスタの現在価格しかない」システムが多い。あるいは「単価は保存しているが、キャンペーン割引や会員値引きが考慮されておらず、実際の請求額と合わない」ケースもある。

設計B:価格改定履歴テーブルを使う(今回のテーマ)

-- product_price_history(価格改定履歴:Type 2 SCD)
-- product_id   : 商品ID
-- price        : 価格
-- valid_from   : この価格が有効になった日時
-- valid_to     : この価格が無効になった日時(NULLは現在有効)

注文日時をこの有効期間に突き合わせれば「注文時点の価格」が分かる。


使用するテーブル

-- products(商品マスタ)
-- product_id   : 商品ID
-- product_name : 商品名
-- category     : カテゴリ

-- product_price_history(価格改定履歴)
-- price_id     : 価格履歴ID(主キー)
-- product_id   : 商品ID
-- price        : 価格(円)
-- valid_from   : 有効開始日時(TIMESTAMP)
-- valid_to     : 有効終了日時(TIMESTAMP、NULLは現在有効)
-- changed_by   : 変更者

-- orders(注文テーブル)
-- order_id     : 注文ID
-- customer_id  : 顧客ID
-- ordered_at   : 注文日時(TIMESTAMP)
-- status       : 'completed' / 'cancelled'

-- order_items(注文明細)
-- order_id     : 注文ID
-- product_id   : 商品ID
-- quantity     : 数量
-- (unit_price カラムがない、または信頼できない前提)

price_history のイメージ(商品 P001)

price_idproduct_idpricevalid_fromvalid_to
PH001P0011,8002023-01-01 00:00:002024-03-31 23:59:59
PH002P0011,9802024-04-01 00:00:002024-07-31 23:59:59
PH003P0011,6802024-08-01 00:00:00NULL

P001 は2024年4月に値上げ(1,800→1,980円)、8月にセール価格(1,680円)に改定された。


STEP 1 ― 価格履歴テーブルを LEAD で整備する

valid_to が明示的に管理されていない場合(valid_from だけ記録している場合)、まず LEADvalid_to を計算する。

-- STEP1: valid_fromだけの履歴からvalid_toをLEADで補完する

WITH price_history_with_valid_to AS (
    SELECT
        price_id,
        product_id,
        price,
        valid_from,

        -- 次のレコードのvalid_fromの1秒前がvalid_to
        DATE_ADD('second', -1,
            LEAD(valid_from) OVER (
                PARTITION BY product_id
                ORDER BY valid_from ASC
            )
        )  AS valid_to,

        -- 最新レコードか
        CASE
            WHEN LEAD(valid_from) OVER (
                PARTITION BY product_id
                ORDER BY valid_from ASC
            ) IS NULL
            THEN 1 ELSE 0
        END  AS is_current
    FROM product_price_history
)
SELECT *
FROM price_history_with_valid_to
ORDER BY product_id, valid_from;

出力イメージ(P001)

product_idpricevalid_fromvalid_tois_current
P0011,8002023-01-01 00:00:002024-03-31 23:59:590
P0011,9802024-04-01 00:00:002024-07-31 23:59:590
P0011,6802024-08-01 00:00:00NULL1

STEP 2 ― 注文明細と価格履歴を AS-OF JOIN する

注文日時(ordered_at)が価格の有効期間(valid_fromordered_atvalid_to)に収まるレコードを結合する。

-- STEP2: 注文時点の単価を取得する(AS-OF JOIN)

WITH price_history AS (
    SELECT
        product_id,
        price,
        valid_from,
        DATE_ADD('second', -1,
            LEAD(valid_from) OVER (
                PARTITION BY product_id
                ORDER BY valid_from ASC
            )
        )  AS valid_to
    FROM product_price_history
)
SELECT
    oi.order_id,
    oi.product_id,
    p.product_name,
    o.ordered_at,
    oi.quantity,
    ph.price                                    AS unit_price_at_order,
    oi.quantity * ph.price                      AS line_total
FROM order_items   oi
JOIN orders        o   ON oi.order_id    = o.order_id
JOIN products      p   ON oi.product_id  = p.product_id
-- 注文日時が価格の有効期間内に収まるレコードを結合
JOIN price_history ph
    ON  oi.product_id  = ph.product_id
    AND o.ordered_at   >= ph.valid_from
    AND (
        o.ordered_at   <  ph.valid_to      -- valid_toが存在する場合
        OR ph.valid_to IS NULL              -- または現在有効(valid_toがない)
    )
WHERE o.status = 'completed'
ORDER BY oi.order_id, oi.product_id;

出力イメージ

order_idproduct_idproduct_nameordered_atquantityunit_price_at_orderline_total
O101P001オーガニックシャンプー2024-01-15 10:30:0021,8003,600
O284P001オーガニックシャンプー2024-05-20 14:22:0011,9801,980
O412P001オーガニックシャンプー2024-09-08 21:45:0031,6805,040

同じP001でも注文日によって単価が正しく変わっている。O101は1,800円(値上げ前)、O284は1,980円(値上げ後)、O412は1,680円(セール価格)だ。


STEP 3 ― 価格改定前後の売上を比較する(完成版)

価格改定の効果を測る。「価格を変えたことで売上・数量はどう変わったか」を同一商品で比較する。

-- STEP3: 価格改定前後の売上・数量比較

WITH price_history AS (
    SELECT
        product_id,
        price,
        valid_from,
        DATE_ADD('second', -1,
            LEAD(valid_from) OVER (
                PARTITION BY product_id
                ORDER BY valid_from ASC
            )
        )  AS valid_to,
        ROW_NUMBER() OVER (
            PARTITION BY product_id
            ORDER BY valid_from ASC
        )  AS price_version  -- 価格改定の世代番号
    FROM product_price_history
),
order_with_price AS (
    SELECT
        oi.product_id,
        ph.price                    AS unit_price,
        ph.price_version,
        ph.valid_from               AS price_valid_from,
        ph.valid_to                 AS price_valid_to,
        oi.quantity,
        oi.quantity * ph.price      AS line_total
    FROM order_items   oi
    JOIN orders        o   ON oi.order_id   = o.order_id
    JOIN price_history ph
        ON  oi.product_id  = ph.product_id
        AND o.ordered_at   >= ph.valid_from
        AND (o.ordered_at  <  ph.valid_to OR ph.valid_to IS NULL)
    WHERE o.status = 'completed'
)
SELECT
    product_id,
    price_version,
    unit_price,
    price_valid_from,
    price_valid_to,

    -- 集計
    COUNT(*)                        AS order_line_count,
    SUM(quantity)                   AS total_quantity,
    SUM(line_total)                 AS total_sales,
    ROUND(AVG(quantity), 2)         AS avg_quantity_per_order,

    -- 前バージョンとの数量比較(LAG で前世代を参照)
    LAG(SUM(quantity)) OVER (
        PARTITION BY product_id
        ORDER BY price_version ASC
    )  AS prev_version_quantity,

    -- 数量の変化率
    ROUND(
        (SUM(quantity) - LAG(SUM(quantity)) OVER (
            PARTITION BY product_id ORDER BY price_version ASC
        )) * 100.0
        / NULLIF(LAG(SUM(quantity)) OVER (
            PARTITION BY product_id ORDER BY price_version ASC
        ), 0)
    , 1)  AS quantity_change_pct
FROM order_with_price
GROUP BY product_id, price_version, unit_price, price_valid_from, price_valid_to
ORDER BY product_id, price_version;

出力イメージ(P001)

product_idprice_versionunit_pricetotal_quantitytotal_salesquantity_change_pct
P0011(1,800円)1,8004,8128,661,600NULL
P0012(1,980円)1,9803,2416,417,180-32.7
P0013(1,680円)1,6806,18410,389,120+90.8

1,800円から1,980円への値上げで数量が32.7%減少した。その後1,680円のセール価格では一気に90.8%増加している。価格弾力性が高い商品であることが数字で示される。


STEP 4 ― 結合できなかった注文を検知する(データ品質チェック)

AS-OF JOIN は価格履歴に「その注文日時をカバーする期間」が存在しない場合、行が結合されない(JOIN漏れ)。これは LEFT JOIN + WHERE IS NULL で検知できる。

-- STEP4: 価格履歴が存在しない注文明細を検知する

WITH price_history AS (
    SELECT
        product_id, price, valid_from,
        DATE_ADD('second', -1,
            LEAD(valid_from) OVER (
                PARTITION BY product_id ORDER BY valid_from ASC
            )
        )  AS valid_to
    FROM product_price_history
)
SELECT
    oi.order_id,
    oi.product_id,
    o.ordered_at,
    '価格履歴なし'  AS issue
FROM order_items   oi
JOIN orders        o   ON oi.order_id   = o.order_id
LEFT JOIN price_history ph
    ON  oi.product_id = ph.product_id
    AND o.ordered_at  >= ph.valid_from
    AND (o.ordered_at <  ph.valid_to OR ph.valid_to IS NULL)
WHERE
    o.status  = 'completed'
    AND ph.product_id IS NULL  -- JOINできなかった行
ORDER BY o.ordered_at;

JOIN 漏れが発生する主な原因は次の3つだ。

  • 価格履歴テーブルへの登録漏れ(新商品の価格が未登録)
  • 注文日時が価格の valid_from より前(価格設定前に注文が入った)
  • タイムゾーンのずれ(注文はJSTだが価格はUTCで格納されているなど)

この検知クエリを定期実行しておくと、分析精度の問題を早期に発見できる。


実務での設計判断:いつ AS-OF JOIN を使うか

状況推奨アプローチ
新規システムを設計する注文明細に unit_price_at_order を保存する(設計A)。シンプルで確実
既存システムに price_history があるAS-OF JOIN(設計B)で注文時点の価格を再現する
price_history すら存在しない現在価格しか取得できない。分析の限界として明示し、今後の履歴保持を設計する
セール・クーポン割引が絡む定価の AS-OF JOIN では不十分。注文確定時の実請求額を別途保存する設計が必須

最も堅牢なのは「設計A(注文確定時に単価を保存)と設計B(価格履歴テーブル)の両方を持つ」ことだ。注文明細の単価を検証・修正する際に価格履歴が役立つ。


実務での運用ヒント

valid_from を「秒」より細かい精度で管理する

セールが深夜0時ちょうどに始まる場合、valid_from = '2024-08-01 00:00:00' だと、その秒に注文した顧客がセール価格の対象か否かで微妙な問題が起きる。実務では valid_from をマイクロ秒まで管理するか、「セール開始は0:00:01から」とシステム側で1秒余裕を持たせる運用が多い。

② 価格が複数の軸で変わる場合

「商品価格」「会員ランク別割引価格」「地域別価格」が別々に存在するシステムでは、AS-OF JOINが複数回必要になる。CTEを分けて段階的に適用し、最終的に LEAST(通常価格, 会員価格, ...) などで最終適用価格を決定する設計になる。

③ Treasure Dataでの実行コスト

product_price_historyorder_items のJOINは、範囲条件(>=<)を含む非等値JOINになる。等値JOINと比べてハッシュ結合が効きにくく、ネステッドループになることがある。商品数・価格改定回数が少なければ問題ないが、動的価格(毎日価格が変わるフラッシュセール等)の場合は price_history の件数が膨大になり、パフォーマンスに注意が必要だ。


まとめ

今回のクエリの核心を振り返る。

  1. LEAD(valid_from) - 1秒valid_to を補完し、価格の有効期間を整備する
  2. ordered_at >= valid_from AND (ordered_at < valid_to OR valid_to IS NULL)AS-OF JOINで注文時点の単価を取得する
  3. price_versionLAG を組み合わせて価格改定前後の売上・数量変化率を計算する
  4. LEFT JOIN + IS NULL で**JOIN漏れ(価格履歴の欠落)**を検知する

次のアクション

SQLやデータ活用を、手元で試しながら理解する

記事で読んだ考え方を、SQL練習場や関連カテゴリの記事でさらに深掘りできます。相談やご依頼もお問い合わせページから受け付けています。

SQL練習場で試す お問い合わせ

この記事を書いた人:martechfarm

Treasure Data Top Lapidarist Award受賞。

SQL / Digdag / Python / CDP設計 / CRM設計を横断し、企業のデータ活用を支援。

実績・支援内容を見る →

MarTech Farmをもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む