概觀
Alforse 由兩個共用同一套製造資料模型的應用程式組成:
兩者背後是同一個後端。
如何閱讀本頁
先看系統概覽,再看完整追蹤一次寫入;後者以前者為前提。之後各節自成一體,可以任意順序閱讀。 技術術語會連結到頁尾詞彙表中的簡短定義,例如 Postgres。系統概覽
所有內容都放在單一 monorepo 中,以 pnpm workspaces 與 Turborepo 運作,分為兩層。畫面
兩個 React Router 應用程式apps/erp 與 apps/mes 在伺服器端渲染其 React。它們共用的程式碼打包在 packages/web/* 之下;其中包括 auth、database、api、jobs、planning、react、utils 與 workflows。
後端
應用程式主機只有一個,即apps/backend。除了提供 API 之外,它還組合了以下原生層:
所有資料都存放在同一個 Postgres 資料庫中,它是記錄系統。它依公司套用列級權限,也包含供給背景作業的交易式發件匣。每個部分都在逐層說明中解釋。
塑造一切的五項特性
- 畫面從不直接存取資料庫。 ERP 與 MES 的伺服器端程式碼與掛載於
/api/web的後端閘道溝通。閘道理解畫面最初撰寫時所用的同一套 REST、RPC、驗證與儲存協定,並把每個呼叫轉交給位於kernel/、modules/、products/或packages/database/之一的原生擁有者。 - 資料庫讓租戶彼此隔離。 列級安全性意味著一家公司的資料列無法被另一家公司存取;驗證與授權說明其做法。
- 相關的寫入一起提交。 原生擁有者參與呼叫端的工作單元,其引發的任何事件都在同一筆交易中寫入發件匣。
- 沒有獨立的工作系統。 後端程序內的執行器處理排程與佇列中的任務,另有一個投遞工作者負責送出通知與 webhook。詳情見寫入之後。
- CAD 處理同樣在後端內。 幾何轉換與組裝規劃都在
/api/web/assembler之後進行;不需要另外部署其他東西。
完整追蹤一次寫入
範例:採購人員儲存一張新的採購單。請跟著這個請求走過表單、驗證、權限檢查、寫入資料庫、文件編號配置,最後到重新整理的畫面。以一張圖呈現整趟旅程
資料列一儲存完成,請求就結束了。通知與 webhook 等副作用稍後才發生,由發件匣取出處理。程式碼中的逐步過程
1. 按鈕
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535
permissions.can(...) 會隱藏使用者不能做的事,這純粹是一種體貼;真正有效的判斷是在伺服器上做出的。連結一律透過 path.to.*,也就是 apps/erp/app/utils/path.ts 中的路由登錄表,因此應用程式的 URL 絕不會用手打。
2. 表單
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116
表單元件是 ValidatedForm(套件 @alforse/web-form)。底層它就是一般的 HTML 表單;在其上,它會顯示 schema 的錯誤,無論錯誤來自瀏覽器或伺服器。驗證器與預設值透過 props 傳入。
3. Schema
apps/erp/app/modules/purchasing/purchasing.models.ts:146
規則寫在 purchaseOrderValidator 中,它是一個 zod 物件,同時產生執行期檢查與對應的 TypeScript 型別。HTML 表單值由 zod-form-data(zfd)整理:例如 zfd.text() 會把空欄位的 "" 對應為 undefined。瀏覽器與伺服器共用這同一個 schema,因此套用相同的規則。
4. 路由
apps/erp/app/routes/x+/purchase-order+/new.tsx:30
apps/erp/app/routes/ 底下的路由檔案可以匯出三樣與此流程相關的東西:
loader 與 action 都不會進入瀏覽器的 bundle,這就是兩者可以使用僅限伺服器的程式碼的原因。
5. 服務函式
apps/erp/app/modules/purchasing/purchasing.service.ts:1663(insertPurchaseOrder)
資料存取委派給服務函式,絕不在路由中進行。每個服務函式的第一個參數是 client,並以 supabase-js 熟悉的 { data, error } 形式回應。該 client 由 getAlforseServiceRole()(packages/web/auth/src/lib/supabase/client.server.ts:13)建立;儘管名稱是歷史遺留,它是以本次請求登入的使用者身分,與位於 /api/web 的原生閘道溝通。一個 client 只屬於一次請求,無法在其他請求中使用。
6. 資料庫中的編號
client.rpc("get_next_sequence", ...) 這個呼叫是一個 RPC,由編號模組 modules/master-document-numbering/ 回應。該模組在與送出動作相同的交易內,領取下一個計數值,並對它套用公司的前綴、補零與日期代碼。編號序列說明其格式。
7. 回到畫面
最後,action 執行throw redirect(...)。React Router 接著載入目的地:其 loader 透過閘道取得最新狀態,頁面隨之渲染。另外,每次非 GET 的送出之後,React Router 會自行重新執行目前頁面的 loader。
逐層說明
瀏覽器與路由檔案
兩個畫面應用程式都建立在框架模式的 React Router v7 上,並在伺服器端渲染 React,因此單一路由檔案可同時包含資料載入與 UI。ERP 或 MES 畫面不使用 Remix 與 Next.js;建置由 Vite 負責,樣式使用 Tailwind v4。只有apps/backend 不同,它是一個 Next.js 應用程式主機。
路由即檔案
apps/erp/app/routes/ 有超過 1,600 個檔案,直接對應到 URL 空間:多虧 remix-flat-routes(設定於 apps/erp/app/routes.ts),資料夾結構_就是_ URL 結構。
最上層資料夾依對象區分應用程式:
讀取資料
頁面的資料來自伺服器上的loader,它呼叫服務函式並回傳 JSON;元件以 useLoaderData<typeof loader>() 取得。每個頁面都需要的資料(使用者、公司、權限與功能旗標)由 apps/erp/app/routes/x+/_layout.tsx 載入一次。元件透過 apps/erp/app/hooks/ 中的 hooks 取得,例如 useUser()、usePermissions() 與 useCompanySettings()。
從 UI 寫入資料
表單 post 到其路由的action,與追蹤的那次寫入完全相同。當使用者應該留在頁面上時,例如重新排序資料列、行內編輯或刪除,請改用 useFetcher() 送出;頁面事後仍會重新驗證。
共用 UI
撰寫新元件之前,請先看這些地方:瀏覽器中的狀態
瀏覽器保存的狀態很少。各處通用的參照清單(品項、客戶、供應商、人員)存放在apps/erp/app/stores/ 下的 store 中,並由 RealtimeDataProvider 重新整理。一次性的 UI 狀態放在 zustand。使用者及其權限的來源一律是路由 loader。
翻譯
文字以 Lingui 翻譯:將字串包在t`...` 或 <Trans> 中,再執行 pnpm lingui:compile 建置語系目錄。
MES
MES 共用同一套技術堆疊,但結構較扁平。它沒有modules/ 資料夾;其服務位於 apps/mes/app/services/,約 100 個路由位於同一層,UI 的尺寸則是為戴手套的手在平板上操作而設計。
驗證與授權
沒有任何伺服器端入口會略過requirePermissions,它同時處理驗證與授權:
packages/web/auth/src/services/auth.server.ts:152。一次呼叫會經過四個階段:
- 辨識呼叫者。 瀏覽器請求帶有原生 session cookie;公開 API 請求帶有
alforse-key標頭。對於金鑰,原生金鑰擁有者會依路由所需的權限驗證金鑰,並套用速率限制。詳情見 API 金鑰。 - 請求決策。 路由所需的權限,取自
view、create、update、delete,以及選用的role,會被送到後端的原生授權擁有者。它依使用者在所選公司中的授權做出決定。授權由三個來源累加:員工類型、使用者所屬群組,以及直接指派。 - 拒絕不被允許的請求。 沒有有效的 session(沒有或已過期)時,session 會被銷毀,使用者回到登入頁。缺少權限則會產生帶有 Access Denied 訊息的重新導向。如果決策結果所屬的使用者或公司與 session 所持有的不同,請求會因身分不一致而被拒絕。
- 交還上下文。 成功時,路由會取得一個限定於本次請求的 client,以及
companyId、companyGroupId與userId。
多租戶與列級安全性
許多公司共用同一個資料庫,這使 Alforse 成為多租戶。目前啟用哪家公司由 session 決定;URL 不扮演任何角色。 租戶資料表啟用並強制套用列級安全性(RLS);基準遷移packages/database/migrations/0001_baseline.sql 以 ALTER TABLE ... FORCE ROW LEVEL SECURITY 完成此事。在每筆交易開始時,目前的公司與操作者會被存為交易區域設定,每個政策都以這些值檢查資料列。要求另一家公司的記錄,結果為空。
平台與工作區是分開的
工作區生命週期、方案與權益由平台控制平面管理,這是與各客戶工作區分離的安全網域。平台端的權限不會隱含讀取客戶業務記錄的權利。其他類型的使用者
使用入口網站的客戶與供應商只會獲得較窄的權限,任何路由都可以要求{ role: "employee" }。在 share+/ 之下,路由完全不需要登入:存取取決於 URL 中攜帶的難以猜測的權杖。
寫入資料
ERP 與 MES 中的畫面保留了它們最初撰寫時所用的資料存取風格。後端不發放資料庫連線,而是把每種風格轉換成原生擁有者。共有四條路徑。1. 閘道 client
2. 具名原生操作
許多畫面透過packages/web/database/src/client.ts 中的 getPostgresClient,以 Kysely 描述查詢。這個建構器從不產生 SQL。它記錄查詢的形狀,並把它對應到 packages/web/database/native-operations/ 登錄表中恰好一個具名操作,該處有 purchasing.ts、inventory.ts、production-* 與 quality.ts 等檔案。對應不到任何操作的查詢會被拒絕,且拒絕訊息會指出該查詢。像拖曳重新排序(apps/erp/app/modules/shared/sort-order.ts)這類多列寫入,就靠這點達成要嘛全成、要嘛全敗。
3. 原生擁有者與工作單元
重要的業務寫入實作為products/* 與 packages/database/src/ 中的類別;例如出貨或發票過帳、發料,以及完成生產數量。每一個都在一個工作單元內執行,也就是整個業務交易共用一條 Postgres 連線,並在其上設定好操作者與公司。本體中的任何失敗都會讓整體回滾,而預計在寫入成功後觸發的 hook,只有在提交完成之後才會觸發。
4. RPC
最窄的路徑是以client.rpc("name", args) 叫用的具名函式;追蹤的那次寫入中的 get_next_sequence 就是一例。每個 RPC 都有一個對應的原生擁有者,或出現在一份明確的未對應 RPC 清單上。
規劃是獨立的領域:MRP 與生產排程實作在 packages/web/planning 中,並搭配規劃擁有者。
資料庫
資料庫是 PostgreSQL,前面沒有任何東西:沒有 PostgREST、沒有 Supabase 專案、沒有 edge functions。後端是資料庫唯一的 client,並以專用角色連線,即一個受 RLS 約束的執行期角色,以及排程器與投遞工作者各自獨立的角色。遷移
每個遷移是packages/database/migrations/ 中一個編號的 SQL 檔案。
規則記載於
packages/database/migrations/README.md;執行器是 packages/database/src/migrations.ts。
Schema 與資料表
基準帶來大約 740 個資料表,分布於兩個 schema。platform 服務控制平面與原生擁有者;web 是畫面讀取所用的契約 schema。檢視表提供讀取面。
識別碼
ID 由應用程式產生,而非由 Postgres 產生,格式為{prefix}_{base58(uuidv7)},並存放在 text 主鍵中。每種記錄類型擁有其他類型不會使用的前綴。結果可依建立時間排序,同時在日誌行或 URL 中仍然易讀。
讀取與寫入
規則是透過擁有者寫入,從關聯讀取。寫入只儲存來源資料。畫面在其之上顯示的一切,例如合計、名稱與縮圖,都由讀取關聯推導而來。檔案
上傳的檔案寫入物件儲存,畫面透過/api/web/storage 路由存取。支援三種提供者類型:Cloudflare R2、任何與 S3 相容的儲存,以及客戶儲存閘道(見 apps/backend/src/production/blob-storage-provider.ts 及其鄰近檔案)。每家公司都有自己的金鑰命名空間與配額,並可加上備份複製設定檔。
寫入之後:事件與背景作業
負責寫入的程式碼不會同時執行後續工作。相反地,擁有者會在進行業務變更的那筆交易內,把網域事件加入交易式發件匣(platform."outbox")。如果該交易回滾,事件也隨之消失;如果提交,事件就保證存在。
投遞保證
投遞為至少一次(at-least-once),這就是處理常式被寫成冪等的原因。暫時性失敗會依帶有嘗試次數上限與有上限的指數退避的政策重試;永久性失敗則停止。kernel/events/ 包含事件匯流排、該重試政策與 webhook 投遞。
原生執行器
背景任務由執行器執行,它是後端程序本身的一部分(apps/backend/src/runner/)。每個任務都經過四個階段:
- 接收。 在
platform."backgroundTaskRun"新增一列,指出公司、任務與冪等鍵。 - 領取。 執行器取得該列的租約。
- 執行。 為該任務註冊的處理常式,在綁定該公司的交易中執行。
- 記錄。 該次執行最終狀態為
pending、running、retrying、succeeded或deadLettered。
alforse/company-export 這類任務名稱與 packages/web/jobs 套件保留了這些名稱,但其背後沒有外部的工作服務。
排程工作
apps/backend/src/runner/native-runner-schedules.ts 宣告排程。其 cron 表達式以 UTC 解讀,每次觸發都會分散到所有啟用中的公司。
投遞有其自己的排程:通知中繼每分鐘執行,維護到期派工每兩分鐘執行,另有清理 blob 與備份新增檔案的其他工作。
通知
通知會進入每位使用者的收件匣。後端發布變更推送,例如私有主題user:<userId>:notification;推送會告訴 ERP 哪些資料列變更了,但不包含其內容,ERP 再自行取得所需資料。電子郵件則透過所設定的任一交易型郵件傳輸送出。
其他引擎
工作流程
工作流程是客戶在拖放畫布上自行設計的自動化。不應與執行器內建的任務混淆:
定義由
packages/web/workflows 與 kernel/workflows 中的程式碼描述並驗證。每次執行及其內的每個步驟,都由引擎記錄以供日後檢視。有兩個執行器排程支援它:每十分鐘一次的工作流程後備機制,以及執行歷史的保留。功能本身在工作流程中說明。
CAD 與組裝規劃
這裡有兩項工作。STEP 格式的 CAD 檔案會轉成瀏覽器可顯示的 3D 模型,並計算出避免碰撞的拆解順序,用來驅動組裝說明。程式碼位於後端的apps/backend/src/cad/:occt-import-js(編譯為 WebAssembly 的 OpenCASCADE)負責解析幾何,運動規劃器則在旁邊。
兩個應用程式都在 /api/web/assembler 呼叫它,並以 bearer 金鑰驗證,再以 packages/web/viewer 顯示輸出。耗時較長的轉換會作為執行器任務執行;呼叫的頁面可以等待完成或輪詢。
詞彙表
本頁所連結術語的簡短定義,依 A 到 Z 排列。Action
路由檔案中用來處理送出表單的匯出。它在伺服器上執行,絕不會送到瀏覽器。Kysely
TypeScript 的 SQL 查詢建構器。在此處它只描述一個操作;後端把該描述轉換成具名原生操作。Lingui
用於翻譯的函式庫。程式碼中標記的字串會被抽取成每種語言一個檔案,供譯者填寫。Loader
路由檔案中用來收集其頁面所顯示資料的匯出。與 action 一樣僅在伺服器端執行。Migration(遷移)
一個編號的 SQL 檔案,內含一項 schema 變更。基準加上依序的後續檔案合在一起,就是 schema。Monorepo
單一 Git 儲存庫,容納多個應用程式與共用函式庫,並一起版本控管與發行。MRP
物料需求規劃(Material Requirements Planning)的縮寫:根據需求與現有庫存,決定要採購或生產什麼,以及何時完成。Multi-tenancy(多租戶)
以一個資料庫與一個執行中的應用程式服務許多獨立的客戶公司,同時每家公司只看得到自己的資料。Postgres
PostgreSQL,Alforse 所建構於其上的關聯式資料庫。RLS
列級安全性(Row Level Security)。Postgres 會自動把每個資料表的規則附加到每個查詢上,正是這項規則讓各公司看不到彼此的資料。RPC
在本頁中:從應用程式碼透過client.rpc("name", args) 叫用具名函式,由後端轉給原生擁有者。
Turborepo
透過追蹤各套件之間的相依關係來協調建置,使pnpm build 只重做有變動的部分。