Bỏ qua để đến nội dung

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).

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ầngKý hiệuVí 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.

Đâ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.

Đừ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ên
cost = 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.

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_product
round(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 cost

Trong đó 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ànhDoanh thuGiá vốnBiên
Nội thất Văn phòng115,5 tr69,4 tr39,9%
Nội thất Gia đình8,8 tr5,5 tr38,0%
Dịch vụ Thiết kế & Tư vấn0,95 tr0,30 tr68,1%

Đừ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 ra

Đâ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 FUNCTIONS
with 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 w

Ba 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ángDoanh thuMoMYoYYTDRun-rate
2025-015,78 tr+29,6%+63,0%5,78 tr69,3 tr
2026-085,09 tr+22,4%+79,8%34,3 tr51,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.

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_pct

Giờ 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.

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óngMartKPI mở khoáKỹ thuật
✅ 0fct_salesCOGS thật, biên, thuếΣ / join
✅ 1kpi_sales_trendMoM · YoY · YTD · run-rate⧉ Window
2kpi_pareto, kpi_product_rankTop-N, ABC SP, rank trong ngành# Xếp hạng
3kpi_customer_cohortKhách mới/quay lại/churn, RFM, CLV⟳ Cohort
  1. Giá vốn (và mọi số master) đến từ hệ nguồn — dbt đọc, không bịa.
  2. Giá trị dbt ở window · xếp hạng · cohort · blend — không ở phép cộng của một fact.
  3. Cube là ngôn ngữ business — một định nghĩa, mọi báo cáo.
Chia sẻ: