Menu
ESC

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

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

Đang tải...

Inventory analysis: stock, turnover, reorder point

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

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

Nếu bạn làm BA cho bất kỳ công ty nào có hàng hóa vật lý — bán lẻ, e-commerce, dược phẩm, F&B, mỹ phẩm, sản xuất — thì sớm muộn gì bạn cũng phải trả lời những câu hỏi đắt tiền: "Chúng ta đang chôn bao nhiêu tiền trong kho?", "Sản phẩm nào sắp hết hàng?", "Hàng nào bán chậm đang ngốn vốn?", "Khi nào thì nên đặt hàng lại?". Đây không phải câu hỏi mang tính học thuật. Một quyết định sai về tồn kho có thể khiến công ty mất doanh thu vì hết hàng (stockout), hoặc kẹt vốn vì ôm hàng tồn (overstock).

Tôi từng làm việc với một chuỗi cửa hàng mỹ phẩm tại Việt Nam. Họ có hơn 8.000 mã sản phẩm (SKU). Mỗi tháng, đội mua hàng ngồi nhìn file Excel khổng lồ để đoán xem nên nhập gì. Kết quả: vài mã serum bán chạy thì liên tục hết hàng, trong khi kho lại đầy ắp son môi màu lỗi mốt nhập cách đây 14 tháng. Khi tôi viết vài câu SQL phân tích tồn kho, bức tranh hiện ra rõ ràng đến mức ban giám đốc phải họp khẩn. Đó là sức mạnh của bài học này.

Trong bài này, bạn sẽ học ba trụ cột của phân tích tồn kho bằng SQL: giá trị tồn kho hiện tại (stock valuation), vòng quay hàng tồn (inventory turnover), và điểm đặt hàng lại (reorder point). Đây là những con số mà BA giỏi mang lên bàn họp, biến dữ liệu thô thành quyết định mua hàng cụ thể.

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

1. Snapshot tồn kho và giá trị tồn kho

Bước đầu tiên luôn là chụp lại bức tranh hiện tại: mỗi sản phẩm đang còn bao nhiêu trong kho, và số hàng đó trị giá bao nhiêu tiền. Giá trị tồn kho (inventory value) = số lượng tồn × giá vốn (cost), không phải giá bán.

SELECT
  p.id,
  p.name,
  p.category_id,
  p.stock,
  p.cost,
  p.stock * p.cost AS inventory_value
FROM products p
WHERE p.stock > 0
ORDER BY inventory_value DESC;

Một lưu ý nghiệp vụ rất quan trọng: nhiều bạn mới hay dùng p.price (giá bán) để tính giá trị tồn kho. Sai. Giá trị tồn kho phải tính theo giá vốn (cost) — số tiền công ty thực sự bỏ ra để có hàng. Dùng giá bán sẽ thổi phồng con số và làm méo báo cáo tài chính.

2. Inventory Turnover — vòng quay hàng tồn

Vòng quay hàng tồn cho biết trong một kỳ (thường là 1 năm hoặc 1 tháng), hàng tồn được "bán hết và thay mới" bao nhiêu lần. Công thức kinh điển:

> Inventory Turnover = Giá vốn hàng bán (COGS) trong kỳ / Tồn kho trung bình trong kỳ

Turnover cao = hàng bán nhanh, vốn quay vòng tốt. Turnover thấp = hàng nằm ì, chôn vốn. Một chỉ số liên quan là Days Inventory Outstanding (DIO) — số ngày trung bình một sản phẩm nằm trong kho:

> DIO = Số ngày trong kỳ / Inventory Turnover

Ví dụ: nếu turnover của một mã là 12 lần/năm, thì DIO = 365 / 12 ≈ 30 ngày — tức trung bình hàng nằm kho 30 ngày trước khi bán. Một mã có DIO = 200 ngày là dấu hiệu cảnh báo: hàng ế, cần khuyến mãi xả hoặc ngừng nhập.

3. Reorder Point — điểm đặt hàng lại

Reorder point (ROP) là ngưỡng tồn kho mà khi chạm tới, bạn phải đặt hàng ngay để không bị hết hàng trong lúc chờ nhà cung cấp giao. Công thức cơ bản:

> Reorder Point = (Nhu cầu trung bình mỗi ngày × Lead time tính bằng ngày) + Safety stock

