從 Legacy 防呆系統到可維護的 Production Workflow:SMT Assistant 重構紀錄

CASE STUDY / LEGACY PRODUCTION SYSTEM

一套已經在 SMT 產線上運作的系統,要怎麼在不能停機、也不能全面重寫的前提下,慢慢變得可維護?

Python · FastAPI · SQLAlchemy · Alembic · pytest · Vue 3 · TypeScript · Pinia · XState · AG Grid · Vitest · Playwright


1. Why This System Exists

SMT 上料是一個「人、料、機器、程式」必須同時對得上的流程。這類錯誤最麻煩的地方在於,它通常不會在發生的當下被發現,而是在後段才以不良品的形式浮現。這套系統最初要解決的就是這一件事:防止上錯料,在錯誤還能挽回的那一步把它擋下來。

系統上線之後,需求逐步往外長:除了防錯,還要能保留必要的 operation record,讓事後可以回頭確認發生過什麼,也就是 traceability。功能長大了,原本的結構就開始撐不住——這篇文章談的主要是後面這段。

為了不涉及公司內部資訊,本文不描述實際的生產流程步驟、barcode 規則、料號與站別編碼,也不包含任何 schema 或系統位址。

2. The System I Inherited

我接手這套系統時的狀況,大概是很多工廠軟體的常態:

  • 原開發者已經離開,沒有完整的交接。
  • 系統已經在 production 被實際使用,不可能先停下來重寫完再上線。
  • Frontend 有大量邏輯集中在畫面層。
  • Backend 缺乏清楚的分層,business logic 與資料存取糾纏在一起。
  • 部署與維護高度依賴人工步驟。
  • 我的角色是 application developer,沒有完整的 IT / infrastructure 權限。

Production 發生問題時,目前主要仍是這條路徑:使用者回報 → 查看專案 log → 分析問題 → 修改與驗證。我要誠實地說:這套系統目前尚未正式導入完整的 observability,沒有 metrics、centralized logging、tracing 或 alerting 的正式建置。實際維護 production 的經驗,反而讓我更明確意識到 observability 對工廠系統維運有多重要——但那是我的體會,不是這套系統已經完成的成果。

3. Why I Chose Incremental Refactoring

「打掉重寫」在這種環境裡幾乎不是一個選項。產線持續在生產,系統中斷的成本不是由開發者承擔;而 infrastructure 的調整有正式的變更與審核流程,不是我想改就能改。權限限制本身就是系統設計的 constraint,不是可以繞過去的障礙。

所以我選的是另一條路:在既有 production、權限限制與持續生產的條件下,逐步降低技術債,而不是一次全面重寫。實務上的判準很簡單——每一次改動,我都要能回答「這次修改的影響範圍到哪裡為止」。答不出來的改動,就先拆小。

4. Backend Architecture

Backend 以 Python + FastAPI 為主,資料存取使用 SQLAlchemy,schema 變更用 Alembic 管理,測試用 pytest。原本的架構較為集中,後續逐步往下面這個方向拆分:

BACKEND LAYERING

Interface / API
↓
Application / Service
↓
Domain
↓
Repository
↓
Database

我想特別說明動機,因為這件事很容易被誤解成「為了用 design pattern 而用 design pattern」。實際的理由是需求一直長:

  • 降低 business logic 與 DB 的耦合,讓規則的改動不會牽動資料存取細節。
  • 增加 testability——邏輯能被單獨測,才有辦法安心改。
  • 控制修改的影響範圍,這在 production 系統上是最實際的需求。
  • 讓重構可以是漸進的:一次換一層,而不是一次換整個系統。

資料層也做過一次遷移:原本使用的 Python Prisma ORM 後續缺乏維護,因此逐步轉向 SQLAlchemy + Alembic。對一個要長期活著的 production 系統來說,「這個套件還有沒有人在維護」本身就是架構決策的一部分。

這裡我想講清楚範圍:目前做的是 domain-oriented layering / separation of concerns。我不會宣稱這套系統已經完整實作 DDD、CQRS、Unit of Work 或 Event Sourcing——分層是為了讓修改可控,不是為了掛上名詞。

5. Modeling Scanner Workflow with XState

