วัตถุประสงค์การเรียนรู้

เมื่อจบบทนี้ ผู้เรียนจะสามารถ

  1. อธิบายความแตกต่างระหว่าง window functions กับ GROUP BY ว่าทั้งคู่คำนวณ aggregate ได้ แต่ window functions ไม่ยุบแถว
  2. เขียน window functions ด้วย OVER, PARTITION BY และ ORDER BY
  3. ใช้ ROW_NUMBER, RANK และ DENSE_RANK เพื่อจัดอันดับและทำ top-N analysis
  4. คำนวณ running total และ moving average ด้วย SUM/AVG OVER พร้อม ROWS frame
  5. ใช้ LAG เปรียบเทียบแถวปัจจุบันกับแถวก่อนหน้า
  6. ใช้ NTILE แบ่งแถวข้อมูล

บทนำ

Window Function เป็นกลไกสำคัญในภาษา SQL ที่ออกแบบมาเพื่อรองรับการวิเคราะห์ข้อมูลในระดับแถว โดยสามารถคำนวณค่าจากชุดข้อมูลที่เกี่ยวข้องกับแถวปัจจุบันได้ โดยไม่ทำให้จำนวนแถวของผลลัพธ์ลดลงเหมือนการสรุปข้อมูลด้วย GROUP BY

แนวคิดนี้ช่วยให้ผู้ใช้สามารถแสดงข้อมูลต้นฉบับควบคู่ไปกับผลการคำนวณเชิงวิเคราะห์ได้ภายในคำสั่งเดียว ไม่ว่าจะเป็นการจัดอันดับ การคำนวณผลรวมสะสม การหาค่าเฉลี่ยเคลื่อนที่ หรือการเปรียบเทียบค่าของแถวปัจจุบันกับแถวก่อนหน้าและแถวถัดไป

ในทางปฏิบัติ Window Function มีบทบาทอย่างมากในงานวิเคราะห์ข้อมูลทางธุรกิจ ตัวอย่างเช่น การหาว่าสาขาใดมียอดขายเป็นอันดับหนึ่งในแต่ละเดือน การตรวจสอบว่าลูกค้ารายใดมีการใช้จ่ายสูงกว่าค่าเฉลี่ยของลูกค้ากลุ่มเดียวกัน หรือการวิเคราะห์ว่ารายได้ในวันนี้เพิ่มขึ้นหรือลดลงจากวันก่อนหน้าเป็นกี่เปอร์เซ็นต์

องค์ประกอบสำคัญของ Window Function คือการใช้ OVER() เพื่อกำหนดขอบเขต หรือ “หน้าต่าง” ของข้อมูลที่จะนำมาคำนวณ ภายใน OVER() ผู้ใช้สามารถระบุ PARTITION BY เพื่อแบ่งข้อมูลออกเป็นกลุ่มย่อย และใช้ ORDER BY เพื่อกำหนดลำดับของแถวสำหรับการประมวลผลได้อย่างเป็นระบบ

นอกจากนี้ ยังสามารถกำหนดกรอบของการคำนวณให้ละเอียดขึ้นได้ด้วยเงื่อนไข เช่น ROWS หรือ RANGE เพื่อควบคุมว่าแต่ละการคำนวณจะพิจารณาข้อมูลกี่แถว หรือช่วงค่าใดบ้าง ทั้งนี้เพื่อให้เหมาะสมกับลักษณะของข้อมูลและวัตถุประสงค์ของการวิเคราะห์

ด้วยเหตุนี้ การทำความเข้าใจและประยุกต์ใช้ Window Function อย่างถูกต้องจึงเป็นพื้นฐานสำคัญของการเขียน SQL เชิงวิเคราะห์ เพราะช่วยให้คำสั่งมีความชัดเจน ยืดหยุ่น และลดความซับซ้อนลงได้มาก เมื่อเทียบกับการใช้ subquery หลายชั้นเพื่อแก้ปัญหาเดียวกัน

องค์ประกอบหลักของ Window Functions

องค์ประกอบสำคัญ 3 ส่วนคือ

องค์ประกอบ เปรียบเทียบการขาย ความหมาย ตัวอย่าง
PARTITION BY แบ่งสาขาแยกกัน คำนวณแยกตามกลุ่ม/สาขา PARTITION BY store_name
ORDER BY เรียงยอดขายในสาขา กำหนดลำดับ มาก→น้อย หรือเรียงวันที่ ORDER BY revenue DESC
ROWS BETWEEN ดูกี่วันรอบ ๆ กำหนดหน้าต่างข้อมูลรอบแถวปัจจุบัน ROWS BETWEEN 2 PRECEDING AND CURRENT ROW

ลำดับการประมวลผล Window Functions

graph TD
 A["1. FROM โหลดตาราง"] --> B["2. WHERE กรองแถว"]
 B --> C["3. GROUP BY แบ่งกลุ่ม"]
 C --> D["4. SELECT คำนวณ window functions"]
 D --> E["5. ORDER BY เรียงลำดับ"]

รูปที่ 11.1: Window Functions ทำงานที่ขั้นตอน SELECT หลังจาก GROUP BY เสร็จแล้ว

Window functions ทำงานหลัง GROUP BY แต่ก่อน ORDER BY สุดท้าย นี่คือเหตุผลที่ใช้ window function ใน WHERE clause ไม่ได้โดยตรง ถ้าต้องการกรองผลลัพธ์ของ window function ต้องห่อด้วย CTE หรือ subquery ก่อน แล้วจึงกรองใน WHERE ชั้นนอก

Ranking Functions

ใน SQL ฟังก์ชัน ROW_NUMBER(), RANK() และ DENSE_RANK() จัดอยู่ในกลุ่ม window functions ที่ใช้กำหนดลำดับหรืออันดับให้แก่แถวข้อมูลภายใต้เงื่อนไขการเรียงลำดับที่กำหนดใน OVER (ORDER BY ...) แม้ทั้งสามฟังก์ชันจะมีวัตถุประสงค์ร่วมกันคือการแสดงลำดับของข้อมูล แต่หลักการกำหนดเลขลำดับในกรณีที่ค่าข้อมูลซ้ำกันแตกต่างกันอย่างมีนัยสำคัญ

ลักษณะเฉพาะของแต่ละฟังก์ชัน

  • ROW_NUMBER() ใช้กำหนดหมายเลขลำดับให้แต่ละแถวแบบไม่ซ้ำกันเสมอ ดังนั้นแม้ข้อมูลสองแถวจะมีค่าเท่ากัน ก็ยังได้รับหมายเลขคนละลำดับ
  • RANK() ใช้กำหนดอันดับโดยอนุญาตให้แถวที่มีค่าเท่ากันได้รับอันดับเดียวกัน และจะเว้นช่องว่างของอันดับถัดไปตามจำนวนแถวที่ซ้ำกัน
  • DENSE_RANK() มีหลักการคล้าย RANK() คือแถวที่มีค่าเท่ากันจะได้รับอันดับเดียวกัน แต่จะไม่เว้นช่องว่างของอันดับในลำดับถัดไป

แสดงการเปรียบเทียบ RANK, DENSE_RANK และ ROW_NUMBER ด้วยการจัดลำดับและอันดับจำนวนบาริสตาในสาขา

SELECT
 store_name, num_baristas,                                    
 ROW_NUMBER() OVER (ORDER BY num_baristas DESC) AS row_num,   -- เลขลำดับแถว (ไม่ซ้ำกันแม้คะแนนเท่ากัน)
 RANK() OVER (ORDER BY num_baristas DESC) AS rnk,             -- อันดับ (มีช่องว่างเมื่อคะแนนเท่ากัน)
 DENSE_RANK() OVER (ORDER BY num_baristas DESC) AS dense_rnk, -- อันดับ (ไม่มีช่องว่างเมื่อคะแนนเท่ากัน)
FROM stores
ORDER BY num_baristas DESC;                               -- เรียงจากจำนวน baristas สูงสุดไปต่ำสุด
store_name num_baristas rnk dense_rnk row_num
PiCha ICONSIAM 8 1 1 1
PiCha centralwOrld 5 2 2 2
PiCha Siam Square 5 2 2 3
PiCha Park Silom 3 4 3 4
PiCha Exchange Tower 3 4 3 5
PiCha Vanit Village 3 4 3 6

เมื่อพิจารณาชุดข้อมูลจำนวนบาริสตา สาขาที่มีค่า num_baristas เท่ากับ 8 จะได้รับลำดับที่ 1 จากทั้งสามฟังก์ชัน เนื่องจากเป็นค่ามากที่สุดในชุดข้อมูล สำหรับสาขาที่มีค่าเท่ากับ 5 จำนวนสองแถว ฟังก์ชัน RANK() และ DENSE_RANK() จะกำหนดอันดับเป็น 2 เหมือนกันทั้งสองแถว ขณะที่ ROW_NUMBER() จะกำหนดเป็น 2 และ 3 ตามลำดับของแถว ต่อมา เมื่อพิจารณาค่าที่เท่ากับ 3 ซึ่งมีอยู่สามแถว RANK() จะให้ค่าอันดับเป็น 4 เนื่องจากอันดับที่ 3 ถูกเว้นไว้จากกรณีที่มีอันดับ 2 ร่วมกันสองแถว ในทางตรงกันข้าม DENSE_RANK() จะกำหนดค่าเป็น 3 เพราะนับเฉพาะจำนวน “กลุ่มของค่าที่แตกต่างกัน” โดยไม่เว้นลำดับอันดับ

ตัวอย่าง: ROW_NUMBER, RANK และ DENSE_RANK

ฟังก์ชันทั้งสามนี้ทำหน้าที่ กำหนดลำดับ (ranking) ให้กับแต่ละแถว แต่จัดการกับ ค่าซ้ำ (ties) แตกต่างกัน

ฟังก์ชัน เมื่อค่าซ้ำกัน มีช่องว่างในลำดับ?
ROW_NUMBER() ให้เลขลำดับไม่ซ้ำกันเสมอ ไม่มี — ทุกแถวได้เลขต่างกัน
RANK() ให้ลำดับเดียวกัน มี — ข้ามเลขที่ถูกใช้ไป
DENSE_RANK() ให้ลำดับเดียวกัน ไม่มี — ลำดับต่อเนื่องเสมอ

