Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

VN context: schema e-commerce Tiki/Shopee mô phỏng

SQL and Data Analysis cho BA Bài 40/60

Mở đầu — vì sao bài này quan trọng

Suốt 39 bài trước, bạn đã học SELECT, JOIN, GROUP BY, window functions, CTE và đủ thứ kỹ thuật. Nhưng có một sự thật mà ít khóa SQL nào nói thẳng: kỹ thuật SQL chỉ là một nửa câu chuyện. Nửa còn lại — thường quyết định bạn là một BA giỏi hay một người chỉ biết gõ query — là khả năng hiểu schema thực tế của doanh nghiệp mà bạn đang phân tích.

Khi bạn vào làm cho một công ty e-commerce như Tiki, Shopee, Lazada hay một sàn nhỏ hơn ở Việt Nam, không ai đưa bạn một bảng sales sạch đẹp với đúng 5 cột. Bạn nhận được một database có 40–80 bảng, đặt tên bằng tiếng Anh lẫn tiếng Việt không nhất quán, có những cột status chứa mã số mà chỉ team backend mới hiểu, có bảng orders và bảng order_items tách rời, có flash_sale, có voucher, có seller, có shipping_provider. Nếu bạn không đọc được "bản đồ" này, mọi kỹ thuật SQL của bạn trở nên vô dụng vì bạn không biết JOIN bảng nào với bảng nào, đo doanh thu lấy ở cột nào.

Bài này tập trung vào một schema e-commerce mô phỏng theo phong cách Tiki/Shopee — đủ thực tế để bạn cảm nhận được độ phức tạp của một marketplace Việt Nam, nhưng đủ gọn để học. Chúng ta sẽ dựng schema, hiểu vì sao nó được thiết kế như vậy, và viết những query nghiệp vụ điển hình mà một BA e-commerce phải làm hàng ngày. Đây cũng là schema nền cho các bài VN context tiếp theo (ride-hailing, fintech) và cho capstone cuối khóa.

Khái niệm cốt lõi

Marketplace khác store đơn lẻ ở điểm nào?

Điểm cốt lõi đầu tiên: Tiki/Shopee là marketplace (sàn) chứ không phải một cửa hàng đơn lẻ. Nghĩa là có nhiều người bán (seller/shop) cùng bán trên một nền tảng. Điều này thay đổi toàn bộ thiết kế dữ liệu:

  • Một sản phẩm thuộc về một seller cụ thể, không phải thuộc về sàn.
  • Một đơn hàng của khách có thể chứa sản phẩm từ nhiều seller khác nhau, và khi đó hệ thống thường tách thành nhiều "sub-order" (đơn con) để mỗi seller xử lý phần của mình.
  • Doanh thu của sàn không phải là tổng giá trị hàng bán, mà là phí hoa hồng (commission) thu trên mỗi giao dịch. Đây là một hiểu lầm chết người nếu bạn báo cáo sai.

Schema mô phỏng

Dưới đây là schema rút gọn nhưng đủ dùng. Tôi viết theo cú pháp PostgreSQL/MySQL tương thích, có chú thích để bạn hiểu ý đồ thiết kế.

-- Khách hàng
CREATE TABLE customers (
  id          BIGINT PRIMARY KEY,
  email       VARCHAR(255),
  phone       VARCHAR(20),
  full_name   VARCHAR(255),
  city        VARCHAR(100),    -- Hà Nội, TP.HCM, Đà Nẵng...
  created_at  TIMESTAMP
);

-- Người bán (shop trên sàn) CREATE TABLE sellers ( id BIGINT PRIMARY KEY, shop_name VARCHAR(255), is_official BOOLEAN, -- gian hàng chính hãng (Mall) city VARCHAR(100), created_at TIMESTAMP );

-- Danh mục ngành hàng (cây phân cấp) CREATE TABLE categories ( id BIGINT PRIMARY KEY, name VARCHAR(255), parent_id BIGINT -- NULL nếu là danh mục gốc );

-- Sản phẩm CREATE TABLE products ( id BIGINT PRIMARY KEY, seller_id BIGINT, -- FK -> sellers.id category_id BIGINT, -- FK -> categories.id name VARCHAR(500), price DECIMAL(12,0), -- VND, không có phần thập phân created_at TIMESTAMP );

-- Đơn hàng (đơn cha của khách) CREATE TABLE orders ( id BIGINT PRIMARY KEY, customer_id BIGINT, -- FK -> customers.id status VARCHAR(30), -- pending, confirmed, shipping, delivered, cancelled, returned payment_method VARCHAR(30), -- cod, momo, zalopay, card, vnpay voucher_code VARCHAR(50), -- NULL nếu không dùng shipping_fee DECIMAL(12,0), created_at TIMESTAMP, delivered_at TIMESTAMP -- NULL nếu chưa giao xong );

-- Chi tiết đơn (mỗi dòng = 1 sản phẩm trong đơn) CREATE TABLE order_items ( id BIGINT PRIMARY KEY, order_id BIGINT, -- FK -> orders.id product_id BIGINT, -- FK -> products.id seller_id BIGINT, -- FK -> sellers.id (denormalize để query nhanh) quantity INT, unit_price DECIMAL(12,0), -- giá tại thời điểm mua (KHÔNG dùng products.price) discount DECIMAL(12,0) -- giảm giá áp cho dòng này );

Ba nguyên tắc thiết kế bạn phải khắc cốt ghi tâm

Nguyên tắc 1 — unit_price nằm ở order_items, không phải products.price. Giá sản phẩm thay đổi liên tục (flash sale, tăng giá theo mùa). Khi khách mua, hệ thống "đóng băng" giá vào order_items.unit_price. Nếu bạn tính doanh thu bằng products.price, bạn sẽ lấy giá hiện tại chứ không phải giá lúc bán — sai hoàn toàn với những đơn cũ.

Nguyên tắc 2 — đơn hàng tách thành cha (orders) và con (order_items). Một đơn có nhiều sản phẩm. Giá trị đơn (GMV) là tổng của các dòng order_items, cộng shipping_fee, trừ giảm giá. Đừng bao giờ tìm một cột "total_amount" thần thánh ở orders rồi tin tuyệt đối — nhiều schema có cột đó nhưng nó có thể lệch do trừ voucher; luôn kiểm chứng bằng cách cộng từ order_items.

Nguyên tắc 3 — status quyết định doanh thu có được tính hay không. Đơn cancelled hoặc returned KHÔNG được tính vào doanh thu thực. Rất nhiều báo cáo sai vì BA quên lọc status.

Tình huống thực tế

Tình huống 1 — Báo cáo GMV mà sếp tưởng tăng, thực ra giảm

Một sàn TMĐT giả định tên "ChợViệt" có một BA mới tên Linh. Tháng 5, Linh chạy query đếm tổng giá trị tất cả đơn hàng và báo cáo GMV tăng 18% so với tháng 4. Sếp mừng. Nhưng đến cuối quý, kế toán phát hiện doanh thu thực không khớp.

Vấn đề: Linh viết như sau (sai):

SELECT SUM(oi.quantity * oi.unit_price) AS gmv
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.created_at >= '2026-05-01' AND o.created_at < '2026-06-01';

Query này cộng cả những đơn bị huỷ và đơn bị trả hàng. Tháng 5 vừa có một đợt flash sale tạo ra rất nhiều đơn ảo (khách đặt rồi huỷ vì không có hàng). Khi lọc lại:

SELECT SUM(oi.quantity * oi.unit_price - oi.discount) AS net_gmv
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE o.created_at >= '2026-05-01' AND o.created_at < '2026-06-01'
  AND o.status IN ('delivered', 'shipping', 'confirmed');  -- loại cancelled, returned

GMV thực chỉ tăng 4%, không phải 18%. Bài học: trong e-commerce, "đơn hàng" và "doanh thu thực" là hai khái niệm khác nhau. Luôn hỏi: status nào được tính? Tính theo created_at hay delivered_at? Có trừ discount và trừ đơn hoàn không?

Tình huống 2 — Top seller theo doanh thu để quyết định ưu đãi

Đội vận hành của ChợViệt muốn chọn 10 shop bán chạy nhất quý 2 để mời tham gia chương trình "Gian hàng vàng" miễn phí phí sàn. Họ cần BA xếp hạng seller theo doanh thu thực, kèm số đơn và số khách riêng biệt.

SELECT
  s.shop_name,
  s.is_official,
  COUNT(DISTINCT o.id)                          AS so_don,
  COUNT(DISTINCT o.customer_id)                 AS so_khach,
  SUM(oi.quantity * oi.unit_price - oi.discount) AS doanh_thu
FROM order_items oi
JOIN orders   o ON o.id = oi.order_id
JOIN sellers  s ON s.id = oi.seller_id
WHERE o.created_at >= '2026-04-01' AND o.created_at < '2026-07-01'
  AND o.status IN ('delivered', 'shipping', 'confirmed')
