跳轉到主要內容
本頁寫給要與 Alforse 整合,或想了解畫面背後運作方式的開發人員。內容先追蹤一次從瀏覽器寫入資料庫、再傳回的完整過程,然後逐層說明。

概觀

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 資料庫中,它是記錄系統。它依公司套用列級權限,也包含供給背景作業的交易式發件匣。每個部分都在逐層說明中解釋。

塑造一切的五項特性

  1. 畫面從不直接存取資料庫。 ERP 與 MES 的伺服器端程式碼與掛載於 /api/web 的後端閘道溝通。閘道理解畫面最初撰寫時所用的同一套 REST、RPC、驗證與儲存協定,並把每個呼叫轉交給位於 kernel/、modules/、products/ 或 packages/database/ 之一的原生擁有者。
  2. 資料庫讓租戶彼此隔離。 列級安全性意味著一家公司的資料列無法被另一家公司存取;驗證與授權說明其做法。
  3. 相關的寫入一起提交。 原生擁有者參與呼叫端的工作單元,其引發的任何事件都在同一筆交易中寫入發件匣。
  4. 沒有獨立的工作系統。 後端程序內的執行器處理排程與佇列中的任務,另有一個投遞工作者負責送出通知與 webhook。詳情見寫入之後。
  5. CAD 處理同樣在後端內。 幾何轉換與組裝規劃都在 /api/web/assembler 之後進行;不需要另外部署其他東西。

完整追蹤一次寫入

範例:採購人員儲存一張新的採購單。請跟著這個請求走過表單、驗證、權限檢查、寫入資料庫、文件編號配置,最後到重新整理的畫面。

以一張圖呈現整趟旅程

資料列一儲存完成,請求就結束了。通知與 webhook 等副作用稍後才發生,由發件匣取出處理。

程式碼中的逐步過程

1. 按鈕

apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535
請注意,這是通往表單的連結,而不是提交按鈕。在 UI 中,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
Alforse 中的寫入都遵循同一個順序:
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 只屬於一次請求,無法在其他請求中使用。
服務接著會儲存該訂單的交貨與付款資料列。其中每個 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。一次呼叫會經過四個階段:
  1. 辨識呼叫者。 瀏覽器請求帶有原生 session cookie;公開 API 請求帶有 alforse-key 標頭。對於金鑰,原生金鑰擁有者會依路由所需的權限驗證金鑰,並套用速率限制。詳情見 API 金鑰。
  2. 請求決策。 路由所需的權限,取自 view、create、update、delete,以及選用的 role,會被送到後端的原生授權擁有者。它依使用者在所選公司中的授權做出決定。授權由三個來源累加:員工類型、使用者所屬群組,以及直接指派。
  3. 拒絕不被允許的請求。 沒有有效的 session(沒有或已過期)時,session 會被銷毀,使用者回到登入頁。缺少權限則會產生帶有 Access Denied 訊息的重新導向。如果決策結果所屬的使用者或公司與 session 所持有的不同,請求會因身分不一致而被拒絕。
  4. 交還上下文。 成功時,路由會取得一個限定於本次請求的 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/)。每個任務都經過四個階段:
  1. 接收。 在 platform."backgroundTaskRun" 新增一列,指出公司、任務與冪等鍵。
  2. 領取。 執行器取得該列的租約。
  3. 執行。 為該任務註冊的處理常式,在綁定該公司的交易中執行。
  4. 記錄。 該次執行最終狀態為 pending、running、retrying、succeeded 或 deadLettered。
失敗的嘗試會重試,直到上限。由於冪等鍵在重試與租約接手過程中保持不變,處理常式可以判斷自己是否已完成工作。畫面仍以熟悉的名稱稱呼這些工作(job),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 只重做有變動的部分。

View(檢視表)

一個儲存的查詢,可以像資料表一樣讀取,但不能寫入。

Vite

建置 ERP 與 MES,並在開發期間提供服務。

zod

讓你只需定義一次資料的形狀,就能同時推導出驗證與 TypeScript 型別。

zustand

極簡的 React 狀態函式庫,在此僅用於可拋棄的 UI 狀態,例如對話框是否顯示。

相關內容