優惠與金額:折扣算錯,比超賣還難查

· tech

#war-story#live-commerce#pricing

📑 目錄

超賣會炸、金流會叫,折扣算錯不會有任何聲音——客人多付了 13 元不會知道,少收了 13 元你也不會知道,直到對帳那天一筆一筆挖。這章講優惠:規則怎麼收納、聰明演算法怎麼輸給主播的一句話、以及讓分攤永遠加總相等的笨算術。

優惠的地圖:規則、慣例、實驗

當年的優惠分三個棲息地:

需要券:一張券 = 三個參數的組合 效果 滿額折抵 滿額免運 門檻 滿額多少才生效 + 商品白名單(算進額度的) 範疇 特定檔期/多個檔期 全檔期合算 例:「滿 3000 折 300・限檔期 A・限指定商品」 不需要券:多件優惠 買多少送多少 一個商品可以掛多種 套用順序的故事在下一節 實驗層:Django admin 各種買 A 送 B 的花式組合 實驗性緊急套用 站穩了,才升級成正式規則
參數化你會重複的(券的三軸),隔離你不確定的(admin 裡的花式)。
  • 需要券的,收斂成三個正交參數:效果(折抵/免運)× 門檻(滿額多少,外加「哪些商品算進額度」的白名單)× 範疇(特定檔期/多個檔期/全檔期合算)。新券=填一組參數,不是寫一段 if——檔期第四次成為系統的自然邊界:券的生效範圍,直接用檔期表達。
  • 不需要券的多件優惠(買多少送多少),掛在商品上,一個商品可以掛多種——它的套用順序是下一節的主角。
  • 分類不了的,進 admin 試驗場:各種買 A 送 B 的花式組合,實驗性緊急套用——上一章的成熟度光譜,在優惠這裡有了最頻繁的用例:行銷的點子永遠比規則表快,試驗場讓點子先跑,站穩了才值得一個正式參數。

最優解輸給了順序

多件優惠可以疊,一個商品掛多種——那客人的購物車該套哪個組合?當年的第一版答案很工程師:寫一個最優惠組合演算法,幫客人算出全場最省的套法。這是組合優化,朝著 NP-hard 的方向長,但商品數不大,算是算得動。

然後主播說:不要。按照順序,每次採最大扣除就好。

我很久之後才真正理解這個要求有多對。最優解的問題不在算力,在它的答案沒辦法對人解釋:

  • 不穩定:客人多加一件商品,整個最優組合可能重排——螢幕上的折扣數字跳來跳去,客人不會覺得你聰明,只會覺得被坑。
  • 不能口播:主播在鏡頭前要能一句話講清楚規則。「按順序、每次挑折最多的」講得出口;「我們的演算法會為您求解全域最優」講出口就是客訴。
  • 不單調:最優解下,多買有時反而讓某個舊折扣消失——「我多買一件,那邊怎麼變貴了」是客服解釋不完的災難。

主播要的規則精確地說是:優惠有固定順序,依序一個套到滿、再換下一個——先把買 5 送 3 套到不能再套,才輪到買 3 送 2。答案穩定、單調、可預期——它不是全域最省,但每一步都看得懂。這跟狀態機被主播拔掉是同一族的故事:工程師寫了聰明的,現場要求換笨的,而現場是對的。收一句:演算法的正確性標準由使用情境定義——直播的「正確」,是客人聽得懂、數字不跳。

岔題,但值得:它到底是不是 NP-hard?

砍掉它之前,這個演算法值得一次誠實的複雜度鑑定——答案分三層,而且最深的一層跟複雜度無關。

第一層:單一商品、單一價格——這是無界背包,不是 NP-hard。 把問題寫出來:客人下單付費 nn 件;「買 aia_ibib_i」每套用一次,用掉 aia_i 件付費商品、額外多送 bib_i;每件付費商品只能被一個優惠算一次。目標是最大化送出件數 bixi\sum b_i x_i,限制 aixin\sum a_i x_i \le n。這是無界背包(找零錢問題的親戚):形式上 weakly NP-hard,但有 O(n×k)O(n \times k) 的 DP——nn 是購買件數,現實裡是幾十,瞬間解完。有趣的是,greedy 在這個最簡單的情境就已經不是最優。拿「買 3 送 2」和「買 5 送 3」來算,greedy 依主播的順序先套大的:

  • n=8n=8:買 5 送 3(用掉 5 件)+ 剩 3 件套買 3 送 2——送 5 件;最優也是 5。打平。
  • n=9n=9:greedy 套買 5 送 3、再套買 3 送 2,剩 1 件閒著——送 5 件;最優是買 3 送 2 套三次——送 6 件。

