霧中前行, 第 3 篇: 三千三百元的雞肉腸
「霧中前行」第三篇 — 一個關於建造 Ridgeline 的系列,Ridgeline 是為 Outward Bound Hong Kong 設計的野外糧食配給決策引擎。第二篇講述了 scaling 問題 — 63 個項目中有 46 個不會隨人數調整,因為試算表是為剛好 14 人而建的。
167 包
工作台上顯示了一個成本欄。某天下午我看着它,雞肉腸寫着 $3,323.30。
雞肉腸要三千三。
配給清單說這個情境需要 167 包。CP Chicken Frank 變種是每包 $19.90,6 條裝,340g。167 乘以 $19.90 等於 $3,323.30。算術是對的。包數不對。
餐單項目要求 1,000g 雞肉腸。變種以每包 6 條出售。計算器用克數除以條數:
ceil(1000 / 6) = 167 packs
正確的計算應該用變種的重量 — 每包 340g:
ceil(1000 / 340) = 3 packs × $19.90 = $59.70
$59.70,不是 $3,323.30。差了 56 倍。
「Pack」有三種意思的部分
根本原因早於成本 bug。當試算表最初被數碼化時(01f95d7,2 月 16 日),食材數量用的是試算表原有的單位:雞肉腸「2 packs」、咖喱粉「1 pack」、雜果仁「1 pack」。單位類型是 pack。
問題是 pack 不是一個單位。它是一個容器。一包雞肉腸是 340g。一包雜果仁是 150g。一包咖喱粉是 50g。計算器無法在不同東西的 pack 之間做轉換,因為「pack」不包含任何關於量級的資訊。
野外員工需要重量作為事實來源 — 他們按重量分配和切割食材。採購系統需要把重量轉換成可購買的包數。這是兩個不同的操作,而「pack」把它們混成了一個含糊的詞。
修正分兩個階段完成,就在模板被導入的同一天。
第一階段(c3bad06,20:40):為大約 25 個只有內容重量的食材添加了可購買單位的包裝大小。如果一個食材只列出 500g 作為包裝大小,當餐單項目用 pack 單位時,計算器就找不到匹配的包裝。這是一個補丁 — 它讓數字算對了,但沒有修正根本的歧義。
第二階段(8da3e37,21:52):消除 pack 作為單位類型。一個 309 行的 migration 把 31 個食材從 pack 轉為 g(基於重量、可分割)以及 9 個從 pack 轉為 pcs(離散可數的項目如能量棒)。十七個餐單項目的數量從「N packs」改寫為實際的重量或件數。
-- 'pack' is ambiguous — field staff need weight as source of truth
-- for dividing/chopping items. The calculator then figures out
-- how many purchasable packs to buy.
update public.ingredients
set unit_type = 'g',
pack_sizes = '[{"qty":500,"unit":"g"}]'
where name_en = 'Chicken Sausage';
這個 migration 之後,雞肉腸的餐單項目寫的是 1000g 而不是 2 packs。食材的單位是克。問題變成:我需要買多少包才能湊夠 1,000g?
Bug 出現在三個地方
這個問題在三個獨立的位置被回答 — 而三個都有同一個 bug。
位置 1: calculator.ts — 配給引擎。計算每行項目的成本。
位置 2: scenarios/[id]/page.tsx — 工作台 UI。在表格中顯示包數。
位置 3: csv.ts — CSV 匯出。將包數寫入可下載的檔案。
三個位置都用同樣的方式計算包數:
// calculator.ts (before fix)
const packsNeeded = Math.ceil(packed / variantInfo.variant.pack_qty)
// workbench UI (before fix)
{Math.ceil(agg.total_packed_qty / agg.variant.pack_qty)}
// CSV export (before fix)
const packs = String(Math.ceil(agg.total_packed_qty / agg.variant.pack_qty))
同一條公式。同一個假設:項目的單位和變種的 pack_qty 單位是一樣的。當雞肉腸是 1,000g 而變種的 pack 是 6 pcs 時,你用克數除以條數,得到的是無意義的數字。
這個 bug 存活下來是因為它只在單位不匹配時觸發 — 而在 pack 轉 g 的 migration 之前,大部分項目恰好和它們的變種共用相同單位。一旦食材開始用真實的單位,不匹配就出現了。
兩個修正,一個原則
修正 1(3aef6f9,2 月 25 日,16:14):單位轉換橋接。計算器不再盲目地除以 pack_qty,而是先檢查單位是否兼容。如果不兼容,就用變種的 weight_g 欄位來橋接:
const v = variantInfo.variant
const converted = convertPackToItemUnit(v.pack_qty, v.pack_unit, item.unit_type)
if (converted !== null && converted > 0) {
// Units compatible (same unit or kg↔g) — divide directly
packsNeeded = Math.ceil(packed / converted)
} else if (
(item.unit_type === 'g' || item.unit_type === 'kg') && v.weight_g > 0
) {
// Item is weight-based, variant is count-based — use weight_g
const packedG = item.unit_type === 'kg' ? packed * 1000 : packed
packsNeeded = Math.ceil(packedG / v.weight_g)
} else {
// No conversion possible — fall back to simple division
packsNeeded = Math.ceil(packed / v.pack_qty)
}
測試精確地捕捉了雞肉腸的情境:
it('uses weight_g for cost when item unit is g but variant pack_unit is pcs', () => {
// item: 1000g chicken sausage
// variant: 6 pcs per pack, 340g, $19.90
// correct: ceil(1000/340) = 3 packs × $19.90 = $59.70
expect(item.line_cost).toBeCloseTo(59.7, 2)
})
修正 2(2b158de,2 月 25 日,16:22):集中化。把 packs_needed 作為 ProvisionItem 和 AggregatedItem 的一個欄位。計算器只算一次。工作台和 CSV 讀取計算好的值,而不是重新計算:
// Workbench — after
{agg.packs_needed != null ? agg.packs_needed : '—'}
// CSV — after
const packs = agg.packs_needed != null ? String(agg.packs_needed) : ''
兩個 commit 之間相隔八分鐘。第一個修正了數學。第二個消除了這類 bug — 如果只有一個地方做計算,三個地方計算同一件事就不會出現偏差。
克作為通用語言
更深層的洞察不是關於雞肉腸。而是關於當你把一個為人類理解而設計的系統數碼化時會發生什麼。
試算表寫着「2 packs」。Rocky 知道那代表 680g。物資主任知道要買哪種包裝。軟件什麼都不知道 — 它需要明確的單位和它們之間明確的轉換。「Pack」在試算表裏行得通,因為總有一個人在迴路中。在程式碼裏,pack 是一個謊言。
克成為了通用語言。食譜指定重量。變種帶有 weight_g。計算器在它們之間橋接。唯一保留用 pcs 的是那些你永遠不會切開一半的東西 — 能量棒、豬肉漢堡、水果。1
下一篇:第四篇 — 每天 1,200 千卡,正確的 scaling 揭示了原來的餐單只提供了一個青少年在遠征中所需卡路里的一半。
Footnotes
-
pack的 enum 值從未從數據庫中移除 — 刪除 Postgres 的 enum 值是破壞性操作,需要重新建立整個類型。它作為一個歷史遺跡留了下來,沒有任何東西引用它。Schema 中的一塊墓碑。 ↩