ตัวอย่างที่ 1 — ไม่ใช้ PARTITION BY (จัดอันดับทุกเมนูรวมกัน)

SELECT
    sku_name,
    family,
    list_price_thb,
    ROW_NUMBER() OVER (ORDER BY list_price_thb DESC) AS row_num,
    RANK()       OVER (ORDER BY list_price_thb DESC) AS rnk,
    DENSE_RANK() OVER (ORDER BY list_price_thb DESC) AS dense_rnk
FROM menus
ORDER BY list_price_thb DESC, family, sku_name;

ผลลัพธ์

sku_name family list_price_thb row_num rnk dense_rnk
Fresh Orange Jasmine Tea L fruit_tea 150 1 1 1
Mango Sticky Rice Tea L fruit_tea 135 2 2 2
Da Hong Pao Milk Tea L milk_tea 130 4 3 3
Tie Guan Yin Milk Tea L milk_tea 130 3 3 3
Thai Tea Frappe L blended 125 5 5 4
Jasmine Milk Tea L milk_tea 115 8 6 5
Osmanthus Milk Tea L milk_tea 115 7 6 5
Pu’er Milk Tea L milk_tea 115 6 6 5
White Peach Milk Tea L milk_tea 115 9 6 5
Da Hong Pao Milk Tea R milk_tea 110 11 10 6
Tie Guan Yin Milk Tea R milk_tea 110 10 10 6
Tie Guan Yin Pure Tea R pure_tea 110 12 10 6
Jasmine Grapefruit Tea L fruit_tea 105 13 13 7
Lemon Glutinous Green Tea L fruit_tea 105 14 13 7
Glutinous Green Milk Tea L milk_tea 100 15 15 8
Peach Jasmine Tea L fruit_tea 95 20 16 9
Jasmine Milk Tea R milk_tea 95 18 16 9
Osmanthus Milk Tea R milk_tea 95 17 16 9
Pu’er Milk Tea R milk_tea 95 16 16 9
White Peach Milk Tea R milk_tea 95 19 16 9
Glutinous Green Milk Tea R milk_tea 85 21 21 10
Glutinous Green Pure Tea R pure_tea 85 22 21 10

อธิบายความแตกต่าง

ROW_NUMBER() กำหนดเลขลำดับ 1–22 โดยไม่มีซ้ำแม้ราคาเท่ากัน เช่น Da Hong Pao L และ Tie Guan Yin L ราคาเท่ากันทั้งคู่ที่ 130 บาท แต่ได้ row_num เป็น 3 และ 4 ตามลำดับ (DuckDB ตัดสินด้วย row ภายในซึ่งไม่รับประกันลำดับ) ฟังก์ชันนี้เหมาะสำหรับงานที่ต้องการหมายเลขไม่ซ้ำ เช่น การกำหนด ID ลำดับ หรือการแบ่งหน้า (pagination)

RANK() ให้อันดับเดียวกันกับเมนูที่ราคาเท่ากัน เช่น เมนูราคา 130 บาท 2 รายการได้ rnk = 3 ทั้งคู่ แต่เมนูถัดไปราคา 125 บาทได้ rnk = 5 (ข้ามอันดับ 4 ไป) เพราะอันดับ 3 ถูกใช้ไป 2 ตำแหน่ง สังเกตว่าเมนูราคา 115 บาทมี 4 รายการได้ rnk = 6 แต่เมนูถัดไปได้ rnk = 10 (ข้ามอันดับ 7, 8, 9)

DENSE_RANK() ให้อันดับเดียวกันกับเมนูที่ราคาเท่ากัน แต่ไม่ข้ามเลข เช่น เมนูราคา 130 บาทได้ dense_rnk = 3 เมนูราคา 125 บาทถัดมาได้ dense_rnk = 4 (ต่อเนื่องเสมอ) แม้จะมีการซ้ำกี่รายการก็ตาม ผลลัพธ์จึงมีเพียง 10 ระดับราคาที่ไม่ซ้ำกัน (distinct price levels) สะท้อนโครงสร้างราคาของเมนูได้ชัดเจนกว่า

จุดสังเกต: หากไม่มี PARTITION BY ฟังก์ชันทั้งสามจะพิจารณา ทุกแถวในตารางเป็น partition เดียว กล่าวคืออันดับถูกคำนวณรวมกันทั้งหมดโดยไม่แบ่งกลุ่ม

ตัวอย่างที่ 2 — ใช้ PARTITION BY family (จัดอันดับภายในแต่ละหมวด)

SELECT
    sku_name,
    family,
    list_price_thb,
    ROW_NUMBER() OVER (PARTITION BY family ORDER BY list_price_thb DESC) AS row_num,
    RANK()       OVER (PARTITION BY family ORDER BY list_price_thb DESC) AS rnk,
    DENSE_RANK() OVER (PARTITION BY family ORDER BY list_price_thb DESC) AS dense_rnk
FROM menus
ORDER BY family, list_price_thb DESC, sku_name;

ผลลัพธ์

sku_name family list_price_thb row_num rnk dense_rnk
Thai Tea Frappe L blended 125 1 1 1
Fresh Orange Jasmine Tea L fruit_tea 150 1 1 1
Mango Sticky Rice Tea L fruit_tea 135 2 2 2
Jasmine Grapefruit Tea L fruit_tea 105 3 3 3
Lemon Glutinous Green Tea L fruit_tea 105 4 3 3
Peach Jasmine Tea L fruit_tea 95 5 5 4
Da Hong Pao Milk Tea L milk_tea 130 2 1 1
Tie Guan Yin Milk Tea L milk_tea 130 1 1 1
Jasmine Milk Tea L milk_tea 115 5 3 2
Osmanthus Milk Tea L milk_tea 115 4 3 2
Pu’er Milk Tea L milk_tea 115 3 3 2
White Peach Milk Tea L milk_tea 115 6 3 2
Da Hong Pao Milk Tea R milk_tea 110 8 7 3
Tie Guan Yin Milk Tea R milk_tea 110 7 7 3
Glutinous Green Milk Tea L milk_tea 100 9 9 4
Jasmine Milk Tea R milk_tea 95 12 10 5
Osmanthus Milk Tea R milk_tea 95 11 10 5
Pu’er Milk Tea R milk_tea 95 10 10 5
White Peach Milk Tea R milk_tea 95 13 10 5
Glutinous Green Milk Tea R milk_tea 85 14 14 6
Tie Guan Yin Pure Tea R pure_tea 110 1 1 1
Glutinous Green Pure Tea R pure_tea 85 2 2 2

อธิบายผลของ PARTITION BY

การเพิ่ม PARTITION BY family ทำให้ลำดับ เริ่มนับใหม่ ในแต่ละ family แทนที่จะนับต่อเนื่องรวมกันทั้งตาราง เปรียบเหมือนการแข่งขันแยกประเภท — แต่ละ family มีอันดับที่ 1 ของตัวเอง

  • blended: มีสินค้าเพียงรายการเดียว (Thai Tea Frappe L) ทุกฟังก์ชันให้อันดับ 1
  • fruit_tea: Fresh Orange Jasmine Tea L ราคา 150 บาทเป็นอันดับ 1 ภายใน family นี้ โดยไม่สนใจว่าเมนู milk_tea บางรายการราคาใกล้เคียงกัน
  • milk_tea: Tie Guan Yin L และ Da Hong Pao L ราคา 130 บาทเป็น rnk = 1 ภายใน milk_tea โดยที่ RANK() ข้ามไปเป็น rnk = 3 สำหรับกลุ่ม 115 บาท
  • pure_tea: มีสินค้าเพียง 2 รายการ จึงได้อันดับ 1 และ 2

เปรียบเทียบหลัก: ไม่ใช้ PARTITION BY = จัดอันดับรวมทั้งเมนู (22 รายการ) ใช้ PARTITION BY family = จัดอันดับแยกในแต่ละ family โดยอิสระ

สรุปเมื่อไรควรใช้ฟังก์ชันใด

สถานการณ์ ฟังก์ชันที่เหมาะสม
ต้องการเลขแถวไม่ซ้ำ เช่น ดึง Top-N แถวแรกต่อกลุ่ม ROW_NUMBER()
ต้องการอันดับแข่งขันจริง เช่น “อันดับ 1 ร่วม” RANK()
ต้องการนับจำนวน tier ราคาหรือ tier ประสิทธิภาพที่ไม่ซ้ำกัน DENSE_RANK()
ต้องการอันดับรวมทั้งชุด ละ PARTITION BY ออก
ต้องการอันดับในแต่ละกลุ่ม (เช่น แต่ละ family, แต่ละสาขา) ใส่ PARTITION BY <คอลัมน์กลุ่ม>

PARTITION BY

ต้องการรู้ว่าแต่ละสาขามี average order value สูงหรือต่ำกว่าค่าเฉลี่ยรวมของทั้งบริษัทเท่าไหร่

SELECT DISTINCT
 s.store_name, -- ชื่อสาขา
 ROUND(AVG(t.net_sales_thb) OVER (PARTITION BY s.store_name), 0) AS store_avg, -- AOV ของแต่ละสาขา (บาท)
 ROUND(AVG(t.net_sales_thb) OVER (), 0) AS company_avg, -- AOV เฉลี่ยทั้งบริษัท (บาท)
 ROUND(AVG(t.net_sales_thb) OVER (PARTITION BY s.store_name) - AVG(t.net_sales_thb) OVER (), 0) AS diff, -- ส่วนต่าง AOV สาขากับค่าเฉลี่ยบริษัท (บาท)
 COUNT(*) OVER (PARTITION BY s.store_name) AS order_count -- จำนวนออร์เดอร์ทั้งหมดของสาขา
FROM transactions t
JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
ORDER BY store_avg DESC; -- เรียงจากสาขาที่มี AOV สูงสุดไปต่ำสุด
store_name store_avg company_avg diff order_count
PiCha Siam Square 215 202 13 10,738
PiCha centralwOrld 205 202 3 11,935
PiCha ICONSIAM 203 202 0 8,702
PiCha Vanit Village 202 202 0 7,257
PiCha Park Silom 191 202 -12 7,611
PiCha Exchange Tower 190 202 -12 7,080