Trong đó:

  • Lead time: thời gian từ lúc đặt hàng đến lúc hàng về kho (ví dụ 7 ngày).
  • Safety stock (tồn kho an toàn): lượng đệm để phòng khi bán tăng đột biến hoặc nhà cung cấp giao trễ.
Hiểu đơn giản: nếu mỗi ngày bạn bán 10 chai serum, nhà cung cấp giao trong 7 ngày, và bạn muốn dự phòng thêm 20 chai, thì ROP = (10 × 7) + 20 = 90. Khi tồn kho chạm 90 chai, hệ thống phải báo "đặt hàng ngay".

Tình huống thực tế

Tình huống 1: Chuỗi mỹ phẩm phát hiện 1,8 tỷ đồng vốn chết

Quay lại chuỗi mỹ phẩm tôi kể ở đầu. Họ cần biết: trong 8.000 SKU, những mã nào đang chôn vốn mà gần như không bán được. Tôi kết hợp snapshot tồn kho với dữ liệu bán hàng 90 ngày gần nhất:

SELECT
  p.id,
  p.name,
  p.stock,
  p.cost,
  p.stock * p.cost AS inventory_value,
  COALESCE(s.qty_sold_90d, 0) AS qty_sold_90d
FROM products p
LEFT JOIN (
  SELECT product_id, SUM(quantity) AS qty_sold_90d
  FROM order_items oi
  JOIN orders o ON o.id = oi.order_id
  WHERE o.created_at >= CURRENT_DATE - INTERVAL '90 days'
    AND o.status = 'completed'
  GROUP BY product_id
) s ON s.product_id = p.id
WHERE p.stock > 0
  AND COALESCE(s.qty_sold_90d, 0) = 0
ORDER BY inventory_value DESC;

Câu này tìm các mã còn tồn nhưng bán được 0 đơn vị trong 90 ngày — gọi là dead stock (hàng chết). Kết quả: 1.240 SKU rơi vào nhóm này, tổng giá trị 1,8 tỷ đồng. Ban giám đốc lập tức triển khai chiến dịch xả hàng "combo giảm 50%" và ngừng nhập các dòng màu lỗi mốt.

Bài học: LEFT JOIN cộng với điều kiện qty_sold = 0 là vũ khí kinh điển để tìm hàng chết. Phải dùng LEFT JOIN (không phải INNER JOIN) vì hàng chết theo định nghĩa là hàng không có giao dịch bán — nếu dùng INNER JOIN bạn sẽ vô tình loại chúng ra khỏi kết quả.

Tình huống 2: Tiki tính vòng quay theo ngành hàng

Giả sử bạn làm BA cho một sàn e-commerce kiểu Tiki, quản lý kho cho mảng FBT (Fulfillment by Tiki). Ban lãnh đạo muốn so sánh vòng quay giữa các ngành hàng để quyết định phân bổ diện tích kho. Ngành nào quay nhanh nên ưu tiên không gian; ngành nào quay chậm cần xem lại.

WITH cogs AS (
  SELECT
    p.category_id,
    SUM(oi.quantity * p.cost) AS cogs_30d
  FROM order_items oi
  JOIN orders o ON o.id = oi.order_id
  JOIN products p ON p.id = oi.product_id
  WHERE o.created_at >= CURRENT_DATE - INTERVAL '30 days'
    AND o.status = 'completed'
  GROUP BY p.category_id
),
inv AS (
  SELECT
    category_id,
    SUM(stock * cost) AS avg_inventory_value
  FROM products
  GROUP BY category_id
)
SELECT
  c.category_id,
  c.cogs_30d,
  i.avg_inventory_value,
  ROUND(c.cogs_30d / NULLIF(i.avg_inventory_value, 0), 2) AS turnover_30d,
  ROUND(30 / NULLIF(c.cogs_30d / NULLIF(i.avg_inventory_value, 0), 0), 1) AS dio_days
FROM cogs c
JOIN inv i ON i.category_id = c.category_id
ORDER BY turnover_30d DESC;

Kết quả giả định: ngành "Đồ điện tử phụ kiện" có turnover 30 ngày = 2,1 (DIO ≈ 14 ngày) — quay rất nhanh. Ngành "Đồ gia dụng lớn" turnover = 0,3 (DIO ≈ 100 ngày) — quay chậm, chiếm diện tích kho mà ít sinh lời trên mỗi mét vuông.