GROUP BY s.id, s.shop_name, s.is_official
ORDER BY doanh_thu DESC
LIMIT 10;

Lưu ý cái hay ở đây: ta JOIN qua oi.seller_id (đã denormalize sẵn ở order_items) thay vì phải đi vòng qua products. Nếu schema không denormalize, bạn phải JOIN thêm products để lấy seller_id — chậm hơn và dễ sai khi một đơn có nhiều seller. Bài học: hiểu vì sao một cột được denormalize giúp bạn viết query gọn và đúng hơn.

Tình huống 3 — Phân tích theo thành phố để mở kho mới

Ban giám đốc cân nhắc mở kho ở Đà Nẵng. Câu hỏi: tỷ trọng đơn hàng và doanh thu từ miền Trung (Đà Nẵng, Huế, Quảng Nam) đang là bao nhiêu, và thời gian giao trung bình ở đó có chậm hơn Hà Nội / TP.HCM không?

SELECT
  c.city,
  COUNT(DISTINCT o.id)                                          AS so_don,
  SUM(oi.quantity * oi.unit_price - oi.discount)                AS doanh_thu,
  ROUND(AVG(EXTRACT(EPOCH FROM (o.delivered_at - o.created_at)) / 86400.0), 1)
                                                                AS so_ngay_giao_tb
FROM orders o
JOIN customers   c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'delivered'
  AND o.delivered_at IS NOT NULL
  AND o.created_at >= '2026-01-01'
GROUP BY c.city
ORDER BY doanh_thu DESC;

Kết quả giả định: Đà Nẵng chiếm 9% doanh thu nhưng thời gian giao trung bình 4.8 ngày so với 1.9 ngày của TP.HCM — một lý do mạnh để mở kho. Bài học: schema e-commerce VN luôn có trường địa lý (city), và phân tích theo vùng là một trong những câu hỏi nghiệp vụ giá trị nhất. Để ý cách tính khoảng thời gian giao: delivered_at - created_at rồi đổi ra ngày.

Hướng dẫn từng bước

Khi nhận một schema e-commerce lạ, đây là quy trình tôi khuyên bạn làm trước khi viết bất kỳ query nghiệp vụ nào:

Bước 1 — Liệt kê bảng và phân loại. Tách bảng thành hai nhóm: bảng thực thể (customers, products, sellers, categories — mô tả "vật") và bảng sự kiện/giao dịch (orders, order_items — ghi lại "việc gì xảy ra"). Báo cáo doanh thu luôn xuất phát từ nhóm sự kiện.

Bước 2 — Vẽ quan hệ khoá. Xác định khoá chính và khoá ngoại. Ở schema trên: order_items.order_id → orders.id, orders.customer_id → customers.id, order_items.product_id → products.id, products.seller_id → sellers.id. Vẽ ra giấy thành sơ đồ mũi tên.

Bước 3 — Tìm "cột vàng". Với mỗi câu hỏi nghiệp vụ, xác định cột chứa số bạn cần đo. Doanh thu → order_items.unit_price, quantity, discount. Thời gian → created_at (đặt đơn) hay delivered_at (giao xong)?

Bước 4 — Làm rõ business rule về status. Liệt kê tất cả giá trị status có thể có và hỏi (team data hoặc tự kiểm tra bằng SELECT DISTINCT status, COUNT(*) FROM orders GROUP BY status) đâu là status "tính doanh thu", đâu là "không tính".

Bước 5 — Viết query thăm dò trước khi viết query báo cáo. Đếm số dòng mỗi bảng, kiểm tra NULL ở các cột khoá, kiểm tra phân bố status. Đừng nhảy thẳng vào báo cáo cuối.

Bước 6 — Đối chiếu kết quả. So sánh con số bạn ra với một nguồn khác (dashboard cũ, báo cáo kế toán). Lệch nhỏ thường do filter status hoặc múi giờ; lệch lớn thường do JOIN sai gây nhân đôi dòng.

Lỗi thường gặp & mẹo

Lỗi 1 — JOIN gây nhân đôi doanh thu (fan-out). Khi bạn JOIN orders với order_items rồi SUM(o.shipping_fee), phí ship bị nhân lên theo số dòng item của đơn. Một đơn 3 sản phẩm sẽ cộng shipping_fee ba lần. Mẹo: tính ở đúng "hạt" của nó — doanh thu hàng tính từ order_items, còn shipping_fee tính riêng từ orders (dùng subquery hoặc CTE), rồi cộng lại.