Siam Square มี AOV สูงสุดที่ 215 บาท สูงกว่าค่าเฉลี่ยบริษัท (202 บาท) อยู่ 13 บาท ขณะที่สาขา office ทั้งสอง (Park Silom และ Exchange Tower) ต่ำกว่าค่าเฉลี่ย 12 บาท ซึ่งน่าจะเป็นเพราะลูกค้าออฟฟิศมักสั่งทีละแก้วในช่วงพักงาน ไม่ใช่เพราะคุณภาพสินค้า การผลักดันโปรโมชั่น “second cup half price” อาจช่วยเพิ่ม AOV ได้ แต่ต้องประเมินผลกระทบต่อ margin ก่อน ในเชิงเทคนิค การใช้ OVER () โดยไม่มี PARTITION BY ทำให้ได้ค่า company_avg = 202 ทุกแถว ช่วยให้เปรียบเทียบ store_avg กับค่าเฉลี่ยบริษัทได้ในแถวเดียว โดยไม่ต้องเขียน subquery แยกต่างหาก

OVER() แบบไม่มี PARTITION BY ยังนำไปประยุกต์ได้ในรูปแบบที่สอง: ใช้เป็น denominator เพื่อคำนวณสัดส่วนรายได้ภายในกลุ่ม ซึ่งช่วยให้วิเคราะห์ channel mix ของแต่ละสาขาได้ในแถวเดียว โดยแทนที่ OVER() ด้วย OVER (PARTITION BY store_name) เพื่อให้ฐานหารเป็นรายได้รวมของสาขานั้น

หากต้องการหาออเดอร์ที่มียอดขายสูงสุด 3 อันดับแรกของแต่ละสาขา โดยจัดอันดับแยกเฉพาะแยกตามสาขา

WITH ranked AS (                  -- CTE จัดอันดับธุรกรรมสูงสุดในแต่ละสาขา
 SELECT
  s.store_name,                   -- ชื่อสาขา
  t.transaction_id,               -- รหัสธุรกรรม
  t.channel,                      -- ช่องทางการขาย
  t.net_sales_thb,                -- ยอดขายสุทธิ (บาท)
  ROW_NUMBER() OVER (             -- กำหนดเลขลำดับในแต่ละสาขา
   PARTITION BY s.store_name      -- แบ่งกลุ่มตามสาขา
   ORDER BY t.net_sales_thb DESC  -- เรียงจากยอดขายสูงสุดไปต่ำสุด
  ) AS rn                         -- เลขลำดับภายในสาขา
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id   -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
)
SELECT store_name, transaction_id, channel, net_sales_thb, rn  -- แสดงข้อมูลธุรกรรมพร้อมลำดับ
FROM ranked
WHERE rn <= 3                 -- กรองเฉพาะ Top 3 ของแต่ละสาขา
ORDER BY store_name, rn;      -- เรียงตามสาขา แล้วตามลำดับยอดขาย
store_name transaction_id channel net_sales_thb rn
PiCha Exchange Tower 26,730 delivery 598 1
PiCha Exchange Tower 35,100 delivery 594.8 2
PiCha Exchange Tower 50,337 delivery 587 3
PiCha ICONSIAM 41,627 delivery 695 1
PiCha ICONSIAM 28,940 delivery 633 2
PiCha ICONSIAM 39,100 delivery 627 3
PiCha Park Silom 51,668 delivery 609 1
PiCha Park Silom 15,585 delivery 588 2
PiCha Park Silom 40,976 delivery 587 3
PiCha Siam Square 46,797 delivery 640 1
PiCha Siam Square 48,503 delivery 634 2
PiCha Siam Square 50,221 delivery 612 3
PiCha Vanit Village 52,852 delivery 630 1
PiCha Vanit Village 14,594 delivery 618 2
PiCha Vanit Village 27,651 delivery 615 3
PiCha centralwOrld 17,854 delivery 628 1
PiCha centralwOrld 1,627 delivery 603 2
PiCha centralwOrld 13,622 delivery 602.78 3

ออเดอร์ที่ใหญ่ที่สุดทุกสาขามาจาก delivery channel ทั้งหมด สะท้อนว่าออเดอร์มูลค่าสูงมักเกิดจากการสั่งหลายเมนูในครั้งเดียวผ่าน delivery ขณะที่ลูกค้า walk-in มักสั่งทีละแก้ว

ดังนั้น PiCha ควรรักษาคุณภาพ delivery service ไว้ให้ดี ในขณะเดียวกันอาจออก premium bundle เพื่อดัน basket size ของ dine_in และ to_go แต่ควรตรวจสอบสัดส่วนออเดอร์ใกล้ 400 บาทก่อนว่ามีมากพอสำหรับแคมเปญหรือไม่

หลังจากเข้าใจการจัดอันดับพื้นฐานทั้งสามรูปแบบแล้ว เราจะต่อยอดด้วยการประยุกต์ ROW_NUMBER ใน two real-world scenarios ที่ใช้บ่อยในงานวิเคราะห์: การหา hero product ของแต่ละสาขา และการระบุวันที่มียอดขายสูงสุด

คำถามถัดมาเป็นการหาเมนูขายดีที่สุดของแต่ละสาขา

-- หา SKU ที่สร้างรายได้สูงสุดของแต่ละสาขา
WITH sku_store AS ( -- CTE คำนวณรายได้และจำนวนครั้งที่สั่งของแต่ละ SKU แยกตามสาขา
 SELECT
  s.store_name, -- ชื่อสาขา
  m.sku_name, -- ชื่อสินค้า
  m.family, -- กลุ่มสินค้า
  COUNT(*) AS times_ordered, -- จำนวนครั้งที่สั่งซื้อ
  ROUND(SUM(oi.net_line_sales_thb), 0) AS total_revenue -- รายได้รวม (บาท)
 FROM order_items oi
 JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อดึงชื่อและกลุ่มสินค้า
 JOIN transactions t ON oi.transaction_id = t.transaction_id -- เชื่อมตารางธุรกรรม
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, m.sku_name, m.family -- จัดกลุ่มตามสาขาและสินค้า
),
ranked AS ( -- CTE จัดอันดับ SKU ตามรายได้ภายในแต่ละสาขา
 SELECT *,
  ROW_NUMBER() OVER (PARTITION BY store_name ORDER BY total_revenue DESC) AS rn -- ลำดับที่ 1 = รายได้สูงสุดในสาขานั้น
 FROM sku_store
)
SELECT store_name, sku_name, family, times_ordered, total_revenue
FROM ranked
WHERE rn = 1 -- กรองเฉพาะ SKU อันดับ 1 ของแต่ละสาขา
ORDER BY total_revenue DESC; -- เรียงตามรายได้จากสาขาที่สูงสุดไปต่ำสุด
store_name sku_name family times_ordered total_revenue
PiCha centralwOrld Jasmine Milk Tea L milk_tea 2,145 255,279
PiCha Siam Square Jasmine Milk Tea L milk_tea 2,024 245,232
PiCha ICONSIAM Jasmine Milk Tea L milk_tea 1,558 182,340
PiCha Park Silom Jasmine Milk Tea L milk_tea 1,372 160,894
PiCha Vanit Village Jasmine Milk Tea L milk_tea 1,342 159,775
PiCha Exchange Tower Jasmine Milk Tea L milk_tea 1,257 147,628

จะพบว่า Jasmine Milk Tea L เป็นเมนูขายดีอันดับ 1 ของทุกสาขาอย่างสม่ำเสมอ โดยสาขา centralwOrld ขายได้มากที่สุด 2,145 ครั้ง สร้างรายได้ 255,279 บาท ขณะที่ Exchange Tower ขายน้อยที่สุด 1,257 ครั้ง ซึ่งรูปแบบดังกล่าวสะท้อนความสม่ำเสมอของ top SKU ทุกสาขาและยืนยันว่า Jasmine Milk Tea L เป็น hero product ที่แท้จริง ในเชิงบริหารจึงต้องให้ความสำคัญกับการบริหาร stock วัตถุดิบ jasmine tea ไม่ให้ขาดในทุกสาขา เพราะเป็น SKU ที่สร้างรายได้สูงสุดและการขาด stock แม้เพียงวันเดียวจะกระทบรายได้ทันที อย่างไรก็ตาม ความสม่ำเสมอของ top SKU ในทุกสาขายังบ่งชี้ว่า PiCha พึ่งพา SKU เดียวมากเกินไปในระดับบริษัท ซึ่งก่อให้เกิด concentration risk หากวัตถุดิบหลักขาดตลาด ต้นทุนเพิ่มขึ้น หรือจำเป็นต้องปรับสูตร จะส่งผลกระทบต่อรายได้ทุกสาขาพร้อมกัน ดังนั้นควรพัฒนา “second hero” SKU ในแต่ละทำเลเพื่อลดความเสี่ยงนี้ โดยควรสำรวจว่า SKU อันดับ 2–3 ของแต่ละสาขามีความหลากหลายหรือไม่ (เช่น ปรับเงื่อนไขจาก WHERE rn = 1 เป็น WHERE rn <= 3) เพื่อประเมินศักยภาพของสินค้าอันดับรองในแต่ละพื้นที่

ในทำนองเดียวกัน ROW_NUMBER สามารถนำไปใช้กับมิติเวลาเพื่อระบุวันที่รายได้สูงสุดของแต่ละสาขา ซึ่งช่วยวางแผน event-based marketing ได้อย่างมีเป้าหมาย

ตัวอย่างถัดไป แสดงการหาวันที่มีรายได้สูงสุด 2 วันของแต่ละสาขา เพื่อนำ insight ไปใช้วางแผน event-based marketing

