存貨成本先進先出法(FIFO),
可以用 SQL 在資料倉儲算嗎?
存貨成本的先進先出法(FIFO)可以用 SQL 的 window function 在資料倉儲裡計算,不必把資料搬進應用程式逐筆處理。 我們處理過的一個案例,客戶的工程師用 500 多行程式還沒完成,改用 SQL 約 50 行就完成。
為什麼放在資料倉儲算
放在資料倉儲算,是因為資料量一大,把資料從資料庫搬到程式裡的時間,會遠多於計算本身的時間。 把運算放到資料所在的地方,也就是「搬移程式到資料端」(move code to data),可以省掉大量 IO。
另一個好處是除錯。每一步的中間結果都可以寫成一個 view 直接查詢,像在 Excel 裡逐欄檢查一樣,容易找出哪一步算錯。
作法:用累計數量對應累計成本
FIFO 的關鍵,是把每筆出庫換算成「累計出庫量」,再看它落在哪一批進貨裡:
- 進貨:依時間算出每批的累計數量與累計成本。
- 出庫:依時間算出累計出庫量。
- 用累計出庫量找到對應的進貨批次,按該批的單價內插出「累計出庫成本」。
- 本筆出庫成本=本筆的累計出庫成本-上一筆的累計出庫成本。一筆出庫即使跨了兩批進貨,成本也正確。
以一個品項為例,依時間先後:
| 異動 | 數量 | FIFO 成本 | 說明 |
|---|---|---|---|
| 進貨 | 10 | 單價 100 | 第一批 |
| 進貨 | 10 | 單價 120 | 第二批 |
| 出庫 | 4 | 400 | 4 個取自第一批 |
| 出庫 | 6 | 600 | 剛好用完第一批 |
| 出庫 | 5 | 600 | 5 個取自第二批 |
| 進貨 | 10 | 單價 150 | 第三批 |
| 出庫 | 8 | 1,050 | 跨兩批:5 個 × 120 + 3 個 × 150 |
以下 SQL 改寫自《從試算表到資料平台》第十章的範例,在 DuckDB 測試過,算出的就是上表的結果。stock_moves 每列是一筆進貨(in)或出庫(out):
-- stock_moves: one row per receipt ('in') or issue ('out') of an item
WITH receipts AS ( -- running totals of quantity and cost received
SELECT item,
sum(qty) OVER w AS cum_in_qty,
coalesce(sum(qty) OVER prev_w, 0) AS prev_in_qty,
sum(qty * unit_cost) OVER w AS cum_in_cost,
coalesce(sum(qty * unit_cost) OVER prev_w, 0) AS prev_in_cost
FROM stock_moves
WHERE direction = 'in'
WINDOW w AS (PARTITION BY item ORDER BY moved_at),
prev_w AS (PARTITION BY item ORDER BY moved_at
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)
),
issues AS ( -- running total of quantity issued
SELECT move_id, item, moved_at, qty,
sum(qty) OVER (PARTITION BY item ORDER BY moved_at) AS cum_out_qty
FROM stock_moves
WHERE direction = 'out'
),
issue_cost AS ( -- cumulative FIFO cost at each issue's running total
SELECT i.move_id, i.item, i.moved_at, i.qty,
r.prev_in_cost + (i.cum_out_qty - r.prev_in_qty)
/ (r.cum_in_qty - r.prev_in_qty) * (r.cum_in_cost - r.prev_in_cost) AS cum_out_cost
FROM issues i
JOIN receipts r
ON i.item = r.item
AND i.cum_out_qty > r.prev_in_qty -- half-open range: an issue ending exactly
AND i.cum_out_qty <= r.cum_in_qty -- on a batch boundary matches one batch only
)
SELECT move_id, item, moved_at, qty,
cum_out_cost - coalesce(lag(cum_out_cost) OVER (PARTITION BY item ORDER BY moved_at), 0)
AS fifo_cost
FROM issue_cost
ORDER BY item, moved_at;
使用時要注意兩點:同一品項的 moved_at 不能重複,否則先後順序不確定;出庫量超過累計進貨量的那幾筆,不會出現在結果裡。
Excel 裡的計算,搬進資料倉儲的一般作法
FIFO 是一個例子。其他原本在 Excel 裡做的計算,搬進資料倉儲時大致是同一套作法:
- 抽取與載入:用 Meltano 這類 EL 工具,把 ERP、營運資料庫與上傳的 Excel 載入資料倉儲,例如 ClickHouse。
- 寬表轉長表:每月一欄的寬表,先 UNPIVOT 成每格一列才能 JOIN;比對兩個版本,就是兩張長表 FULL OUTER JOIN 後算差額。
- 資料轉換:用 dbt 把計算步驟寫成一層層 SQL view,也就是函數式資料轉換,每一層的中間結果都能查詢。
- 排程:每期重跑用 cron 就夠,工作之間有依賴關係、排程變複雜時再用 Kestra。
- 報表:Metabase 直接接資料倉儲。
延伸閱讀
- 每個月花最多時間整理的那份 Excel,能不能自動化?(給財務與營運主管)
- 〈搬移程式到資料端 (move code to data)〉(睿博洞察)
- 〈函數式資料轉換〉(睿博洞察)
- 《從試算表到資料平台:重構資料工程的技術與團隊》第二、十章
想討論貴公司的計算怎麼搬進資料倉儲,歡迎直接來信。
來信談談你的資料問題