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

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

  1. เขียน Common Table Expression (CTE) ได้
  2. อธิบายได้ว่าทำไม CTEs ทำให้โค้ดดูแลง่ายและทำงานร่วมกันเป็นทีมได้ดีขึ้น

บทนำ

คำถามทางธุรกิจหลายข้อไม่สามารถตอบได้ด้วยการดูข้อมูลดิบโดยตรง แต่ต้องเริ่มจากการสร้าง “ค่าอ้างอิง” ขึ้นมาก่อน แล้วจึงนำค่าดังกล่าวมาใช้เปรียบเทียบ วิเคราะห์ หรือคัดกรองข้อมูลที่สนใจ ตัวอย่างเช่น สาขาใดมีรายได้รายวันสูงกว่าระดับปกติอย่างผิดสังเกต และความผิดปกตินั้นเกิดจากปัจจัยใด หรือมีกลุ่มลูกค้าใดที่สั่งเฉพาะ milk tea โดยไม่เคยสั่ง fruit tea เลย และธุรกิจควรตอบสนองต่อพฤติกรรมดังกล่าวอย่างไร

การตอบคำถามลักษณะนี้มักต้องอาศัยการคำนวณข้อมูลอ้างอิงล่วงหน้า เช่น รายได้เฉลี่ยของทั้งบริษัท รายได้เฉลี่ยต่อวันของแต่ละสาขา หรือรายชื่อลูกค้าที่มีพฤติกรรมการสั่งซื้อเฉพาะแบบ เมื่อพยายามเขียนโจทย์เช่นนี้ด้วย subquery หลายชั้นภายในคำสั่งเดียว โค้ดมักยาว ซับซ้อน และตรวจสอบได้ยาก

ส่วนถัดไปแนะนำการใช้ Common Table Expression ซึ่งช่วยแยกโจทย์ขนาดใหญ่ออกเป็นขั้นตอนย่อยที่มีความหมายชัดเจน แต่ละขั้นสามารถอ่าน ทำความเข้าใจ และทดสอบได้แยกจากกัน ก่อนนำผลลัพธ์ไปประกอบเป็นคำตอบทางธุรกิจที่สมบูรณ์ วิธีคิดเช่นนี้ไม่เพียงช่วยให้ query อ่านง่ายขึ้น แต่ยังเหมาะสำหรับการต่อยอดไปสู่งานวิเคราะห์ที่มีโครงสร้างซับซ้อนในลักษณะของกระบวนการวิเคราะห์แบบเป็นลำดับขั้น (pipeline) ได้อย่างมีประสิทธิภาพ

CTE คืออะไร?

CTE (Common Table Expression) คือผลลัพธ์ชั่วคราวที่มีชื่อ ซึ่งประกาศไว้ตอนต้นของคำสั่ง SQL ด้วย WITH แล้วนำชื่อดังกล่าวมาอ้างอิงต่อภายในคำสั่งเดียวกัน ผลลัพธ์นี้ทำหน้าที่เสมือนเป็นตารางชั่วคราวที่มีขอบเขตการใช้งานเฉพาะใน query นั้นเท่านั้น

กล่าวอีกนัยหนึ่ง CTE คือการนำ query หนึ่งมาตั้งชื่อ เพื่อให้คำสั่ง SQL อ่านง่ายขึ้น มีโครงสร้างชัดเจนขึ้น และสามารถนำผลลัพธ์ที่นิยามไว้มาใช้ต่อได้ภายใน statement เดียว แนวทางนี้มีประโยชน์อย่างมากเมื่อ query เริ่มซับซ้อน เพราะช่วยแยกปัญหาใหญ่ออกเป็นขั้นตอนย่อยที่เข้าใจและตรวจสอบได้ง่ายขึ้น

รูปแบบคำสั่งพื้นฐานของ CTE

โครงสร้างทั่วไปของ CTE มีลักษณะดังนี้

WITH cte_name AS (
    SELECT ...
    FROM ...
    WHERE ...
)
SELECT ...
FROM cte_name
WHERE ...;