greedy 輸的原因跟找零錢一樣:送得大方的優惠,「每付一件的送率」不一定高——買 5 送 3 的送率是 3/5=60%,買 3 送 2 是 2/3≈66.7%,而勝負常常取決於尾數(那 1 件掛空的付費商品)。所以精確地說,當年的 greedy 從來不是「近似最優」,它就是「規則簡單」——而這正是它被選中的理由,不必替它冠上最優的名。

而 DP 解這題有多便宜?設 f(j)f(j) 為用 jj 件付費商品的額度最多能送幾件:

f(j)=max(f(j1), maxi:aij{f(jai)+bi}),f(0)=0f(j) = \max\Big(f(j-1),\ \max_{i:\,a_i \le j} \big\{ f(j-a_i) + b_i \big\}\Big),\qquad f(0)=0

答案是 f(n)f(n),時間 O(n×k)O(n \times k)。拿 n=9 走一遍:

jj123456789
f(j)f(j)002234456

f(9)=6f(9)=6,正是「買 3 送 2 套三次」。但這張表藏著一個更重要的觀察:看 f(5)f(6)f(5) \to f(6)——最優組合從「買 5 送 3」整組換成「買 3 送 2 × 2」。客人多加一件商品,螢幕上的優惠組合整個重排。DP 給你最優值,但最優解的組合會隨 n 跳動——這是最優性本身的性質,跟用什麼演算法無關。 可解釋性的問題,DP 解不掉:就算計算免費,當年砍掉最優解仍然是對的。

第二層:同一件商品有好幾種價格——「最優」的定義開始漂。 商品有直播價、商城價、style 各自的價格,cart item 上還有一個「特殊價」欄位給特殊優惠用——同一台購物車裡,同一件商品可能多種價格並存。優惠套下去,免的是哪一件?扣除額按哪個價算?當年沒有明確定義「哪個價一定最便宜」——此時最優解演算法最脆弱的地方不是算力,是規格:連「最優」都沒有唯一定義,演算法是在最佳化一個沒有人簽名的目標函數。而且未定義不只讓答案漂,它讓空間爆炸:在優化器眼裡,每個未定義處都是一個變數——m 種價格解讀 × n 件商品,就是 mnm^n 種世界,每一種世界還要各自解一次分組最優再互比。規格少寫一句話,搜尋空間就多乘一倍;greedy 按順序套用,本質是用定義消滅變數——順序是常數、每步規則是常數,空間坍縮成一條直線。它省的不是 CPU,是規格債。

第三層:跨商品的組合,才是 NP-hard 真正的門檻。 常規的多件優惠不跨商品,券又跟非券互不影響(獨立兩層)——所以常規問題其實整個可解。但 admin 實驗層的花式買 A 送 B 一旦跨商品,加上「每件只能被佔用一次」的互斥,問題就變成 weighted set packing:從所有可能的「優惠應用」(每個是一組帶權的商品集合)裡選出互不重疊的組合、最大化總折扣——strongly NP-hard,沒有 DP 可救。當年「應該是 NP-hard」的直覺,對的就是這一層。

「還能不能 DP」其實有一條好記的判準:看新條件問的是「多少件」還是「是哪幾件」。 只改變數量或金額的條件,DP 加一維就活(滿額門檻是這類);開始需要知道「是哪幾件」的條件(送最便宜、每件有自己的特殊價、跨商品綁定),單位失去可互換性,狀態就壓不動了——DP 死於異質性,不是死於規則數量。而工程上的死亡通常更早:每個新條件都逼你重新設計狀態、重新證明正確性,在理論宣判之前,維護成本已經先執行了死刑。

三層看完,greedy 各贏一次:第一層它輸最優、贏簡單;第二層它用順序定義了語意——「按順序、每次最大扣除」同時回答了「怎麼算」和「算什麼才算對」,在價格語意模糊的地方,過程就是規格;第三層它讓花式優惠不會把整台購物車捲進組合爆炸。所以砍掉最優解的理由,按重要性排序是:解釋不動 > 定義不清 > 算不動——算力反而是最不重要的那一個。

錢往哪放:三層各有欄位

優惠算完的結果,直接在 orders payment、order、order item 上開欄位——哪層合適放哪層。這不是隨性,是三層結構的正確用法:錢的欄位跟著它的語意住——商品自己的多件優惠寫在 item、檔期限定券寫在 order(它的範疇就是檔期)、全檔期合算的寫在 payment。金額一經結帳就定格(承諾點原則),發票、退款、對帳全部站在定格值上。

整數、捨去、減法:分攤的算術

金額全部用整數算——這是錢的程式碼的第一戒律。真正的考驗在分攤:一張檔期券折了 100 元,攤在兩件商品上;客人退其中一件,該退多少?

