「当時の価格」を再現できないと何が困るか
商品の価格は変わる。セール期間中は下がり、原材料高騰で値上げされ、需要に応じて動的に変化する。
問題はデータベースの設計だ。多くのシステムでは商品マスタの 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_id | product_id | price | valid_from | valid_to |
|---|---|---|---|---|
| PH001 | P001 | 1,800 | 2023-01-01 00:00:00 | 2024-03-31 23:59:59 |
| PH002 | P001 | 1,980 | 2024-04-01 00:00:00 | 2024-07-31 23:59:59 |
| PH003 | P001 | 1,680 | 2024-08-01 00:00:00 | NULL |
P001 は2024年4月に値上げ(1,800→1,980円)、8月にセール価格(1,680円)に改定された。
STEP 1 ― 価格履歴テーブルを LEAD で整備する
valid_to が明示的に管理されていない場合(valid_from だけ記録している場合)、まず LEAD で valid_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_id | price | valid_from | valid_to | is_current |
|---|---|---|---|---|
| P001 | 1,800 | 2023-01-01 00:00:00 | 2024-03-31 23:59:59 | 0 |
| P001 | 1,980 | 2024-04-01 00:00:00 | 2024-07-31 23:59:59 | 0 |
| P001 | 1,680 | 2024-08-01 00:00:00 | NULL | 1 |
STEP 2 ― 注文明細と価格履歴を AS-OF JOIN する
注文日時(ordered_at)が価格の有効期間(valid_from ≤ ordered_at ≤ valid_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_id | product_id | product_name | ordered_at | quantity | unit_price_at_order | line_total |
|---|---|---|---|---|---|---|
| O101 | P001 | オーガニックシャンプー | 2024-01-15 10:30:00 | 2 | 1,800 | 3,600 |
| O284 | P001 | オーガニックシャンプー | 2024-05-20 14:22:00 | 1 | 1,980 | 1,980 |
| O412 | P001 | オーガニックシャンプー | 2024-09-08 21:45:00 | 3 | 1,680 | 5,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_id | price_version | unit_price | total_quantity | total_sales | quantity_change_pct |
|---|---|---|---|---|---|
| P001 | 1(1,800円) | 1,800 | 4,812 | 8,661,600 | NULL |
| P001 | 2(1,980円) | 1,980 | 3,241 | 6,417,180 | -32.7 |
| P001 | 3(1,680円) | 1,680 | 6,184 | 10,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_history と order_items のJOINは、範囲条件(>= と <)を含む非等値JOINになる。等値JOINと比べてハッシュ結合が効きにくく、ネステッドループになることがある。商品数・価格改定回数が少なければ問題ないが、動的価格(毎日価格が変わるフラッシュセール等)の場合は price_history の件数が膨大になり、パフォーマンスに注意が必要だ。
まとめ
今回のクエリの核心を振り返る。
LEAD(valid_from) - 1秒でvalid_toを補完し、価格の有効期間を整備するordered_at >= valid_from AND (ordered_at < valid_to OR valid_to IS NULL)のAS-OF JOINで注文時点の単価を取得するprice_versionとLAGを組み合わせて価格改定前後の売上・数量変化率を計算するLEFT JOIN + IS NULLで**JOIN漏れ(価格履歴の欠落)**を検知する