-- หา 2 วันที่มีรายได้สูงสุดของแต่ละสาขา
WITH daily AS ( -- CTE คำนวณรายได้รายวันและจัดอันดับภายในแต่ละสาขา
 SELECT
  s.store_name, -- ชื่อสาขา
  t.order_date, -- วันที่
  ROUND(SUM(t.net_sales_thb), 0) AS daily_revenue, -- รายได้รวมต่อวัน (บาท)
  ROW_NUMBER() OVER ( -- จัดอันดับวันตามรายได้ภายในสาขา
   PARTITION BY s.store_name -- แบ่งกลุ่มตามสาขา
   ORDER BY SUM(t.net_sales_thb) DESC -- เรียงจากรายได้สูงสุดไปต่ำสุด
  ) AS rn -- ลำดับที่ 1 = วันที่มีรายได้สูงสุดของสาขานั้น
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, t.order_date -- จัดกลุ่มตามสาขาและวันที่
)
SELECT store_name, order_date, daily_revenue, rn
FROM daily
WHERE rn <= 2 -- กรองเฉพาะ 2 วันที่มีรายได้สูงสุดของแต่ละสาขา
ORDER BY store_name, rn; -- เรียงตามสาขา แล้วตามลำดับรายได้
store_name order_date daily_revenue rn
PiCha Exchange Tower 2026-02-18 50,200 1
PiCha Exchange Tower 2026-04-01 46,986 2
PiCha ICONSIAM 2026-02-15 74,013 1
PiCha ICONSIAM 2026-02-14 66,539 2
PiCha Park Silom 2026-02-19 48,873 1
PiCha Park Silom 2026-03-31 46,386 2
PiCha Siam Square 2026-02-15 76,545 1
PiCha Siam Square 2026-02-14 70,553 2
PiCha Vanit Village 2026-02-15 52,952 1
PiCha Vanit Village 2026-02-14 41,987 2
PiCha centralwOrld 2026-02-15 104,486 1
PiCha centralwOrld 2026-02-14 81,356 2

8 จาก 12 ตำแหน่ง top 2 คือวันที่ 14–15 กุมภาพันธ์ วาเลนไทน์รวมกับวันเสาร์สร้าง demand ที่สาขาห้างฯ (ICONSIAM, Siam Square, Vanit Village, centralwOrld) ทุกแห่งสูงกว่ารายได้เฉลี่ยต่อวันประมาณ 2.5 เท่า สาขา office ไม่อยู่ใน pattern นี้เลย Exchange Tower peak วันทำงาน 18 ก.พ. และ Park Silom วัน 19 ก.พ. สะท้อนฐานลูกค้าที่แตกต่างกันอย่างชัดเจน: สาขาห้างฯ ขับเคลื่อนด้วย holiday traffic, สาขา office ขับเคลื่อนด้วย event เฉพาะตึก

กล่าวได้ว่าวาเลนไทน์เป็น “super event” ของ PiCha ที่สาขาห้างฯ การวางแผนล่วงหน้าสำหรับวาเลนไทน์ปีถัดไป stock วัตถุดิบเพิ่ม 2.5 เท่า, เพิ่มพนักงาน, เตรียม limited-edition menu ควรเริ่มอย่างน้อย 1 เดือนก่อน สำหรับสาขา office ควรวิเคราะห์แยกว่า peak days เกิดจากเหตุการณ์ใดในตึก เพื่อสร้างโอกาส peak เพิ่มขึ้นแทนที่จะรอให้เกิดเอง

ROW_NUMBER ตอบคำถาม “อะไรอยู่อันดับต้น” ได้ทั้งในมิติสินค้า ออร์เดอร์ และเวลา คำถามถัดไปคือ “ความสำเร็จนั้นสะสมขึ้นอย่างไรตามลำดับวัน” ซึ่งต้องใช้ pattern ต่างออกไป: running total

Running Total และ Cumulative Metrics

ต้องการดูว่ารายได้สะสมของสาขา ICONSIAM เพิ่มขึ้นอย่างไรในแต่ละวัน เพื่อติดตามความก้าวหน้าเทียบกับเป้ารายเดือน

WITH daily AS ( -- CTE คำนวณรายได้รายวันของแต่ละสาขา
 SELECT
  s.store_name, -- ชื่อสาขา
  t.order_date, -- วันที่สั่งซื้อ
  ROUND(SUM(t.net_sales_thb), 0) AS daily_revenue -- รายได้สุทธิรวมต่อวัน (บาท)
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, t.order_date -- จัดกลุ่มตามสาขาและวันที่
)
SELECT
 store_name, -- ชื่อสาขา
 order_date, -- วันที่
 daily_revenue, -- รายได้ต่อวัน (บาท)
 SUM(daily_revenue) OVER ( -- คำนวณรายได้สะสม
  PARTITION BY store_name -- แบ่งกลุ่มตามสาขา (รีเซ็ตเมื่อเปลี่ยนสาขา)
  ORDER BY order_date -- เรียงตามวันที่จากเก่าไปใหม่
  ROWS UNBOUNDED PRECEDING -- รวมทุกแถวตั้งแต่ต้นจนถึงแถวปัจจุบัน
 ) AS cumulative_revenue -- รายได้สะสมรวม (บาท)
FROM daily
WHERE store_name = 'PiCha ICONSIAM' -- กรองเฉพาะสาขา ICONSIAM
ORDER BY order_date -- เรียงตามวันที่จากเก่าไปใหม่
LIMIT 10; -- แสดงเฉพาะ 10 วันแรก
store_name order_date daily_revenue cumulative_revenue
PiCha ICONSIAM 2026-02-01 36,618 36,618
PiCha ICONSIAM 2026-02-02 13,407 50,025
PiCha ICONSIAM 2026-02-03 15,166 65,191
PiCha ICONSIAM 2026-02-04 17,566 82,757
PiCha ICONSIAM 2026-02-05 15,128 97,885
PiCha ICONSIAM 2026-02-06 26,490 124,375
PiCha ICONSIAM 2026-02-07 36,869 161,244
PiCha ICONSIAM 2026-02-08 34,960 196,204
PiCha ICONSIAM 2026-02-09 11,869 208,073
PiCha ICONSIAM 2026-02-10 19,797 227,870

ICONSIAM มีรายได้สะสม 10 วันแรกอยู่ที่ 227,870 บาท โดยวันที่ 1 ก.พ. สูงสุดที่ 36,618 บาท และเสาร์–อาทิตย์ช่วยดันรายได้สะสมอย่างมีนัยสำคัญ ยืนยันว่า ICONSIAM เป็น “weekend destination” และช่วยให้ผู้จัดการวางแผนกำลังคนเทน้ำหนักไปที่วันหยุดได้

Syntax Spotlight: ROWS UNBOUNDED PRECEDING

SUM(daily_revenue) OVER (
  PARTITION BY store_name
  ORDER BY order_date
  ROWS UNBOUNDED PRECEDING
)
  • หมายถึง “รวมตั้งแต่แถวแรกของ partition จนถึงแถวปัจจุบัน”
  • หากไม่ระบุ frame clause หลาย database จะใช้ RANGE UNBOUNDED PRECEDING เป็นค่า default ซึ่งอาจให้ผลต่างออกไปเมื่อมีค่าซ้ำ จึงควรระบุ frame ให้ชัด

จาก cumulative metrics นี้ เราจะต่อยอดไปดู แนวโน้ม ด้วย moving average

Moving Average และ Trend Detection

ใช้ moving average หาค่าเฉลี่ยรายได้ 7 วันของสาขา Siam Square เพื่อดูแนวโน้มรายได้ว่าเติบโตต่อเนื่องหรือไม่

WITH daily AS ( -- CTE คำนวณรายได้รายวันของแต่ละสาขา
 SELECT
  s.store_name, -- ชื่อสาขา
  t.order_date, -- วันที่สั่งซื้อ
  ROUND(SUM(t.net_sales_thb), 0) AS daily_revenue -- รายได้สุทธิรวมต่อวัน (บาท)
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, t.order_date -- จัดกลุ่มตามสาขาและวันที่
)
SELECT
 store_name, -- ชื่อสาขา
 order_date, -- วันที่
 daily_revenue, -- รายได้ต่อวัน (บาท)
 ROUND(AVG(daily_revenue) OVER ( -- คำนวณค่าเฉลี่ยเคลื่อนที่ 7 วัน
  PARTITION BY store_name -- แบ่งกลุ่มตามสาขา
  ORDER BY order_date -- เรียงตามวันที่
  ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- ใช้ข้อมูล 6 วันก่อนหน้า + วันปัจจุบัน
 ), 0) AS rolling_7d_avg -- ค่าเฉลี่ยเคลื่อนที่ 7 วัน (บาท)
FROM daily
WHERE store_name = 'PiCha Siam Square' -- กรองเฉพาะสาขา Siam Square
ORDER BY order_date -- เรียงตามวันที่จากเก่าไปใหม่
LIMIT 15; -- แสดงเฉพาะ 15 วันแรก
store_name order_date daily_revenue rolling_7d_avg
PiCha Siam Square 2026-02-01 37,645 37,645
PiCha Siam Square 2026-02-02 21,326 29,486
PiCha Siam Square 2026-02-03 17,814 25,595
PiCha Siam Square 2026-02-04 25,363 25,537
PiCha Siam Square 2026-02-05 27,019 25,833
PiCha Siam Square 2026-02-06 32,815 26,997
PiCha Siam Square 2026-02-07 37,640 28,517
PiCha Siam Square 2026-02-08 36,319 28,328
PiCha Siam Square 2026-02-09 23,423 28,628
PiCha Siam Square 2026-02-10 20,301 28,983
PiCha Siam Square 2026-02-11 25,844 29,052
PiCha Siam Square 2026-02-12 21,443 28,255
PiCha Siam Square 2026-02-13 44,558 29,933
PiCha Siam Square 2026-02-14 70,553 34,634
PiCha Siam Square 2026-02-15 76,545 40,381