Bài học: Chú ý NULLIF(..., 0) để tránh lỗi chia cho 0 — một ngành mới có thể chưa có tồn kho hoặc chưa có doanh số. Đây là tình huống thực tế cực kỳ phổ biến mà nếu quên xử lý, cả query sẽ văng lỗi giữa lúc giám đốc đang chờ báo cáo.

Tình huống 3: Cửa hàng tiện lợi cảnh báo đặt hàng lại

Một chuỗi cửa hàng tiện lợi (kiểu Circle K, GS25) muốn hệ thống tự động liệt kê các mã cần đặt hàng lại trong sáng nay. Mỗi mã có lead time và safety stock riêng, lưu sẵn trong bảng products. Nhu cầu trung bình mỗi ngày được tính từ doanh số 30 ngày gần nhất.

WITH daily_demand AS (
  SELECT
    oi.product_id,
    SUM(oi.quantity) / 30.0 AS avg_daily_demand
  FROM order_items oi
  JOIN orders o ON o.id = oi.order_id
  WHERE o.created_at >= CURRENT_DATE - INTERVAL '30 days'
    AND o.status = 'completed'
  GROUP BY oi.product_id
)
SELECT
  p.id,
  p.name,
  p.stock,
  ROUND(d.avg_daily_demand, 1) AS avg_daily_demand,
  p.lead_time_days,
  p.safety_stock,
  ROUND(d.avg_daily_demand * p.lead_time_days + p.safety_stock) AS reorder_point,
  CASE
    WHEN p.stock <= d.avg_daily_demand * p.lead_time_days + p.safety_stock
    THEN 'ĐẶT HÀNG NGAY'
    ELSE 'OK'
  END AS reorder_status
FROM products p
JOIN daily_demand d ON d.product_id = p.id
WHERE p.stock <= d.avg_daily_demand * p.lead_time_days + p.safety_stock
ORDER BY (d.avg_daily_demand * p.lead_time_days + p.safety_stock) - p.stock DESC;

Câu này chỉ trả về những mã đã chạm hoặc dưới reorder point, sắp theo mức độ cấp bách (chênh lệch giữa ROP và tồn hiện tại càng lớn càng gấp). Đội mua hàng mỗi sáng chạy đúng một query là có danh sách cần đặt.

Bài học: Reorder point biến dữ liệu thành hành động cụ thể. BA giỏi không chỉ báo cáo "tồn kho thấp" — họ giao thẳng danh sách "đặt mã này, số lượng này". Cột CASE tạo nhãn dễ đọc giúp người không rành SQL vẫn dùng được kết quả.

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

Khi nhận một yêu cầu phân tích tồn kho, hãy đi theo trình tự sau:

Bước 1 — Xác định bảng và cột nguồn. Bạn cần tối thiểu: bảng products (có stock, cost, và lý tưởng là lead_time_days, safety_stock), bảng ordersorder_items để tính doanh số. Hỏi rõ team data: stock là tồn kho thời điểm hiện tại hay snapshot cuối ngày? cost là giá vốn bình quân hay giá vốn lần nhập gần nhất?

Bước 2 — Làm snapshot giá trị tồn kho. Bắt đầu đơn giản: stock * cost AS inventory_value. Đây là nền tảng cho mọi phân tích sau.

Bước 3 — Ghép doanh số vào để đo độ "sống" của hàng. Dùng subquery hoặc CTE tổng hợp SUM(quantity) theo product_id trong một khoảng thời gian, rồi LEFT JOIN về products. Nhớ COALESCE(..., 0) cho các mã không bán được đơn nào.

Bước 4 — Tính turnover và DIO. Lấy COGS chia tồn kho trung bình. Trong thực tế nhanh, nhiều BA dùng tồn kho hiện tại thay cho "tồn kho trung bình" — chấp nhận được khi báo cáo nhanh, nhưng phải ghi chú rõ giả định này.

Bước 5 — Tính reorder point và gắn nhãn cảnh báo. Kết hợp nhu cầu trung bình ngày, lead time, safety stock. Dùng CASE WHEN để tạo cột trạng thái dễ đọc.

Bước 6 — Sắp xếp theo mức ưu tiên hành động. ORDER BY theo giá trị tồn kho (cho hàng chết) hoặc theo mức độ cấp bách (cho reorder). Đừng để người đọc tự dò.

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

Lỗi 1: Dùng giá bán thay vì giá vốn. Như đã nói, giá trị tồn kho phải tính theo cost. Nhầm sang price là lỗi nghiệp vụ nghiêm trọng nhất trong phân tích tồn kho.

