クーポンは「売上を生んでいる」のか「売上を前借りしている」のか
クーポンを発行すると、たいていその週の売上は上がる。でもこの問いに答えられるだろうか。
「そのクーポンがなかったとしても、その顧客は買っていたのではないか」
クーポンの本当のコストは「割引額」だけではない。クーポンがなくても買う予定だった顧客への割引は、純粋なコストだ。逆に「クーポンがあったから初めて買った」顧客への割引は、新規獲得コストとして正当化できる。
この2つを混同したまま「クーポンの効果」を語っても意味がない。
この記事では、クーポンの使用率・回収率・実質的な売上貢献額をSQLで計算し、クーポン施策が本当に利益に貢献しているかを検証するクエリを段階的に作っていく。
クーポン分析で見るべき5つの指標
クエリを書く前に、何を測るかを整理しておく。
| 指標 | 計算式 | 意味 |
|---|---|---|
| 発行数 | 発行したクーポンの総数 | 施策のリーチ規模 |
| 使用率 | 使用数 ÷ 発行数 | クーポンが使われた割合 |
| 使用時売上 | クーポン使用注文の合計金額(割引前) | 紐づいた総売上 |
| 割引総額 | 使用されたクーポンの割引額合計 | 実際のコスト |
| 回収率 | 使用時売上 ÷ 割引総額 | 1円の割引が何円の売上を生んだか |
回収率が最重要指標だ。回収率10倍なら「100円の割引が1,000円の売上を生んでいる」ことを意味する。回収率が1倍を下回れば割引コストが売上を上回っており、施策として成立していない。
使用するテーブル
今回は3つのテーブルを使う。
-- coupons(クーポンマスタ)
-- coupon_id : クーポンID(主キー)
-- coupon_code : クーポンコード
-- campaign_id : 紐づくキャンペーンID
-- discount_type : 'fixed'(固定額)/ 'rate'(割引率)
-- discount_value : 割引額(fixed)または割引率(rate、0〜1の小数)
-- issued_at : 発行日時
-- customer_id : 発行対象の顧客ID(NULL なら汎用クーポン)
-- expires_at : 有効期限
-- orders(注文テーブル)
-- order_id : 注文ID
-- customer_id : 顧客ID
-- order_date : 注文日
-- total_amount : 注文金額(割引後)
-- original_amount : 注文金額(割引前)
-- coupon_id : 使用したクーポンID(NULL なら未使用)
-- status : 'completed' / 'cancelled'
-- campaigns(キャンペーンマスタ)
-- campaign_id : キャンペーンID
-- campaign_name : キャンペーン名
-- start_date : 開始日
-- end_date : 終了日
STEP 1 ― クーポン使用状況の基礎集計
まずクーポンごとに「発行されたか」「使われたか」を1行にまとめる。
-- STEP1: クーポンの発行・使用状況を把握する
WITH coupon_usage AS (
SELECT
c.coupon_id,
c.coupon_code,
c.campaign_id,
c.discount_type,
c.discount_value,
c.customer_id AS issued_to,
CAST(c.issued_at AS DATE) AS issued_date,
CAST(c.expires_at AS DATE) AS expires_date,
-- 注文テーブルと紐づける(使用された注文)
o.order_id,
o.order_date,
o.original_amount,
o.total_amount,
-- 割引額を計算(fixedはそのまま、rateは元値から計算)
CASE
WHEN c.discount_type = 'fixed'
THEN c.discount_value
WHEN c.discount_type = 'rate'
THEN ROUND(o.original_amount * c.discount_value, 0)
ELSE 0
END AS discount_amount,
-- 使用フラグ
CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END AS is_used
FROM coupons c
LEFT JOIN orders o
ON c.coupon_id = o.coupon_id
AND o.status = 'completed'
)
SELECT *
FROM coupon_usage
ORDER BY issued_date, coupon_id;
出力イメージ
| coupon_code | discount_type | discount_value | order_id | original_amount | discount_amount | is_used |
|---|---|---|---|---|---|---|
| SUMMER10 | rate | 0.10 | 1089 | 8,000 | 800 | 1 |
| SUMMER10 | rate | 0.10 | NULL | NULL | NULL | 0 |
| WINTER500 | fixed | 500 | 1102 | 5,500 | 500 | 1 |
| WINTER500 | fixed | 500 | NULL | NULL | NULL | 0 |
| VIP2000 | fixed | 2000 | 1145 | 18,000 | 2,000 | 1 |
STEP 2 ― キャンペーン別の使用率・回収率を集計する
STEP1 のデータを使って、キャンペーン単位に集約する。
-- STEP2: キャンペーン別クーポン効果サマリ
WITH coupon_usage AS (
SELECT
c.coupon_id,
c.campaign_id,
CAST(c.issued_at AS DATE) AS issued_date,
o.order_id,
o.order_date,
o.original_amount,
o.total_amount,
CASE
WHEN c.discount_type = 'fixed' THEN c.discount_value
WHEN c.discount_type = 'rate' THEN ROUND(o.original_amount * c.discount_value, 0)
ELSE 0
END AS discount_amount,
CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END AS is_used
FROM coupons c
LEFT JOIN orders o
ON c.coupon_id = o.coupon_id
AND o.status = 'completed'
)
SELECT
cu.campaign_id,
ca.campaign_name,
-- 発行数・使用数・使用率
COUNT(cu.coupon_id) AS issued_count,
SUM(cu.is_used) AS used_count,
ROUND(SUM(cu.is_used) * 100.0
/ NULLIF(COUNT(cu.coupon_id), 0), 1) AS usage_rate_pct,
-- 売上・割引額
COALESCE(SUM(cu.original_amount), 0) AS gross_sales,
COALESCE(SUM(cu.discount_amount), 0) AS total_discount,
COALESCE(SUM(cu.total_amount), 0) AS net_sales,
-- 回収率(売上 ÷ 割引額)
ROUND(
COALESCE(SUM(cu.original_amount), 0) * 1.0
/ NULLIF(COALESCE(SUM(cu.discount_amount), 0), 0)
, 1) AS recovery_rate,
-- 使用1件あたりの平均注文金額(割引前)
ROUND(
COALESCE(SUM(cu.original_amount), 0)
/ NULLIF(SUM(cu.is_used), 0)
, 0) AS avg_order_value_per_use
FROM coupon_usage cu
JOIN campaigns ca ON cu.campaign_id = ca.campaign_id
GROUP BY cu.campaign_id, ca.campaign_name
ORDER BY recovery_rate DESC;
完成した出力イメージ
| campaign_name | issued_count | used_count | usage_rate_pct | gross_sales | total_discount | recovery_rate |
|---|---|---|---|---|---|---|
| 誕生日クーポン2000円OFF | 892 | 534 | 59.9 | 12,816,000 | 1,068,000 | 12.0 |
| VIP限定10%OFF | 1,240 | 868 | 70.0 | 18,228,000 | 1,822,800 | 10.0 |
| 新規登録500円OFF | 3,840 | 1,344 | 35.0 | 10,752,000 | 672,000 | 16.0 |
| 全顧客一律15%OFF | 18,200 | 5,460 | 30.0 | 32,760,000 | 4,914,000 | 6.7 |
回収率の差が歴然だ。
「新規登録500円OFF」は回収率16倍で、1円の割引が16円の売上を生んでいる。「全顧客一律15%OFF」は回収率6.7倍と低く、しかも割引総額が約490万円と最大だ。「全顧客に一律割引」という設計の非効率さが数字に出ている。
STEP 3 ― 「クーポンがなくても買った顧客」を除いたネット効果を測る
ここからが本記事の核心だ。
クーポン使用顧客の中には「クーポンがなくても買う予定だった顧客」が必ず混在する。それを取り除いてクーポンによる純増売上を推計する。
考え方はシンプルだ。クーポン使用者の中で「過去30日以内に購買歴がある顧客」は、クーポンがなくても近いうちに購買した可能性が高い。これをコントロールグループ的な視点で分離する。
-- STEP3: クーポン起因の「新規行動」と「元々買う予定だった行動」を分離
WITH coupon_usage AS (
SELECT
c.coupon_id,
c.campaign_id,
c.customer_id AS issued_to,
o.order_id,
o.customer_id AS purchaser_id,
o.order_date,
o.original_amount,
CASE
WHEN c.discount_type = 'fixed' THEN c.discount_value
WHEN c.discount_type = 'rate' THEN ROUND(o.original_amount * c.discount_value, 0)
ELSE 0
END AS discount_amount,
CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END AS is_used
FROM coupons c
LEFT JOIN orders o
ON c.coupon_id = o.coupon_id
AND o.status = 'completed'
),
-- クーポン使用日の30日前までに購買歴があるかを確認
prior_purchase_check AS (
SELECT
cu.coupon_id,
cu.order_id,
cu.purchaser_id,
cu.order_date,
cu.original_amount,
cu.discount_amount,
-- 30日以内に購買歴があれば「元々買う予定だった可能性が高い」
CASE
WHEN EXISTS (
SELECT 1
FROM orders prev
WHERE prev.customer_id = cu.purchaser_id
AND prev.status = 'completed'
AND prev.order_date >= DATE_ADD('day', -30, cu.order_date)
AND prev.order_date < cu.order_date
) THEN '直近購買あり(自然購買の可能性)'
ELSE '直近購買なし(クーポン誘引の可能性)'
END AS purchase_likelihood
FROM coupon_usage cu
WHERE cu.is_used = 1
)
SELECT
purchase_likelihood,
COUNT(order_id) AS order_count,
SUM(original_amount) AS gross_sales,
SUM(discount_amount) AS total_discount,
ROUND(AVG(original_amount), 0) AS avg_order_value
FROM prior_purchase_check
GROUP BY purchase_likelihood
ORDER BY purchase_likelihood;
出力イメージ
| purchase_likelihood | order_count | gross_sales | total_discount | avg_order_value |
|---|---|---|---|---|
| 直近購買あり(自然購買の可能性) | 823 | 9,876,000 | 1,481,400 | 12,000 |
| 直近購買なし(クーポン誘引の可能性) | 521 | 8,336,000 | 1,250,400 | 16,000 |
クーポン使用者1,344人のうち、823人(61%)はクーポンがなくても近いうちに購買していた可能性が高い。この823人への割引148万円は「もったいない割引」だったかもしれない。
一方、521人(39%)はクーポンが引き金になって買ったと推定でき、こちらへの割引125万円は新規購買を獲得するコストとして正当化できる。
注意:これはあくまで近似推計
「直近30日以内に購買あり」=「クーポンがなくても買った」とは断定できない。正確な検証にはランダムに配信しなかったコントロールグループとの比較(A/Bテスト)が必要だ。このクエリは「傾向を掴む」ための参考指標として使ってほしい。
STEP 4 ― クーポン種別・割引額帯別のROI分析
発行数が多いキャンペーンでは、割引額の大きさによって購買行動が変わることがある。割引額を帯(バケット)に分けて比較する。
-- STEP4: 割引額帯別のROI比較
WITH coupon_usage AS (
SELECT
c.coupon_id,
c.discount_type,
o.order_id,
o.original_amount,
CASE
WHEN c.discount_type = 'fixed' THEN c.discount_value
WHEN c.discount_type = 'rate' THEN ROUND(o.original_amount * c.discount_value, 0)
ELSE 0
END AS discount_amount,
CASE WHEN o.order_id IS NOT NULL THEN 1 ELSE 0 END AS is_used
FROM coupons c
LEFT JOIN orders o
ON c.coupon_id = o.coupon_id
AND o.status = 'completed'
),
with_bucket AS (
SELECT
*,
CASE
WHEN discount_amount < 500 THEN '〜499円'
WHEN discount_amount < 1000 THEN '500〜999円'
WHEN discount_amount < 2000 THEN '1000〜1999円'
WHEN discount_amount < 5000 THEN '2000〜4999円'
ELSE '5000円以上'
END AS discount_bucket
FROM coupon_usage
WHERE is_used = 1
)
SELECT
discount_bucket,
COUNT(order_id) AS order_count,
ROUND(AVG(original_amount), 0) AS avg_order_value,
ROUND(AVG(discount_amount), 0) AS avg_discount,
ROUND(AVG(original_amount) * 1.0
/ NULLIF(AVG(discount_amount), 0), 1) AS avg_recovery_rate,
SUM(original_amount) AS total_sales,
SUM(discount_amount) AS total_discount
FROM with_bucket
GROUP BY discount_bucket
ORDER BY
CASE discount_bucket
WHEN '〜499円' THEN 1
WHEN '500〜999円' THEN 2
WHEN '1000〜1999円' THEN 3
WHEN '2000〜4999円' THEN 4
ELSE 5
END;
出力イメージ
| discount_bucket | order_count | avg_order_value | avg_discount | avg_recovery_rate |
|---|---|---|---|---|
| 〜499円 | 412 | 4,800 | 320 | 15.0 |
| 500〜999円 | 687 | 8,200 | 680 | 12.1 |
| 1000〜1999円 | 534 | 14,400 | 1,280 | 11.3 |
| 2000〜4999円 | 289 | 22,000 | 2,800 | 7.9 |
| 5000円以上 | 78 | 38,000 | 6,200 | 6.1 |
割引額が大きいほど回収率が下がる傾向が見える。大型割引は高単価顧客を動かしているが、割引コストが売上に対して重くなってきている。ただし絶対額の総売上は2000〜4999円帯が高く、施策の「量」と「効率」のトレードオフをここで検討する。
実務での運用ヒント
① 有効期限切れクーポンの発行数除外
発行したが有効期限内に使われなかったクーポンは、使用率の分母に含めるかどうかで数字が変わる。「1年有効のクーポンを先月発行した」ならまだ使われる可能性があるので分母から除いたほうが正確だ。expires_at > CURRENT_DATE でフィルタする方法がある。
② 同一注文への複数クーポン適用
ECシステムによっては1注文に複数クーポンを適用できる場合がある。その場合 orders.coupon_id が1つしか持てない設計だと片方しか記録されない。クーポン使用ログを別テーブル(order_coupons)で持つ設計が理想的だ。
③ クーポン未使用者への購買フォロー
発行したが使用していないクーポンの所有者は「興味はあるが行動していない顧客」の可能性がある。有効期限3日前に「もうすぐ期限です」のリマインドメールを送るセグメントリストは、このクエリの is_used = 0 AND expires_at > CURRENT_DATE で簡単に抽出できる。
まとめ
今回のクエリの流れを振り返る。
couponsとordersをcoupon_idでLEFT JOINし、発行・使用の両方を1行に持つデータを作るdiscount_typeに応じて割引額を計算(固定額 vs 割引率)する- キャンペーン別に使用率・回収率を集計して施策の費用対効果を比較する
- クーポン使用者の中で「直近購買あり」と「なし」を分けて純増効果を近似推計する
- 割引額帯別に回収率を比較して最適な割引設計を検討する
クーポンは「発行すれば売上が上がる」という幻想から脱して、「どの顧客に」「いくらの割引を」「どのタイミングで」配布すれば利益が最大化するかを設計する時代に入っている。そのための判断材料をSQLで作ることが、このクエリの本質的な価値だ。