แนวคิดสำคัญของโครงสร้างนี้มีดังนี้

  • WITH ใช้สำหรับประกาศการสร้าง CTE
  • cte_name คือชื่อของ CTE ที่จะนำไปอ้างอิงต่อใน query หลัก
  • คำสั่งภายในวงเล็บ (...) คือ SELECT ที่ใช้กำหนดผลลัพธ์ของ CTE

นอกจากนี้ ในคำสั่ง SQL หนึ่งชุดยังสามารถประกาศ CTE ได้มากกว่าหนึ่งตัว โดยคั่นแต่ละ CTE ด้วยเครื่องหมายจุลภาค , ดังตัวอย่างต่อไปนี้

WITH cte1 AS (
    SELECT ...
),
cte2 AS (
    SELECT ...
    FROM cte1
)
SELECT ...
FROM cte2;

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

CTE ต่างจาก subquery อย่างไร?

แม้ว่า CTE และ subquery จะมีจุดร่วมตรงที่ต่างก็ใช้สร้างผลลัพธ์ชั่วคราวเพื่อช่วยจัดการ query ที่ซับซ้อน แต่ทั้งสองแนวทางมีลักษณะการใช้งานและจุดเด่นที่แตกต่างกันในทางปฏิบัติ โดย subquery มักถูกเขียนซ้อนอยู่ภายใน SELECT, FROM, WHERE หรือ HAVING ของ query หลัก ขณะที่ CTE จะถูกประกาศไว้ล่วงหน้าด้วย WITH แล้วจึงนำชื่อที่ตั้งไว้มาอ้างอิงในส่วนถัดไปของคำสั่ง

ความแตกต่างที่สำคัญคือ CTE ช่วยให้โครงสร้างของ query ชัดเจนขึ้น เพราะสามารถแยกการประมวลผลออกเป็นขั้นตอนที่ตั้งชื่อได้อย่างมีความหมาย เช่น ขั้นเตรียมข้อมูลรายวัน ขั้นคำนวณค่าเฉลี่ย หรือขั้นจัดกลุ่มลูกค้า ในทางตรงกันข้าม หากใช้ subquery ซ้อนหลายชั้น query อาจอ่านยากขึ้น โดยเฉพาะเมื่อมีตรรกะหลายส่วนที่ต้องทำงานต่อเนื่องกัน

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

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

ข้อดีของ CTE ในงานวิเคราะห์ข้อมูล

CTE มีประโยชน์อย่างมากในงานวิเคราะห์ข้อมูล เพราะช่วยให้ query ที่ซับซ้อนมีโครงสร้างที่เข้าใจง่ายและตรวจสอบได้เป็นขั้นตอน ผู้เรียนสามารถอ่านแต่ละ CTE เสมือนเป็น “บล็อกย่อย” ของตรรกะการวิเคราะห์ ซึ่งช่วยลดภาระในการทำความเข้าใจคำสั่งที่ยาวหรือมี subquery หลายชั้น

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

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

กล่าวโดยสรุป CTE ไม่ได้เป็นเพียงทางเลือกทางไวยากรณ์ของ SQL เท่านั้น แต่เป็นเครื่องมือที่ช่วยให้ผู้เรียนออกแบบ query ได้อย่างมีระบบ อ่านง่าย และเหมาะกับการวิเคราะห์ข้อมูลที่มีความซับซ้อนมากขึ้นในทางปฏิบัติ

ตารางเปรียบเทียบ

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

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

เมื่อมี CTE หรือ subquery อยู่ในคำสั่งเดียวกัน การเข้าใจลำดับการประมวลผลจะช่วยให้เขียน query ได้ถูกต้องมากขึ้น โดยทั่วไป CTE จะถูกประมวลผลก่อน แล้วผลลัพธ์ที่ได้จะถูกนำมาใช้เสมือนเป็นตารางชั่วคราวใน query หลัก จากนั้นระบบจึงทำงานตามลำดับของ SQL clause ตามปกติ