Lỗi 2 — Dùng products.price thay vì order_items.unit_price. Như đã nói ở nguyên tắc 1, đây là lỗi kinh điển. Luôn lấy giá từ dòng đơn hàng.

Lỗi 3 — Quên lọc status. Cộng cả đơn huỷ/hoàn là cách nhanh nhất để bị sếp bắt lỗi. Đặt việc lọc status thành phản xạ.

Lỗi 4 — Nhầm created_at với delivered_at. Báo cáo "doanh thu tháng 5" — là đơn đặt trong tháng 5 hay đơn giao xong trong tháng 5? Hai con số khác nhau. Hỏi rõ trước khi viết.

Lỗi 5 — Quên múi giờ. created_at thường lưu ở UTC, trong khi nghiệp vụ tính theo giờ Việt Nam (UTC+7). Một đơn lúc 23:30 ngày 31/5 giờ VN sẽ là 16:30 ngày 31/5 UTC (vẫn ổn), nhưng đơn lúc 06:00 ngày 1/6 giờ VN lại là 23:00 ngày 31/5 UTC — nếu không cộng 7 giờ, nó bị tính nhầm sang tháng 5. Mẹo: created_at + INTERVAL '7 hours' (Postgres) trước khi lọc theo ngày.

Mẹo vàng: Mỗi khi nhận schema mới, viết một file "data dictionary mini" cho riêng mình — ghi lại ý nghĩa từng cột status, đâu là cột giá vàng, business rule nào áp dụng. Bài 50 sẽ nói sâu về việc này, nhưng hãy bắt đầu thói quen ngay từ bây giờ.

Bài tập thực hành

Dùng schema mô phỏng ở trên, hãy viết query cho các yêu cầu sau:

  • Doanh thu theo tháng năm 2026 (chỉ tính đơn delivered, shipping, confirmed), theo created_at đã chỉnh về giờ VN. Mỗi dòng: tháng, số đơn, doanh thu thực.
  • Top 5 danh mục ngành hàng có doanh thu cao nhất quý 2/2026. Gợi ý: JOIN order_items → products → categories. Nếu muốn nâng cao, gộp về danh mục gốc bằng parent_id.
  • So sánh gian hàng chính hãng (Mall) vs gian hàng thường: với mỗi nhóm (is_official = true/false), tính số đơn, doanh thu, và giá trị đơn trung bình (AOV). Nhận xét nhóm nào có AOV cao hơn.
  • Tỷ lệ huỷ đơn theo phương thức thanh toán: với mỗi payment_method, tính tổng số đơn và % đơn cancelled. Giả thuyết: COD có tỷ lệ huỷ cao hơn thanh toán trước (momo/zalopay). Kiểm chứng bằng query.
  • (Thử thách) Tìm những đơn có giá trị bất thường: đơn mà tổng tiền hàng (SUM(quantity * unit_price - discount)) lớn hơn 50 triệu VND. Trả về order_id, tên khách, thành phố, tổng tiền.
Sau khi viết, hãy tự kiểm tra: bạn đã lọc status chưa? JOIN có gây nhân đôi không? Bạn đo bằng unit_price hay products.price?

Tóm tắt

  • Tiki/Shopee là marketplace nhiều người bán, nên schema có sellers, đơn có thể chứa nhiều seller, và doanh thu sàn bản chất là hoa hồng.
  • Schema cốt lõi: customers, sellers, products, categories, orders (đơn cha), order_items (đơn con). GMV tính từ order_items, không từ một cột tổng thần thánh.
  • Ba nguyên tắc sống còn: dùng order_items.unit_price (giá đóng băng lúc mua), tách cha/con để không nhân đôi, và luôn lọc status để tách doanh thu thực khỏi đơn huỷ/hoàn.
  • Năm lỗi kinh điển: fan-out nhân đôi phí ship, dùng giá hiện tại, quên status, nhầm created_at/delivered_at, quên múi giờ UTC+7.
  • Quy trình tiếp cận schema lạ: phân loại bảng → vẽ quan hệ khoá → tìm cột vàng → làm rõ rule status → query thăm dò → đối chiếu kết quả.
Schema này sẽ là nền cho hai bài VN context kế tiếp (ride-hailing Be/Grab và fintech MoMo/ZaloPay) và cho dự án capstone. Hãy nắm thật chắc, vì 80% công việc thực tế của một BA e-commerce xoay quanh đúng sáu bảng này.