iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
ChatGPT & Codex

AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發系列 第 2

Day 2|先別急著改程式:建立 AI Agent 的 Legacy Code 維護契約

  • 分享至 

  • xImage
  •  

昨天我替這場 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。

AI 產生的第一版 Trigger

下面是依照當時錯誤版本的結構還原、再經過匿名與縮減的 Trigger,並非逐字公開公司原始碼。它會透過 inserteddeleted 找出從「簽核中」變成「退回」的單據,再依表單類型解除鎖定:

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,它其實寫得很像一份完整答案:

  • 有判斷修改前後的狀態。
  • 使用集合式 SQL,可以同時處理多筆資料。
  • 三種表單都有各自的解除鎖定邏輯。
  • 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 不是不會寫,而是很容易提早收工

這次遺漏讓我重新思考:為什麼 AI 已經讀過需求,也協助修改了 SQL,還是可能少處理一張資料表?

1. AI 通常只處理目前看見的範圍

如果 AI 先整理到 CONTRACT_DETAILCONTRACT_MASTER 的分支,很容易沿著這兩張表繼續完成資料庫端邏輯。除非我要求它重新掃描整支函式並核對每一個 else if,否則它不一定會主動追問:

  • 是否還有其他資料表保存相同狀態?
  • 是否還有名稱不同、用途相同的鎖定欄位?
  • 其他入口是不是也會執行退簽?
  • 前端顯示成功時,後端的所有狀態真的一致嗎?

2. 程式完整,不代表需求完整

AI 很擅長檢查一段 SQL 是否能執行、變數是否存在、交易控制是否合理。但即使每一行都正確,也不能證明原始需求的每一項都已經完成。

這次 SQL 的問題不是寫錯,而是少寫。

3. 找到合理答案後,AI 容易停止追查

當 AI 找到一個能解釋問題的原因,便可能開始產生修改版本。這種「提早收斂」在人類身上也會發生,只是 AI 產生答案的速度更快、語氣又很肯定,因此更容易讓人放下戒心。

講得直接一點:AI 也會偷懶。

所以不能只問它:「這樣改對嗎?」我們必須要求它交代,為什麼認為已經完成。


我替 AI Agent 訂下的七項維護契約

從今天開始,每當 AI 準備修改 Legacy Code,都必須先回答以下七個問題。

1. 原流程是什麼?

先說明程式入口、主要事件、資料流與最後寫入的位置。如果連原本怎麼運作都說不清楚,就不能直接修改。

2. 判斷證據在哪裡?

指出結論是來自哪個事件、函式、SQL、資料表、欄位或執行結果,並區分:

  • 已確認的事實
  • 根據程式推測的行為
  • 仍缺少資料的未知項目

3. 為什麼需要修改?

不能只說「這樣比較安全」或「建議最佳化」,而要說明目前會在哪個條件下產生什麼錯誤,以及修改如何對應真正的問題。

4. 影響範圍有哪些?

列出可能受到影響的畫面、事件、共用函式、資料表、Trigger、預存程序及其他操作入口。

5. 原始需求是否全部完成?

修改後必須重新閱讀最初需求,建立「需求—實作對照表」。不能只因為程式可以編譯或 SQL 可以執行,就判定整項工作完成。

6. 要怎麼驗證?

至少提出:

  • 正常情境
  • 邊界情境
  • 失敗情境

如果涉及多張資料表,還必須確認每一張表的修改前後狀態,而不是只看畫面上的成功訊息。

7. 修改失敗時如何回復?

說明如何回到原始版本、需要備份哪些程式或資料庫物件,以及資料寫入一半時是否能夠 Rollback。


用需求對照表抓出「沒有寫錯,只是漏掉」

如果把這次退簽需求重新整理成表格,遺漏就會非常明顯:

原始需求 對應位置 驗證方式 初次檢查結果
更新簽核流程狀態 SIGN_FLOW 查詢退簽後的狀態與關卡 已完成
解除合約明細鎖定 CONTRACT_DETAIL.DETAIL_SIGN_LOCK FORM_DETAIL 測試退簽 已完成
解除採發/合約主檔鎖定 PURCHASE_SIGN_LOCKMASTER_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 的證據是否支持結論。
  • 確認需求對照表沒有少列項目。
  • 親自執行測試並觀察實際資料。

AI Agent 可以協助搜尋、整理、比較與產生修改方案,但「是否真的可以交付」,仍然必須由維護者做最後判斷。


今天的結論

Legacy System 最危險的修改,往往不是無法編譯,而是程式正常執行,資料卻只更新了一半。

所以在讓 AI 動手以前,我先和它約定:

不只要告訴我怎麼改,還要交代原流程、判斷證據、修改理由、影響範圍、完整性檢查、驗證方式與回復方案。

我不要求 AI 每一次都立即找到正確答案,但它不能把推測當成事實,也不能在只完成部分需求時告訴我「已經完成」。

好的 AI Agent,不是修改速度最快的那一個,而是能清楚說明:

我看到了什麼、還沒看到什麼,以及這次修改可能動到哪裡。

維護契約已經訂好了。下一篇,我們再正式進入 Delphi 專案,先從辨認專案中的 .dpr.pas.dfm 開始,找出 Legacy Code 的真正入口。


上一篇
Day 1|AI 救得了祖傳系統嗎?我的 30 天企業 Legacy System × AI 實驗
下一篇
Day 3|面對一萬行 Delphi 程式,我該先把什麼交給 AI?
系列文
AI 救得了祖傳系統嗎?30 天實戰企業 Legacy System × AI 協作開發5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
lin1015
iT邦新手 5 級 ‧ 2026-09-16 17:40:55

七項維護契約很完整,尤其是「程式完整不代表需求完整」很有感。如果今天是緊急修復、沒有時間七項全部走完,你會把哪三項列為絕對不能省的最低檢查?我猜影響範圍和回復方案一定會入選。

謝謝你的留言,這題真的很實務!

如果今天是緊急修復,而且只能留下三項最低檢查,我會選:

  1. 原流程與判斷證據:至少先確認問題發生在哪裡,不能在還沒看懂時直接改。
  2. 影響範圍與完整性檢查:確認有沒有其他入口、資料表或平行邏輯也需要一起處理。
  3. 驗證方式:至少要有問題重現、修改後驗證,以及一個失敗或邊界情境。

「回復方案」我也認為很重要,只是它可以依風險分級。單純程式修改,如果已有版本控制與舊版部署流程,可以沿用既有方案;但只要牽涉資料異動、跨表更新或不可逆操作,回復方案就會升級成絕對不能省。

換句話說,七項契約不是要求每次都寫成七頁報告,而是七個不能忘記思考的方向。緊急時可以縮短篇幅,但不能用「趕時間」取代風險判斷。

我要留言

立即登入留言