ค่าเฉลี่ย 7 วันของสาขา Siam Square เพิ่มขึ้นต่อเนื่องจาก 25,537 บาท (4 ก.พ.) เป็น 40,381 บาท (15 ก.พ.) หรือเพิ่มขึ้น 58% ใน 11 วัน โดยวันที่ 14–15 ก.พ. (วาเลนไทน์ + วันเสาร์) มีรายได้ 70,553 และ 76,545 บาทตามลำดับ ซึ่งเป็นแรงดันสำคัญที่ผลักค่าเฉลี่ยขึ้นอย่างชัดเจน ทิศทางที่เป็นบวกนี้บ่งชี้ว่าสาขากำลังเติบโต อย่างไรก็ตาม หากค่าเฉลี่ยเคลื่อนที่เริ่ม flat หรือลดลง ผู้บริหารควรหาสาเหตุทันที เพราะ moving average ทำหน้าที่ “กรองสัญญาณรบกวน” และช่วยให้มองเห็น underlying trend ได้ชัดเจนกว่ารายได้รายวัน รายได้สูงเพียงวันเดียวอาจเป็นแค่ event เฉพาะกิจ แต่ถ้าค่าเฉลี่ยเคลื่อนที่เพิ่มขึ้นต่อเนื่อง แสดงว่าธุรกิจกำลังขยายตัวจริง

LAG เปรียบเทียบข้ามเวลา

เมื่อมีแนวโน้มแล้ว ขั้นถัดไปคือการดู “การเปลี่ยนแปลงระหว่างจุดเวลา” เช่น วันนี้เทียบเมื่อวาน หรือออร์เดอร์ล่าสุดเทียบครั้งก่อน

มาดูวิธีการหา รายได้รายวันของสาขา PiCha CentralWorld เปลี่ยนแปลงเป็นเปอร์เซ็นต์เท่าไหร่เมื่อเทียบกับวันก่อนหน้า (Day-over-Day % Change) และแต่ละวันในสัปดาห์มีรูปแบบการเปลี่ยนแปลงอย่างไร?

WITH daily AS ( -- CTE คำนวณรายได้รายวันของแต่ละสาขา
 SELECT
  s.store_name,       -- ชื่อสาขา
  t.order_date,       -- วันที่สั่งซื้อ
  t.day_of_week,      -- วันในสัปดาห์ (เช่น Monday, Tuesday)
  ROUND(SUM(t.net_sales_thb), 0) AS daily_revenue -- รายได้สุทธิรวมต่อวัน (บาท)
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, t.order_date, t.day_of_week -- จัดกลุ่มตามสาขา วันที่ และวันในสัปดาห์
),
lagged AS ( -- CTE ดึงรายได้วันก่อนหน้ามาเปรียบเทียบ
 SELECT
  store_name,         -- ชื่อสาขา
  order_date,         -- วันที่
  day_of_week,        -- วันในสัปดาห์
  daily_revenue,      -- รายได้วันปัจจุบัน (บาท)
  LAG(daily_revenue, 1) OVER ( -- ดึงรายได้ย้อนหลัง 1 วัน
   PARTITION BY store_name ORDER BY order_date -- แบ่งกลุ่มตามสาขา เรียงตามวันที่
  ) AS prev_day_revenue -- รายได้วันก่อนหน้า (บาท)
 FROM daily
 WHERE store_name = 'PiCha centralwOrld' -- กรองเฉพาะสาขา CentralWorld
)
SELECT
 store_name,          -- ชื่อสาขา
 order_date,          -- วันที่
 day_of_week,         -- วันในสัปดาห์ (ช่วยอ่านรูปแบบรายสัปดาห์ได้ทันที)
 daily_revenue,       -- รายได้วันปัจจุบัน (บาท)
 prev_day_revenue,    -- รายได้วันก่อนหน้า (บาท)
 daily_revenue - prev_day_revenue AS dod_change, -- ส่วนต่างรายได้เทียบวันก่อนหน้า (บาท)
 ROUND(100.0 * (daily_revenue - prev_day_revenue)
       / NULLIF(prev_day_revenue, 0), 1) AS dod_pct -- %เปลี่ยนแปลง DoD (ป้องกันหารด้วยศูนย์ด้วย NULLIF)
FROM lagged
ORDER BY order_date  -- เรียงตามวันที่จากเก่าไปใหม่
LIMIT 10;            -- แสดงเฉพาะ 10 วันแรก
store_name order_date day_of_week daily_revenue prev_day_revenue dod_change dod_pct
PiCha centralwOrld 2026-02-01 Sunday 42,700
PiCha centralwOrld 2026-02-02 Monday 18,427 42,700 −24,273 −56.8%
PiCha centralwOrld 2026-02-03 Tuesday 21,719 18,427 +3,292 +17.9%
PiCha centralwOrld 2026-02-04 Wednesday 23,555 21,719 +1,836 +8.5%
PiCha centralwOrld 2026-02-05 Thursday 29,111 23,555 +5,556 +23.6%
PiCha centralwOrld 2026-02-06 Friday 36,801 29,111 +7,690 +26.4%
PiCha centralwOrld 2026-02-07 Saturday 40,018 36,801 +3,217 +8.7%
PiCha centralwOrld 2026-02-08 Sunday 40,968 40,018 +950 +2.4%
PiCha centralwOrld 2026-02-09 Monday 18,429 40,968 −22,539 −55.0%
PiCha centralwOrld 2026-02-10 Tuesday 24,411 18,429 +5,982 +32.5%

วันจันทร์ = “Monday Cliff” ที่รุนแรงที่สุด เพราะรายได้ดิ่งลงจากอาทิตย์ −56.8% และ −55.0% ในสองสัปดาห์ติดกัน (~42,000 → ~18,400 บาท) สะท้อนว่า CentralWorld พึ่งพา Weekend Foot Traffic เป็นหลัก โดยลูกค้าวันธรรมดาไม่สามารถชดเชย Volume ที่หายไปได้

ส่วน วันศุกร์มี Momentum สูงสุด (+26.4%) ในบรรดาวันที่รายได้เพิ่มขึ้น ชี้ให้เห็นว่าลูกค้าเริ่มเข้าห้างมากขึ้นตั้งแต่ปลายสัปดาห์ การทำ Friday Promotion จะเป็นการ “เร่ง Momentum” ที่มีอยู่แล้ว ไม่ใช่การสร้างใหม่

มาดูวิธีการใช้ LAG ในการหา Customer Recency เพื่อต้องการหาลูกค้าที่ไม่ได้สั่งมานานที่สุด เพื่อวางแผน win-back campaign

-- ค้นหาลูกค้าที่ห่างหายนานที่สุด โดยดูจากช่วงเวลาระหว่างออร์เดอร์ล่าสุดกับครั้งก่อนหน้า
WITH cust_orders AS ( -- CTE ดึงประวัติออร์เดอร์และวันที่สั่งซื้อครั้งก่อนของลูกค้าแต่ละราย
 SELECT
  t.customer_id, -- รหัสลูกค้า
  t.order_date, -- วันที่สั่งซื้อ
  t.net_sales_thb, -- ยอดขายสุทธิ (บาท)
  LAG(t.order_date) OVER ( -- ดึงวันที่สั่งซื้อครั้งก่อนหน้า
   PARTITION BY t.customer_id ORDER BY t.order_date -- แบ่งกลุ่มตามลูกค้า เรียงตามวันที่
  ) AS prev_order_date, -- วันที่สั่งซื้อครั้งก่อนหน้า
  ROW_NUMBER() OVER (
   PARTITION BY t.customer_id ORDER BY t.order_date DESC -- เรียงจากออร์เดอร์ล่าสุดไปเก่าสุด
  ) AS rn_desc -- ลำดับที่ 1 = ออร์เดอร์ล่าสุดของลูกค้า
 FROM transactions t
 WHERE t.customer_id IS NOT NULL -- กรองเฉพาะแถวที่มีรหัสลูกค้า
)
SELECT
 customer_id, -- รหัสลูกค้า
 order_date AS last_order_date, -- วันที่สั่งซื้อล่าสุด
 prev_order_date, -- วันที่สั่งซื้อครั้งก่อนหน้า
 order_date - prev_order_date AS days_since_last, -- จำนวนวันระหว่างออร์เดอร์ล่าสุดกับครั้งก่อน
 net_sales_thb AS last_order_value -- มูลค่าออร์เดอร์ล่าสุด (บาท)
FROM cust_orders
WHERE rn_desc = 1 -- กรองเฉพาะออร์เดอร์ล่าสุดของแต่ละลูกค้า
 AND prev_order_date IS NOT NULL -- กรองเฉพาะลูกค้าที่เคยสั่งมากกว่า 1 ครั้ง
ORDER BY days_since_last DESC -- เรียงจากลูกค้าที่ห่างหายนานที่สุด
LIMIT 10; -- แสดงเฉพาะ 10 อันดับแรก
customer_id last_order_date prev_order_date days_since_last last_order_value
2044 2026-04-01 2026-02-20 40 235
3430 2026-04-01 2026-02-20 40 300
1695 2026-03-31 2026-02-20 39 185
2485 2026-03-31 2026-02-20 39 104.2
3429 2026-04-01 2026-02-22 38 465
2413 2026-04-01 2026-02-22 38 142
2834 2026-03-29 2026-02-20 37 288
1272 2026-03-31 2026-02-22 37 108
3202 2026-03-28 2026-02-19 37 419
3339 2026-03-27 2026-02-19 36 165.75

ข้อค้นพบคือมีลูกค้าที่หายไปนานที่สุด 40 วันจำนวน 2 คน (สั่งครั้งก่อนวันที่ 20 ก.พ. แล้วกลับมาอีกครั้งวันที่ 1 เม.ย.) โดยลูกค้า ID 3429 มีมูลค่าการสั่งซื้อครั้งล่าสุดสูงถึง 465 บาท สะท้อนว่าเป็นลูกค้าคุณภาพที่หายไป ขณะที่รูปแบบที่น่าสนใจคือกลุ่มลูกค้าที่หายไปนานมักสั่งครั้งสุดท้ายในช่วงกลางถึงปลายเดือนกุมภาพันธ์ แล้วกลับมาในช่วงปลายมีนาคมถึงต้นเมษายน ซึ่งมีช่วงห่างประมาณ 5–6 สัปดาห์ และอาจสะท้อนรอบพฤติกรรมการซื้อซ้ำตามธรรมชาติของลูกค้ากลุ่มนี้ ในเชิงบริหาร ลูกค้าที่หายไปเกิน 30 วันควรถูกนำเข้า win-back campaign เช่น การส่งคูปองส่วนลด 20% ผ่าน LINE หรือ SMS อย่างไรก็ตาม ควรแยกกลุ่มลูกค้าที่ “หายไปแล้วกลับมาเอง” ออกจากลูกค้าที่ “หายไปจริง” (ไม่กลับมาอีกเลย) เนื่องจากต้องใช้กลยุทธ์ที่แตกต่างกัน โดยข้อมูลชุดนี้แสดงเฉพาะลูกค้าที่กลับมาแล้ว หากต้องการวิเคราะห์ลูกค้าที่หายไปจริง ควรปรับเงื่อนไข query เป็น rn_desc = 1 แล้วพิจารณาวันที่ last_order_date ว่าห่างจากปัจจุบันกี่วัน