Frontend 使用 Vue 3 + TypeScript,狀態管理用 Pinia,表格用 AG Grid,測試用 Vitest 與 Playwright。但真正的難題不是技術選型,而是:這不是一般的 CRUD form。

現場 operator 的操作方式跟辦公室完全不同。他們主要使用 scanner、tablet 或 production terminal,輸入來源是 keyboard-like 的 barcode input,鍵盤打字越少越好。而 workflow 本身包含多步操作、validation、retry、error handling、input focus 與 state transition。

如果這種流程繼續依靠 boolean flag、if / else、component state、watch 與 event handler 拼湊,結果是可預期的:沒有人能說清楚「現在到底在哪一個狀態」,也沒辦法測。所以我把 UI workflow 用 XState 明確建模成 state machine:每個狀態定義了允許的事件與轉移條件,UI 只是狀態的投影。非法操作在狀態機層面就無法發生,不需要在每個按鈕上重複防禦;狀態轉移本身也成為可讀的流程文件。

6. A Real Production Bug: Scanner Focus Race Condition

Problem. 現場回報操作有時候會「跳掉」:掃描之後,focus 跑到了不該去的欄位,畫面呈現的狀態和操作者以為的狀態不一致。這種問題在我自己的開發機上不容易重現。

Investigation. 關鍵線索是「只在某些裝置上比較容易發生」。Scanner 在掃描完成後會非常快地送出 Enter / Tab,而在效能較慢的裝置上,這個速度會超過前端處理輸入的速度。

RACE CONDITION (ABSTRACT)

Barcode input
↓
State update 尚未完成
↓
Enter / Tab 已經觸發
↓
Focus 提前跳到下一個欄位

抽象時序示意,不包含實際 barcode 格式、欄位名稱或操作碼。

Root Cause. 這是一個典型的 race condition:輸入事件的到達順序,和應用程式內部狀態更新完成的順序,並沒有被任何東西保證。當 focus 的移動由瀏覽器的預設行為決定,而不是由流程的狀態決定時,慢一點的裝置就會把這個隱性假設戳破。

Design Improvement. 這個問題真正的價值,是它把幾件原本隱性的東西推到檯面上,成為後續設計時必須明確處理的項目:

  • Explicit state transition:下一步該不該發生,由狀態機決定,不由輸入事件的到達順序決定。
  • Focus control:focus 是流程狀態的一部分,應該被明確控制,而不是交給預設行為。
  • Validation timing:驗證在哪個時間點完成、完成前的輸入怎麼處理,必須是明確的決定。
  • Retry:被擋下來之後要怎麼回到可操作的狀態,是流程的一部分,不是例外處理。

Regression Test. 這類問題最不能接受的是「修好之後過幾個月又跑回來」。scanner 的互動、state transition 與 focus 行為屬於需要用 automated E2E testing 守住的範圍——因為它幾乎不可能靠人工測試穩定重現。至於每一項修正的實作細節,我不在這裡逐一宣稱,避免把「應該處理的方向」寫成「已經全部完成的事實」。

7. Data Normalization Across Factory Equipment

系統需要整合不同設備產出的資料,例如 Panasonic 與 Fuji 的設備檔案。不同廠牌在 format、命名方式、編碼與資料表示法上都可能不同;實際檔案也常見 GBK 等非 UTF-8 編碼,如果在讀取階段沒有做編碼判斷與轉換,後面的比對就會在「看起來只是亂碼」的地方靜靜失敗。

所以原則很明確:不讓 business logic 直接面對每一種 raw format。差異在邊界被吸收掉——先做 parsing 與 normalization,轉換成內部一致的 representation,domain 層只面對乾淨的模型。解析失敗或資料不完整的狀況會被明確標記與回報,而不是靜默略過。

SYSTEM OVERVIEW (ABSTRACT)

Production Operator
↓
Vue 3 / TypeScript
↓
Workflow / XState
↓
FastAPI
↓
Application Layer
↓
Domain / Repository
↓
Database
Factory Data Sources
↓
Parsing / Normalization
↓
→ Application Layer

抽象架構示意,不包含實際的 hostname、network topology、資料表或外部系統位址。

8. Designing for Traceability