graph TD
 A["CTEs: ประมวลผลทีละ CTE"] --> B["FROM: โหลดตารางหลัก"]
 B --> C["JOIN: รวมตาราง"]
 C --> D["WHERE: กรองแถว (ใช้ subquery ได้)"]
 D --> E["GROUP BY: แบ่งกลุ่ม"]
 E --> F["HAVING: กรองกลุ่ม"]
 F --> G["SELECT: เลือกคอลัมน์"]
 G --> H["ORDER BY: เรียงลำดับ"]

เพื่อให้เห็นการใช้งาน CTE ส่วนต่อไปจะเริ่มจากตัวอย่างที่ใช้ CTE เพียงหนึ่งชุดสำหรับสรุปข้อมูล เพื่อหาเมนูที่ขายได้มากกว่า 5,000 แก้ว

WITH menu_summary AS (    -- ตั้งชื่อผลลัพธ์กลางว่า menu_summary
 SELECT
  m.sku_name,             -- ชื่อเมนู
  m.family,               -- หมวดเมนู (milk_tea, fruit_tea ฯลฯ)
  COUNT(*) AS units_sold, -- จำนวนแก้วที่ขายได้ทั้งหมด
  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
 GROUP BY m.sku_id, m.sku_name, m.family
)
SELECT
 sku_name,
 family,
 units_sold,
 total_revenue
FROM menu_summary         -- ใช้ menu_summary เหมือนตารางจริง
WHERE units_sold > 5000   -- กรองด้วย alias ได้เลย
ORDER BY units_sold DESC;
sku_name family units_sold total_revenue
Jasmine Milk Tea L milk_tea 9,698 1,151,147
White Peach Milk Tea L milk_tea 8,555 1,014,856
Da Hong Pao Milk Tea L milk_tea 7,811 1,047,280
Peach Jasmine Tea L fruit_tea 5,224 513,396

มีเพียง 4 เมนูที่ผ่านเกณฑ์ 5,000 แก้ว ทั้งหมดเป็นขนาด L และ 3 ใน 4 อยู่ใน family milk_tea Jasmine Milk Tea L ขายได้สูงสุดเกือบ 10,000 แก้ว และสร้างรายได้ 1.15 ล้านบาท คิดเป็นประมาณ 9% ของรายได้ทั้งเครือจากเมนูเดียว

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

การตอบคำถามนี้ต้องทำ 3 ขั้นตอนตามลำดับ

  1. รวมรายได้แต่ละสาขาSUM แยก GROUP BY store_name
  2. คำนวณค่าเฉลี่ยของทุกสาขาAVG จากผลลัพธ์ขั้นที่ 1
  3. เปรียบเทียบ — นำรายได้แต่ละสาขาลบด้วยค่าเฉลี่ย
WITH store_revenue AS (  -- ขั้น 1: รวมรายได้แต่ละสาขา
 SELECT
  s.store_name,                                    -- ชื่อสาขา
  ROUND(SUM(t.net_sales_thb), 0) AS total_revenue  -- รายได้รวมของสาขา (บาท)
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id
 GROUP BY s.store_name
),
company_avg AS (         -- ขั้น 2: คำนวณค่าเฉลี่ย จาก store_revenue
 SELECT ROUND(AVG(total_revenue), 0) AS avg_revenue
 FROM store_revenue      -- อ้างอิง CTE ขั้น 1 ได้โดยตรง
)
SELECT                   -- ขั้น 3: เปรียบเทียบสาขากับค่าเฉลี่ย
 sr.store_name,
 sr.total_revenue,
 ca.avg_revenue,
 sr.total_revenue - ca.avg_revenue AS diff  -- บวก = สูงกว่าเฉลี่ย, ลบ = ต่ำกว่าเฉลี่ย
FROM store_revenue sr
CROSS JOIN company_avg ca                   -- company_avg มีแถวเดียว จึง CROSS JOIN ได้
ORDER BY sr.total_revenue DESC;
store_name total_revenue avg_revenue diff
PiCha CentralWorld 2,451,113 1,797,900 +653,213
PiCha Siam Square 2,311,860 1,797,900 +513,960
PiCha ICONSIAM 1,763,926 1,797,900 −33,974
PiCha Vanit Village 1,465,764 1,797,900 −332,136
PiCha Park Silom 1,450,877 1,797,900 −347,023
PiCha Exchange Tower 1,343,861 1,797,900 −454,039

