3. Aggregation และ GROUP BY

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

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

  1. ใช้ฟังก์ชันรวม (Aggregate Functions) ได้แก่ COUNT, SUM, AVG, MIN, MAX เพื่อสร้างตัวชี้วัด
  2. ใช้ GROUP BY เพื่อแยกสรุปตามมิติธุรกิจ เช่น สาขา ช่องทาง วันในสัปดาห์
  3. แยกความแตกต่างระหว่าง WHERE (กรองแถว) กับ HAVING (กรองกลุ่ม)
  4. ใช้ COUNT(*) เทียบกับ COUNT(column) และ COUNT(DISTINCT column) อย่างถูกต้อง

บทนำ

บทที่ 2 ผู้เรียนได้เรียนรู้วิธีดึงและกรองแถวข้อมูล แต่ในทางปฏิบัติผู้บริหารไม่ต้องการดูแถวในตาราง transactions ทั้ง 53,323 แถว สิ่งที่ผู้บริหารต้องการคือคำตอบในภาพรวม เช่น สาขาใดมียอดขายสูงสุด ช่องทางใดสร้างกำไรได้ดีที่สุด และช่วงเวลาใดมีการใช้บริการมากที่สุด

บทนี้จึงมุ่งแนะนำ Aggregate Functions หรือฟังก์ชันสำหรับสรุปรวมข้อมูล ควบคู่กับการใช้ GROUP BY เพื่อจัดกลุ่มข้อมูลก่อนคำนวณผลลัพธ์ เครื่องมือทั้งสองส่วนนี้มีบทบาทสำคัญในการเปลี่ยนข้อมูลธุรกรรมจำนวนมากให้กลายเป็นตัวชี้วัดทางธุรกิจ หรือ KPI ที่ช่วยสนับสนุนการตัดสินใจของผู้บริหารได้อย่างรวดเร็วและเป็นระบบ

หากผู้บริหารถามว่า

“เดือนที่แล้วเรามียอดขายรวมเท่าไร สาขาใดทำผลงานได้ดีที่สุด และมีสาขาใดที่ควรพิจารณาเปิดเพิ่มหรือปรับกลยุทธ์”

คำตอบย่อมไม่ใช่รายการธุรกรรมทั้งหมดในตาราง แต่เป็นข้อมูลสรุปเพียงไม่กี่บรรทัดที่สะท้อนภาพรวมของธุรกิจได้อย่างชัดเจน ซึ่งก็คือบทบาทสำคัญของการสรุปรวมข้อมูลและการใช้ GROUP BY นั่นเอง ในบทนี้ ตารางหลักที่ใช้คือ transactions ซึ่งบันทึกข้อมูลคำสั่งซื้อ 53,323 รายการ และเชื่อมโยงกับตาราง stores ซึ่งเก็บข้อมูลสาขาทั้ง 6 แห่งผ่าน store_id ดังแสดงในแผนภาพด้านล่าง

---
config:
  theme: redux-color
---