系統從單純的防錯,逐步增加了 operation record 與 traceability:保存必要的 production / operation record,讓問題發生時可以回頭確認操作發生的時間、操作的角色、當下的 production context、material operation 與 operation history。

做這件事的過程中,最重要的一個設計概念是區分兩種資料:

CURRENT STATE

目前系統/設備所使用的設定,會隨著生產調整而改變。

HISTORICAL RECORD

過去某一次生產真正發生時的狀態,不應該因為之後 current state 被修改而跟著變動。

這是 traceability 的核心原則,也是很容易寫錯的地方:如果歷史紀錄只是指向「目前的設定」,那它就不是紀錄,而是一個會隨時間變臉的參照。要能回答「那天到底發生了什麼」,歷史就必須是被保存下來的事實,而不是被重新計算出來的結果。

同一個原則也適用在「生產模式」這類 context:正式生產或試產,會影響事後怎麼判讀一筆紀錄。它原本只存在前端的網址參數裡,換頁或重新整理就可能遺失;後來改成在建立生產 session 時就寫進後端,之後查紀錄時才知道那一批是在什麼模式下做的。

9. Moving a Guard Rule to the Backend

盤點防呆規則時,我發現有一條規則只存在於前端:「掃到的料,有沒有出現在這張料站表的任何一個位置上」。後端只負責另一個問題——「這捲料符不符合這個位置的期待」,而要問這個問題,前端得先挑出位置。當答案是「整張表都對不到」時,前端直接擋下,後端從頭到尾不知道有人掃了一捲不該掃的料。

這造成三個問題:

  • 規則無法被強制:畫面有 bug、現場還開著舊版前端、或有人直接呼叫 API,這條檢查就不存在。
  • 沒有稽核軌跡:被擋下來的掃描沒有任何紀錄,「掃了三次才掃對」這種事查不到,也無法統計哪些材料最常被掃錯。
  • 兩個廠牌兩套實作:同一條規則在前端寫了兩次,遲早會漂移。

做法是把規則收進後端的 domain 層,兩個廠牌共用同一份;前端在掃到材料時先向後端查詢資格,後端寫入掃描紀錄的路徑裡也做同一道檢查,繞過查詢直接寫入也過不去。每一次掃描,不論放行或被拒,都會留下一筆可查詢、可統計的紀錄。

GUARD RULE (ABSTRACT)

Frontend local check
連續掃碼時的即時回饋
↓
Backend eligibility (domain rule)
兩者不一致時以後端為準
↓
Scan attempt record
放行與被拒都留紀錄

抽象示意,不包含實際的 API 路徑、錯誤碼或資料表名稱。

比較值得說的是導入方式。這條規則一旦在後端開始阻斷,判斷錯了就會直接卡住產線,所以阻斷開關預設是關閉的:先只記錄、不阻斷。等紀錄累積到足以確認前後端的判斷一致,再打開阻斷。前端的本地檢查也保留下來,負責連續掃碼時的即時回饋;兩邊結果不一致時以後端為準;後端暫時連不上時不阻擋、退回本地判斷。最後這一點是刻意的取捨——在過渡期,我選擇讓產線不因網路問題停下來,而不是讓防呆在每一種失敗情境下都最嚴格。

10. Testing a Production Workflow

Backend 用 pytest 撰寫 unit test 與 API / integration test;frontend 用 Vitest 做單元測試,用 Playwright 做 E2E。測試的重點不是覆蓋率數字,而是這些情境能不能被穩定地重現:

  • Normal workflow:正常情況下流程可以完整走完。
  • Validation failure:該被擋下來的一定要被擋下來,而且說得出理由。
  • Retry:被擋下來之後能不能回到可操作的狀態。
  • Scanner interaction:以 scanner 為主的輸入行為。
  • State transition:狀態轉移的條件是否如預期。
  • Regression prevention:修好的問題不要再回來。

對我來說,testability 本身就是架構的一部分。前面之所以要拆分層次、之所以要把 workflow 變成 state machine,很大一部分的理由就是為了讓這些情境「測得起來」。

本文不列出測試案例數量。那個數字必須以 repository 的最新結果為準,在我重新確認之前,我不會把它寫進公開文章裡。

11. Extending to a New Machine Type: Prototype First

