上一篇,我花了不少篇幅處理一個在 Legacy System 裡很現實的問題:
沒有完整的 Unit Test,也沒有自動化的 Regression Test,AI 改完程式之後,我到底要怎麼知道它沒有改壞?
我的做法不是先相信 AI,而是先建立 Baseline,再用 Row Count、Aggregate、Sample Row 與 Production 歷史資料的形狀,一層一層建立 Evidence。
但真的進入 Debug 現場之後,還有另一個很容易踩進去的陷阱。
最近我處理一個工程資料轉入問題。
使用者反映:
某些資料轉入之後,金額會變成 0。
看到「金額變成 0」,第一個直覺通常是什麼?
很容易先想到公式。
金額 = 數量 × 單價
是不是數量算錯?
是不是單價沒有帶到?
是不是原本應該加總,結果被覆蓋?
甚至可以直接把相關函式丟給 AI:
「這段 Delphi 金額計算有問題,請幫我找出為什麼會變成 0。」
AI 很快就能開始分析公式。
但這次我真正學到的事情反而是:
畫面上的金額是 0,不代表算式把它算成了 0。
它也可能在進入公式以前,就已經是 NULL、空值或 0。
更麻煩的是,在 Delphi Dataset 裡,某些讀值方式還可能讓原本的 NULL 語意在後續計算中只剩下數值 0。
所以這次 Debug,我沒有先改公式。
我先問了一個更基本的問題:
這個 0,到底是從哪裡開始出現的?
Legacy System 很容易讓人產生一種錯覺:
畫面金額錯誤
↓
金額計算錯誤
但真正的資料流通常比較像:
來源資料
↓
SQL / Dataset
↓
Delphi 讀取欄位
↓
暫存變數
↓
商業邏輯計算
↓
寫入 Dataset
↓
Post / ApplyUpdates
↓
Database
↓
重新查詢
↓
畫面顯示
畫面只是最後一站。
如果最後看到:
Amount = 0
至少有幾種完全不同的可能:
來源資料本來就是 0
來源資料其實是 NULL
Dataset 讀值後只留下數值 0
中間變數沒有正確取得來源值
計算過程真的算出 0
計算正確,但寫入時被其他值覆蓋
資料庫正確,但重新查詢時抓錯資料
這些問題最後長得都一樣:
畫面上是 0。
可是 Root Cause 完全不同。
如果沒有先確認資料在哪一層開始變化,就直接修改公式,很可能只是修改了第一個看起來可疑的地方。
把實際系統欄位簡化之後,原本的程式概念大致像這樣:
// 簡化後的示意程式碼
Qty :=
dtSource.FieldByName('CHANGE_QTY').AsFloat;
Price :=
dtSource.FieldByName('CHANGE_PRICE').AsFloat;
Amount := Qty * Price;
如果最後:
Amount = 0
直覺上當然會開始檢查:
Qty * Price
甚至 AI 也很容易沿著這個方向繼續推論:
這些假設都不是完全沒有道理。
問題是,在還不知道 Qty 與 Price 進入公式以前到底是多少時,討論這些事情都太早了。
真正應該先確認的是:
Qty = ?
Price = ?
Amount = ?
而不是:
Amount 的公式應該怎麼改?
這兩種 Debug 思路看起來只差一點點,實際上差很多。
我先暫時不看 Delphi。
直接回到來源資料。
例如:
SELECT
ItemNo,
ChangeQty,
ChangePrice,
ChangeAmount
FROM SourceDetail
WHERE ProjectNo = @ProjectNo;
先確認最基本的事情。
假設查到:
ChangeQty = NULL
ChangePrice = 350
ChangeAmount = 0
或者另一筆資料是:
ChangeQty = 0
ChangePrice = 350
ChangeAmount = 0
這兩組資料最後算出來都可能是:
0
但它們的語意完全不一樣。
在這個案例裡,0 代表這個欄位已經有明確數值,而且值就是零;NULL 則代表來源值尚未建立。
這個差異非常重要。
因為:
數量 = 0
跟:
數量尚未建立
不是同一件事情。
而換到另一套系統,NULL 也可能代表未知、不適用或尚未產生。
資料真正代表什麼,仍然要回到系統的 Context 與商業規則判斷。
.AsFloat 可能讓原本的 NULL 語意消失接著,我回到 Delphi Dataset 檢查來源欄位。
假設程式直接這樣讀:
SD_QTY :=
dtSource.FieldByName('CHANGE_QTY').AsFloat;
如果 Dataset 欄位本身是 NULL,後續直接以 .AsFloat 取得數值時,就可能讓程式只剩下 0 這個數值結果,而原本的 NULL 語意不再出現在後續計算裡。
以這次的資料追蹤結果來看,可以簡化成:
Database
CHANGE_QTY = NULL
↓
Delphi Dataset
Field.IsNull = True
↓
.AsFloat
↓
SD_QTY = 0
到了後面的程式:
SD_AMOUNT := SD_QTY * SD_PRICE;
假設此時:
SD_QTY = 0
SD_PRICE = 350
結果當然是:
SD_AMOUNT = 0
這時候如果只看最後這一行:
SD_AMOUNT := SD_QTY * SD_PRICE;
公式完全正確。
真正遺失的資訊,是更前面的:
這個 0 原本不是業務上的 0,而是來源欄位沒有值。
這裡的重點也不是宣稱所有 Delphi Dataset、所有 Field 類型都一定會有完全相同的行為,而是:
Debug 時不能只看
.AsFloat之後的結果,還要確認原始 Field 的IsNull狀態。
NULL 變成 0,不只是型別轉換問題這也是我覺得 Legacy System Debug 很麻煩的地方。
程式沒有 Exception。
SQL 沒有報錯。
Delphi 也正常執行。
甚至:
0 × 350 = 0
數學上完全正確。
可是商業語意可能已經錯了。
程式正確執行 ≠ 資料語意正確
這種 Bug 特別容易騙過 AI。
因為如果只把最後的計算函式交給 AI:
SD_AMOUNT := SD_QTY * SD_PRICE;
它看到的世界只有:
0 × 350 = 0
它不會憑空知道:
這個 0 在資料庫裡原本其實是 NULL。
除非我把這個 Context 一起提供給它。
所以後來我把 Debug 問題改寫成另一種形式。
不是問:
為什麼金額算成 0?
而是要求 AI 幫我追:
Source Field
↓
Dataset Field
↓
Local Variable
↓
Calculation
↓
Target Field
例如整理成:
| 階段 | 欄位/變數 | 值 |
|---|---|---|
| Database | CHANGE_QTY |
NULL |
| Dataset | CHANGE_QTY.IsNull |
True |
| Delphi | CHANGE_QTY.AsFloat |
0 |
| Local Variable | SD_QTY |
0 |
| Calculation | SD_QTY * SD_PRICE |
0 |
| Target Dataset | CHANGE_AMOUNT |
0 |
做到這裡,問題就完全不一樣了。
原本看起來是:
金額公式算錯
現在真正要調查的是:
NULL
↓
數值讀取
↓
0
↓
乘法
↓
0
公式反而是整條資料流裡最無辜的一段。
更重要的是,我已經找到:
原本的資料語意第一次消失的位置。
如果這個案例的商業規則要求來源數量一定要存在,那麼比較安全的處理方式,不是偷偷幫它補成 0,而是先把異常攔下來。
例如:
// 先確認來源欄位是否真的有資料
if dtSource.FieldByName('CHANGE_QTY').IsNull then
begin
raise Exception.Create(
'來源數量為空值,無法計算變更金額'
);
end;
// 確認不是 NULL 之後,再讀取數值
SD_QTY :=
dtSource.FieldByName('CHANGE_QTY').AsFloat;
接著才進入真正的計算:
SD_PRICE :=
dtSource.FieldByName('CHANGE_PRICE').AsFloat;
SD_AMOUNT :=
SD_QTY * SD_PRICE;
這裡有一個很重要的順序:
先驗證資料
↓
再取得數值
↓
最後才計算
而不是:
先全部 AsFloat
↓
得到一堆 0
↓
再猜哪個 0 有問題
NULL 與 0 不應該太早被當成同一件事同樣的概念也適用在金額欄位。
以這次案例來說,我會先保留三種狀態的差異:
| 狀態 | 在這個案例中的處理方向 |
|---|---|
NULL |
先確認來源值為什麼尚未建立 |
0 |
已經有明確數值,但是否合理仍要看商業規則 |
非 0 |
繼續後續正常流程 |
因此,Debug 的第一步不是把所有 NULL 都轉成 0,而是先保留這兩種狀態的差異。
如果太早把:
NULL
和:
0
都壓成同一個數值,後面就很難再判斷這個 0 原本到底代表什麼。
以前我可能會直接問:
這段 Delphi 程式轉入後金額會變成 0,
請幫我修正金額計算公式。
這個 Prompt 其實已經偷偷替 AI 做了一個假設:
Bug 在公式。
於是 AI 接下來自然會努力找一條「更好的公式」。
後來我改成:
這是一段 Delphi Legacy System 的資料轉入流程。
目前症狀:
某些資料轉入後,目標金額會變成 0。
請先不要修改公式,也不要直接提供修正版。
請依照以下順序追蹤資料:
1. 來源 Dataset 的數量、單價、金額欄位。
2. 檢查來源欄位是否可能為 NULL。
3. 確認 Delphi 使用 AsFloat 後的實際值。
4. 追蹤值進入哪些 Local Variable。
5. 確認計算前的數量與單價。
6. 確認計算結果。
7. 確認最後寫入 Target Dataset 的值。
請把每一層整理成:
來源 → 轉換 → 計算 → 寫入
只有在確認數值第一次發生異常的位置後,
再提出可能的 Root Cause。
這個 Prompt 最大的差別不是比較長。
而是我把 AI 的任務從:
Fix
改成:
Trace
這次真正改變的不是 Prompt 長度,而是我把 AI 從「修程式的人」,改成了「協助建立證據鏈的人」。
這也是我現在處理 Legacy System 時,越來越重視的一件事。
AI 可以提出修改方案,但要不要進入 Fix,應該建立在足夠的 Evidence 上。
Data Trace 建立之後,我真正要找的其實只有一件事:
第一個跟預期不一樣的值,出現在哪裡?
例如:
Database Qty = NULL
Dataset IsNull = True
CHANGE_QTY.AsFloat = 0 ← 原本的 NULL 語意在這裡消失
Local Qty = 0
Amount = 0
找到這個位置後,Debug 範圍就已經縮小很多。
接下來才需要判斷:
這個轉換符合商業規則嗎?還是這才是真正需要修正的位置?
而不是一路從畫面上的 Amount = 0 猜回去。
後來我把整個流程整理成五個 Checkpoint。
先確認來源資料:
NULL?
0?
正常數值?
不要從畫面上的結果反推來源。
接著確認 Delphi 怎麼讀這個欄位。
例如:
Field.IsNull
Field.AsFloat
我要知道的不只是:
AsFloat = 0
而是:
這個 0 原本是不是 NULL?
接著才在 Debugger 裡確認計算前的輸入:
Qty = ?
Price = ?
例如:
// 可以在這裡下 Breakpoint
SD_QTY :=
dtSource.FieldByName('CHANGE_QTY').AsFloat;
SD_PRICE :=
dtSource.FieldByName('CHANGE_PRICE').AsFloat;
// 確認輸入後,再看計算結果
SD_AMOUNT := SD_QTY * SD_PRICE;
如果進入公式以前,資料的語意就已經不對了:
異常輸入
↓
正確公式
↓
異常結果
這時再怎麼修改公式,都只是在修錯地方。
就算 Debugger 顯示:
SD_QTY = 10
SD_PRICE = 350
SD_AMOUNT = 3500
也還不能宣布 Bug 解決。
這只能證明:
計算當下 = 3500
後面仍然可能在寫入 Dataset 時出問題。
因此還要確認:
dtTarget.Edit;
dtTarget.FieldByName('CHANGE_AMOUNT').AsFloat :=
SD_AMOUNT;
dtTarget.Post;
也就是:
Local Variable
↓
Target Field
↓
Post
這一段是不是真的保留了正確的值。
最後再重新從 Database 查一次:
SELECT
ItemNo,
ChangeQty,
ChangePrice,
ChangeAmount
FROM TargetDetail
WHERE ProjectNo = @ProjectNo;
我要確認的是:
Debugger = 3500
Database = 3500
重新查詢 = 3500
這才形成完整的 Evidence Chain。
整條流程最後可以收斂成:
Source
↓
Read
↓
Calculate
↓
Write
↓
Reload
只要找到哪一個 Checkpoint 第一次出現異常,Debug 範圍通常就能縮小很多。
這次我覺得 AI 最有價值的地方,反而不是直接幫我改:
Qty * Price
而是當我把 Dataset、欄位來源與中間變數一起提供之後,它可以快速協助整理:
這個欄位從哪裡來?
↓
在哪裡被讀取?
↓
存進哪個變數?
↓
在哪裡參與計算?
↓
最後寫到哪個欄位?
在幾千行甚至上萬行的 Delphi Legacy Code 裡,這件事情其實非常有價值。
因為人最容易被「症狀」吸引。
看到:
金額 = 0
就一直搜尋:
AMOUNT
PRICE
QTY
CALAMT
然後開始修改每一個看起來可疑的地方。
AI 則可以拿來協助建立 Data Flow。
但前提仍然是:
不要讓 AI 在證據不足的時候直接進入 Fix Mode。
這次問題最後讓我重新調整了 Debug 的順序。
以前可能是:
看到錯誤
↓
找相關函式
↓
看公式
↓
改程式
↓
測試
現在我比較傾向:
看到錯誤
↓
確認 Database
↓
確認 Dataset
↓
確認 Local Variable
↓
確認 Calculation
↓
確認 Write
↓
重新 Query
↓
找到第一個異常點
↓
才決定要不要改程式
這個流程看起來比較慢。
但實際上常常更快。
因為它避免了一件在 Legacy System 裡很昂貴的事情:
修正一段原本根本沒有錯的程式。
Day 12 談的是:
沒有完整自動化測試時,先建立 Baseline 與 Evidence,才知道修改前後到底有沒有改壞。
Day 13 則把同一個概念帶進真正的 Debug 現場。
當使用者告訴我:
「金額突然變成 0。」
我現在不會第一時間問:
「公式哪裡寫錯了?」
而是先問:
「這個 0,第一次是在哪裡出現的?」
因為:
畫面是 0
≠
公式算錯
公式算出 0
≠
公式本身有問題
程式沒有 Exception
≠
資料語意正確
尤其在這次案例裡,原本的:
NULL
在進入後續數值運算之後,只剩下:
0
如果沒有保留 IsNull 這一層資訊,就很容易把「沒有值」和「值就是零」當成同一件事情。
所以這次我留下來的 Debug 原則很簡單:
先追資料,再懷疑公式。
先找到第一個錯的值,再決定第一行該改的程式。
AI 可以很快提出修法,但在 Legacy System 裡,我更需要它幫我建立 Data Trace,而不是在證據不足時直接 Fix。
最後真正要回答的,仍然是:
「我有沒有證據證明,錯誤真的發生在這裡?」
下一篇,我會繼續沿著這個問題往下追。
因為當來源資料、數量、單價與金額都確認過之後,我又碰到另一個更麻煩的情況:
同樣是資料轉入,為什麼有些資料正常,有些資料卻走出了完全不同的結果?
這時候,問題就不一定還在「值」本身了。
而可能是——程式到底走了哪一條路。