เทคนิค: รวม LAG กับ ROW_NUMBER

คำสั่งนี้ใช้ window functions 2 ตัวร่วมกัน

  • LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) หาวันที่สั่งครั้งก่อน
  • ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date DESC) หาออเดอร์ล่าสุด

การรวม window functions หลายตัวใน CTE เดียวกัน แล้วกรองใน query ชั้นนอก เป็น pattern ที่ใช้บ่อยในงานวิเคราะห์ลูกค้า

จากการวัด recency ระดับลูกค้า ขั้นถัดไปคือการจัดกลุ่มลูกค้าตามพฤติกรรมการใช้จ่ายโดยรวม เพื่อเปลี่ยนข้อมูลรายบุคคลให้กลายเป็น actionable segments ด้วย NTILE

NTILE: Customer Segmentation

NTILE(n) แบ่งแถวทั้งหมดออกเป็น n กลุ่มที่มีขนาดใกล้เคียงกัน โดยจัดเรียงตาม ORDER BY ที่กำหนด ต่างจาก RANK และ ROW_NUMBER ที่ให้ลำดับรายแถว NTILE ให้ป้ายกลุ่ม (1, 2, 3, …) ซึ่งเหมาะสำหรับการสร้าง spending quartiles หรือ frequency quintiles เพื่อออกแบบ loyalty program

ต้องการแบ่งลูกค้า 2,111 คนออกเป็น 4 กลุ่มตามยอดใช้จ่ายสะสม เพื่อเปรียบเทียบพฤติกรรมและวางแผน loyalty program

WITH customer_spend AS ( -- CTE คำนวณสถิติการใช้จ่ายของลูกค้าแต่ละราย
 SELECT
  t.customer_id, -- รหัสลูกค้า
  COUNT(*) AS total_orders, -- จำนวนออร์เดอร์ทั้งหมด
  ROUND(SUM(t.net_sales_thb), 0) AS total_spend, -- ยอดใช้จ่ายรวม (บาท)
  ROUND(AVG(t.net_sales_thb), 0) AS avg_order -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
 FROM transactions t
 WHERE t.customer_id IS NOT NULL -- กรองเฉพาะแถวที่มีรหัสลูกค้า
 GROUP BY t.customer_id -- จัดกลุ่มตามลูกค้าแต่ละราย
)
SELECT
 quartile, -- กลุ่มที่ (1 = ใช้จ่ายสูงสุด, 4 = ต่ำสุด)
 COUNT(*) AS num_customers, -- จำนวนลูกค้าในกลุ่ม
 MIN(total_spend) AS min_spend, -- ยอดใช้จ่ายต่ำสุดในกลุ่ม (บาท)
 MAX(total_spend) AS max_spend, -- ยอดใช้จ่ายสูงสุดในกลุ่ม (บาท)
 ROUND(AVG(total_spend), 0) AS avg_spend, -- ยอดใช้จ่ายเฉลี่ยในกลุ่ม (บาท)
 ROUND(AVG(total_orders), 1) AS avg_orders -- จำนวนออร์เดอร์เฉลี่ยในกลุ่ม
FROM (
 SELECT
  customer_id, -- รหัสลูกค้า
  total_orders, -- จำนวนออร์เดอร์
  total_spend, -- ยอดใช้จ่ายรวม
  avg_order, -- ค่าเฉลี่ยต่อออร์เดอร์
  NTILE(4) OVER (ORDER BY total_spend DESC) AS quartile -- แบ่งลูกค้าเป็น 4 กลุ่มตามยอดใช้จ่าย
 FROM customer_spend -- อ้างอิงจาก CTE customer_spend
)
GROUP BY quartile -- จัดกลุ่มตาม quartile
ORDER BY quartile; -- เรียงจากกลุ่มที่ใช้จ่ายสูงสุดไปต่ำสุด
quartile num_customers min_spend max_spend avg_spend avg_orders
1 528 1,822 10,450 3,023 14.4
2 528 964 1,821 1,339 6.5
3 528 423 964 679 3.5
4 527 68 421 232 1.5

Quartile 1 (top 25%) ประกอบด้วยลูกค้า 528 คน มียอดใช้จ่ายเฉลี่ย 3,023 บาท/คน และสั่งซื้อเฉลี่ย 14.4 ออร์เดอร์ ขณะที่ Quartile 4 (bottom 25%) ลูกค้า 527 คน มียอดเฉลี่ยเพียง 232 บาท และ 1.5 ออร์เดอร์ ช่องว่างระหว่างสองกลุ่มอยู่ที่กว่า 13 เท่า โดยประมาณการรายได้รวม top quartile อยู่ที่ ~1,596,144 บาท เทียบกับ bottom quartile เพียง ~122,264 บาท สอดคล้องกับหลัก Pareto (80/20 rule) ที่ลูกค้าส่วนน้อยสร้างรายได้ส่วนใหญ่ ในเชิงบริหาร การรักษาลูกค้า top quartile จึงสำคัญที่สุด เพราะการสูญเสียลูกค้าเพียง 10% ของกลุ่มนี้จะกระทบรายได้ราว 160,000 บาท ควรลงทุนใน loyalty program เช่น priority queue, free upgrades และ birthday rewards สำหรับกลุ่มนี้โดยตรง ในขณะเดียวกัน หากสามารถยกระดับ bottom quartile ขึ้น 1 ขั้น (จาก 232 เป็น 679 บาท/คน) จะเพิ่มรายได้ ~235,519 บาท แต่ต้องชั่งน้ำหนักกับต้นทุนแคมเปญด้วย

ข้อควรระวัง: จำนวนกลุ่มของ NTILE

NTILE(4) แบ่งเป็น 4 กลุ่มเท่า ๆ กัน (quartiles) โดยอัตโนมัติ ถ้าจำนวนแถวหารไม่ลงตัว กลุ่มแรก ๆ จะมีสมาชิกมากกว่ากลุ่มหลัง 1 คน (ในตัวอย่างนี้ 2,111 ลูกค้า ÷ 4 = 528, 528, 528, 527) การเปลี่ยนจำนวนกลุ่มเป็น NTILE(5) หรือ NTILE(10) จะได้ segments ที่ละเอียดขึ้น การเลือกจำนวนกลุ่มขึ้นอยู่กับ business context ไม่ใช่สูตรตายตัว สำหรับ PiCha ที่ต้องการ actionable segments, quartiles (4 กลุ่ม) เป็นจุดเริ่มต้นที่เหมาะสม

ตัวอย่างต่อไปจะพาออกจากมุมมอง per-customer ชั่วคราว กลับไปดูมุมมองระดับบริษัท โดยใช้ OVER() แบบไม่มี PARTITION BY เพื่อคำนวณ benchmark กลางของทั้งบริษัทแล้วเปรียบเทียบกับแต่ละสาขาในแถวเดียว

ถัดมาเป็นการดูว่าแต่ละสาขาพึ่งพาช่องทางไหนมากที่สุด เพื่อประเมิน channel concentration risk

-- วิเคราะห์สัดส่วนรายได้แยกตามช่องทางการขายของแต่ละสาขา
WITH channel_rev AS ( -- CTE คำนวณรายได้แยกตามสาขาและช่องทางการขาย
 SELECT
  s.store_name, -- ชื่อสาขา
  t.channel, -- ช่องทางการขาย
  ROUND(SUM(t.net_sales_thb), 0) AS channel_revenue -- รายได้รวมของช่องทางนั้นในสาขานั้น (บาท)
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name, t.channel -- จัดกลุ่มตามสาขาและช่องทาง
)
SELECT
 store_name, -- ชื่อสาขา
 channel, -- ช่องทางการขาย
 channel_revenue, -- รายได้ของช่องทางนั้น (บาท)
 ROUND(100.0 * channel_revenue / SUM(channel_revenue) OVER (PARTITION BY store_name), 1) AS pct_of_store, -- สัดส่วนรายได้ช่องทางนั้นต่อรายได้รวมของสาขา (%)
 SUM(channel_revenue) OVER (PARTITION BY store_name) AS store_total -- รายได้รวมทั้งหมดของสาขา (บาท)
FROM channel_rev
ORDER BY store_name, channel_revenue DESC; -- เรียงตามสาขา แล้วตามรายได้ช่องทางจากมากไปน้อย
store_name channel channel_revenue pct_of_store store_total
PiCha Exchange Tower to_go 703,563 52.4 1,343,861
PiCha Exchange Tower delivery 573,086 42.6 1,343,861
PiCha Exchange Tower dine_in 67,212 5 1,343,861
PiCha ICONSIAM delivery 917,245 52 1,763,926
PiCha ICONSIAM dine_in 455,380 25.8 1,763,926
PiCha ICONSIAM to_go 391,301 22.2 1,763,926
PiCha Park Silom to_go 759,089 52.3 1,450,877
PiCha Park Silom delivery 618,342 42.6 1,450,877
PiCha Park Silom dine_in 73,446 5.1 1,450,877
PiCha Siam Square delivery 1,399,430 60.5 2,311,859
PiCha Siam Square to_go 744,373 32.2 2,311,859
PiCha Siam Square dine_in 168,056 7.3 2,311,859
PiCha Vanit Village delivery 741,464 50.6 1,465,763
PiCha Vanit Village to_go 556,672 38 1,465,763
PiCha Vanit Village dine_in 167,627 11.4 1,465,763
PiCha centralwOrld delivery 1,309,803 53.4 2,451,114
PiCha centralwOrld to_go 570,940 23.3 2,451,114
PiCha centralwOrld dine_in 570,371 23.3 2,451,114