สังเกตสิ่งสำคัญในโค้ดข้างต้น: company_avg ในขั้นที่ 2 อ้างอิง store_revenue ได้โดยตรง ราวกับว่า store_revenue เป็นตารางจริงในฐานข้อมูล นี่คือหัวใจของ CTE — แต่ละ CTE มองเห็น CTE ที่ประกาศไว้ก่อนหน้าได้ทุกตัว จึงสามารถต่อยอดการประมวลผลเป็นลำดับขั้นได้โดยไม่ต้องซ้อน subquery

จากผลลัพธ์พบว่ามีเพียง CentralWorld (+653,213 บาท) และ Siam Square (+513,960 บาท) ที่ทำรายได้สูงกว่าค่าเฉลี่ย ส่วน Exchange Tower มี diff ติดลบมากที่สุดที่ −454,039 บาท คิดเป็น 25% ต่ำกว่าค่าเฉลี่ยเครือ ซึ่งสอดคล้องกับที่ตั้งแบบ office ที่ traffic ต่ำในวันหยุด

สรุป

บทนี้แสดงให้เห็นว่า CTE เป็นโครงสร้างสำคัญของการเขียน SQL แบบ “คิดเป็นขั้นตอน” ตั้งแต่การสรุปยอดขายของเมนู การสร้าง baseline เพื่อเปรียบเทียบผลลัพธ์ ไปจนถึงงานที่ซับซ้อนขึ้น เช่น anomaly detection และการวิเคราะห์มูลค่าลูกค้า แนวคิดร่วมของทุกตัวอย่างคือ การแยก “ขั้นเตรียมข้อมูลอ้างอิง” ออกจาก “ขั้นตอบคำถามทางธุรกิจ” อย่างชัดเจน

เมื่อใช้ CTE เราสามารถแบ่ง query ขนาดใหญ่ให้กลายเป็นลำดับของขั้นตอนย่อยที่มีหน้าที่เฉพาะ ทำให้โค้ดอ่านง่ายขึ้น ตรวจสอบได้ง่ายขึ้น และบำรุงรักษาได้สะดวกกว่าการเขียนทุกอย่างรวมไว้ในคำสั่งเดียว

กล่าวโดยสรุป CTE คือ ตารางชั่วคราวที่ถูกกำหนดไว้ก่อน SELECT หลัก ผ่าน WITH clause และมีประโยชน์สำคัญอย่างน้อย 3 ประการ

  1. ช่วยแยกการประมวลผลออกเป็นขั้นตอนที่ชัดเจน
  2. ช่วยให้สามารถอ้างอิงผลลัพธ์เดิมซ้ำได้ภายใน query เดียวกัน
  3. ช่วยให้โค้ดอ่านง่ายขึ้น คล้ายการเขียนโปรแกรมแบบหลายบรรทัดที่มีลำดับตรรกะชัดเจน

คำถามท้ายบท

  1. หาลูกค้า dine_in แต่ไม่เคยสั่ง delivery เลย
คลิกเพื่อดูเฉลย
-- นับจำนวนลูกค้าที่ใช้บริการทานในร้านเท่านั้น ไม่เคยสั่งเดลิเวอรีเลย
WITH cte AS ( -- CTE หาลูกค้าที่ใช้ dine_in แต่ไม่เคยใช้ delivery
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่เคยทานในร้าน (ไม่ซ้ำ)
 FROM transactions
 WHERE channel = 'dine_in' AND customer_id IS NOT NULL -- กรองเฉพาะช่องทางทานในร้านและมี customer_id
 EXCEPT -- ลบออกลูกค้าที่เคยใช้บริการเดลิเวอรีด้วย
 SELECT DISTINCT customer_id -- รหัสลูกค้าที่เคยสั่งเดลิเวอรี (ไม่ซ้ำ)
 FROM transactions
 WHERE channel = 'delivery' AND customer_id IS NOT NULL -- กรองเฉพาะช่องทางเดลิเวอรีและมี customer_id
)
SELECT COUNT(customer_id) FROM cte; -- นับจำนวนลูกค้าที่ใช้ dine_in อย่างเดียว
  1. หาสาขาที่มีรายได้สูงกว่าค่าเฉลี่ยของทุกสาขา

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

