第 2 篇2026-03-09

霧中前行, 第 2 篇: 擴展的難題

「霧中前行」第二篇 — 一個關於建造 Ridgeline 的系列,Ridgeline 是為 Outward Bound Hong Kong 設計的野外糧食配給決策引擎。第一篇講述了實地考察、試算表,以及為什麼最難的部分不是建造 — 而是理解要建造什麼。

企業靜修營

在倉庫辦公室錄音的其中一次,Rocky 提到團隊不一定都是 14 個青少年。企業靜修營可以更大 — 20、30 人。不同課程有不同規模。

這種事情聽起來像是功能需求,實際上是地雷。試算表是為 14 人而建的。每個格子裏的每個數量都假設是 14 人。Rocky 知道這一點。物資主任也知道。試算表之所以有效,是因為使用的人已經知道該調整什麼。

軟件沒有這種本事。

什麼都沒變的部分

當我把試算表數碼化到 Ridgeline 時,數量是按原樣錄入的:1.5kg 意粉、2 個洋蔥、1 包咖喱粉、2 包雞肉腸。數據庫 schema 在每個餐單項目上有一個 scaling 欄位,提供兩個選項 — per_person 或 per_group。

明顯的項目被標記為 per_person:豬肉漢堡(14 件 — 明顯是每人 1 件)、烏冬麵(14 件)、即食飯(14 件)。所有數量是 14 且備註寫着「1 per person」的項目。

其他所有東西都標記為 per_group。預設值。

第一天晚餐的 seed migration(01f95d7):

insert into public.menu_template_meal_items
  (meal_id, ingredient_id, quantity, unit_type, scaling, notes)
values
  (m_d1, ...'Spaghetti',        1.5, 'kg',   'per_group', null),
  (m_d1, ...'Onion',            2,   'pcs',  'per_group', null),
  (m_d1, ...'Chicken Sausage',  2,   'pack', 'per_group', null),
  (m_d1, ...'Pork Burger',      14,  'pcs',  'per_person', '1 per person'),
  (m_d1, ...'Cheese Powder',    1,   'pack', 'per_group', null),
  (m_d1, ...'Tomato Paste',     1,   'can',  'per_group', null);

scaling 公式很直接:

if (scaling === 'per_person') {
  return qty * (groupSize / baseGroupSize) * portionFactor
}
// per_group: unchanged
return qty

對於一組 20 人、baseGroupSize 為 14 的情況,per_person 項目按 20/14 = 1.43 倍調整。per_group 項目紋風不動。

問題是:63 個餐單項目中只有 17 個是 per_person。其餘 46 個 — 意粉、飯、雞肉腸、魚蛋、罐頭吞拿魚、所有蔬菜、所有零食 — 不管你輸入什麼數字都固定不變。把人數從 14 改成 100,配給清單幾乎沒有反應。

這不是程式碼的 bug。程式碼做了數據叫它做的事。數據是忠實地從試算表抄過來的。試算表是正確的 — 如果是 14 人的話。

一個問題

修正方法不是一個演算法。而是一個問題:

「如果我把人數加倍,這個東西需要更多嗎?」

意粉?是 — 1.5kg 給 14 人大約是每人 107g。人數加倍,意粉加倍。Per person。

咖喱粉?不 — 一包就夠調味整鍋,不論是 10 人還是 20 人。Per group。

洋蔥?這就開始有趣了。

十九分鐘內三次重分類

重分類在二月二十五日的三個 commit 中完成,歷時十九分鐘。每一次都揭露了前一次分錯的項目。

第一次(5c7f942,23:31):明顯的項目。意粉、飯、麵包、穀物片。雞肉腸、魚蛋、蟹柳、火腿、吞拿魚、午餐肉。讓每人都有一碗的湯粉。雜果仁、餅乾、葡萄糖糖果。二十個項目從 per_group 轉為 per_person。

第二次(b63d3b4,23:47):罐頭餡料。粟米、焗豆、紅腰豆、鷹嘴豆、罐頭蘑菇 — 這些是放入墨西哥卷和沙律的。一罐分給 14 個人已經很薄。28 個人就不夠了。還有奶粉(每人早餐穀物都要配奶)、紫菜湯、乾果。再多八個項目。