จะเห็นได้ว่า Siam Square พึ่งพา delivery มากที่สุดที่ 60.5% ขณะที่ Exchange Tower และ Park Silom พึ่งพา to_go มากที่สุดในช่วง 52.3–52.4% และ ICONSIAM มีสัดส่วน dine_in สูงสุดที่ 25.8% ซึ่งสอดคล้องกับบทบาทของ flagship experience store โดยรูปแบบที่น่าสนใจคือสาขาในอาคารสำนักงาน เช่น Exchange Tower และ Park Silom มี to_go เป็นช่องทางหลัก สะท้อนพฤติกรรมพนักงานออฟฟิศที่ซื้อกลับไปดื่มที่โต๊ะทำงาน ในขณะที่สาขาในห้างฯ พึ่งพา delivery เป็นหลัก และ centralwOrld มีสัดส่วน dine_in และ to_go ใกล้เคียงกันที่ 23.3% ทำให้มี channel mix ที่กระจายตัวที่สุดใน 6 สาขา ในเชิงบริหารพบว่า Siam Square มีการพึ่งพา delivery เกิน 60% ซึ่งสร้าง platform risk ค่อนข้างสูง หากเกิดการเปลี่ยนแปลงค่าคอมมิชชันหรือปัญหาทางเทคนิคจากแพลตฟอร์มจะกระทบรายได้อย่างมีนัยสำคัญมากกว่าสาขาอื่น ดังนั้นควรวางแผนเพิ่มสัดส่วน dine_in และ to_go เพื่อลดการพึ่งพาช่องทางเดียวและกระจายความเสี่ยงในระยะยาว

Syntax Spotlight: Revenue Share ด้วย Window Function

ROUND(
 100.0 * channel_revenue
 / SUM(channel_revenue) OVER (PARTITION BY store_name),
 1
) AS pct_of_store

SUM(channel_revenue) OVER (PARTITION BY store_name) คำนวณรายได้รวมของแต่ละสาขาแล้วนำมาเป็นฐานหาร ทำให้ได้ % share ต่อ channel โดยไม่ต้อง self-join หรือ subquery

เมื่อเข้าใจ pattern หลักของ window functions ทั้งหมดแล้ว สิ่งสำคัญเท่ากันคือรู้ว่าเมื่อใดที่ควรหยุดใช้และกลับไปใช้ GROUP BY แทน

สรุป

ในบทนี้ เราได้เรียนรู้การใช้ window functions ซึ่งเป็นเครื่องมือสำคัญสำหรับการวิเคราะห์ข้อมูลเชิงลำดับ เปรียบเทียบค่าในบริบทของกลุ่ม และติดตามแนวโน้มของตัวชี้วัดทางธุรกิจโดยไม่ต้องลดจำนวนแถวลงเหมือน aggregate functions แบบปกติ จุดเด่นของ window functions คือสามารถคำนวณค่าระดับกลุ่มหรือระดับช่วงเวลา พร้อมกับยังคงรายละเอียดของแต่ละแถวไว้ได้

ตารางต่อไปนี้สรุปแนวคิดสำคัญ syntax และตัวอย่างการประยุกต์ใช้ในบริบทธุรกิจของ PiCha

แนวคิด Syntax การใช้ธุรกิจ (PiCha)
ROW_NUMBER ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) ใช้จัดลำดับข้อมูลแบบไม่ให้มีอันดับซ้ำ เช่น หา Top 3 สินค้าขายดีที่สุดของแต่ละสาขา
RANK RANK() OVER (PARTITION BY ... ORDER BY ...) ใช้จัดอันดับโดยอนุญาตให้มีอันดับร่วม เหมาะสำหรับจัดอันดับ SKU ตามรายได้ต่อวัน
DENSE_RANK DENSE_RANK() OVER (PARTITION BY ... ORDER BY ...) ใช้จัดอันดับแบบไม่มีช่องว่างของลำดับ เหมาะสำหรับสร้าง performance tier ของสาขาในแต่ละเดือน
Running Total SUM(col) OVER (PARTITION BY ... ORDER BY ... ROWS UNBOUNDED PRECEDING) ใช้คำนวณรายได้สะสมตามวันของแต่ละสาขาหรือแต่ละช่องทางการขาย
Moving Average AVG(col) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN n PRECEDING AND CURRENT ROW) ใช้คำนวณค่าเฉลี่ยเคลื่อนที่ เช่น rolling 7-day หรือ 3-day average เพื่อดูแนวโน้มและใช้เป็น baseline สำหรับการแจ้งเตือน
LAG LAG(col, 1) OVER (PARTITION BY ... ORDER BY ...) ใช้เปรียบเทียบยอดขายวันนี้กับเมื่อวาน หรือเปรียบเทียบออเดอร์ล่าสุดกับคำสั่งซื้อครั้งก่อนของลูกค้าแต่ละคน
NTILE NTILE(n) OVER (ORDER BY ...) ใช้แบ่งลูกค้าตามยอดใช้จ่ายหรือจำนวนออเดอร์ออกเป็นกลุ่ม เช่น quartiles หรือ quintiles
OVER() แบบไม่ partition AVG(col) OVER () ใช้คำนวณ benchmark ระดับบริษัท เช่น AOV ทั้งบริษัท แล้วนำไปเปรียบเทียบกับแต่ละสาขา
Window + CASE CASE WHEN col > window_avg * 1.2 THEN ... END ใช้สร้างสถานะ เช่น Above Avg / Below Avg หรือสัญญาณเตือนความผิดปกติของรายได้และ KPI อื่น ๆ

เมื่อมองภาพรวม window functions ช่วยให้เราตอบคำถามทางธุรกิจได้ละเอียดขึ้น เช่น “สาขานี้อยู่ลำดับที่เท่าไรในเดือนนี้” “ยอดขายวันนี้สูงกว่าค่าเฉลี่ยล่าสุดหรือไม่” หรือ “ลูกค้าคนนี้อยู่ในกลุ่มใช้จ่ายระดับไหน” จึงเป็นเครื่องมือสำคัญสำหรับทั้งการทำ dashboard การวิเคราะห์เชิงปฏิบัติการ และการติดตาม performance อย่างต่อเนื่อง

คำถามท้ายบท

  1. ใช้ ROW_NUMBER() หา top 3 สินค้าขายดีที่สุดของแต่ละสาขา (ตามจำนวนชิ้น)

ผลลัพธ์ที่คาดหวัง

store_name rank sku_name units_sold
PiCha CentralWorld 1 Jasmine Milk Tea L 2,145
PiCha CentralWorld 2 White Peach Milk Tea L 1,846
PiCha CentralWorld 3 Da Hong Pao Milk Tea L 1,686
PiCha Exchange Tower 1 Jasmine Milk Tea L 1,257
PiCha Exchange Tower 2 White Peach Milk Tea L 1,146
PiCha Exchange Tower 3 Da Hong Pao Milk Tea L 1,017
PiCha ICONSIAM 1 Jasmine Milk Tea L 1,558
PiCha ICONSIAM 2 White Peach Milk Tea L 1,494
PiCha ICONSIAM 3 Da Hong Pao Milk Tea L 1,335
PiCha Park Silom 1 Jasmine Milk Tea L 1,372
PiCha Park Silom 2 White Peach Milk Tea L 1,187
PiCha Park Silom 3 Da Hong Pao Milk Tea L 1,070
PiCha Siam Square 1 Jasmine Milk Tea L 2,024
PiCha Siam Square 2 White Peach Milk Tea L 1,714
PiCha Siam Square 3 Da Hong Pao Milk Tea L 1,622
PiCha Vanit Village 1 Jasmine Milk Tea L 1,342
PiCha Vanit Village 2 White Peach Milk Tea L 1,168
PiCha Vanit Village 3 Da Hong Pao Milk Tea L 1,081
คลิกเพื่อดูเฉลย
-- หา 3 สินค้าขายดีที่สุด (ตามจำนวนชิ้น) ของแต่ละสาขา
WITH ranked AS ( -- CTE จัดอันดับสินค้าตามจำนวนที่ขายได้ภายในแต่ละสาขา
 SELECT
  s.store_name, -- ชื่อสาขา
  m.sku_name, -- ชื่อสินค้า
  SUM(oi.qty) AS units_sold, -- จำนวนชิ้นที่ขายได้รวม
  RANK() OVER (PARTITION BY s.store_id ORDER BY SUM(oi.qty) DESC) AS rn -- ลำดับที่ 1 = ขายดีที่สุดในสาขานั้น
 FROM order_items oi
 JOIN transactions t  ON oi.transaction_id = t.transaction_id -- เชื่อมตารางธุรกรรม
 JOIN stores s        ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 JOIN menus m         ON oi.sku_id = m.sku_id -- เชื่อมตารางเมนูเพื่อดึงชื่อสินค้า
 GROUP BY s.store_id, s.store_name, m.sku_id, m.sku_name -- จัดกลุ่มตามสาขาและสินค้า
)
SELECT store_name, rn AS rank, sku_name, units_sold
FROM ranked
WHERE rn <= 3 -- กรองเฉพาะ Top 3 ของแต่ละสาขา
ORDER BY store_name, rn; -- เรียงตามสาขา แล้วตามลำดับยอดขาย
  1. ใช้ LAG() คำนวณ week-over-week growth rate ของรายได้รวมทั้งบริษัท (ต้อง GROUP BY week ก่อน แล้วจึงใช้ LAG เปรียบเทียบสัปดาห์ปัจจุบันกับสัปดาห์ก่อนหน้า)

ผลลัพธ์ที่คาดหวัง

