前兩篇,我分別用 Data Trace 追金額在哪一層變成 0,再用 Branch Trace 確認程式實際走進哪個分支。這次我碰到的問題更麻煩:資料已經在系統裡待了一段時間,等使用者發現時,當初的執行現場早就不在了。
到了請款期,使用者檢查某個工程項次,才發現歷次併入與追減後,變更後數量竟然是負數。
我回查資料,看到其中一筆轉入紀錄是 2026/04/24。但這個項次並非只異動過一次,而是在一段時間內持續併入、追減。4 月有轉入紀錄,只能證明當天發生過轉入;不能證明負數就是那天第一次出現。請款期才被發現,也不等於問題是請款操作造成的。
偏偏我現在打開的程式,已經有負數防呆。如果會擋,資料當初怎麼留下來的?是某條路徑沒經過檢查、當時的 Client 還沒有這段防呆,還是後續某次異動才把數量推成負數?我帶著這些問題,把 Delphi 轉入流程交給 AI 協助檢查。
在我檢查的這份現行原始碼裡,轉入操作有一個主要入口。它很長,但和這次判斷有關的骨幹可以縮成下面幾步。**以下 Form、函式、變數與目標資料表名稱均已匿名化,保留運算關係與控制流程;示意省略了「式」、已請數量與其他分支,不能當作完整原始碼執行。**文中的 TargetDetail 代稱轉入的目標明細資料。
procedure TTransferForm.ExecuteTransferClick(Sender: TObject);
begin
// 前段:按項次彙總歷次追減與本次變更。
if (TotalReductionQty <> 0) and
(Abs(OriginalQty + QtyTolerance - TotalReductionQty) > 0.000001) and
(OriginalQty + QtyTolerance - TotalReductionQty < 0) then
Inc(ErrorCount);
// 其他項次與條件也可能使 ErrorCount 增加。
if ErrorCount > 0 then Exit;
// 後段:通過檢查後,依項次條件更新目標明細欄位。
RemainingQty := OriginalQty - TotalReductionQty;
RemainingAmount := RoundByRule(RemainingQty * UnitPrice);
end;
其中 OriginalQty 是原合約數量,TotalReductionQty 是此處彙總的追減數量;RoundByRule 是實際精度處理函式的匿名代稱,且「式」等分支另有算法。關鍵在於:前段先累計錯誤筆數,若 ErrorCount > 0 就結束;後段在通過後才整理要寫入的欄位。
我現在檢查的轉入 Form 是一支約 1.6 萬行的 .pas 原始碼。這是檔案總長度,不能拿來當作「當時一次餵給 AI 的程式碼長度」。在這樣的長程式裡,AI 能指出一個具體行號,本身就很容易讓人覺得它已經看懂整段流程。
它第一輪判斷的大意是:前面既然已檢查負數,後面約第 5796 行又重新計算,看起來有重複計算的問題;修改方向可以是整理或移除後段計算。這裡是我對當時建議的摘要,不是 AI 回覆的逐字引文;行號也只對應我檢查的那份檔案。
有行號、有看似合理的推理,還有能立即動手的建議,我一瞬間確實覺得「啊,原來兇手在這裡」。這種符合自己直覺的答案很誘人,也讓我差點陷入確證偏誤(Confirmation Bias),只找支持它的證據。我把滑鼠停下來,先問了自己一句:
如果第一次遇到負數就會被擋住,後面為什麼還要再算一次?那一次究竟在算什麼?
這裡其實有兩個要分開回答的問題。
第一個是現在這筆歷史資料為什麼是負數。要回答它,得知道哪一次異動讓數量首次變負、當次走了哪個入口,以及當時執行的程式和設定。
第二個是後段計算是否有錯。即使它真的值得懷疑,也必須先追清楚它讀取哪些欄位、計算哪個範圍,最後把結果寫到哪裡。找到一段看似重複的 Code,還不能把它連到這筆資料的形成過程。
| 目前看到的事 | 可以說到哪裡 | 不能直接推成什麼 |
|---|---|---|
ExecuteTransferClick 的一般項次分支檢查 OriginalQty + QtyTolerance - TotalReductionQty,錯誤累計到 ErrorCount |
現行這條路徑有負數檢查,ErrorCount > 0 會 Exit |
歷次異動都使用這版程式並走到同一分支 |
後段更新 RemainingQty、RemainingAmount |
通過檢查後仍有實際欄位要計算與寫入 | 後段是多餘計算,或造成歷史負數 |
| 其中一筆紀錄於 2026/04/24 轉入 | 當天有轉入紀錄 | 負數就是在那次轉入形成 |
| 現在看到的數量是負數 | 歷次異動需要回溯 | 最近一次操作就是根因 |
AI 的問題不在於它找不到程式碼。它找到得很快;真正危險的是,把程式碼上的可疑形狀,提早升格成已確認的因果關係。
我差點想直接整理或刪掉後段計算。先把這個修改當成假設,反過來問:通過檢查的項次,接下來還需要哪些值?
若移除後段計算,部分項次可能失去更新 RemainingQty 與 RemainingAmount 的步驟;若改成直接沿用前段的檢查值,又可能把包含 QtyTolerance 的「放行條件」誤當成「應寫入的數量」。前者要核對欄位是否仍由其他分支賦值,後者要用容許值邊界測試兩道公式的結果。
這些是修改可能帶來的風險,不是已發生的事故。即使驗證後確定某種改法會出錯,也不能由此反推歷史負數就是這段程式造成的。這正是我暫時沒有照 AI 的建議動手的理由。
我沿著 ExecuteTransferClick 前後看,前段在一般項次的分支檢查 OriginalQty + QtyTolerance - TotalReductionQty 是否低於零;如果有錯誤,增加 ErrorCount,彙整後 ErrorCount > 0 便顯示錯誤並 Exit。後段則在檢查通過後整理 TargetDetail 的 RemainingQty、RemainingAmount 等欄位。這解釋了「為什麼後面還要算」:檢查用的預計結果,仍須在允許轉入後落到目標欄位;欄位何時進入資料庫,還要繼續追 Post 與 ApplyUpdates。
仔細看,還有一個比「算兩次」更值得追的技術差異:檢查式包含數量容許值 QtyTolerance,一般項次後段計算 RemainingQty 時,卻是 OriginalQty - TotalReductionQty;部分路徑還會依規則處理到六位精度。 因此不能只說前段會擋負數,就推論後段算出的每個中間值一定大於等於零。
以同一個一般項次分支作教學示意,假設原合約數量 OriginalQty = 10、尚未請款,並先忽略其他條件:
追減合計 TotalReductionQty |
容許值 QtyTolerance |
前段檢查 10 + 容許值 - 追減 |
後段候選值 10 - 追減 |
|---|---|---|---|
| 12 | 0 | −2,記入 ErrorCount,之後 Exit |
不應走到此分支的後段 |
| 2 | 0 | 8,通過 | 8 |
| 10.03 | 0.05 | 0.02,這一道負數檢查不會增加 ErrorCount |
−0.03,須追後續分支、覆寫與寫入結果 |
這些數字都不是問題項次的歷史數值。 第三列揭露的潛在風險是:檢查時加上容許值,可能讓小幅超出原數量的追減通過;寫入式若不加容許值,便可能算出小幅負數。這不等於已重現 Bug:程式還有已請數量檢查、特殊單位、不同公司的設定、後續覆寫與寫入路徑。要判定負數是否真的留下,必須用實際設定走完該分支,比對 Post、ApplyUpdates 前後及資料庫最後的值。
這是從目前這份程式可確認的控制流程,不能因此宣布整支程式完全正確。AI 的「後段多餘,應刪除」缺乏證據;但容許值與寫入式的落差,值得列成另一個待驗證假說。這兩個判斷可以同時成立,也都不能直接替歷史負數定因。
這也讓我有了更明確的追查方式:沿實際執行順序,分別記錄這幾個觀察點:
| 觀察點 | 要核對的資料 |
|---|---|
| 操作入口 | 使用者按了什麼、進入哪個 Event Handler、經過哪些條件分支 |
| 前段檢查 | 異動前數量、本次增減、檢查時計算出的數量與擋下條件 |
| 後段計算 | 讀取哪個 Dataset/欄位、計算的項次與範圍、結果交給誰 |
| 寫入結果 | TargetDetail 或其他目標的實際值,Post 與 ApplyUpdates 是否執行、有無後續覆寫 |
在這份 ClientDataset 流程中,程式可以先 Post,後面才呼叫 ApplyUpdates。對不熟 Delphi 的讀者,可以把它理解成「記憶體中的本地待提交狀態」與「將更新送往資料庫」的時間差;Post 不是資料庫 Commit,ApplyUpdates 之後也仍須確認錯誤結果與實際落庫狀態。只有把這條資料流接起來,才能判斷檢查值、Dataset 中的值與資料庫最後的值是否一致。這次並沒有拿到能證明後段造成歷史負數的那組證據。
即使今天確認現行程式會擋負數,也還無法回答「為什麼歷史資料沒有被擋住」。這幾個月系統陸續增加各種防呆,而這個項次的異動橫跨更長的時間。
Git 的 Commit 日期只能告訴我原始碼何時改過,不能直接證明某台 Client 何時安裝了哪支 EXE,更不能證明那次操作走到哪個 Branch。我本機與出問題公司目前的相關設定也不同;拿今天的環境重跑,未必等於重現當時的條件。
要追到「第一次變負數」的那一次,至少得把以下證據對在同一個時間點:
這些證據目前沒有全部補齊。我還不知道哪一次異動首次產生負數,也不能判定 2026/04/24 的轉入或今天看到的第 5796 行就是根因。 這次只能查到這裡。與其替案件補一個漂亮結局,不如把缺的證據說清楚。
回頭整理這次經驗,下次遇到類似情況,我會這樣要求 AI;以下是事後整理的 Prompt 範本,不是當時對話的逐字紀錄:
請分析這段 Delphi 轉入流程,先不要修改程式。
1. 依執行順序列出負數檢查與後段計算的入口、輸入欄位、
計算目的及最終寫入位置,並標出對應程式碼。
2. 區分「程式碼可確認」、「需要 Runtime 驗證」與
「需要歷史版本/資料才能確認」;不要把 Dataset 當成 Procedure。
3. 如果認為後段是 Bug,請提供一組具體輸入、預期與實際結果,
以及可重現的操作步驟。
4. 若證據不足,列出下一個最有用的觀察點,不要提出 Fix。
這樣問的目的,是讓「看起來重複」變成一個可以驗證、也可以被推翻的主張。若拿不出會出錯的輸入與執行路徑,就先保留疑點,不把假說寫成修正單。
這次沒有找出歷史負數的完整根因,卻讓我看清另一個問題:防呆可以攔住符合條件的新操作,無法替過去缺少紀錄的操作補出證據。
後續要改善,我會先盤點哪些入口能改動項次與 TargetDetail,再為每次轉入建立一個 OperationId,讓同一次操作的檢查與寫入 Log 能串在一起。至少需要留下:
| Log 欄位 | 為什麼要記 |
|---|---|
| 操作時間、使用者、項次與操作入口 | 知道是哪一次、從哪個畫面進來 |
OperationId、實際 Client 版本與影響判斷的設定 |
對上當時執行的程式與條件,而不只看今天的原始碼 |
| 異動前數量、本次增減、前段檢查算出的數量 | 回推哪一次開始變負,以及防呆當時看到了什麼 |
後段計算結果、TargetDetail 實際寫入值與異動後數量 |
比對檢查用的值和最後存進資料庫的值 |
| 經過的關鍵分支、成功或失敗及失敗階段 | 區分「有檢查但未通過」與「根本沒走到檢查」 |
在這套 Delphi ERP 裡,落地的第一步可以是在現有轉入入口建立 OperationId,於重要檢查分支記錄判斷值,並在實際資料庫寫入界線記錄結果;成功事件可規劃寫入獨立的 Audit Log 表,讓它與目標資料對上同一次成功提交。若交易失敗而回滾,錯誤紀錄則需要另一個可靠的寫入時點,不能讓它跟著回滾消失。系統已有多處 Post、ApplyUpdates 與其他寫入入口,實作前得先盤點交易邊界,不能只在畫面按鈕裡加一行 Log 就宣稱已覆蓋所有路徑。Log 也要記實際執行版本;Git Commit 日期無法還原使用者那台電腦當時跑的 EXE。
這是後續改善方向,不是我已完成的修正。現有負數資料也不能因為加了新檢查就自動改回來;請款所需的正確數量仍須先按歷次異動與業務基準釐清,再決定如何處理。
這個案例換成 Java、C#、Python 或 SQL,問題仍然成立。看到「前面驗證過,後面又計算」,先不要只比較兩行算式長得像不像,而是把它們各自拆成驗證條件、實際寫入公式、持久化時點:
| 在本例看到的事 | 換到其他系統要問的問題 |
|---|---|
檢查式加入 QtyTolerance,寫入式沒有 |
驗證容許的邊界值,最後會存成什麼?驗證與寫入是否使用同一套規則? |
ErrorCount 累計完才 Exit |
哪些分支會增加錯誤、哪些分支跳過?錯誤是否真的阻止後續寫入? |
Dataset 的 Post 與 ApplyUpdates 分開 |
畫面或記憶體裡的值,何時才真正進資料庫?失敗時會留下什麼? |
| Git 有防呆的 Commit | 發生異常的那次操作,實際執行的是哪個部署版本與設定? |
實務上可以拿一筆測試資料,依序記下 輸入 → 驗證使用的值 → 通過的分支 → 寫入前的值 → 資料庫最後的值。若其中一格拿不到,就把它列為缺少的觀測證據;不要用今天的程式推測昨天的執行結果。這個追法不依賴 Delphi,適用於任何有驗證、轉換、寫入與版本部署的系統。
AI 指出第 5796 行附近的計算,確實讓我注意到一個值得追的地方。但「前面檢查過,後面又算一次」還不足以證明它是 Bug,更不足以證明它造成了這筆歷史負數。這次我沒有依照那個判斷刪改程式;反而從檢查式的容許值與後段寫入式之間,整理出一個可測的邊界案例。
這件事從請款期的一筆負數開始,最後停在一個誠實的結論:已確認目前有負數資料,也看到現行程式的防呆與可疑的後段計算;尚未確認首次異常發生在哪一次執行。
Day 13 讓我追值,Day 14 讓我追 Branch。今天多練了一件事:當 AI 很肯定地指出 Bug,先請它連起當時的版本、實際執行路徑、這筆資料與最後結果。學會察覺自己的確證偏誤,把 AI 當成推理夥伴而非裁決者,才能避免讓一個看似漂亮的答案,變成我們親手種下的下一個 Bug。