store_name total_revenue avg_rev diff
PiCha centralwOrld 2,451,113 1,797,900 653,213
PiCha Siam Square 2,311,860 1,797,900 513,960
คลิกเพื่อดูเฉลย
-- หาสาขาที่มีรายได้รวมสูงกว่าค่าเฉลี่ยของทุกสาขา
WITH store_revenue AS (                            -- CTE คำนวณรายได้รวมของแต่ละสาขา
 SELECT
  s.store_name,                                    -- ชื่อสาขา
  ROUND(SUM(t.net_sales_thb), 0) AS total_revenue  -- รายได้สุทธิรวมทั้งหมด
 FROM transactions t
 JOIN stores s ON t.store_id = s.store_id          -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
 GROUP BY s.store_name                             -- จัดกลุ่มตามสาขา
)
SELECT
 store_name,                                       -- ชื่อสาขา
 total_revenue,                                    -- รายได้รวมของสาขา (บาท)
 ROUND(AVG(total_revenue) OVER (), 0) AS avg_rev,  -- ค่าเฉลี่ยรายได้รวมของทุกสาขา (บาท)
 total_revenue - ROUND(AVG(total_revenue) OVER (), 0) AS diff   -- ส่วนต่างระหว่างรายได้สาขากับค่าเฉลี่ย
FROM store_revenue
WHERE total_revenue > (SELECT AVG(total_revenue) FROM store_revenue)  -- กรองเฉพาะสาขาที่มีรายได้สูงกว่าค่าเฉลี่ย
ORDER BY total_revenue DESC;                       -- เรียงจากสาขาที่มีรายได้สูงสุดไปต่ำสุด
  1. วิเคราะห์ว่าแต่ละช่องทาง (channel) มีสัดส่วนลูกค้าใหม่ (สั่งครั้งแรก) เท่าไหร่

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

channel new_customers total_customers new_customer_pct
to_go 1,039 1,801 57.7%
delivery 755 1,635 46.2%
dine_in 432 1,307 33.1%
คลิกเพื่อดูเฉลย
WITH first_orders AS (                             -- CTE ที่ 1: หาวันสั่งซื้อครั้งแรกของลูกค้าแต่ละราย
 SELECT
  customer_id,                                     -- รหัสลูกค้า
  MIN(order_date) AS first_date                    -- วันที่สั่งซื้อครั้งแรก
 FROM transactions                                 -- จากตารางธุรกรรม
 WHERE customer_id IS NOT NULL                     -- กรองเฉพาะแถวที่มีรหัสลูกค้า
 GROUP BY customer_id                              -- จัดกลุ่มตามลูกค้าแต่ละราย
),
channel_first AS (                                 -- CTE ที่ 2: นับลูกค้าใหม่แยกตามช่องทาง
 SELECT
  t.channel,                                       -- ช่องทางการขาย
  COUNT(DISTINCT t.customer_id) AS new_customers   -- จำนวนลูกค้าใหม่ที่ไม่ซ้ำกัน
 FROM transactions t                               -- จากตารางธุรกรรม
 JOIN first_orders f                               -- เชื่อมกับ CTE first_orders
  ON t.customer_id = f.customer_id                 -- เงื่อนไข: รหัสลูกค้าตรงกัน
  AND t.order_date = f.first_date                  -- เงื่อนไข: เป็นวันสั่งซื้อครั้งแรก
 GROUP BY t.channel                                -- จัดกลุ่มตามช่องทาง
),
channel_total AS (                                 -- CTE ที่ 3: นับลูกค้าทั้งหมดแยกตามช่องทาง
 SELECT
  channel,                                         -- ช่องทางการขาย
  COUNT(DISTINCT customer_id) AS total_customers   -- จำนวนลูกค้าทั้งหมดที่ไม่ซ้ำกัน
 FROM transactions                                 -- จากตารางธุรกรรม
 WHERE customer_id IS NOT NULL                     -- กรองเฉพาะแถวที่มีรหัสลูกค้า
 GROUP BY channel                                  -- จัดกลุ่มตามช่องทาง
)
SELECT
 cf.channel,                                            -- ช่องทางการขาย
 cf.new_customers,                                      -- จำนวนลูกค้าใหม่
 ct.total_customers,                                    -- จำนวนลูกค้าทั้งหมด
 ROUND(100.0 * cf.new_customers / ct.total_customers, 1) AS new_customer_pct  -- เปอร์เซ็นต์ลูกค้าใหม่ (ทศนิยม 1 ตำแหน่ง)
