昨天我替這場 30 天實驗訂下了一個目標:
我不是要證明 AI 能替我寫完一套 Legacy System,而是要驗證它能不能幫助我更快讀懂系統,同時不讓修改失去控制。
既然如此,今天應該開始把 Delphi 專案交給 AI 了嗎?
還不行。
在真正讓 AI 修改程式以前,我想先替它訂下一份「Legacy Code 維護契約」。因為我逐漸發現,AI 最危險的時候,往往不是它回答「我不知道」,而是它只完成了眼前看到的部分,接著很有自信地告訴我:
這樣修改應該就沒有問題了。
而在企業系統裡,「應該」兩個字,有時候真的很貴。
最近我處理一個簽核系統的退簽功能。
為了避免公開公司的實際系統架構,以下程式碼除了刪除部分查詢條件、連線方式與重複內容,也將函式、表單代號、資料表及欄位全部改成用途相近的虛構名稱。程式分支與這次遺漏發生的方式則保持不變。
這個功能表面上很單純:管理者選擇退回關卡、輸入原因,系統完成退簽後重新查詢資料。
Delphi 前端大致像這樣:
procedure TFormSign.ReturnButtonClick(Sender: TObject);
begin
ExecuteReturn(ReturnSeq, ReturnReason);
ReloadSignData;
ShowMessage('退簽完成');
end;
真正的退簽邏輯,原本並不在一支整齊的預存程序裡,而是散落在 Delphi 表單事件與函式中。在這套系統裡,使用者送簽後,除了新增簽核流程,還會依不同表單把相關業務資料鎖住,避免簽核期間仍有人修改內容。
因此,「退簽」不能只改 SIGN_FLOW 的狀態,還必須回頭解除原系統留下的鎖。
舊程式裡有一支很重要的狀態重設函式。本文將它匿名為 Reset_Business_Status,它其實就是判斷「不同表單退簽時,要回復哪些狀態」的重要依據。
以下保留原本的分支結構與關鍵欄位,但省略重複的 SQL 字串拼接及部分查詢條件:
procedure TFormSign.Reset_Business_Status(ReturnSeq: Integer);
var
CommandText: string;
begin
// 分包合約明細:解除明細資料的鎖定
if SignData.FieldByName('FORM').AsString = 'FORM_DETAIL' then
begin
CommandText :=
'UPDATE CONTRACT_DETAIL ' +
'SET DETAIL_SIGN_LOCK = '''' ' +
'WHERE COMPANY_ID = ''' + SignData.FieldByName('COMPANY_ID').AsString + ''' ' +
'AND PROJECT_ID = ''' + SignData.FieldByName('PROJECT_ID').AsString + ''' ' +
'AND CONTRACT_ID = ''' + SignData.FieldByName('CONTRACT_ID').AsString + '''';
SqlExecutor.CommandText := CommandText;
SqlExecutor.Execute;
end
// 採發相關表單:解除主檔的採發鎖定
else if (SignData.FieldByName('FORM').AsString = 'FORM_PURCHASE_A') or
(SignData.FieldByName('FORM').AsString = 'FORM_PURCHASE_B') then
begin
CommandText :=
'UPDATE CONTRACT_MASTER SET PURCHASE_SIGN_LOCK = '''' ' +
'WHERE COMPANY_ID = ''' + SignData.FieldByName('COMPANY_ID').AsString + ''' ' +
'AND PROJECT_ID = ''' + SignData.FieldByName('PROJECT_ID').AsString + ''' ' +
'AND CONTRACT_ID = ''' + SignData.FieldByName('CONTRACT_ID').AsString + '''';
SqlExecutor.CommandText := CommandText;
SqlExecutor.Execute;
end
// 合約主檔:解除一般簽核鎖定
else if (SignData.FieldByName('FORM').AsString = 'FORM_CONTRACT_A') or
(SignData.FieldByName('FORM').AsString = 'FORM_CONTRACT_B') then
begin
CommandText :=
'UPDATE CONTRACT_MASTER SET MASTER_SIGN_LOCK = '''' ' +
'WHERE COMPANY_ID = ''' + SignData.FieldByName('COMPANY_ID').AsString + ''' ' +
'AND PROJECT_ID = ''' + SignData.FieldByName('PROJECT_ID').AsString + ''' ' +
'AND CONTRACT_ID = ''' + SignData.FieldByName('CONTRACT_ID').AsString + '''';
SqlExecutor.CommandText := CommandText;
SqlExecutor.Execute;
end
// 包商/業主請款:除了鎖定,還要依退回關卡清除簽核紀錄
else if (SignData.FieldByName('FORM').AsString = 'FORM_PAYMENT_A') or
(SignData.FieldByName('FORM').AsString = 'FORM_PAYMENT_B') then
begin
CommandText :=
'UPDATE PAYMENT_CONTROL ' +
'SET APPROVAL_STATUS = '''', PAYMENT_SIGN_LOCK = ''''';
if ReturnSeq <= 1 then
CommandText := CommandText +
', APPROVER_1 = '''', RESULT_1 = '''', SIGN_DATE_1 = ''''';
if ReturnSeq <= 2 then
CommandText := CommandText +
', APPROVER_2 = '''', RESULT_2 = '''', SIGN_DATE_2 = ''''';
// 第 3~8 關使用相同方式依序清除,本文省略
CommandText := CommandText +
' WHERE COMPANY_ID = ''' + SignData.FieldByName('COMPANY_ID').AsString + ''' ' +
'AND PROJECT_ID = ''' + SignData.FieldByName('PROJECT_ID').AsString + ''' ' +
'AND CONTRACT_ID = ''' + SignData.FieldByName('CONTRACT_ID').AsString + ''' ' +
'AND APPLY_DATE = ''' + SignData.FieldByName('APPLY_DATE').AsString + '''';
SqlExecutor.CommandText := CommandText;
SqlExecutor.Execute;
end;
end;
這段 Legacy Code 雖然有大量字串拼接,但它清楚透露了一件事:退簽不是只有一種回復方式。光是這個函式裡,就有四組不同的表單分支:
| 表單類型 | 必須回復的資料 | 主要動作 |
|---|---|---|
FORM_DETAIL |
CONTRACT_DETAIL |
清除 DETAIL_SIGN_LOCK |
FORM_PURCHASE_A/B |
CONTRACT_MASTER |
清除 PURCHASE_SIGN_LOCK |
FORM_CONTRACT_A/B |
CONTRACT_MASTER |
清除 MASTER_SIGN_LOCK |
FORM_PAYMENT_A/B |
PAYMENT_CONTROL |
清除 PAYMENT_SIGN_LOCK,並依退回關卡清除簽核紀錄 |
後來為了避免 Delphi 分多次更新造成狀態不一致,我把退簽邏輯移往資料庫端,請 AI 協助整理 Trigger。
下面是依照當時錯誤版本的結構還原、再經過匿名與縮減的 Trigger,並非逐字公開公司原始碼。它會透過 inserted 與 deleted 找出從「簽核中」變成「退回」的單據,再依表單類型解除鎖定:
CREATE TRIGGER dbo.TRG_SIGN_FLOW_RETURN
ON dbo.SIGN_FLOW
AFTER UPDATE
AS
BEGIN
SET NOCOUNT ON;
-- 沒有任何資料被退回時,不需要處理
IF NOT EXISTS
(
SELECT 1
FROM inserted i
INNER JOIN deleted d
ON d.SIGN_ID = i.SIGN_ID
WHERE d.SIGN_STATUS = 'SIGNING'
AND i.SIGN_STATUS = 'RETURNED'
)
RETURN;
-- 對應舊函式的第一個分支:合約明細
UPDATE DetailTable
SET DetailTable.DETAIL_SIGN_LOCK = ''
FROM dbo.CONTRACT_DETAIL DetailTable
INNER JOIN inserted i
ON i.COMPANY_ID = DetailTable.COMPANY_ID
AND i.PROJECT_ID = DetailTable.PROJECT_ID
AND i.CONTRACT_ID = DetailTable.CONTRACT_ID
INNER JOIN deleted d
ON d.SIGN_ID = i.SIGN_ID
WHERE i.FORM = 'FORM_DETAIL'
AND d.SIGN_STATUS = 'SIGNING'
AND i.SIGN_STATUS = 'RETURNED';
-- 對應舊函式的第二個分支:採發鎖定
UPDATE MasterTable
SET MasterTable.PURCHASE_SIGN_LOCK = ''
FROM dbo.CONTRACT_MASTER MasterTable
INNER JOIN inserted i
ON i.COMPANY_ID = MasterTable.COMPANY_ID
AND i.PROJECT_ID = MasterTable.PROJECT_ID
AND i.CONTRACT_ID = MasterTable.CONTRACT_ID
INNER JOIN deleted d
ON d.SIGN_ID = i.SIGN_ID
WHERE i.FORM IN ('FORM_PURCHASE_A', 'FORM_PURCHASE_B')
AND d.SIGN_STATUS = 'SIGNING'
AND i.SIGN_STATUS = 'RETURNED';
-- 對應舊函式的第三個分支:合約主檔鎖定
UPDATE MasterTable
SET MasterTable.MASTER_SIGN_LOCK = ''
FROM dbo.CONTRACT_MASTER MasterTable
INNER JOIN inserted i
ON i.COMPANY_ID = MasterTable.COMPANY_ID
AND i.PROJECT_ID = MasterTable.PROJECT_ID
AND i.CONTRACT_ID = MasterTable.CONTRACT_ID
INNER JOIN deleted d
ON d.SIGN_ID = i.SIGN_ID
WHERE i.FORM IN ('FORM_CONTRACT_A', 'FORM_CONTRACT_B')
AND d.SIGN_STATUS = 'SIGNING'
AND i.SIGN_STATUS = 'RETURNED';
END;
只看這支 Trigger,它其實寫得很像一份完整答案:
UPDATE 位於同一個交易範圍,不再由 Delphi 分段送出 SQL。SQL 可以建立,前三種表單的退簽測試也能通過。因此當 AI 告訴我「已完成退簽解鎖邏輯」時,表面上沒有明顯理由懷疑它。
問題是:舊 Delphi 函式明明有四個分支,這支 Trigger 卻只有三段 UPDATE。
它完全沒有處理下面這組對應關係:
FORM_PAYMENT_A/FORM_PAYMENT_B
↓
PAYMENT_CONTROL.PAYMENT_SIGN_LOCK
↓
依 ReturnSeq 清除退回關卡之後的簽核人員、結果與日期
結果是採發相關表單可以正常退簽,請款表單的 SIGN_FLOW 狀態也會改變;但是 PAYMENT_CONTROL.PAYMENT_SIGN_LOCK 仍然有值,部分關卡的簽核人員與日期也沒有清除。使用者回到原系統時,畫面仍可能判定單據正在簽核,無法繼續修改。
這不是 SQL 語法寫錯,而是 AI 在搬移邏輯時,只整理了最前面的幾條分支,沒有把原函式的所有 else if 與新實作逐項對照。
也就是說,程式沒有當機、SQL 沒有報錯,甚至多數表單的退簽流程都能成功;可是整個系統只恢復了一部分狀態。
這類問題比編譯錯誤更加危險。
編譯錯誤會立即提醒我「事情還沒完成」;局部成功卻可能讓我誤以為已經完成,直到使用者在另一個流程踩到問題。
這次遺漏讓我重新思考:為什麼 AI 已經讀過需求,也協助修改了 SQL,還是可能少處理一張資料表?
如果 AI 先整理到 CONTRACT_DETAIL 與 CONTRACT_MASTER 的分支,很容易沿著這兩張表繼續完成資料庫端邏輯。除非我要求它重新掃描整支函式並核對每一個 else if,否則它不一定會主動追問:
AI 很擅長檢查一段 SQL 是否能執行、變數是否存在、交易控制是否合理。但即使每一行都正確,也不能證明原始需求的每一項都已經完成。
這次 SQL 的問題不是寫錯,而是少寫。
當 AI 找到一個能解釋問題的原因,便可能開始產生修改版本。這種「提早收斂」在人類身上也會發生,只是 AI 產生答案的速度更快、語氣又很肯定,因此更容易讓人放下戒心。
講得直接一點:AI 也會偷懶。
所以不能只問它:「這樣改對嗎?」我們必須要求它交代,為什麼認為已經完成。
從今天開始,每當 AI 準備修改 Legacy Code,都必須先回答以下七個問題。
先說明程式入口、主要事件、資料流與最後寫入的位置。如果連原本怎麼運作都說不清楚,就不能直接修改。
指出結論是來自哪個事件、函式、SQL、資料表、欄位或執行結果,並區分:
不能只說「這樣比較安全」或「建議最佳化」,而要說明目前會在哪個條件下產生什麼錯誤,以及修改如何對應真正的問題。
列出可能受到影響的畫面、事件、共用函式、資料表、Trigger、預存程序及其他操作入口。
修改後必須重新閱讀最初需求,建立「需求—實作對照表」。不能只因為程式可以編譯或 SQL 可以執行,就判定整項工作完成。
至少提出:
如果涉及多張資料表,還必須確認每一張表的修改前後狀態,而不是只看畫面上的成功訊息。
說明如何回到原始版本、需要備份哪些程式或資料庫物件,以及資料寫入一半時是否能夠 Rollback。
如果把這次退簽需求重新整理成表格,遺漏就會非常明顯:
| 原始需求 | 對應位置 | 驗證方式 | 初次檢查結果 |
|---|---|---|---|
| 更新簽核流程狀態 | SIGN_FLOW |
查詢退簽後的狀態與關卡 | 已完成 |
| 解除合約明細鎖定 | CONTRACT_DETAIL.DETAIL_SIGN_LOCK |
以 FORM_DETAIL 測試退簽 |
已完成 |
| 解除採發/合約主檔鎖定 | PURCHASE_SIGN_LOCK、MASTER_SIGN_LOCK |
分別測試對應表單 | 已完成 |
| 解除請款控制鎖定 | PAYMENT_CONTROL.PAYMENT_SIGN_LOCK |
以 FORM_PAYMENT_A/B 退簽後重新編輯 |
遺漏 |
| 清除退回關卡之後的簽核紀錄 | APPROVER_1~8 等欄位 |
以不同 ReturnSeq 比對結果 |
遺漏 |
| 任一步驟失敗時不可只完成一半 | 整段退簽流程 | 模擬其中一步失敗 | 待確認 |
AI 第一次給出的 SQL 並不是每一行都有錯,但只要對照表中還有一項遺漏,整個需求就不能標示為完成。
這也讓我替維護契約加上一條最高原則:
AI 寫完了,不代表需求完成了。
之後請 AI 分析或修改 Legacy Code 時,我可以先加入以下要求:
你現在的角色是 Legacy Code 維護協作者。
在提出任何修改前,請先完成以下內容:
1. 說明目前程式的入口、原始流程與資料流。
2. 列出支持判斷的程式碼、事件、SQL、欄位或執行結果。
3. 區分「已確認事實」、「合理推測」及「仍待確認事項」。
4. 說明問題原因,以及本次修改如何對應問題。
5. 列出直接與間接影響範圍。
6. 重新閱讀原始需求,建立「需求—實作對照表」。
7. 檢查是否存在名稱不同但用途相同的平行欄位、資料表、
事件、Trigger、預存程序或其他操作入口。
8. 提出正常、邊界及失敗情境的驗證方式。
9. 說明修改失敗時的回復方案。
如果資訊不足,不得自行假設完整流程並直接給出最終修改版;
請先指出缺少哪些程式、資料表結構或執行結果。
不得只以程式可以編譯、SQL 可以執行或主要畫面操作成功,
判定整項需求已經完成。
這段文字不是什麼能讓 AI 突然變聰明的魔法 Prompt。它真正的作用,是把我原本只放在腦中的維護標準,變成 AI 與我都必須遵守的檢查流程。
即使訂下這些規則,也不代表 AI 從此不會漏掉任何內容。
如果我沒有提供完整需求、沒有讓它看到相關程式,或者我自己也不知道系統還有第三個鎖定位置,AI 仍然可能得到不完整的結論。
因此,人的責任不是把程式貼給 AI 後等待答案,而是:
AI Agent 可以協助搜尋、整理、比較與產生修改方案,但「是否真的可以交付」,仍然必須由維護者做最後判斷。
Legacy System 最危險的修改,往往不是無法編譯,而是程式正常執行,資料卻只更新了一半。
所以在讓 AI 動手以前,我先和它約定:
不只要告訴我怎麼改,還要交代原流程、判斷證據、修改理由、影響範圍、完整性檢查、驗證方式與回復方案。
我不要求 AI 每一次都立即找到正確答案,但它不能把推測當成事實,也不能在只完成部分需求時告訴我「已經完成」。
好的 AI Agent,不是修改速度最快的那一個,而是能清楚說明:
我看到了什麼、還沒看到什麼,以及這次修改可能動到哪裡。
維護契約已經訂好了。下一篇,我們再正式進入 Delphi 專案,先從辨認專案中的 .dpr、.pas 與 .dfm 開始,找出 Legacy Code 的真正入口。
七項維護契約很完整,尤其是「程式完整不代表需求完整」很有感。如果今天是緊急修復、沒有時間七項全部走完,你會把哪三項列為絕對不能省的最低檢查?我猜影響範圍和回復方案一定會入選。
謝謝你的留言,這題真的很實務!
如果今天是緊急修復,而且只能留下三項最低檢查,我會選:
「回復方案」我也認為很重要,只是它可以依風險分級。單純程式修改,如果已有版本控制與舊版部署流程,可以沿用既有方案;但只要牽涉資料異動、跨表更新或不可逆操作,回復方案就會升級成絕對不能省。
換句話說,七項契約不是要求每次都寫成七頁報告,而是七個不能忘記思考的方向。緊急時可以縮短篇幅,但不能用「趕時間」取代風險判斷。