Hướng dẫn: Xây KPI bán hàng trên dbt
Bài này hướng dẫn cách xây KPI bán hàng trên bảng fct_sales bằng dbt — từ chỉ tiêu đơn giản tới chỉ tiêu mà một pivot Odoo thông thường không làm được. Mục tiêu: cho thấy giá trị của dbt nằm ở đâu, và làm đúng chỗ dễ sai nhất (giá vốn).
1. Giá trị của dbt nằm ở đâu?
Phần tiêu đề “1. Giá trị của dbt nằm ở đâu?”Một hiểu lầm thường gặp: nhìn vào các chỉ tiêu của một bảng giao dịch (doanh thu, số lượng, thuế) và kết luận “dbt chỉ cộng — Odoo cũng làm được”. Đúng một nửa: ở một fact đơn lẻ, phần lớn chỉ tiêu là Odoo bê thẳng. Giá trị dbt không nằm ở đó — mà ở bốn tầng cao hơn:
| Tầng | Ký hiệu | Ví dụ | Odoo pivot làm được? |
|---|---|---|---|
| Tổng / Đếm | Σ | Doanh thu, số đơn, sản lượng | ✅ Có |
| Dẫn xuất | ÷ | AOV, biên LN %, ASP | ✅ Có (tỷ lệ 2 số tổng) |
| Window | ⧉ | MoM, YoY, YTD, run-rate | ❌ Chỉ dbt/SQL |
| Xếp hạng | # | Pareto, ABC, top-N, rank trong ngành | ❌ Chỉ dbt/SQL |
| Cohort | ⟳ | Khách mới/quay lại, churn, RFM, CLV | ❌ Chỉ dbt/SQL |
| Blend | ⋈ | Kế hoạch vs thực tế, % đạt | ❌ Chỉ dbt/SQL |
Bốn tầng dưới (window/xếp hạng/cohort/blend) cần so sánh giữa các dòng — lag theo thời gian, xếp hạng toàn tập, ghép nhiều nguồn. Đây là nơi dbt (chạy trên ClickHouse cột) toả sáng, và là nơi bạn nên đầu tư.
Ngoài ra, ngay trên fct_sales, dbt còn tạo giá trị ở các chiều đã làm giàu: customer_group (phân hạng ABC bằng window), region (rollup địa lý), business_line/category (phân cấp SP) — những lát cắt Odoo không tự đẻ ra.
2. Giá vốn (COGS) — làm đúng cách
Phần tiêu đề “2. Giá vốn (COGS) — làm đúng cách”Đây là chỉ tiêu dễ làm sai nhất. Quy tắc vàng:
Giá vốn phải đến từ hệ nguồn (Odoo), KHÔNG được “bịa” trong fact.
Phản mẫu (đừng làm)
Phần tiêu đề “Phản mẫu (đừng làm)”Đừng chế giá vốn bằng công thức tuỳ tiện trong SQL của fact:
-- ❌ SAI: bịa giá vốn theo ngành + kênh + số ngẫu nhiêncost = revenue * ( multiIf(business_line = 'Dịch vụ', 0.32, 'Văn phòng', 0.64, 0.70) -- tỷ lệ tuỳ ý + multiIf(channel = 'Online', -0.05, 0.0) -- fudge theo kênh + (cityHash64(product) % 11 - 5) / 100.0 -- jitter ngẫu nhiên)Cách này cho ra biên lợi nhuận “đẹp” nhưng vô nghĩa — nó không phản ánh giá vốn thực, và che mất việc giá vốn phải được quản lý ở hệ nguồn.
Đúng: lấy từ cost master của Odoo
Phần tiêu đề “Đúng: lấy từ cost master của Odoo”Odoo lưu giá vốn định mức ở product.standard_price, và giá vốn thực xuất kho ở stock.valuation.layer. dbt chỉ việc join và nhân với số lượng:
-- ✅ ĐÚNG: COGS = qty × đơn giá vốn (từ Odoo), qua dim_productround(toFloat64(l.qty) * multiIf( dprod.unit_cost > 0, dprod.unit_cost, -- 1) giá vốn Odoo (standard_price) dprod.list_price > 0, dprod.list_price * 0.62, -- 2) demo thiếu → 62% giá niêm yết toFloat64(l.price_unit) * 0.62 -- 3) cùng lắm → 62% giá bán), 2) as costTrong đó dprod.unit_cost lấy từ dim_product — đọc thẳng standard_price của Odoo. dbt không tính giá vốn, chỉ đọc nó.
Sau khi cost master được đặt đúng, biên lợi nhuận trở nên có ý nghĩa và slice được theo mọi chiều:
| Ngành | Doanh thu | Giá vốn | Biên |
|---|---|---|---|
| Nội thất Văn phòng | 115,5 tr | 69,4 tr | 39,9% |
| Nội thất Gia đình | 8,8 tr | 5,5 tr | 38,0% |
| Dịch vụ Thiết kế & Tư vấn | 0,95 tr | 0,30 tr | 68,1% |
Thuế cũng vậy
Phần tiêu đề “Thuế cũng vậy”Đừng suy tax = price_total − price_subtotal. Odoo có sẵn field price_tax — lấy thẳng:
toFloat64(l.price_tax) as tax -- ✅ field NATIVE của Odoo (VAT), không suy ra3. KPI Window — “đòn” của dbt
Phần tiêu đề “3. KPI Window — “đòn” của dbt”Đây là nhóm KPI mà pivot Odoo không làm được: so sánh một tháng với tháng trước (MoM), cùng kỳ năm trước (YoY), luỹ kế trong năm (YTD), hay năm hoá (run-rate). Tất cả cần window functions.
Ta xây một mart riêng, grain = tháng:
-- kpi_sales_trend.sql — KPI xu hướng bằng WINDOW FUNCTIONSwith m as ( select toStartOfMonth(order_date) as month, sum(revenue) as revenue, sum(gross_profit) as gross_profit, count(distinct order_id) as orders from {{ ref('fct_sales') }} group by month),w as ( select *, -- lag theo cửa sổ thời gian lagInFrame(revenue, 1) over (order by month rows between unbounded preceding and current row) as rev_prev_month, lagInFrame(revenue, 12) over (order by month rows between unbounded preceding and current row) as rev_prev_year, -- luỹ kế trong NĂM (reset mỗi năm) sum(revenue) over (partition by toYear(month) order by month rows between unbounded preceding and current row) as ytd_revenue from m)select month, revenue, round(100.0 * (revenue - rev_prev_month) / nullIf(rev_prev_month, 0), 1) as mom_pct, -- Tăng trưởng MoM round(100.0 * (revenue - rev_prev_year) / nullIf(rev_prev_year, 0), 1) as yoy_pct, -- Tăng trưởng YoY ytd_revenue, -- Luỹ kế YTD round(ytd_revenue / toMonth(month) * 12, 0) as run_rate -- Run-rate năm hoáfrom wBa kỹ thuật cốt lõi:
lagInFrame(revenue, 1)— lấy doanh thu 1 dòng trước (tháng trước) → MoM.lagInFrame(revenue, 12)— 12 dòng trước (cùng tháng năm ngoái) → YoY. (Chỉ đúng khi chuỗi tháng liền mạch; ở đây 33 tháng 2024-01→2026-09 không gap.)sum(revenue) over (partition by toYear(month) order by month ...)— cộng dồn trong năm, tự reset sang năm mới → YTD.nullIf(mẫu, 0)— tránh chia 0 ở đầu chuỗi (tháng đầu không có “tháng trước”).
Kết quả (mẫu):
| Tháng | Doanh thu | MoM | YoY | YTD | Run-rate |
|---|---|---|---|---|---|
| 2025-01 | 5,78 tr | +29,6% | +63,0% | 5,78 tr | 69,3 tr |
| 2026-08 | 5,09 tr | +22,4% | +79,8% | 34,3 tr | 51,5 tr |
Ba tháng đầu 2024 không có YoY (chưa có năm trước) → trả NULL, đúng bản chất. Không một pivot Odoo nào cho bạn cột YoY này — đó là giá trị dbt.
4. Đưa lên Cube (ngôn ngữ business)
Phần tiêu đề “4. Đưa lên Cube (ngôn ngữ business)”Mart kpi_sales_trend xong ở dbt là đã “giải” KPI. Bước cuối: phơi ra lớp ngữ nghĩa Cube để business user tự dùng, với nhãn tiếng Việt:
cubes: - name: sales_trend sql_table: kpi_sales_trend dimensions: - name: month sql: month type: time measures: - name: mom_growth title: "Tăng trưởng MoM (%)" type: max # đã tính sẵn 1 dòng/tháng sql: mom_pct - name: yoy_growth title: "Tăng trưởng YoY (%)" type: max sql: yoy_pctGiờ mọi báo cáo (Superset, pivot) đều gọi chung một định nghĩa “Tăng trưởng YoY” — nhất quán tuyệt đối.
5. Lộ trình 50 KPI
Phần tiêu đề “5. Lộ trình 50 KPI”fct_sales + các mart KPI có thể giải trọn ~50 KPI bán hàng — mỗi KPI gắn với một kỹ thuật (Σ / ÷ / ⧉ window / # xếp hạng / ⟳ cohort / ⋈ blend). Thứ tự đề xuất triển khai:
| Sóng | Mart | KPI mở khoá | Kỹ thuật |
|---|---|---|---|
| ✅ 0 | fct_sales | COGS thật, biên, thuế | Σ / join |
| ✅ 1 | kpi_sales_trend | MoM · YoY · YTD · run-rate | ⧉ Window |
| 2 | kpi_pareto, kpi_product_rank | Top-N, ABC SP, rank trong ngành | # Xếp hạng |
| 3 | kpi_customer_cohort | Khách mới/quay lại/churn, RFM, CLV | ⟳ Cohort |
Nguyên tắc rút ra
Phần tiêu đề “Nguyên tắc rút ra”- Giá vốn (và mọi số master) đến từ hệ nguồn — dbt đọc, không bịa.
- Giá trị dbt ở window · xếp hạng · cohort · blend — không ở phép cộng của một fact.
- Cube là ngôn ngữ business — một định nghĩa, mọi báo cáo.