erDiagram
    direction LR

    STORES ||--o{ TRANSACTIONS : receives

    STORES {
        BIGINT store_id PK
        VARCHAR store_name
        VARCHAR branch_type
        VARCHAR district
        BIGINT seats
        BIGINT takeaway_heavy_flag
        DOUBLE office_traffic_index
        DOUBLE mall_traffic_index
        DOUBLE delivery_catchment_score
        DATE opening_date
        BIGINT flagship_flag
        BIGINT floor_area_sqm
        BIGINT monthly_rent_thb
        BIGINT num_employees
        BIGINT num_baristas
        BIGINT daily_capacity
        BIGINT bts_distance_m
        BIGINT nearby_competitors
        DOUBLE google_rating
        BIGINT has_parking
        DATE lease_end_date
    }

    TRANSACTIONS {
        BIGINT transaction_id PK
        TIMESTAMP order_datetime
        DATE order_date
        BIGINT hour
        VARCHAR day_of_week
        BIGINT dow_int
        BIGINT month
        BIGINT store_id FK
        VARCHAR channel
        VARCHAR platform
        BIGINT customer_id FK "nullable"
        BIGINT basket_qty
        DOUBLE gross_sales_thb
        DOUBLE item_discount_thb
        DOUBLE order_discount_thb
        DOUBLE delivery_fee_paid_by_customer
        DOUBLE platform_subsidy_thb
        DOUBLE net_sales_thb
        DOUBLE cogs_thb
        DOUBLE packaging_cost_thb
        DOUBLE platform_commission_thb
        DOUBLE labor_alloc_thb
        DOUBLE contribution_margin_thb
        DOUBLE prep_time_min
        DOUBLE wait_time_min
        DOUBLE pickup_time_min
        BIGINT satisfaction_score
        VARCHAR promo_id
        VARCHAR weather_flag
        BIGINT peak_flag
        BIGINT weekend_flag
        BIGINT morning_flag
    }

รูปที่ 3.1: ความสัมพันธ์ระหว่าง STORES และ TRANSACTIONS หนึ่งสาขารับออเดอร์หลายรายการ

SQL Aggregation ทำงานอย่างไร

graph TB
 A["คำถามธุรกิจ"] --> B["FROM: โหลดตาราง"]
 B --> C["WHERE: กรองแถว"]
 C --> D["GROUP BY: แบ่งกลุ่ม"]
 D --> E["Aggregate: COUNT SUM AVG"]
 E --> F["HAVING: กรองกลุ่ม"]
 F --> G["ORDER BY: เรียงลำดับ"]

รูปที่ 3.2: ลำดับการทำงานของ SQL Aggregation

Aggregate Functions คืออะไร?

Aggregate Functions คือฟังก์ชันสำหรับสรุปข้อมูล ที่ทำการ “คำนวณข้อมูลหลายแถวแล้วให้ผลลัพธ์เป็นค่าเดียว” เช่น หายอดขายรวมของเดือน จำนวนครั้งที่แต่ละเมนูถูกสั่งในวันนี้ หรือหาคะแนนความพึงพอใจเฉลี่ยของลูกค้า เป็นต้น

  • ถ้าเปรียบเทียบกับ Excel
    • แถว (rows) = รายการแต่ละบิล / แต่ละลูกค้า
    • Aggregate functions = ฟังก์ชันอย่าง SUM, AVERAGE, COUNT
  • ใน SQL aggregate function ใช้คำนวณทั้งคอลัมน์หรือทั้งกลุ่มของข้อมูล เช่น
    • ยอดขายรวมต่อวัน
    • ยอดขายเฉลี่ยต่อบิล
    • จำนวนลูกค้าที่ซื้ออย่างน้อย 1 ครั้งในสัปดาห์นั้น

คุณสมบัติสำคัญ

  1. ทำงานกับ “หลายแถว” แต่คืนค่าเดียว (summary)
  2. มักใช้คู่กับ GROUP BY เพื่อสรุปตามมิติธุรกิจ เช่น สรุปตามสาขา ตามวัน ตามสินค้า
  3. ส่วนใหญ่จะ “ไม่สนใจค่า NULL” (ยกเว้น COUNT(*))
  4. เหมาะกับการตอบคำถามธุรกิจระดับสรุป เช่น
    • เดือนที่แล้วยอดขายรวมเท่าไหร่?
    • สาขาไหนยอดขายสูงสุด?
    • ค่าเฉลี่ยเวลาให้บริการต่อลูกค้าหนึ่งบิลเป็นเท่าไหร่?

ไวยากรณ์หลักของการใช้ Aggregate functions และ GROUPBY

SELECT
 dimension_column,          -- มิติที่ต้องการแยกกลุ่ม
 AGG(measure_column)        -- ตัวชี้วัดที่ต้องการรวม
FROM table
GROUP BY dimension_column   -- คอลัมน์ที่ใช้แบ่งกลุ่ม
ORDER BY AGG(measure_column) DESC;  -- เรียงจากมากไปน้อย
  • มิติ (dimension) = สิ่งที่ต้องการแยกวิเคราะห์ เช่น store_name, channel, day_of_week
  • ตัวชี้วัด (measure) = สิ่งที่ต้องการรวม เช่น net_sales_thb, contribution_margin_thb

ตัวอย่างแรกจะใช้ aggregate functions เพื่อสร้างตัวชี้วัดระดับบริษัท (company-wide KPIs) ก่อน แล้วค่อยลงลึกไปยังรายสาขา รายช่องทาง และมิติเวลาต่าง ๆ

-- สรุปสถิติภาพรวมของธุรกรรมทั้งหมด
SELECT
 COUNT(*) AS total_orders, -- จำนวนคำสั่งซื้อทั้งหมด
 COUNT(DISTINCT customer_id) AS unique_customers, -- จำนวนลูกค้าที่ไม่ซ้ำกัน
 MIN(order_date) AS start_date, -- วันที่สั่งซื้อครั้งแรก
 MAX(order_date) AS end_date, -- วันที่สั่งซื้อครั้งล่าสุด
 ROUND(SUM(net_sales_thb), 0) AS total_revenue, -- รายได้รวมทั้งหมด (บาท)
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value, -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
 ROUND(MEDIAN(net_sales_thb), 0) AS median_order_value, -- มูลค่าคำสั่งซื้อค่ามัธยฐาน (บาท)
 ROUND(MIN(net_sales_thb), 0) AS min_order, -- คำสั่งซื้อที่มีมูลค่าน้อยที่สุด (บาท)
 ROUND(MAX(net_sales_thb), 0) AS max_order, -- คำสั่งซื้อที่มีมูลค่ามากที่สุด (บาท)
 ROUND(AVG(basket_qty), 2) AS avg_basket -- จำนวนสินค้าเฉลี่ยต่อคำสั่งซื้อ
FROM transactions;
total_orders unique_customers start_date end_date total_revenue avg_order_value median_order_value min_order max_order avg_basket
53323 2111 2026-02-01 2026-04-01 10787401.0 202.0 153.0 68.0 695.0 1.68

จากผลลัพธ์ข้างต้น ขนาดธุรกิจและโครงสร้างการซื้อของลูกค้าในช่วงเวลา 2 เดือน PiCha มีออเดอร์ทั้งหมด 53,323 รายการ จากลูกค้าที่ลงทะเบียนแล้ว 2,111 คน สร้างรายได้รวม 10,787,401 บาท มูลค่าเฉลี่ยต่อออเดอร์ (AOV) อยู่ที่ 202 บาท ออเดอร์เล็กสุด 68 บาท ใหญ่สุด 695 บาท และลูกค้าเฉลี่ยสั่ง 1.68 รายการต่อออเดอร์

ช่องว่างระหว่างออเดอร์มูลค่าต่ำสุด (68 บาท) กับสูงสุด (695 บาท) ต่างกันกว่า 10 เท่า บ่งชี้ว่ามีลูกค้าทั้งกลุ่มที่ซื้อเพียงแก้วเดียว และกลุ่มที่ซื้อหลายแก้วต่อออเดอร์ อัตราส่วนลูกค้า 2,111 คนต่อ 53,323 ออเดอร์ เท่ากับ 25.3 ออเดอร์ต่อคนโดยประมาณในช่วง 2 เดือน อย่างไรก็ตาม ตัวเลขนี้คำนวณจากเฉพาะลูกค้าที่ลงทะเบียน ลูกค้าที่ไม่ระบุตัวตน (รหัสลูกค้าที่สั่งออเดอร์นั้น ๆ มีค่าเป็น NULL)

ขั้นตอนการทำงานของ Aggregate Functions ของตัวอย่างนี้ เป็นดังนี้

  1. FROM โหลดตาราง transactions ทั้งหมด (53,323 แถว)
  2. WHERE (ไม่มีเงื่อนไข) ทุกแถวผ่าน
  3. SELECT + Aggregates รวมทุกแถวเป็นแถวเดียว: นับ, รวม, หาค่าเฉลี่ย
  4. เมื่อไม่ใช้ GROUP BY ทุกแถวจะถูกบีบเป็นแถวเดียว ซึ่งตีความเป็นภาพรวมของกิจการได้

ตัวอย่างนี้สร้าง “ภาพรวมบริษัท 1 บรรทัด” ซึ่งตอบคำถามระดับบนได้ดี แต่คำถามของผู้บริหารมักต้องการรายละเอียดว่า “สาขาไหน ช่องทางไหน หรือวันไหน” ทำให้ผลลัพธ์ต้องแตกออกเป็นหลายบรรทัดตามมิติต่าง ๆ ตรงนี้เองที่ GROUP BY เข้ามาทำหน้าที่สำคัญ

จัดกลุ่มด้วย GROUP BY

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

แยกตามสาขา

“แต่ละสาขามีประสิทธิภาพและพฤติกรรมการสั่งซื้อต่างกันอย่างไร เมื่อดูทั้ง AVG และ MEDIAN ควบคู่กัน?”

-- สรุปยอดขายและผลการดำเนินงานแยกตามสาขา
SELECT
 s.store_name, -- ชื่อสาขา
 s.branch_type, -- ประเภทสาขา
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รายได้รวม (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value, -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
 ROUND(MEDIAN(t.net_sales_thb), 0) AS median_order_value, -- มูลค่าคำสั่งซื้อค่ากลาง (บาท)
 avg_order_value - median_order_value AS avg_median_diff, -- ส่วนต่างระหว่างค่าเฉลี่ยและค่ามัธยฐาน (บาท)
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm, -- กำไรส่วนเกินรวม (บาท)
 ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction -- คะแนนความพึงพอใจเฉลี่ย
FROM transactions t -- ตารางหลักที่เก็บข้อมูลธุรกรรม
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อและประเภทสาขา (จะสอนในบทที่ 4)
GROUP BY s.store_name, s.branch_type -- จัดกลุ่มตามชื่อสาขาและประเภทสาขา
ORDER BY total_revenue DESC; -- เรียงจากสาขาที่มีรายได้สูงสุดไปต่ำสุด
Note

คำสั่ง JOIN ที่ปรากฏในคำสั่ง SQL จะอธิบายอย่างละเอียดในบทที่ 4 (INNER JOIN และ LEFT JOIN) ในตอนนี้ให้เข้าใจว่า INNER JOIN เชื่อมตาราง transactions กับ stores โดยจับคู่แถวที่มี store_id ตรงกัน เพื่อให้เห็นชื่อสาขาแทนรหัสตัวเลข

store_name branch_type order_count total_revenue avg median avg_median_diff total_cm satisfaction
PiCha CentralWorld mall 11,935 2,451,113 205 158 47 1,318,149 4.19
PiCha Siam Square street 10,738 2,311,860 215 180 35 1,205,243 3.96
PiCha ICONSIAM mall_flagship 8,702 1,763,926 203 161 42 966,501 4.53
PiCha Vanit Village mixed_use 7,257 1,465,764 202 154 48 797,878 4.07
PiCha Park Silom office 7,611 1,450,877 191 135 56 806,787 3.66
PiCha Exchange Tower office 7,080 1,343,861 190 135 55 747,494 3.63

จากตารางนี้ PiCha CentralWorld เป็นสาขาที่ทำรายได้สูงสุดที่ 2,451,113 บาท (11,935 ออเดอร์) ตามด้วย Siam Square ที่ 2,311,860 บาท (10,738 ออเดอร์) สาขา ICONSIAM มีคะแนนความพึงพอใจสูงที่สุดที่ 4.53 ขณะที่สาขา office ทั้งสองแห่ง (Park Silom และ Exchange Tower) มีคะแนนต่ำที่สุดในช่วง 3.63–3.66

หากมองให้ลึกขึ้น จะเห็น trade-off ระหว่างจำนวนออเดอร์กับความพึงพอใจ สาขา office มีออเดอร์ค่อนข้างมาก (7,000–7,600 ออเดอร์) แต่คะแนนความพึงพอใจต่ำที่สุด ในขณะที่ ICONSIAM มีออเดอร์น้อยกว่าแต่ได้คะแนนสูงที่สุด สาขา office ยังมี AOV ต่ำสุด (190–191 บาท) เทียบกับ Siam Square ที่สูงสุด (215 บาท)

avg_median_diff คือระยะห่างระหว่างค่าเฉลี่ยกับค่ากลาง ยิ่งช่องว่างกว้าง ยิ่งมีแนวโน้มว่ามี outlier มูลค่าสูงดึง AVG ให้สูงกว่าพฤติกรรมของลูกค้า “ทั่วไป” ตัวอย่างเช่น

Park Silom:  AVG 191  ←── 56 บาท ──→  MEDIAN 135   ← ช่องกว้างสุด
Siam Square: AVG 215  ←── 35 บาท ──→  MEDIAN 180   ← ช่องแคบสุด

Park Silom และ Exchange Tower มี avg_median_diff สูงสุด แม้ลูกค้าส่วนใหญ่จ่ายเพียงประมาณ 135 บาทต่อออเดอร์ แต่มีออเดอร์บางส่วนที่มูลค่าสูงและดึง AVG ขึ้นไปถึง 190–191 บาท หากออกแบบโปรโมชั่นโดยใช้ AVG เป็น baseline ก็อาจประเมินกำลังซื้อของลูกค้าทั่วไปสูงเกินจริง ในทางกลับกัน Siam Square มี avg_median_diff ต่ำที่สุด แสดงว่าการกระจายของออเดอร์สม่ำเสมอกว่า ลูกค้าส่วนใหญ่จ่ายใกล้เคียงกับค่าเฉลี่ย โปรโมชั่นที่อิง AVG จึงสะท้อนพฤติกรรมส่วนใหญ่ได้ดี

สำหรับ Vanit Village ช่องว่างระหว่าง AVG และ MEDIAN อยู่ที่ 48 บาท ใกล้เคียงกับ CentralWorld แต่ระดับความพึงพอใจปานกลาง (4.07) จึงน่าเจาะลึกต่อว่า outlier ที่ดึง AVG ขึ้นมานั้นเกิดในช่องทางใด เช่น delivery หรือ dine-in เพื่อออกแบบมาตรการให้เหมาะสม

การนำไปใช้งาน

  • ใช้ MEDIAN เป็น baseline ออกแบบโปรโมชั่น ไม่ใช่ AVG โดยเฉพาะในสาขา office ที่ลูกค้าทั่วไปใช้จ่ายต่ำกว่าค่าเฉลี่ยมาก
  • ติดตาม avg_median_diff ทุกเดือน หากช่องว่างขยายตัวอาจสะท้อนว่า group order เพิ่มขึ้น หากแคบลงอาจบ่งชี้ว่าลูกค้าซื้อเดี่ยวมากขึ้น
  • ใช้ Siam Square เป็นสนามทดลอง campaign ใหม่ เนื่องจากพฤติกรรมลูกค้าสม่ำเสมอและสะท้อนผลของมาตรการได้ชัดเจน
Note

กฎสำคัญ: ทุกคอลัมน์ใน SELECT ที่ไม่ใช่ aggregate function ต้องอยู่ใน GROUP BY ด้วย ถ้าไม่เช่นนั้น SQL จะไม่รู้ว่าควรเลือกค่าใดจากแต่ละกลุ่มมาแสดง

แยกตามช่องทางการขาย

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

“แต่ละช่องทางการขายมีพฤติกรรมลูกค้า ขนาดออเดอร์ และความสามารถในการทำกำไรแตกต่างกันอย่างไร?”

SELECT                                                     -- เลือกข้อมูล
 channel,                                               -- ช่องทางการขาย
 COUNT(*) AS order_count,                               -- นับจำนวนออร์เดอร์
 ROUND(SUM(net_sales_thb), 0) AS total_revenue,         -- รวมยอดขายสุทธิ (บาท)
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value,       -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
 ROUND(MEDIAN(net_sales_thb), 0) AS median_order_value, -- มัธยฐานมูลค่าต่อออร์เดอร์ (บาท)
 ROUND(AVG(basket_qty), 2) AS avg_basket,               -- ค่าเฉลี่ยจำนวนสินค้าต่อออร์เดอร์
 ROUND(SUM(contribution_margin_thb), 0) AS total_cm,    -- รวมกำไรส่วนเกิน (บาท)
 ROUND(100.0 * SUM(contribution_margin_thb) / SUM(net_sales_thb), 1) AS cm_pct -- contribution margin (%)
FROM transactions             -- จากตารางธุรกรรม
GROUP BY channel              -- จัดกลุ่มตามช่องทางการขาย
ORDER BY total_revenue DESC;  -- เรียงตามยอดขายรวมจากมากไปน้อย
channel order_count total_revenue avg_order_value median_order_value avg_basket total_cm cm_pct
delivery 18,795 5,559,370 296 284 2.20 2,547,703 45.8%
to_go 24,552 3,725,939 152 115 1.40 2,349,204 63.0%
dine_in 9,976 1,502,092 151 115 1.39 945,145 62.9%

ช่องทาง delivery สร้างรายได้สูงสุดที่ 5,559,370 บาท หรือประมาณครึ่งหนึ่งของรายได้รวม ทั้งที่มีจำนวนออเดอร์น้อยกว่า to_go เหตุผลคือ AOV ของ delivery สูงกว่ามาก (296 เทียบกับ 152 บาท) อย่างไรก็ตาม CM% ของ delivery ต่ำที่สุดที่ 45.8% เทียบกับ to_go และ dine_in ที่อยู่ราว 63%

กล่าวอีกแบบหนึ่ง delivery สร้างยอดขายตัวเลขใหญ่ แต่กำไรที่เหลือจริงต่อรายได้ต่ำกว่าช่องทางอื่นอย่างชัดเจน เนื่องจากค่าคอมมิชชันแพลตฟอร์ม ค่า packaging และต้นทุนแรงงานที่สูงกว่า การผลักดันให้ลูกค้าส่วนหนึ่งเปลี่ยนจาก delivery มา to_go อาจช่วยเพิ่ม margin ได้โดยไม่จำเป็นต้องหาลูกค้าใหม่ เช่น ด้วยโปรแกรม “รับชาฟรี 1 แก้ว เมื่อมารับที่ร้านครบ 5 ครั้ง” หรือการจัด dedicated pick-up counter เพื่อลดเวลารอของลูกค้า to_go อย่างไรก็ตาม ก่อนปรับกลยุทธ์จำเป็นต้องตรวจสอบก่อนว่าลูกค้า delivery กับ to_go เป็นคนกลุ่มเดียวกันหรือไม่ หากเป็นคนละกลุ่มโดยสิ้นเชิง การจูงใจให้เปลี่ยนช่องทางอาจไม่สำเร็จ

ด้านการกระจายขนาดออเดอร์ ช่องว่างระหว่าง AVG และ MEDIAN ของ delivery มีเพียง 12 บาท (296 เทียบกับ 284) บ่งชี้ว่าลูกค้า delivery มีขนาดการสั่งซื้อค่อนข้างสม่ำเสมอและมี outlier น้อย สอดคล้องกับค่า avg_basket 2.20 ที่แสดงว่าลูกค้ามักสั่งมากกว่า 1 แก้วต่อครั้ง ในขณะที่ to_go และ dine_in มี AVG 151–152 บาท แต่ MEDIAN เพียง 115 บาท ช่องว่าง 37 บาทบ่งบอกว่าลูกค้าส่วนใหญ่ซื้อแก้วเดียวราคาประมาณ 115 บาท และมีกลุ่มเฉพาะบางส่วนที่ซื้อหลายแก้วดึงค่าเฉลี่ยให้สูงขึ้น

แยกตามวันในสัปดาห์

นอกจากสาขาและช่องทางแล้ว “วันในสัปดาห์” เป็นอีกมิติที่สำคัญสำหรับการจัดกำลังคนและวางแผนโปรโมชั่น

-- สรุปยอดขายและความพึงพอใจแยกตามวันในสัปดาห์
SELECT
 day_of_week, -- วันในสัปดาห์
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(SUM(net_sales_thb), 0) AS total_revenue, -- รายได้รวม (บาท)
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value, -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
 ROUND(AVG(satisfaction_score), 2) AS avg_satisfaction -- คะแนนความพึงพอใจเฉลี่ย
FROM transactions -- ตารางหลักที่เก็บข้อมูลธุรกรรม
GROUP BY day_of_week -- จัดกลุ่มตามวันในสัปดาห์
ORDER BY CASE day_of_week -- เรียงตามลำดับวันจันทร์ถึงอาทิตย์ (จะสอนในบทที่ 5)
 WHEN 'Monday' THEN 0 
 WHEN 'Tuesday' THEN 1 
 WHEN 'Wednesday' THEN 2
 WHEN 'Thursday' THEN 3 
 WHEN 'Friday' THEN 4 
 WHEN 'Saturday' THEN 5
 WHEN 'Sunday' THEN 6 END;
Notedownload ข้อมูลร้านขายเครื่องดื่ม PiCha

คำสั่ง CASE ... WHEN ... THEN ใน ORDER BY ด้านล่างจะอธิบายอย่างละเอียดในบทที่ 5 (CASE Expressions)

day_of_week order_count total_revenue avg_order_value avg_satisfaction
Monday 6,445 1,285,679 199 3.99
Tuesday 7,570 1,502,126 198 3.96
Wednesday 8,099 1,621,162 200 3.95
Thursday 7,358 1,465,462 199 4.00
Friday 7,618 1,541,691 202 4.09
Saturday 7,726 1,599,642 207 4.13
Sunday 8,507 1,771,639 208 4.11

วันอาทิตย์เป็นวันที่ทำรายได้สูงสุดที่ 1,771,639 บาท (8,507 ออเดอร์, AOV 208 บาท) ในขณะที่วันจันทร์ต่ำสุดที่ 1,285,679 บาท (6,445 ออเดอร์) ห่างกันประมาณ 38% คะแนนความพึงพอใจสูงสุดอยู่ในวันเสาร์ (4.13) และต่ำสุดในวันพุธ (3.95) จะเห็นแนวโน้มรายได้และความพึงพอใจเพิ่มขึ้นจากต้นสัปดาห์ไปสู่ปลายสัปดาห์ เกิด weekend effect ที่ค่อนข้างชัดเจน

วันพุธมีจำนวนออเดอร์สูงเป็นอันดับสอง (8,099) แต่ AOV ไม่สูงเท่าวันหยุดสุดสัปดาห์ ทำให้วันพุธมีลักษณะ “คึกคักแต่สร้างมูลค่าต่อออเดอร์ไม่สูง” ซึ่งอาจเป็นจุดเหมาะสำหรับโปรโมชันแบบ bundle หรือ upsizing เช่น “Wellness Wednesday” เพื่อเพิ่ม AOV ส่วนวันจันทร์ซึ่งมีออเดอร์ต่ำที่สุด ควรตรวจสอบเพิ่มเติมว่าเกิดจาก demand ต่ำจริง หรือมาจากข้อจำกัดด้านการปฏิบัติการบางอย่าง เช่น เวลาเปิด–ปิด หรือจำนวนพนักงาน

GROUP BY หลายคอลัมน์

ตัวอย่างก่อนหน้าใช้ GROUP BY กับมิติเดียวต่อครั้ง ในทางปฏิบัติ มักต้องการแยกวิเคราะห์สองมิติ เช่น “แต่ละสาขาพึ่งพา delivery มากน้อยแค่ไหน” เราสามารถใช้ GROUP BY หลายคอลัมน์เพื่อดูภาพสาขา × ช่องทางขายในตารางเดียวได้

SELECT                                                  -- เลือกข้อมูล
 s.store_name,                                       -- ชื่อสาขา
 t.channel,                                          -- ช่องทางการขาย
 COUNT(*) AS order_count,                            -- นับจำนวนออร์เดอร์
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,    -- รวมยอดขายสุทธิ (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value,  -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
 ROUND(100.0 * SUM(t.contribution_margin_thb) / SUM(t.net_sales_thb), 1) AS cm_pct -- contribution margin (%)
FROM transactions t                            -- จากตารางธุรกรรม
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมกับตารางสาขา
GROUP BY s.store_name, t.channel               -- จัดกลุ่มตามชื่อสาขาและช่องทาง
ORDER BY s.store_name, total_revenue DESC;     -- เรียงตามชื่อสาขา แล้วยอดขายรวมจากมากไปน้อย
store_name channel order_count total_revenue avg_order_value cm_pct
PiCha Exchange Tower to_go 4,614 703,563 152 63.1
PiCha Exchange Tower delivery 2,027 573,086 283 45.5
PiCha Exchange Tower dine_in 439 67,212 153 63.1
PiCha ICONSIAM delivery 3,009 917,245 305 47.3
PiCha ICONSIAM dine_in 3,036 455,380 150 62.9
PiCha ICONSIAM to_go 2,657 391,301 147 62.9
PiCha Park Silom to_go 5,000 759,089 152 63.2
PiCha Park Silom delivery 2,128 618,342 291 45.4
PiCha Park Silom dine_in 483 73,446 152 63.3
PiCha Siam Square delivery 4,728 1,399,430 296 45.0
PiCha Siam Square to_go 4,901 744,373 152 63.1
PiCha Siam Square dine_in 1,109 168,056 152 62.9
PiCha Vanit Village delivery 2,529 741,464 293 46.1
PiCha Vanit Village to_go 3,643 556,672 153 63.0
PiCha Vanit Village dine_in 1,085 167,627 154 63.0
PiCha centralwOrld delivery 4,374 1,309,803 299 45.8
PiCha centralwOrld to_go 3,737 570,940 153 63.0
PiCha centralwOrld dine_in 3,824 570,371 149 62.9

ทุกสาขามีรูปแบบคล้ายกัน คือ delivery มี AOV สูงที่สุด (ประมาณ 283–305 บาท) แต่ CM% ต่ำสุด (45–47%) ในขณะที่ to_go และ dine_in มี CM% ค่อนข้างคงที่ใกล้ 63% สะท้อนว่าโครงสร้างต้นทุนของการขายหน้าร้านไม่ต้องแบกรับ commission ของแพลตฟอร์ม

สาขา Siam Square มีรายได้จาก delivery สูงถึง 1,399,430 บาท คิดเป็นประมาณ 60.5% ของรายได้สาขานี้ ทำให้ Siam Square พึ่งพา delivery สูงที่สุดในบรรดาทุกสาขา หากแพลตฟอร์ม delivery ปรับเพิ่ม commission ในอนาคต Siam Square จะได้รับผลกระทบต่อ margin มากกว่าเพื่อน การสร้างช่องทางสั่งซื้อโดยตรง (direct ordering channel หรือเว็บ) เพื่อลดการพึ่งพา third-party จึงเป็นทางเลือกหนึ่งที่ควรพิจารณา แต่ก่อนลงทุนควรตอบคำถามให้ได้ก่อนว่าลูกค้า delivery เลือกแพลตฟอร์มเพราะอะไร เช่น ความสะดวกในการค้นหา การชำระเงิน หรือความคุ้นชินในการใช้บริการ

จากนี้ไป เราจะเพิ่มความสามารถให้ GROUP BY ด้วยการผสม HAVING เพื่อกรองเฉพาะกลุ่มที่เข้าเกณฑ์ เช่น สาขาที่มียอดขายเกินตัวเลขหนึ่ง หรือสาขาที่จำนวนออเดอร์ต่ำกว่าเกณฑ์

กรองกลุ่มด้วย HAVING

การวิเคราะห์ข้อมูลธุรกิจด้วย SQL มักต้องการ “สรุปเป็นกลุ่ม” เช่น

  • ยอดขายรวมรายเดือนต่อสาขา
  • จำนวนออร์เดอร์รายวันต่อช่องทาง (dine-in / delivery)
  • คะแนนความพึงพอใจเฉลี่ยของลูกค้าแต่ละสาขา

สิ่งที่ใช้คือ GROUP BY ร่วมกับ Aggregate functions (SUM, COUNT, AVG ฯลฯ) แต่แค่สรุปอย่างเดียวอาจยังไม่พอ เพราะในการตัดสินใจเชิงกลยุทธ์ มักสนใจเฉพาะกลุ่มที่เข้าเงื่อนไข เช่น

  • สาขาที่ยอดขายถึงเกณฑ์
  • สินค้าที่ขายดีจริง
  • สาขาที่คะแนนความพึงพอใจต่ำกว่ามาตรฐาน

ตรงนี้เองที่ HAVING มีบทบาทสำคัญ มันคือเครื่องมือในการกรอง “กลุ่ม” หลังจากรวมกลุ่มด้วย GROUP BY แล้ว

flowchart TD
    A["ข้อมูลดิบจาก FROM/JOIN"] --> B["WHERE<br/>(กรองแถวดิบ)"]
    B --> C["GROUP BY<br/>(จัดกลุ่มตาม key เช่น store_id, month)"]
    C --> D["Aggregate Functions<br/>(SUM, COUNT, AVG, ...)"]
    D --> E["HAVING<br/>(กรองเฉพาะกลุ่มที่เข้าเงื่อนไข)"]
    E --> F["ORDER BY / OUTPUT"]

รูปที่ 3.3: ลำดับการประมวลผล WHERE กรองแถวดิบก่อนจัดกลุ่ม ส่วน HAVING กรองผลลัพธ์หลังจัดกลุ่มแล้ว

WHERE ทำงานก่อนการจัดกลุ่ม ใช้เลือกเฉพาะข้อมูลดิบที่ต้องการ ส่วน HAVING ทำงานหลังการจัดกลุ่ม ใช้เลือกเฉพาะกลุ่มที่ตัวเลขรวมแล้วเข้าเกณฑ์

HAVING เบื้องต้น: กรองกลุ่มจากค่า aggregate โดยตรง

ใช้คำถามว่า “สาขาใดมีรายได้รวมเกิน 2,000,000 บาท” เป็นตัวอย่าง

SELECT                                                 
 s.store_name,                                         -- ชื่อสาขา
 COUNT(*) AS order_count,                              -- นับจำนวนออร์เดอร์
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,      -- รวมยอดขายสุทธิ (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value,    -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm  -- รวมกำไรส่วนเกิน (บาท)
FROM transactions t                             -- จากตารางธุรกรรม
INNER JOIN stores s ON t.store_id = s.store_id  -- เชื่อมกับตารางสาขา
GROUP BY s.store_name                           -- จัดกลุ่มตามชื่อสาขา
ORDER BY total_revenue DESC;                    -- เรียงตามยอดขายรวมจากมากไปน้อย

ผลลัพธ์ที่ได้จะเป็นยอดขายของแต่ละสาขา

Store Orders Revenue (THB) Avg Order Value Contribution Margin
PiCha CentralWorld 11,935 2,451,113 205 1,318,149
PiCha Siam Square 10,738 2,311,860 215 1,205,243
PiCha ICONSIAM 8,702 1,763,926 203 966,501
PiCha Vanit Village 7,257 1,465,764 202 797,878
PiCha Park Silom 7,611 1,450,877 191 806,787
PiCha Exchange Tower 7,080 1,343,861 190 747,494

หากต้องการแสดงเฉพาะสาขาที่มีรายได้เกิน 2,000,000 บาท สามารถเพิ่ม HAVING SUM(t.net_sales_thb) > 2000000 ต่อจาก GROUP BY s.store_name

-- สรุปยอดขายเฉพาะสาขาที่มีรายได้รวมมากกว่า 2,000,000 บาท
SELECT
 s.store_name, -- ชื่อสาขา
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รายได้รวม (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value, -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm -- กำไรส่วนเกินรวม (บาท)
FROM transactions t -- ตารางหลักที่เก็บข้อมูลธุรกรรม
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
GROUP BY s.store_name -- จัดกลุ่มตามชื่อสาขา
HAVING SUM(t.net_sales_thb) > 2000000 -- กรองเฉพาะสาขาที่มีรายได้รวมมากกว่า 2,000,000 บาท
ORDER BY total_revenue DESC; -- เรียงจากสาขาที่มีรายได้สูงสุดไปต่ำสุด
store_name order_count total_revenue avg_order_value total_cm
PiCha centralwOrld 11,935 2,451,113 205 1,318,149
PiCha Siam Square 10,738 2,311,860 215 1,205,243

จะเห็นว่ามีเพียง 2 สาขาจาก 6 สาขาที่มีรายได้เกิน 2,000,000 บาท ได้แก่ CentralWorld และ Siam Square ทั้งสองสาขาตั้งอยู่ในเขต Pathum Wan ซึ่งเป็น prime retail zone ของกรุงเทพฯ จึงอาจมองได้ว่า PiCha มี “กลไกคู่ขับเคลื่อนการเติบโต” อยู่ในพื้นที่เดียวกัน หากเกิดเหตุไม่คาดคิดในเขตนี้ บริษัทอาจสูญเสียรายได้ถึงครึ่งหนึ่ง จึงเป็นความเสี่ยงเชิงภูมิศาสตร์ที่ควรพิจารณา

ใช้ WHERE และ HAVING ร่วมกัน

สรุปความแตกต่างสำคัญระหว่าง WHERE และ HAVING

  • WHERE กรองแถวดิบก่อนจัดกลุ่ม ใช้กับเงื่อนไขที่ไม่เกี่ยวกับ aggregate เช่น channel = 'delivery' หรือเงื่อนไขวันที่
  • GROUP BY จัดกลุ่มแถวที่เหลือให้เป็นกลุ่มตาม key เช่น สาขา เดือน ช่องทาง
  • HAVING กรองผลลัพธ์ระดับกลุ่มหลัง aggregate เช่น SUM(net_sales_thb) > 2000000 หรือ COUNT(*) < 2000

ตัวอย่างการใช้ร่วมกัน: ดูเฉพาะยอดขายที่มาจาก delivery ในเดือนกุมภาพันธ์ 2026 และแสดงเฉพาะสาขาที่มีคำสั่งซื้อน้อยกว่า 2,000 รายการ

-- สรุปยอดขายช่องทาง Delivery เดือนกุมภาพันธ์ 2026 เฉพาะสาขาที่มีคำสั่งซื้อน้อยกว่า 2,000 รายการ
SELECT
 s.store_name, -- ชื่อสาขา
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รายได้รวม (บาท)
 ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value, -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm -- กำไรส่วนเกินรวม (บาท)
FROM transactions t
INNER JOIN stores s 
 ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
WHERE 
 t.channel = 'delivery' -- กรองเฉพาะคำสั่งซื้อช่องทาง Delivery
 AND t.order_date BETWEEN DATE '2026-02-01' AND '2026-02-28' -- กรองเฉพาะเดือนกุมภาพันธ์ 2026
GROUP BY s.store_name -- จัดกลุ่มตามชื่อสาขา
HAVING COUNT(*) < 2000 -- กรองเฉพาะสาขาที่มีคำสั่งซื้อน้อยกว่า 2,000 รายการ
ORDER BY order_count DESC; -- เรียงจากสาขาที่มีคำสั่งซื้อมากสุดไปน้อยสุด
Store Orders Revenue Avg Order CM
PiCha ICONSIAM 1,444 442,580 306 208,316
PiCha Vanit Village 1,190 348,203 293 159,836
PiCha Park Silom 1,000 281,575 282 128,120
PiCha Exchange Tower 919 256,873 280 116,591

ตัวอย่างแสดงการกรองสองระดับในคำสั่งเดียว: WHERE เลือกเฉพาะแถว delivery เดือนกุมภาพันธ์ แล้ว GROUP BY รวมเป็นกลุ่มตามสาขา จากนั้น HAVING ใช้กรองเฉพาะสาขาที่มีจำนวนออเดอร์น้อยกว่า 2,000 หลังจากการรวมกลุ่มแล้ว

ข้อผิดพลาดที่พบบ่อย:

-- ผิด! ใช้ aggregate ใน WHERE ไม่ได้
SELECT store_name, SUM(net_sales_thb)
FROM transactions
WHERE SUM(net_sales_thb) > 2000000  -- ❌ กลุ่มยังไม่มี!

-- ถูก! ใช้ HAVING หลัง GROUP BY
SELECT store_name, SUM(net_sales_thb)
FROM transactions
GROUP BY store_name
HAVING SUM(net_sales_thb) > 2000000;  -- ✅ กลุ่มมีแล้ว

การประยุกต์

เมื่อเข้าใจหลักการทำงานของฟังก์ชันสรุปผล (aggregate functions) และคำสั่ง GROUP BY แล้ว ขั้นต่อไปคือการนำเครื่องมือเหล่านี้ไปใช้ตอบคำถามทางธุรกิจที่พบได้บ่อย เช่น การวิเคราะห์ยอดขาย กำไร หรือพฤติกรรมลูกค้าในมิติต่าง ๆ เช่น สาขา ช่องทางการขาย หรือประเภทสินค้า

การคำนวณช่องทางที่ให้ส่วนลดมากที่สุด

สมมติว่าในธุรกิจหนึ่งมีการขายสินค้าผ่านหลายช่องทาง เช่น หน้าร้าน (offline), แอปพลิเคชัน, เว็บไซต์ และแพลตฟอร์มเดลิเวอรี แต่ละคำสั่งซื้อมีข้อมูล “ส่วนลด” ที่ให้กับลูกค้าเก็บอยู่ในฐานข้อมูล เราสามารถใช้ฟังก์ชันสรุปผลร่วมกับ GROUP BY เพื่อหาว่า “ช่องทางใดมีส่วนลดเฉลี่ยสูงที่สุด” หรือ “ช่องทางใดใช้วงเงินส่วนลดรวมมากที่สุด” ได้อย่างเป็นระบบ

SELECT
 channel,
 COUNT(*) AS order_count,
 ROUND(SUM(gross_sales_thb), 0) AS total_gross,
 ROUND(SUM(item_discount_thb + order_discount_thb), 0) AS total_discount,
 ROUND(100.0 * SUM(item_discount_thb + order_discount_thb) / SUM(gross_sales_thb), 1) AS discount_rate_pct,
 ROUND(AVG(item_discount_thb), 2) AS avg_item_discount,
 ROUND(AVG(order_discount_thb), 2) AS avg_order_discount
FROM transactions                 -- จากตารางธุรกรรม
GROUP BY channel                  -- จัดกลุ่มตามช่องทางการขาย
ORDER BY discount_rate_pct DESC;  -- เรียงตามอัตราส่วนลดจากมากไปน้อย
channel order_count total_gross total_discount discount_rate_pct avg_item_discount avg_order_discount
delivery 18,795 5,195,134 214,804 4.1 6.48 4.95
dine_in 9,976 1,552,915 50,823 3.3 5.09 0
to_go 24,552 3,816,035 90,096 2.4 3.67 0

ช่องทาง delivery แบกรับภาระส่วนลดสูงสุดที่ 4.1% รวมมูลค่า 214,804 บาท สูงกว่าส่วนลดของช่องทาง to_go อย่างมีนัยสำคัญ ตัวขับเคลื่อนหลักคือ avg_order_discount = 4.95 ซึ่งมีเฉพาะช่องทาง delivery บ่งชี้ว่ามีโปรโมชันระดับออเดอร์บางรูปแบบที่ PiCha แบกรับต้นทุนอยู่

ในทางตรงกันข้าม to_go เป็นช่องทางที่มีประสิทธิภาพด้านส่วนลดสูงที่สุดที่ 2.4% โดยไม่มีส่วนลดระดับออเดอร์ และมีส่วนลดต่อรายการเฉลี่ยต่ำสุดที่ 3.67 บาท ทำให้เป็นช่องทางที่ “สะอาดที่สุด” ในมุมกำไร ส่วน dine_in อยู่ในระดับกลางที่ 3.3% ไม่มีส่วนลดระดับออเดอร์เช่นกัน แต่มีส่วนลดต่อรายการในระดับปานกลาง ซึ่งอาจมาจากโปรโมชันหน้าร้านหรือกิจกรรมการตลาดเฉพาะในร้าน

จากการวิเคราะห์นี้ PiCha ควรตรวจสอบโครงสร้างส่วนลดระดับออเดอร์ของช่องทาง delivery เนื่องจากส่วนต่างประมาณ 1.7 จุดเปอร์เซ็นต์เมื่อเทียบกับ to_go คิดเป็นต้นทุนส่วนลดเพิ่มเติมในระดับที่มีนัยสำคัญ หากส่วนลดนี้เป็นการอุดหนุนจากแพลตฟอร์ม third-party ก็อาจลดภาระของ PiCha ได้ แต่ถ้า PiCha เป็นผู้รับผิดชอบเอง การปรับเงื่อนไขโปรโมชัน delivery ให้เข้มงวดขึ้นสามารถเพิ่ม net margin ได้อย่างชัดเจน

สาขาไหน “ทำกำไร”

รายได้สูงไม่ได้แปลว่ากำไรสูง จำเป็นต้องพิจารณา contribution margin (CM) ซึ่งหักต้นทุนผันแปรทั้งหมดแล้ว

SELECT
 s.store_name,
 s.branch_type,
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue,
 ROUND(SUM(t.cogs_thb), 0) AS total_cogs,
 ROUND(SUM(t.platform_commission_thb), 0) AS total_commission,
 ROUND(SUM(t.packaging_cost_thb), 0) AS total_packaging,
 ROUND(SUM(t.labor_alloc_thb), 0) AS total_labor,
 ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm,
 ROUND(100.0 * SUM(t.contribution_margin_thb) / SUM(t.net_sales_thb), 1) AS cm_pct
FROM transactions t                             -- จากตารางธุรกรรม
INNER JOIN stores s ON t.store_id = s.store_id  -- เชื่อมกับตารางสาขา
GROUP BY s.store_name, s.branch_type            -- จัดกลุ่มตามชื่อและประเภทสาขา
ORDER BY total_cm DESC;                         -- เรียงตามกำไรส่วนเกินรวมจากมากไปน้อย
store_name branch_type total_revenue total_cogs total_commission total_packaging total_labor total_cm cm_pct
PiCha CentralWorld mall 2,451,113 743,820 255,344 69,834 63,966 1,318,149 53.8%
PiCha Siam Square street 2,311,860 701,334 278,819 68,518 57,946 1,205,243 52.1%
PiCha ICONSIAM mall_flagship 1,763,926 527,321 174,458 49,144 46,502 966,501 54.8%
PiCha Park Silom office 1,450,877 443,993 120,561 39,153 40,382 806,787 55.6%
PiCha Vanit Village mixed_use 1,465,764 444,888 143,340 40,905 38,753 797,878 54.4%
PiCha Exchange Tower office 1,343,861 410,868 111,748 36,225 37,526 747,494 55.6%

จากตารางนี้ CentralWorld สร้าง CM ในเชิงมูลค่าสูงสุด 1,318,149 บาท (53.8%) แต่ในเชิงสัดส่วน cm_pct พบว่าสาขา office ทั้ง Park Silom และ Exchange Tower ทำได้สูงสุดที่ 55.6% สะท้อนว่ารูปแบบธุรกิจออฟฟิศมีโครงสร้างต้นทุนที่ lean กว่า แม้รายได้รวมต่ำกว่า

COGS คิดเป็นประมาณ 30% ของรายได้เกือบทุกสาขา และแรงงานรวมกับ packaging อยู่ราว 5% ใกล้เคียงกัน ความแตกต่างด้านกำไรหลักจึงมาจาก platform commission เป็นสำคัญ โดยเฉพาะ Siam Square ที่จ่าย commission สูงสุดถึง 278,819 บาท (ประมาณ 12.1% ของรายได้) ทำให้ cm_pct ต่ำสุดที่ 52.1% ทั้งที่มีรายได้รวมเป็นอันดับสอง

ICONSIAM ในฐานะ mall_flagship ทำ cm_pct ได้ 54.8% สูงกว่า CentralWorld แสดงว่าบริหารต้นทุนได้สมดุลกว่า โดยเฉพาะด้าน commission ในขณะที่รายได้รวมยังมีช่องว่างให้เติบโตขึ้นอีก หากสามารถเพิ่มยอดขายโดยรักษาโครงสร้างต้นทุนปัจจุบันไว้ ICONSIAM จะเป็นเครื่องยนต์กำไรที่แข็งแรงในกลุ่มห้าง

ข้อเสนอแนะสำหรับผู้บริหาร

  • ใช้สาขา office (Park Silom และ Exchange Tower) เป็น benchmark ด้านต้นทุน เพราะมี cm_pct สูงที่สุด
  • ลดการพึ่งพา delivery platform ที่ Siam Square เพื่อลด commission และยกระดับ cm_pct โดยพิจารณาช่องทางขายตรงหรือโปรโมชั่นเฉพาะหน้าร้าน
  • พิจารณาลงทุนเพิ่มเพื่อขยายยอดขาย ICONSIAM ซึ่งมี margin ดี แต่รายได้รวมยังต่ำกว่าศักยภาพเมื่อเทียบกับ CentralWorld

เมื่อเข้าใจมิติด้านสาขา ช่องทาง และโครงสร้างกำไรแล้ว การมองตามมิติเวลาจะช่วยให้เห็นการเปลี่ยนแปลงของธุรกิจทั้งในระดับเดือน (แนวโน้มภาพใหญ่) และชั่วโมง (การจัดการปฏิบัติการหน้างาน)

รายได้เปลี่ยนแปลงรายเดือนอย่างไร

SELECT
 month,
 COUNT(*) AS order_count,
 ROUND(SUM(net_sales_thb), 0) AS total_revenue,
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value,
 ROUND(SUM(contribution_margin_thb), 0) AS total_cm,
 ROUND(100.0 * SUM(contribution_margin_thb) / SUM(net_sales_thb), 1) AS cm_pct
FROM transactions
GROUP BY month
ORDER BY month;
month order_count total_revenue avg_order_value total_cm cm_pct
2 24,420 4,905,954 201 2,644,375 53.9
3 27,805 5,660,917 204 3,076,755 54.4
4 1,098 220,531 201 120,923 54.8

เดือนมีนาคมมีออเดอร์เพิ่มขึ้น 13.9% จากกุมภาพันธ์ (27,805 เทียบกับ 24,420) และรายได้เพิ่มขึ้น 15.4% (5,660,917 เทียบกับ 4,905,954 บาท) ขณะที่ cm_pct ขยับขึ้นเล็กน้อยจาก 53.9% เป็น 54.4% การเติบโตทั้ง volume และ margin พร้อมกันเป็นสัญญาณที่ดีของการเติบโตที่มีคุณภาพ

อย่างไรก็ตาม ข้อมูลเดือนเมษายนมีเพียง 1,098 ออเดอร์ เพราะฐานข้อมูลสิ้นสุดที่วันที่ 1 เมษายน 2026 จึงเป็นข้อมูลเพียง 1 วัน ไม่ใช่เดือนเต็ม และไม่ควรนำไปเปรียบเทียบกับเดือนอื่นโดยตรง หากแนวโน้มกุมภาพันธ์–มีนาคมดำเนินต่อเนื่อง ทีมปฏิบัติการควรเตรียมกำลังคนและสินค้าคงคลังให้รองรับการเติบโตต่อไป แต่ในเชิงสถิติ ข้อมูลเพียง 2 เดือนยังไม่เพียงพอสำหรับการสรุปแนวโน้มระยะยาว

ชั่วโมงไหนขายดีที่สุด

มุมมองรายเดือนช่วยให้เห็นแนวโน้มภาพใหญ่ แต่การจัดตารางพนักงานและเตรียมวัตถุดิบต้องใช้ข้อมูลระดับชั่วโมง

SELECT                                            
 hour,                                            -- ชั่วโมง
 COUNT(*) AS order_count,                         -- นับจำนวนออร์เดอร์
 ROUND(SUM(net_sales_thb), 0) AS total_revenue,   -- รวมยอดขายสุทธิ (บาท)
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value  -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
FROM transactions  -- จากตารางธุรกรรม
GROUP BY hour      -- จัดกลุ่มตามชั่วโมง
ORDER BY hour;     -- เรียงตามชั่วโมง
hour order_count total_revenue avg_order_value
9 2,571 497,976 194
10 3,044 595,380 196
11 5,191 1,061,147 204
12 6,742 1,364,026 202
13 5,548 1,120,226 202
14 3,779 767,538 203
15 3,447 688,227 200
16 2,930 597,503 204
17 4,490 910,433 203
18 5,115 1,037,511 203
19 4,389 898,646 205
20 3,098 638,302 206
21 2,107 428,453 203
22 872 182,033 209

มี 2 ช่วง peak ที่ชัดเจน ได้แก่ ช่วงกลางวัน (11:00–13:00) และ ช่วงเย็น (17:00–19:00) โดยชั่วโมง 12:00 มีออเดอร์สูงที่สุด 6,742 รายการและรายได้รวม 1,364,026 บาท สูงกว่าชั่วโมง 9:00 อย่างมาก สะท้อนพฤติกรรมการซื้อชาในมื้อเที่ยงและช่วงหลังเลิกงาน

AOV ค่อนข้างนิ่งตลอดทั้งวัน อยู่ในช่วง 194–209 บาท โดยชั่วโมง 22:00 มี AOV สูงสุดที่ 209 บาท แม้มีออเดอร์น้อยที่สุดเพียง 872 รายการ แสดงว่าลูกค้าช่วงดึกอาจสั่งสินค้ามูลค่าสูงกว่า หรือซื้อหลายแก้วต่อครั้งมากกว่าช่วงเวลาอื่น

ช่วง 14:00–16:00 เป็น off-peak ที่ชัดเจน จำนวนออเดอร์ลดลงอย่างเห็นได้ชัด PiCha อาจใช้ช่วงเวลานี้สำหรับโปรโมชัน Happy Hour เพื่อกระตุ้นยอดขาย หรือปรับลดกำลังคนลงเล็กน้อยเพื่อเพิ่มประสิทธิภาพต้นทุนแรงงาน ขณะเดียวกันควรเสริมกำลังคนในช่วง peak 11:00–13:00 และ 17:00–19:00 เพื่อลดเวลารอและรักษาคะแนนความพึงพอใจ

สรุป

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

ตารางต่อไปนี้สรุปหน้าที่ของฟังก์ชันรวมที่ใช้บ่อย พร้อมตัวอย่างการประยุกต์ใช้ในบริบทธุรกิจ

ฟังก์ชัน ทำอะไร ตัวอย่างธุรกิจ
COUNT(*) นับจำนวนแถวทั้งหมด มีออเดอร์ทั้งหมดกี่รายการ
COUNT(column) นับจำนวนแถวที่คอลัมน์นั้นไม่เป็น NULL มีออเดอร์กี่รายการที่ระบุ customer_id
COUNT(DISTINCT col) นับจำนวนค่าที่ไม่ซ้ำกัน มีลูกค้าไม่ซ้ำทั้งหมดกี่คน
SUM(column) รวมค่าทั้งหมดในคอลัมน์ รายได้รวมเป็นเท่าไร
AVG(column) หาค่าเฉลี่ยของคอลัมน์ มูลค่าออเดอร์เฉลี่ยอยู่ที่เท่าไร
MEDIAN(column) หาค่ากลางของข้อมูล โดยได้รับผลกระทบจาก outlier น้อยกว่าค่าเฉลี่ย ลูกค้าส่วนใหญ่จ่ายจริงต่อออเดอร์ประมาณเท่าไร
MIN(column) หาค่าต่ำสุด มูลค่าออเดอร์ที่ต่ำที่สุดคือเท่าไร
MAX(column) หาค่าสูงสุด มูลค่าออเดอร์ที่สูงที่สุดคือเท่าไร

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

ส่วน HAVING ใช้กรองผลลัพธ์ “หลังจาก” มีการจัดกลุ่มและคำนวณค่า aggregate แล้ว จึงต่างจาก WHERE ที่ใช้กรองข้อมูลดิบ “ก่อน” การจัดกลุ่ม เรามักใช้ HAVING เมื่อต้องการตั้งเงื่อนไขกับค่าที่คำนวณแล้ว เช่น HAVING SUM(net_sales_thb) > 2000000 เพื่อเลือกเฉพาะกลุ่มที่มียอดขายรวมเกิน 2,000,000 บาท หรือ HAVING COUNT(*) < 2000 เพื่อดูเฉพาะกลุ่มที่มีจำนวนรายการไม่มากนัก

เมื่อใช้ Aggregate Functions ร่วมกับ GROUP BY และ HAVING ได้อย่างถูกต้อง เราจะสามารถเปลี่ยนข้อมูลธุรกรรมจำนวนมากให้กลายเป็นสารสนเทศที่พร้อมใช้ในการตัดสินใจทางธุรกิจได้อย่างมีประสิทธิภาพ

บทที่ 4 เน้นการเชื่อมข้อมูลจากหลายตารางด้วย JOIN

คำถามท้ายบท

  1. จงหาจำนวนออเดอร์และรายได้รวม แยกตาม weather_flag และ channel (GROUP BY 2 ระดับ) เรียงตามรายได้รวมจากมากไปน้อย

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

weather_flag channel orders revenue
sunny delivery 13,987 4,119,140
sunny to_go 18,271 2,773,230
sunny dine_in 7,334 1,102,110
rainy delivery 3,459 1,033,730
rainy to_go 4,737 716,317
cloudy delivery 1,349 406,501
rainy dine_in 1,869 281,824
cloudy to_go 1,544 236,394
cloudy dine_in 773 118,163
คลิกเพื่อดูเฉลย
SELECT                                             -- เลือกข้อมูล
 weather_flag,                                  -- สภาพอากาศ
 channel,                                       -- ช่องทางการขาย
 COUNT(*) AS order_count,                       -- นับจำนวนออร์เดอร์
 ROUND(SUM(net_sales_thb), 0) AS total_revenue  -- รวมยอดขายสุทธิ (บาท)
FROM transactions                                  -- จากตารางธุรกรรม
GROUP BY weather_flag, channel                     -- จัดกลุ่มตามสภาพอากาศและช่องทาง
ORDER BY total_revenue DESC;                       -- เรียงตามยอดขายรวมจากมากไปน้อย
  1. จงหาสาขาที่มีค่าเฉลี่ย satisfaction_score ต่ำกว่า 4.0 พร้อมแสดงจำนวนออเดอร์และ CM%

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

store_name order_count avg_satisfaction cm_pct
PiCha Exchange Tower 7,080 3.63 55.6
PiCha Park Silom 7,611 3.66 55.6
PiCha Siam Square 10,738 3.96 52.1
คลิกเพื่อดูเฉลย
-- สรุปสาขาที่มีคะแนนความพึงพอใจเฉลี่ยต่ำกว่า 4.0
SELECT
 s.store_name, -- ชื่อสาขา
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction, -- คะแนนความพึงพอใจเฉลี่ย
 ROUND(100.0 * SUM(t.contribution_margin_thb) / SUM(t.net_sales_thb), 1) AS cm_pct -- อัตรากำไรส่วนเกิน (%)
FROM transactions t
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
GROUP BY s.store_name -- จัดกลุ่มตามชื่อสาขา
HAVING AVG(t.satisfaction_score) < 4.0 -- กรองเฉพาะสาขาที่มีคะแนนความพึงพอใจเฉลี่ยต่ำกว่า 4.0
ORDER BY avg_satisfaction; -- เรียงจากสาขาที่มีคะแนนความพึงพอใจต่ำสุดไปสูงสุด
  1. จงหาชั่วโมงที่มีออเดอร์มากกว่า 5,000 ครั้ง พร้อมแสดง AOV

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

hour orders AOV
12 6,742 202
13 5,548 202
11 5,191 204
18 5,115 203
คลิกเพื่อดูเฉลย
-- สรุปชั่วโมงที่มีคำสั่งซื้อมากกว่า 5,000 รายการ เรียงตามความนิยม
SELECT
 hour, -- ชั่วโมงของวัน (0-23)
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(AVG(net_sales_thb), 0) AS avg_order_value -- มูลค่าคำสั่งซื้อเฉลี่ย (บาท)
FROM transactions -- ตารางหลักที่เก็บข้อมูลธุรกรรม
GROUP BY hour -- จัดกลุ่มตามชั่วโมง
HAVING COUNT(*) > 5000 -- กรองเฉพาะชั่วโมงที่มีคำสั่งซื้อมากกว่า 5,000 รายการ
ORDER BY order_count DESC; -- เรียงจากชั่วโมงที่มีคำสั่งซื้อมากสุดไปน้อยสุด
  1. จงคำนวณจำนวนวันที่มีรายได้เกิน 200,000 บาท พร้อมแสดงวันที่ขายดีที่สุดและแย่ที่สุด

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

วันที่ รายได้
2026-02-15 318,449 THB (สูงสุด)
2026-03-02 97,480 THB (ต่ำสุด)
คลิกเพื่อดูเฉลย
-- นับจำนวนวันที่มีรายได้สูงกว่า 200,000 บาท พร้อมรายได้สูงสุดและต่ำสุดในกลุ่มนั้น
SELECT
 COUNT(*) AS high_revenue_days, -- จำนวนวันที่มีรายได้สูงกว่า 200,000 บาท
 MAX(daily_rev) AS best_day_revenue, -- รายได้สูงสุดในบรรดาวันที่ผ่านเกณฑ์
 MIN(daily_rev) AS worst_day_revenue -- รายได้ต่ำสุดในบรรดาวันที่ผ่านเกณฑ์
FROM (
 SELECT
  order_date, -- วันที่สั่งซื้อ
  SUM(net_sales_thb) AS daily_rev -- รายได้รวมต่อวัน (บาท)
 FROM transactions -- ตารางหลักที่เก็บข้อมูลธุรกรรม
 GROUP BY order_date -- จัดกลุ่มตามวันที่
 HAVING SUM(net_sales_thb) > 200000 -- กรองเฉพาะวันที่มีรายได้รวมมากกว่า 200,000 บาท
); -- Subquery: สรุปรายได้รายวันที่ผ่านเกณฑ์
  1. จงหาสัดส่วนรายได้ของ YumDash เทียบกับ C2U ในแต่ละสาขา (เฉพาะ delivery)

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

store_name YumDash C2U YumDash % C2U %
PiCha Siam Square 893,395 506,036 63.8% 36.2%
PiCha centralwOrld 757,696 552,107 57.8% 42.2%
PiCha ICONSIAM 575,258 341,987 62.7% 37.3%
PiCha Vanit Village 418,180 323,284 56.4% 43.6%
PiCha Park Silom 334,641 283,701 54.1% 45.9%
PiCha Exchange Tower 318,471 254,615 55.6% 44.4%
คลิกเพื่อดูเฉลย
-- สรุปยอดขายและค่าคอมมิชชันแยกตามสาขาและแพลตฟอร์ม Delivery
SELECT
 s.store_name, -- ชื่อสาขา
 t.platform, -- ชื่อแพลตฟอร์ม Delivery
 COUNT(*) AS order_count, -- จำนวนคำสั่งซื้อ
 ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รายได้รวม (บาท)
 ROUND(100.0 * SUM(t.platform_commission_thb) / SUM(t.net_sales_thb), 1) AS commission_pct -- อัตราค่าคอมมิชชันของแพลตฟอร์ม (%)
FROM transactions t
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตารางสาขาเพื่อดึงชื่อสาขา
WHERE t.channel = 'delivery' -- กรองเฉพาะคำสั่งซื้อช่องทาง Delivery
GROUP BY s.store_name, t.platform -- จัดกลุ่มตามชื่อสาขาและแพลตฟอร์ม
ORDER BY s.store_name, total_revenue DESC; -- เรียงตามชื่อสาขา และรายได้จากมากไปน้อยในแต่ละสาขา