下一個需求,是把另一類機台納入防錯料系統。這類機台上的是晶圓與盒裝晶片,不是一般的捲裝料;材料型態決定要從機台的哪一面上料,而且一開始連正式的料站表都沒有。需求來自產線會議紀錄——但會議紀錄上的一句話,常常可以對應到好幾種完全不同的畫面與流程。

所以我沒有直接在正式 repo 裡動手,而是先做一份獨立的可互動原型:只跑在前端、全部是假資料,不連後端、ERP、資料庫或設備,也不碰既有專案的程式。產線可以直接點、直接拿掃碼槍掃,看到的就是之後要上線的流程;每開完一次會,原型就跟著改一版。原型是我和 AI 協作快速迭代出來的,另外也整理成一份不需要 AI、自己就能修改與重新 build 的本機版——需求還在變動的階段,改一次畫面的成本必須夠低。

原型幫我提早看到了幾件,光看會議紀錄不會發現的事:

  • 資料本身的結構限制:用現有的 ERP 查詢,從工單追不到這一類材料,反過來也追不回工單。所以「掃到的料不在工單清單上」在這裡不是異常,而是結構使然——照一般做法寫防呆,會擋死每一片料。第一版因此設計了「主管首件確認+系統自行累積對照表」;等到會議決議料站表由系統管理,就把這條人工放行的路整個拿掉,全部改為硬擋,不留後門。
  • 一致性比新設計重要:後來的回饋是「要跟既有機台的頁面一致」。料站表改回和既有機台同一套上傳流程與版次規則;原型裡為了展示而加的「下一步提示」與「掃碼紀錄」,被標成 Demo 輔助、正式版移除,改由欄位本身的標籤與提示文字告訴操作者下一步要掃什麼。對現場來說,新機台不該多出一套需要重新學習的操作方式。
  • 「數量」其實有好幾種:討論裡的數量,實際上是投入量、實際使用量(估算)、跨工單的累計使用量,以及 ERP 的庫存。它們的來源與更新時間都不同,原型把它們拆成不同欄位,並明確寫下哪一個不能拿來反推另一個。
  • Historical record 的具體做法:生產前設定送出時,當下那一版料站表會被快照進批號。之後料站表被覆蓋或刪除,都不影響已經建立的批號——這就是第 8 節 current state 與 historical record 的區分,落在新功能上的樣子。
  • 同一個編號,在不同情境下是不同的東西:產線料站表上記的「成品號」,和工單系統帶出的成品號對不起來——對產線來說那是成品,對那張工單來說其實是半成品。如果讓系統直接用編號自動挑料站表,挑錯了,防呆就會拿錯的基準去比對,而且不會報錯。所以正式版不靠編號自動猜:料站表由使用者在生產前設定時明確選定,選定的那一版綁進批號。

原型本身也用 Playwright 實際驅動瀏覽器做回歸測試,而且真的抓到兩個和第 6 節同一家族的時序問題:

  • 快速連續掃碼時,前後兩筆條碼黏在一起:畫面重建輸入框時,舊的輸入元素在被替換之後才送出事件,把剛清空的欄位又補回舊值。修法是只接受「目前 focus 中的元素」送出的事件,並用連續快速掃碼的壓力測試守住。
  • 同一秒內先上料、再過站,新材料被算進之前的產出:事件時間只記到秒,排序不穩定。改用單調遞增的毫秒時鐘,並把這個情境固定成測試案例。這也直接變成正式版的一條資料規格:事件時間必須到毫秒,或帶流水號。

12. Planning a Phased Rollout Around External Dependencies

需求收斂之後,下一個問題是「什麼時候能上線」。答案有一大半不在我手上:數量相關的資料,要等 ERP 端提供新的 API。所以上線計畫不是依功能大小切,而是依「這一段在等哪一個外部相依」切成三段:

PHASE 1 · GUARD & TRACE

防呆與追溯

不依賴新的外部 API。錯料硬擋;每一份材料在哪張工單、哪台機台、哪個槽位、什麼時間上下機,都查得到。

PHASE 2 · QUANTITY FROM ERP

數量接上 ERP

等 ERP 的發料與庫存 API。投入量直接來自 ERP,不再人工輸入;防呆補上入庫與發料檢查。