情境:699 + 301 = 1000,檔期券折 100 → 實付 900 ① 按比例攤,然後無條件捨去 item A:699 − (699/1000 × 100) = 629.1 → 捨去 → 629 0.1 元的零頭:公司吸收 ② 最後一件,用減法收尾 item B:900 − 629 = 271 不再算比例,直接補到總額 減法保證總和恆等 檢查:629 + 271 = 900 ✓ 折扣 70 + 30 = 100 ✓ 退 item A 就退 629——每一分錢都有主,對帳永遠對得起來 類似的分攤情境,一律照這個方式處理——一個規則,全系統通用
floor-and-subtract:捨去讓零頭有主(公司吸收),減法讓總和恆等——醜,但每一分錢都對得起來。

規則只有兩條:按比例算出的優惠後價格,無條件捨去小數(零頭讓給客人,公司吸收——爭議永遠往對客人有利的方向倒);最後一份不算比例,用減法補到總額(總和恆等是用結構保證的,不是用測試保證的)。這是 largest-remainder 分攤法的務實簡化,而且當年立了個好規矩:類似的情境一律照此處理——分攤算法全系統只有一種,對帳的人只需要理解一次。

重來會怎麼做

這章的當年設計大半保留:三軸參數表、admin 實驗層、固定順序套滿、floor-and-subtract 全是對的。重來的清單不長:

  1. 價格不是函數,是現場的決定。 直覺的修法是釘死一條價格解析優先序(特殊價 > 來源價 > style 價),把 mnm^n 的搜尋空間用規則消滅——但這是錯的:價格的優先序是主播的自由心證,系統管不了,也不該管。直播定價本來就是現場的藝術:貨是她談的、價是她喊的、要不要給這個客人特殊價是她的生意判斷。重來的正解不是消滅這個自由,是給它一個乾淨的落點——特殊價欄位就是「現場決定」的容器,系統的職責是把決定記成事實(誰、何時、定了什麼價),不是替現場推導價格。搜尋空間的變數,不是被規則消滅的,是被「定價那一刻」消滅的——人做出決定,變數就坍縮成常數。機制歸系統、政策歸人,又一次。
  2. 套用順序給明確的把手。 優惠掛明確的順序欄位,主播/營運指定;演算法簡化到極致——依序、逐一套滿,連「每步挑最大」都不需要,順序本身就是全部的規則:可口播、可預測、要改就改順序,不用改版。
  3. 分攤規則從慣例升級為一個 class。 floor-and-subtract 當年是「類似情況一律照此」的慣例;重來收斂成單一的 allocation policy class(Clean Architecture 的語意:它是一條領域規則,值得一個名字和一個家),配一條 property test——任何輸入,分攤加總恆等於整體。慣例會隨人員流動而漂,class 加測試不會。
  4. 發券前先 dry-run。 優惠引擎是純函式的形狀(購物車+規則 → 結果),天生可離線重放:新券上線前拿歷史訂單跑一輪,先看「這張券大概會花多少錢」——庫存章那次 migration 事故的教訓,同一招預防:動錢的規則,上線前先看數字。
  5. 花式買 A 送 B 不正式化。 規則表達力越強,規則引擎越接近一門程式語言;維持「參數表收斂已證明的、admin 隔離實驗的」雙層,就夠了。

反思

聰明演算法的墳墓,是解釋成本

最優組合演算法是我們寫過技術含量最高的程式碼之一,也是被砍得最快的之一。它輸給 greedy 的原因,值得每個工程師背下來:演算法的總成本=計算成本+解釋成本,而面向消費者的系統裡,解釋成本幾乎總是大頭。客人問「為什麼是這個折扣」時,客服要能答、主播要能講、工程師要能查——最優解在這三關全滅。這是主播第三次教我們設計(狀態機、重喊、greedy),而三次的教訓是同一條:現場智慧的核心是可解釋性,系統要嘛遷就它,要嘛被繞過。

參數化你會重複的,隔離你不確定的

優惠系統最怕長成 if 海——每檔活動加一段特判,兩年後沒人敢動。當年的結構避開了它:會重複的模式(滿額×範疇×效果)收斂成參數表,新券=填表;不確定的點子(花式買 A 送 B)隔離進 admin 試驗場,爛了就丟、站穩了才升格。規則引擎的真義不是「能表達一切」——是把重複的變便宜、把實驗的變安全。兩層各司其職,行銷的創意速度和系統的可維護性才能同時活著。

錢的正確性是會計性質,不是數學性質

floor-and-subtract 在數學家眼裡很醜:分攤不按精確比例、最後一份用減法硬補。但錢的程式碼要回答的從來不是「分得準不準」,是「加總相不相等、零頭歸不歸屬、事後查不查得清」——這是會計的標準,不是數學的標準。0.1 元分給誰根本不重要,重要的是每一分錢都有主、退款永遠退得出一個明確的數、對帳永遠對得平。錢的程式碼,優雅讓位給對帳——這句話值得貼在每個電商工程師的螢幕上。