-- 1 can split across 14 is already thin; for 28 it would be inadequate.
update public.menu_template_meal_items
set scaling = 'per_person'
where ingredient_id = (
  select id from public.ingredients where name_en = 'Baked Beans'
) and scaling = 'per_group';

第三次(1019275,23:50):蔬菜。我本來把洋蔥、紅蘿蔔、薯仔、椰菜、翠玉瓜和青椒留作 per_group,因為它們是放進鍋裏煮的。但它們不是調香料 — 它們是那頓飯實際的蔬菜內容。2 個洋蔥給 14 人已經不算多。28 人肯定需要更多。

migration 的註解記錄了推理過程:

-- These aren't just aromatics — they're the actual vegetable content of meals.
-- 2 onions or 4 potatoes for 14 people is already not much.
-- For 28 people you'd definitely need more.

再多七個項目。三次合共重分類了三十五個項目。

鍋裏剩下的

重分類之後,只有五個項目仍然是 per_group:芝士粉、咖喱粉、番茄醬、沙律醬、檸檬利賓納。真正的「一份就夠調味整鍋」項目。

但即使這樣也是一種簡化。ADR 承認了我開始稱為「鍋閾值」的問題:

一鍋咖喱大約餵 14 個人。20 人仍然勉強用一鍋。到 30 人就需要兩鍋,所有煮食底料蔬菜和調味料都要加倍。

咖喱粉既不是線性的(per person)也不是固定的(per group)。它是一個階梯函數 — per pot。跳躍點在鍋的容量,而容量取決於不同課程使用的設備。線性 scaling 會買多。固定 scaling 在大組時會買少。對於 OBHK 典型的 8 至 20 人範圍,固定已經夠接近了。安全餘量系統和包裝大小的四捨五入會吸收誤差。

未來可以加入 per_pot scaling 類型來更精確地處理。目前,這種不精確是微小且被理解的 — 這使得它可以接受,而原來的「什麼都不 scale」則不行。

缺失的重量

試算表列出了數量 — 1.5kg 意粉、2 包雞肉腸、1 罐蘑菇 — 但很多項目沒有列出每包或每件的重量。要讓 per_person scaling 產生合理的結果,我需要知道「2 包雞肉腸」有多重,這樣系統才能計算每人的克數並四捨五入到包裝大小。

物資主任會從多年處理實物產品的經驗中知道這些。他在放假。

所以我寫了爬蟲。在修正 scaling 的同一天,我為供應商網站寫了爬蟲 — Hart Limited(堅果和乾貨,201 件產品有港幣價格)、Vastland(餅乾和麵包,有包裝大小但無價格)、Garden(小冊子式網站,數據極少)。Hart 的爬蟲光是產出就有一個 1,012 行的 SQL migration,寫入了 35 個食材和 147 個變種的重量和價格(874552a)。

這就是需求不清晰時建造的模式。試算表給你數量但沒有重量。知道重量的物資主任不在。供應商網站有重量但藏在非結構化的產品頁面裏。你寫一個爬蟲、填補空白、然後繼續前進 — 知道數據是近似的,但總比什麼都沒有好。

Scaling 揭示了什麼

重分類修正了即時的問題。把一組人從 14 改成 8,食物項目現在會減少約 43%。份量因子 1.3 倍現在會增加項目 30%,而不是毫無效果。配給清單會對輸入作出反應了。

但它也浮現了一些我沒預料到的東西。當數量正確地歸屬到每人之後,數學顯示原來 14 人的餐單大約提供每人每天 1,200 至 1,600 千卡。這大約是一個參加多天行山遠征的青少年所需的一半。這個發現會引向某個方向 — 但首先,有一個雞肉腸的問題。

下一篇:第三篇 — 三千三百二十三元的雞肉腸,一個單位轉換 bug 產生了 167 包雞肉腸,而且同一個 bug 出現在三個不同的程式碼路徑中。