week_num week_start revenue prev_week_revenue wow_growth_pct
1 2026-01-26 147,428 NULL NULL
2 2026-02-02 946,918 147,428 542.3
3 2026-02-09 1,319,122 946,918 39.3
4 2026-02-16 1,505,723 1,319,122 14.1
5 2026-02-23 1,158,533 1,505,723 -23.1
6 2026-03-02 1,094,839 1,158,533 -5.5
7 2026-03-09 1,285,490 1,094,839 17.4
8 2026-03-16 1,273,984 1,285,490 -0.9
9 2026-03-23 1,446,546 1,273,984 13.5
10 2026-03-30 608,819 1,446,546 -57.9
คลิกเพื่อดูเฉลย
-- วิเคราะห์การเติบโตของรายได้รายสัปดาห์ (Week-over-Week) ทั้งบริษัท
WITH weekly AS ( -- CTE รวมรายได้แยกตามสัปดาห์
 SELECT
   DATE_TRUNC('week', order_date) AS week_start, -- วันเริ่มต้นของแต่ละสัปดาห์ (วันจันทร์)
   ROUND(SUM(net_sales_thb), 0) AS revenue -- รายได้รวมของสัปดาห์นั้น (บาท)
 FROM transactions
 GROUP BY DATE_TRUNC('week', order_date) -- จัดกลุ่มตามสัปดาห์
),
numbered AS ( -- CTE กำหนดเลขลำดับสัปดาห์
 SELECT
   ROW_NUMBER() OVER (ORDER BY week_start) AS week_num, -- ลำดับสัปดาห์ที่ 1, 2, 3, ...
   week_start, -- วันเริ่มต้นสัปดาห์
   revenue -- รายได้ของสัปดาห์
 FROM weekly
)
SELECT
 week_num, -- ลำดับสัปดาห์
 week_start, -- วันเริ่มต้นสัปดาห์
 revenue, -- รายได้สัปดาห์นี้ (บาท)
 LAG(revenue) OVER (ORDER BY week_start) AS prev_week_revenue, -- รายได้สัปดาห์ก่อนหน้า (บาท)
 ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY week_start))
       / LAG(revenue) OVER (ORDER BY week_start), 1) AS wow_growth_pct -- อัตราการเติบโตเทียบสัปดาห์ก่อน (%)
FROM numbered
ORDER BY week_start; -- เรียงตามสัปดาห์จากเก่าไปใหม่
  1. ใช้ NTILE(5) แบ่งลูกค้าเป็น 5 กลุ่มตามจำนวนออเดอร์ พร้อมแสดงสถิติแต่ละกลุ่ม

ผลลัพธ์ที่คาดหวัง

quintile customers min_orders max_orders avg_orders
1 423 1 2 1.2
2 423 2 4 2.7
3 422 4 6 4.7
4 422 6 10 8.0
5 422 10 39,621 109.7
คลิกเพื่อดูเฉลย
-- จัดกลุ่มลูกค้าเป็น 5 ระดับตามความถี่ในการสั่งซื้อ (Quintile Analysis)

WITH customer_orders AS ( -- CTE นับจำนวนออร์เดอร์ของลูกค้าแต่ละราย
 SELECT
   customer_id, -- รหัสลูกค้า
   COUNT(*) AS order_count -- จำนวนออร์เดอร์ทั้งหมด
 FROM transactions
 GROUP BY customer_id -- จัดกลุ่มตามลูกค้าแต่ละราย
),
tiered AS ( -- CTE แบ่งลูกค้าเป็น 5 กลุ่มตามความถี่สั่งซื้อ
 SELECT
   customer_id, -- รหัสลูกค้า
   order_count, -- จำนวนออร์เดอร์
   NTILE(5) OVER (ORDER BY order_count) AS quintile -- แบ่งเป็น 5 กลุ่มเท่า ๆ กัน (1 = น้อยสุด, 5 = มากสุด)
 FROM customer_orders
)
SELECT
 quintile, -- กลุ่มที่ (1–5)
 COUNT(*) AS customers, -- จำนวนลูกค้าในกลุ่ม
 MIN(order_count) AS min_orders, -- จำนวนออร์เดอร์น้อยสุดในกลุ่ม
 MAX(order_count) AS max_orders, -- จำนวนออร์เดอร์มากสุดในกลุ่ม
 ROUND(AVG(order_count), 1) AS avg_orders -- จำนวนออร์เดอร์เฉลี่ยในกลุ่ม
FROM tiered
GROUP BY quintile -- จัดกลุ่มตาม quintile
ORDER BY quintile; -- เรียงจากกลุ่มที่สั่งน้อยสุดไปมากสุด
  1. ใช้ SUM OVER สร้าง cumulative revenue ของแต่ละ channel แยกกัน (PARTITION BY channel)

ผลลัพธ์ที่คาดหวัง (6 แถวแรกของ delivery)

channel order_date daily_revenue cumulative_revenue
delivery 2026-02-01 81,819 81,819
delivery 2026-02-02 53,610 135,429
delivery 2026-02-03 68,029 203,458
delivery 2026-02-04 64,087 267,545
delivery 2026-02-05 66,963 334,507
delivery 2026-02-06 80,783 415,290

ใช้ SUM(SUM(...)) สองชั้น โดยชั้นใน GROUP BY รวมยอดต่อวัน ชั้นนอก Window Function สะสมเพิ่มขึ้นทุกวัน | PARTITION BY channel ทำให้ตัวนับรีเซ็ตต่อช่องทาง ไม่ปะปนกัน

คลิกเพื่อดูเฉลย
-- คำนวณรายได้รายวันและรายได้สะสมแยกตามช่องทางการขาย

SELECT
 channel, -- ช่องทางการขาย
 order_date, -- วันที่
 ROUND(SUM(net_sales_thb), 0) AS daily_revenue, -- รายได้รวมต่อวันของช่องทางนั้น (บาท)
 ROUND(SUM(SUM(net_sales_thb)) OVER (
   PARTITION BY channel ORDER BY order_date -- แบ่งกลุ่มตามช่องทาง เรียงตามวันที่
   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- รวมตั้งแต่วันแรกจนถึงวันปัจจุบัน
 ), 0) AS cumulative_revenue -- รายได้สะสมของช่องทางนั้น (บาท)
FROM transactions
GROUP BY channel, order_date -- จัดกลุ่มตามช่องทางและวันที่
ORDER BY channel, order_date; -- เรียงตามช่องทาง แล้วตามวันที่จากเก่าไปใหม่
  1. ใช้ CASE + window function สร้างสัญญาณเตือนเมื่อรายได้วันไหนสูงหรือต่ำกว่า rolling 7-day average มากกว่า 20%

ผลลัพธ์ที่คาดหวัง

order_date daily_revenue rolling_7d_avg pct_vs_avg status
2026-02-13 193284.0 144370.0 33.9 ALERT: สงกวาคาเฉลย >20%
2026-02-14 272773.0 162541.0 67.8 ALERT: สงกวาคาเฉลย >20%
2026-02-15 318449.0 188446.0 69.0 ALERT: สงกวาคาเฉลย >20%
2026-02-21 179349.0 236579.0 -24.2 ALERT: ตำกวาคาเฉลย >20%
2026-02-22 168118.0 215103.0 -21.8 ALERT: ตำกวาคาเฉลย >20%
2026-02-23 132616.0 206013.0 -35.6 ALERT: ตำกวาคาเฉลย >20%
2026-02-24 154388.0 195874.0 -21.2 ALERT: ตำกวาคาเฉลย >20%
2026-03-02 97480.0 160485.0 -39.3 ALERT: ตำกวาคาเฉลย >20%
2026-03-03 123983.0 156142.0 -20.6 ALERT: ตำกวาคาเฉลย >20%
2026-03-07 186629.0 152733.0 22.2 ALERT: สงกวาคาเฉลย >20%
2026-03-08 197483.0 156406.0 26.3 ALERT: สงกวาคาเฉลย >20%
2026-03-23 140780.0 181118.0 -22.3 ALERT: ตำกวาคาเฉลย >20%
2026-03-27 244102.0 195715.0 24.7 ALERT: สงกวาคาเฉลย >20%
คลิกเพื่อดูเฉลย
-- ตรวจจับวันที่รายได้ผิดปกติ (เบี่ยงเบินจากค่าเฉลี่ย 7 วันมากกว่า 20%)

WITH daily AS ( -- CTE คำนวณรายได้รวมรายวันทั้งบริษัท
 SELECT
   order_date, -- วันที่
   ROUND(SUM(net_sales_thb), 0) AS daily_revenue -- รายได้รวมต่อวัน (บาท)
 FROM transactions
 GROUP BY order_date -- จัดกลุ่มตามวันที่
),
with_avg AS ( -- CTE คำนวณค่าเฉลี่ยเคลื่อนที่ 7 วัน
 SELECT
   order_date, -- วันที่
   daily_revenue, -- รายได้ต่อวัน (บาท)
   ROUND(AVG(daily_revenue) OVER (
     ORDER BY order_date
     ROWS BETWEEN 6 PRECEDING AND CURRENT ROW -- ใช้ข้อมูล 6 วันก่อนหน้า + วันปัจจุบัน
   ), 0) AS rolling_7d_avg -- ค่าเฉลี่ยเคลื่อนที่ 7 วัน (บาท)
 FROM daily
)
SELECT
 order_date, -- วันที่
 daily_revenue, -- รายได้ต่อวัน (บาท)
 rolling_7d_avg, -- ค่าเฉลี่ยเคลื่อนที่ 7 วัน (บาท)
 ROUND(100.0 * (daily_revenue - rolling_7d_avg) / rolling_7d_avg, 1) AS pct_vs_avg, -- % เบี่ยงเบินจากค่าเฉลี่ย
 CASE
   WHEN daily_revenue < rolling_7d_avg * 0.80 THEN 'ALERT: ต่ำกว่าค่าเฉลี่ย >20%' -- รายได้ต่ำผิดปกติ
   WHEN daily_revenue > rolling_7d_avg * 1.20 THEN 'ALERT: สูงกว่าค่าเฉลี่ย >20%' -- รายได้สูงผิดปกติ
   ELSE 'ปกติ'
 END AS status -- สถานะความผิดปกติ
FROM with_avg
WHERE status ILIKE 'ALERT%' -- กรองเฉพาะวันที่มีความผิดปกติ
ORDER BY order_date; -- เรียงตามวันที่จากเก่าไปใหม่