PHASE 3 · ACTUAL USAGE

實際使用量

等產出統計 API。估算實際使用量與跨工單累計,並看出投入與使用之間的差異。

這樣切的關鍵是一個判斷:數量是「補值」,不是「補流程」。投入量可以事後用「工單 × 材料」向 ERP 回補,實際使用量可以用產出數回算——前提是第一階段就把位置關係、時間順序與需要的欄位先留好。所以第一階段不必等任何外部時程,就能提供防呆的價值;後面兩段只補數字、不改結構;每一段也只綁一個外部相依,不會互相卡住。上線方式則是先挑一台機台,與現行作法並行試跑,沒問題再推到其他機台。

第一階段本身再拆成四個里程碑:料站表維護 → 上料助手帶出料站表 → 上料頁的掃碼與防呆鏈 → 管理與追溯頁。料站表維護可以先單獨上線,讓產線一邊建資料、開發一邊往下做,等防呆頁完成時料站表已經有內容可用。至於「第一階段能不能在年底前驗收」,評估的結論是可以,但要三個條件同時成立:把幾個非必要功能延後(資料結構先預留,之後再補)、驗收與試跑和最後一個里程碑重疊進行、這段期間的人力能專注在這個專案上。少一個條件,時程就不成立——所以這是一個「有條件的可行」,而不是一個承諾的日期。

13. From Prototype to Production Code

需求收斂後,新機台在 2026 年 10 月進入正式開發,施工順序照里程碑走:先做料站表的建立與維護,再做生產前設定(把選定的料站表版本綁進批號),接著是上料防呆頁。這些目前都還在開發分支上,尚未上線。

這一段裡比較值得記下來的決定:

  • 沿用同一套流程,而不是複製第三份:上料頁的掃料、掃槽位、換料、接料、卸除與巡檢,和既有兩個廠牌共用同一個 workflow core。原本只屬於其中一個廠牌、但其實是共用邏輯的部分,被抽到共用位置;既有頁面的行為用原本的 E2E 測試確認沒有改變。新機台只補自己特有的東西:槽位解析、機檯面向,以及專屬的防呆規則。
  • 防呆照第 9 節的原則雙重把關:材料型態要放對機台的哪一面、材料要在這批綁定的料站表上、槽位要放料站表指定的料——前端先擋,給連續掃碼時的即時回饋;後端在寫入路徑上再擋一次,並回傳結構化的拒絕原因,讓畫面直接顯示「被擋在哪裡」。
  • 試產模式的語意要跟既有系統一致:開發中我一度讓試產批號放寬「料放到錯的槽位」這條規則,對照既有兩個廠牌的試產語意後發現不對——試產放寬的只有「ERP 查不到的料」(例如廠商的試產料),從來不包括「查得到、但放錯位置的料」。所以把那次修改整個退回,規則定為:查得到但錯 → 正式、試產都擋;查不到 → 正式擋,試產放行,並在紀錄上標記「ERP 查無」。
  • 識別碼的數量關係要先和實體對過:原本設計「同一個包裝條碼同時只能在一個槽位」,後來確認一個包裝裡可能有好幾盤、好幾盒,卻只有一個條碼。這條規則照寫會擋掉正常操作,所以拿掉,改由產線人員自行注意。看起來很合理的唯一性限制,背後都有「一個條碼對應一個實體」的前提——這個前提要先跟現場確認。
  • 少一個畫面就少一步操作:原本送出生產前設定後,會先進一頁唯讀的確認頁。對拿著掃碼槍的操作者來說那只是多一步,所以拿掉,送出後直接進上料頁;一次開了好幾批時,在頁內切換批號。
  • 會影響資料模型的事,不用猜的:同一槽位兩面不同料號、料站表在送出後改版、開始生產的放行條件與統計資料怎麼存——這些先維持保守的行為,列成待決事項,等跟產線確認後才動資料表。

下一段是開始生產與生產頁,接著把新機台接進生產畫面管理與工單追溯;第二、三階段則等 ERP 端的 API。

14. Where It Stands Today

