SQLでECサイトの売上集計レポートを作ってみよう(総合演習)
ここまでの SQL レッスンで学んだことを全部つないで、実務で通用する売上レポートを1本仕上げます。新しい構文は出てきません。JOIN・GROUP BY・HAVING・CASE式・WITH句——すでに練習したものだけで組み立てます。
題材は架空のECサイト「プロ道ストア」です。顧客・商品・注文・注文明細という実際のECサイトとほぼ同じ4つのテーブルを用意しました。「先月の売上を商品別に出して」と頼まれたときに書くのが、まさにこの種のクエリです。
1ステップずつ進むので、途中で止めてもそこまでの分は動きます。各ステップの「実行する」ボタンはブラウザの中でSQLiteを動かしているので、条件を書き換えて試すこともできます。
完成イメージ
最後まで進むと、こう表示されるところまで作ります。下の実行結果は、このページを作るときに実際にコードを走らせて得たものです。
┌─────────┬──────────┬──────────┬───────┬──────┬──────┐ │ 年月 │ 売上合計 │ 周辺機器 │ 家具 │ 書籍 │ 評価 │ ├─────────┼──────────┼──────────┼───────┼──────┼──────┤ │ 2026-06 │ 21160 │ 18760 │ 0 │ 2400 │ 通常 │ │ 2026-07 │ 28880 │ 16480 │ 12400 │ 0 │ 通常 │ │ 2026-08 │ 46480 │ 33080 │ 3800 │ 9600 │ 好調 │ └─────────┴──────────┴──────────┴───────┴──────┴──────┘
STEP 1ECサイトのデータベースを作る
🎯 このステップのゴール: 顧客・商品・注文・注文明細の4テーブルを用意する
まずはデータの置き場所を作ります。ECサイトのデータベースは、たいてい次の4つに分かれています。
customers(顧客)・products(商品)・orders(注文)・order_items(注文明細)——なぜ注文が2つに分かれているのかが要点です。1回の注文で複数の商品を買えるからです。「誰がいつ注文したか」はordersに1行、「何をいくつ買ったか」はorder_itemsに商品の数だけ行が入ります。
この分け方を正規化と呼びます。同じ情報を2か所に持たないので、商品の値段を変えるときもproductsの1行を直すだけで済みます。実務のデータベースはほぼ必ずこの形をしています。
まずは中身を確かめましょう。SELECT * FROM productsで商品一覧が出れば準備完了です。
┌────┬──────────────────────┬──────────┬───────┐ │ id │ name │ category │ price │ ├────┼──────────────────────┼──────────┼───────┤ │ 1 │ ワイヤレスマウス │ 周辺機器 │ 2980 │ │ 2 │ メカニカルキーボード │ 周辺機器 │ 12800 │ │ 3 │ USB-Cハブ │ 周辺機器 │ 4500 │ │ 4 │ ノートPCスタンド │ 家具 │ 3800 │ │ 5 │ デスクライト │ 家具 │ 6200 │ │ 6 │ 入門プログラミング本 │ 書籍 │ 2400 │ └────┴──────────────────────┴──────────┴───────┘
STEP 23つの表をつないで明細を作る
🎯 このステップのゴール: JOINで注文・商品・顧客を1枚の表にまとめる
データが4つに分かれているということは、使うときは繋ぎ直す必要があるということです。ここが正規化の代償で、JOINの出番です。
やることは単純で、「どの列とどの列が同じものを指しているか」をONで教えるだけです。oi.order_id = o.idは「明細の注文番号は、注文表のidのこと」という意味になります。これを3回繰り返せば、4つの表が1枚に繋がります。
ここでASによる別名が効いてきます。order_items AS oiと短くしておくと、oi.quantityのように書けて読みやすくなります。表が3つ以上になると、別名なしでは書いていられません。
金額はその場で計算します。p.price * oi.quantity AS 金額のように、保存されていない値もSELECTの中で作れます。単価と数量さえあれば金額は導けるので、わざわざ保存しないのが定石です。
┌────────────┬──────┬──────────────────────┬──────┬───────┐ │ 注文日 │ 顧客 │ 商品 │ 数量 │ 金額 │ ├────────────┼──────┼──────────────────────┼──────┼───────┤ │ 2026-06-03 │ 佐藤 │ ワイヤレスマウス │ 2 │ 5960 │ │ 2026-06-03 │ 佐藤 │ 入門プログラミング本 │ 1 │ 2400 │ │ 2026-06-15 │ 鈴木 │ メカニカルキーボード │ 1 │ 12800 │ │ 2026-07-02 │ 佐藤 │ USB-Cハブ │ 3 │ 13500 │ │ 2026-07-02 │ 佐藤 │ ワイヤレスマウス │ 1 │ 2980 │ │ 2026-07-18 │ 高橋 │ デスクライト │ 2 │ 12400 │ │ 2026-08-05 │ 田中 │ ノートPCスタンド │ 1 │ 3800 │ │ 2026-08-05 │ 田中 │ ワイヤレスマウス │ 1 │ 2980 │ │ 2026-08-21 │ 鈴木 │ USB-Cハブ │ 1 │ 4500 │ │ 2026-08-21 │ 鈴木 │ メカニカルキーボード │ 2 │ 25600 │ │ 2026-08-28 │ 高橋 │ 入門プログラミング本 │ 4 │ 9600 │ └────────────┴──────┴──────────────────────┴──────┴───────┘
STEP 3商品別に売上を集計する
🎯 このステップのゴール: GROUP BYとSUMで、明細を商品ごとの合計にまとめる
明細のままでは「何が売れているか」が分かりません。商品ごとにまとめます。
GROUP BY p.nameと書くと、同じ商品名の行がひとかたまりに畳まれます。そのかたまりごとにSUM()で合計、COUNT()で件数を出せます。「11行の明細」が「6行の商品別サマリ」に変わります。
ここにSQLで最も多い間違いがあります。GROUP BYに書いていない列は、そのままではSELECTに書けません。かたまりの中で値がひとつに決まらないからです。SUM()のような集計関数で包むか、GROUP BYに足すか、どちらかが必要です。
並べ替えにORDER BY 売上 DESCと別名をそのまま使えます。ORDER BYはSELECTより後に処理されるので、付けた別名を知っているのです。
┌──────────────────────┬────────┬───────┐ │ 商品 │ 販売数 │ 売上 │ ├──────────────────────┼────────┼───────┤ │ メカニカルキーボード │ 3 │ 38400 │ │ USB-Cハブ │ 4 │ 18000 │ │ デスクライト │ 2 │ 12400 │ │ 入門プログラミング本 │ 5 │ 12000 │ │ ワイヤレスマウス │ 4 │ 11920 │ │ ノートPCスタンド │ 1 │ 3800 │ └──────────────────────┴────────┴───────┘
STEP 4売れ筋だけを絞り込む
🎯 このステップのゴール: HAVINGで集計後に絞り、LIMITで上位だけを取り出す
「売上1万円以上の商品トップ3を出して」——実務でよく来る依頼です。ここでWHEREでは書けないことに気づきます。
理由は処理される順番です。WHEREは集計する前の1行1行に効きます。でも「売上1万円以上」は集計した後でないと分かりません。そこで使うのがHAVINGです。GROUP BYで畳んだ結果に対して条件をかけられます。
WHEREは集計前、HAVINGは集計後——この一言を覚えておけば迷いません。WHERE p.category = '周辺機器'とHAVING SUM(...) >= 10000は同時に使えて、それぞれ違う段階で効きます。
最後にLIMIT 3で上位3件だけにします。ORDER BYで並べてからLIMITで切る——この組み合わせが「トップN」の定番です。並べ替えないままLIMITを付けると、どの3件が来るか決まりません。
┌──────────┬──────────────────────┬───────┐ │ 分類 │ 商品 │ 売上 │ ├──────────┼──────────────────────┼───────┤ │ 周辺機器 │ メカニカルキーボード │ 38400 │ │ 周辺機器 │ USB-Cハブ │ 18000 │ │ 家具 │ デスクライト │ 12400 │ └──────────┴──────────────────────┴───────┘
STEP 5月別の推移を出す
🎯 このステップのゴール: 日付から年月を取り出し、月ごとにまとめる
売上は推移で見たいものです。2026-06-03のような日付から年月だけを取り出してまとめましょう。
使うのはstrftime('%Y-%m', o.ordered_on)です。日付を好きな形の文字列にする関数で、%Yが4桁の年、%mが2桁の月を表します。これで2026-06という文字列ができ、これをGROUP BYに使えば月ごとに畳めます。
注文数の数え方に注意が要ります。COUNT(DISTINCT o.id)とDISTINCTを付けているのが要点です。明細をJOINしているので1回の注文が商品の数だけ行に増えています。そのままCOUNT(*)すると明細の数を数えてしまい、注文数が実際より多く出ます。
これはJOINした表を集計するときに最も踏みやすい罠です。数字が妙に大きいときは、たいていこれを疑います。
┌─────────┬────────┬───────┐ │ 年月 │ 注文数 │ 売上 │ ├─────────┼────────┼───────┤ │ 2026-06 │ 2 │ 21160 │ │ 2026-07 │ 2 │ 28880 │ │ 2026-08 │ 3 │ 46480 │ └─────────┴────────┴───────┘
STEP 6クロス集計にして仕上げる
🎯 このステップのゴール: WITH句で下ごしらえし、CASE式で横に並べる
最後の仕上げです。行に月、列にカテゴリを並べたクロス集計表にします。報告資料でそのまま使える形です。
まずWITH sales AS (...)で下ごしらえをします。CTE(共通テーブル式)と呼ばれるもので、「JOINして必要な列だけにした中間結果」に名前を付けられます。後半の集計はその名前を使うだけになるので、1本の長いクエリが「準備」と「集計」の2段に分かれて読みやすくなります。
横に並べるのがSUM(CASE WHEN ... THEN ... ELSE 0 END)です。条件付き集計と呼ばれる定番の書き方で、「周辺機器のときだけ金額を、それ以外は0を足す」という意味になります。カテゴリの数だけ列を並べれば、クロス集計の完成です。
最後の評価列もCASEです。集計した結果に対してもCASEが使えます。しきい値の30000を書き換えれば、判定の基準をすぐ変えられます。
完成です。WHEREを足して期間を絞る、カテゴリの列を増やす——書き換えて試してみてください。
┌─────────┬──────────┬──────────┬───────┬──────┬──────┐ │ 年月 │ 売上合計 │ 周辺機器 │ 家具 │ 書籍 │ 評価 │ ├─────────┼──────────┼──────────┼───────┼──────┼──────┤ │ 2026-06 │ 21160 │ 18760 │ 0 │ 2400 │ 通常 │ │ 2026-07 │ 28880 │ 16480 │ 12400 │ 0 │ 通常 │ │ 2026-08 │ 46480 │ 33080 │ 3800 │ 9600 │ 好調 │ └─────────┴──────────┴──────────┴───────┴──────┴──────┘
よくある質問
この売上レポートを実務でそのまま使えますか?
考え方はそのまま使えます。実務では返品・キャンセル・税・送料を差し引く必要があるので、ordersに状態の列を足してWHERE status = '完了'で絞るのが一般的です。テーブルの分け方とJOINの組み立て方は、ここで作ったものと同じです。
実行するとエラーになります
多いのはGROUP BYに無い列をSELECTに書いてしまう間違いです。集計関数で包むか、GROUP BYに足してください。
次に多いのがJOINのONの書き間違いです。oi.product_id = p.idのところをoi.id = p.idなどとすると、エラーにならないまま結果の行数だけがおかしくなります。件数が想定と違うときは、まずONを疑ってください。
売れていない商品も0円として表示したい
JOINをLEFT JOINに変え、productsを主役(FROMの側)にします。一度も売れていない商品は集計結果がNULLになるので、COALESCEで0に置き換えます。INNER JOINとLEFT JOINの違いが効いてくる場面です。
🚩 次はこのカテゴリへ
SQLで集計できるようになったら、次はその結果を加工して見せる番です。Pythonカテゴリの最終演習では、同じように集計する家計簿アプリを作ります。SQLが「データベースに集計させる」のに対し、Pythonは「手元で集計する」——この2つの違いが見えてくると、道具の使い分けができるようになります。
Pythonのレッスンへ進む