Lỗi 2: Quên NULLIF khi chia. Turnover và DIO đều có phép chia. Một mã mới chưa có tồn hoặc một ngành chưa có doanh số sẽ gây lỗi "division by zero". Luôn bọc mẫu số bằng NULLIF(mau_so, 0).

Lỗi 3: Dùng INNER JOIN khi tìm hàng chết. Hàng chết là hàng không có giao dịch. INNER JOIN với bảng đơn hàng sẽ loại chúng ra — đúng những thứ bạn đang cần tìm lại biến mất. Luôn dùng LEFT JOIN + COALESCE + điều kiện = 0.

Lỗi 4: Không lọc trạng thái đơn hàng. Đơn bị hủy, đơn hoàn, đơn chưa thanh toán không nên tính vào doanh số tính turnover. Luôn thêm WHERE o.status = 'completed' (hoặc tương đương) — nếu không, bạn sẽ phóng đại nhu cầu và đặt hàng dư.

Lỗi 5: Bỏ qua thời vụ. Tính nhu cầu trung bình ngày từ 30 ngày bình thường rồi áp cho mùa Tết hay 11/11 sẽ khiến hết hàng nặng. Mẹo: với hàng thời vụ, dùng cửa sổ thời gian cùng kỳ năm trước, hoặc nhân hệ số mùa vụ vào reorder point.

Mẹo: Khi trình bày cho lãnh đạo, đừng đổ ra bảng 8.000 dòng. Phân loại bằng CASE thành các nhóm hành động: "Đặt hàng ngay", "Theo dõi", "Xả hàng chết", "Bình thường". Quyết định nằm ở nhóm, không nằm ở từng dòng.

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

Giả sử bạn có ba bảng: products(id, name, category_id, stock, cost, price, lead_time_days, safety_stock), orders(id, created_at, status), order_items(order_id, product_id, quantity).

  • Viết query liệt kê top 20 sản phẩm có giá trị tồn kho cao nhất (theo giá vốn), kèm cột inventory_value.
  • Tìm tất cả "hàng chết": các mã còn tồn (stock > 0) nhưng không bán được đơn vị nào trong 60 ngày gần nhất. Sắp theo giá trị tồn kho giảm dần.
  • Tính DIO (số ngày tồn kho) cho từng sản phẩm dựa trên COGS 90 ngày gần nhất. Lọc ra các mã có DIO > 120 ngày và nhớ xử lý chia cho 0.
  • Viết "báo cáo đặt hàng buổi sáng": liệt kê các mã có tồn kho đã chạm hoặc dưới reorder point, kèm cột gợi ý số lượng cần đặt (ví dụ: đặt đủ để nâng tồn lên gấp đôi reorder point), sắp theo mức độ cấp bách.
  • (Nâng cao) Tổng hợp theo category_id: mỗi ngành có tổng giá trị tồn kho, turnover 30 ngày, và số lượng mã đang dưới reorder point. Đây chính là dạng bảng tóm tắt một trang mà ban lãnh đạo muốn thấy.

Tóm tắt

Phân tích tồn kho bằng SQL xoay quanh ba câu hỏi cốt lõi mà mọi BA hàng hóa đều phải trả lời. Một, chúng ta đang giữ bao nhiêu tiền trong kho — tính bằng stock * cost, luôn theo giá vốn chứ không phải giá bán. Hai, hàng quay nhanh hay chậm — đo bằng inventory turnover (COGS / tồn kho trung bình) và DIO, giúp phát hiện hàng chết đang chôn vốn. Ba, khi nào cần đặt hàng lại — tính bằng reorder point = nhu cầu ngày × lead time + safety stock, biến dữ liệu thành danh sách hành động cụ thể.

Về mặt kỹ thuật, những công cụ SQL bạn dựa vào nhiều nhất là: LEFT JOIN + COALESCE để không bỏ sót hàng chết, NULLIF để chống chia cho 0, CASE WHEN để gắn nhãn trạng thái dễ đọc, và CTE để tách logic COGS với logic tồn kho cho query rõ ràng. Giá trị thực sự của một BA không nằm ở việc chạy được query, mà ở việc biến kết quả thành quyết định: xả mã nào, đặt mã nào, ưu tiên ngành nào. Khi bạn mang lên bàn họp một con số như "1,8 tỷ vốn chết" hay "danh sách 47 mã cần đặt sáng nay", bạn không còn là người làm báo cáo — bạn là người định hình quyết định.