目前可以確定的狀態是:系統實際運作於 SMT production environment;功能上從單純防錯逐步擴充到 operation record 與 traceability;backend architecture 持續改善;frontend workflow 逐步改由 state machine 管理;automated testing 已經建立;production issue 由我實際處理;系統的可維護性有改善。

使用規模方面,我只寫我能確定的:每天大約 40~50 次相關的換料/掃碼操作。這是操作次數,不是 API request 數、不是使用者人數、不是產量,也不是錯誤次數。

最近一輪的變更,重點是讓系統「更容易被確認」:

  • 版本可被確認:前後端各自以單一來源管理語意化版本。畫面上直接看得到目前部署的前端版本與 build 資訊,後端也提供版本查詢;出問題時,第一個問題「現場跑的到底是哪一版」不必再用猜的。
  • 發布由人把關:production 不會因為 push 自動部署。完成 pre-production 驗證後,把版本號、commit、changelog 與 build 編號交給 Release Controller,核對後才手動發布正式環境。
  • Pipeline 更穩定:CI 安裝相依套件的步驟加上重試,減少網路抖動造成的 pipeline 失敗。
  • UI 基礎整理:顏色與尺寸收斂成 design tokens;導覽列依功能重新分組,並預留新機台的入口。
  • 樣式整理,畫面零差異:散落在全域的表格樣式拆回各自的模組、重複的規則合併成一份、只有少數頁面用到的規則收回那些頁面。原則是最小差異——每改一個模組就驗一次,樣式相依太複雜的地方寧可保留現狀。驗證不只看 build 有沒有過:比對編譯後的 CSS,並在受影響的頁面狀態下擷取 computed style 與截圖逐像素比對,確認畫面沒有任何變化。一個既有的樣式外洩問題則刻意不在這次一起修,而是記錄成已知問題,避免一次改動夾帶兩種風險。

新機台的擴充目前在正式開發階段:分階段上線計畫已經完成,料站表、生產前設定與上料防呆頁的第一版已在開發分支上完成並通過自動化檢查,下一段是開始生產與生產頁。這些都還沒有上線——第 11~13 節描述的是設計與開發進度,不是已經在產線使用的功能;第一階段的驗收時程,則取決於第 12 節列的那幾個條件。

關於成效,我刻意不給數字。錯料率下降多少、省下多少工時、ROI 多少——這些我目前沒有足夠的 before / after baseline 與營運成本資料可以支撐,因此不對成本與效率的改善幅度做推測。這部分尚未量化。

15. What I Learned

  • Legacy system 不一定需要重寫。很多時候「能不能持續改善」比「能不能重寫」更重要,也更難。
  • Workflow 應該被明確建模。用 boolean 拼湊出來的流程,只是把複雜度藏起來,不是解決它。
  • Production debugging 和本地開發完全是兩回事。本機重現不了的問題,往往才是真正的問題。
  • Testability 本身就是架構的一部分。測不了的設計,遲早會變成不敢改的設計。
  • Infrastructure limitation 也是 system design 的 constraint。權限與變更流程不是藉口,是設計條件。
  • 規則移到後端時,先記錄、再阻斷。先用紀錄證明判斷是對的,再讓它有權力擋下產線。
  • 原型是溝通需求的工具,不是半成品。可以點、可以掃的東西,比會議紀錄更快暴露誤解與資料上的限制。
  • 上線計畫要依外部相依來切。先交付不用等別人的那一段,並把之後要回補的欄位先留好。
  • 放寬規則之前,先確認既有系統的語意。同一個「試產模式」在新舊機台上必須代表同一件事,否則現場會被不一致的行為搞混。
  • 唯一性限制背後都有一個實體假設。先確認「一個識別碼對應幾個東西」,再決定要不要擋。
  • 維運經驗讓我更重視 observability。但我不會假裝這套系統已經導入了它——那是我接下來想補的東西,不是已經做完的成果。

Tech Stack

FRONTEND

Vue 3 · TypeScript · Pinia · XState · AG Grid · Vitest · Playwright

BACKEND & DATA

Python · FastAPI · SQLAlchemy · Alembic · pytest · CSV parsing · Encoding normalization

本文只描述系統設計與工程取捨,不包含任何公司內部資料、客戶資訊、生產數據、barcode 規則、料號、站別編碼或資料庫結構。