FROM channel_first cf                                   -- จาก CTE channel_first
JOIN channel_total ct ON cf.channel = ct.channel        -- เชื่อมกับ CTE channel_total ตามช่องทาง
ORDER BY new_customer_pct DESC;                         -- เรียงจากเปอร์เซ็นต์สูงสุดไปต่ำสุด
  1. ต้องการดูว่าแต่ละช่องทางมีประสิทธิภาพเหนือหรือต่ำกว่ามาตรฐานบริษัทเท่าไหร่ ทั้งในมิติ AOV และ Contribution Margin

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

channel order_count avg_aov company_avg_aov aov_diff cm_pct company_cm_pct cm_diff
delivery 18,795 296 202 94 45.8 54.2 -8.4
to_go 24,552 152 202 -50 63 54.2 8.8
dine_in 9,976 151 202 -51 62.9 54.2 8.7
คลิกเพื่อดูเฉลย
WITH channel_stats AS (                                                           -- CTE ที่ 1: สถิติแยกตามช่องทางการขาย
 SELECT
  channel,                                                                        -- ช่องทางการขาย
  COUNT(*) AS order_count,                                                        -- จำนวนออร์เดอร์ทั้งหมด
  ROUND(AVG(net_sales_thb), 0) AS avg_aov,                                        -- ค่าเฉลี่ยต่อออร์เดอร์ (AOV)
  ROUND(SUM(net_sales_thb), 0) AS total_revenue,                                  -- รายได้รวม
  ROUND(100.0 * SUM(contribution_margin_thb) / SUM(net_sales_thb), 1) AS cm_pct   -- อัตรากำไรส่วนเกิน (%)
 FROM transactions                                                                -- จากตารางธุรกรรม
 GROUP BY channel                                                                 -- จัดกลุ่มตามช่องทาง
),
company_benchmark AS (                                                            -- CTE ที่ 2: ค่าเฉลี่ยภาพรวมทั้งบริษัท
 SELECT
  ROUND(AVG(net_sales_thb), 0) AS company_avg_aov,                                -- AOV เฉลี่ยทั้งบริษัท
  ROUND(100.0 * SUM(contribution_margin_thb) / SUM(net_sales_thb), 1) AS company_cm_pct  -- CM% เฉลี่ยทั้งบริษัท
 FROM transactions                                                                -- จากตารางธุรกรรม
)
SELECT
 c.channel,                                  -- ช่องทางการขาย
 c.order_count,                              -- จำนวนออร์เดอร์
 c.avg_aov,                                  -- AOV ของแต่ละช่องทาง
 b.company_avg_aov,                          -- AOV เฉลี่ยทั้งบริษัท (ใช้เปรียบเทียบ)
 c.avg_aov - b.company_avg_aov AS aov_diff,  -- ส่วนต่าง AOV เทียบกับค่าเฉลี่ยบริษัท
 c.cm_pct,                                   -- CM% ของแต่ละช่องทาง
 b.company_cm_pct,                           -- CM% เฉลี่ยทั้งบริษัท (ใช้เปรียบเทียบ)
 c.cm_pct - b.company_cm_pct AS cm_diff      -- ส่วนต่าง CM% เทียบกับค่าเฉลี่ยบริษัท
FROM channel_stats c                         -- จาก CTE channel_stats
CROSS JOIN company_benchmark b               -- จับคู่ทุกแถวกับค่าเฉลี่ยบริษัท (1 แถว)
ORDER BY c.total_revenue DESC;               -- เรียงตามรายได้รวมจากมากไปน้อย