補寫於 2026 年 9 月 2 日,日期掛回原本要發的那一天。文中關於 Frappe HR 的說法,都以我動筆當天讀到的官方文件為基準。

Frappe HR 最強的一段設計,是它把全系統最重要的那張表交給別人管。

Employee。員工主檔。請假要它、打卡要它、薪資單要它、報帳單也要它。這張表不在 frappe/hrms 這個 repo 裡,它住在 ERPNext。

看到這種結構,直覺反應通常是「欠債」:核心資料不在自己手上,等於把命脈交給上游。但順序其實是反的。正因為 Employee 不歸它管,它才有力氣去長出 160 個自己的 DocType,而不用分神維護一張全公司每個模組都在戳的主檔。

一行相依決定了整個架構

hrms/hooks.py:7 寫著這個:

1
required_apps = ["frappe/erpnext"]

裝 hrms 之前,站台上得先有 frappe(框架)跟 erpnext(ERP 業務層)。三層各自端出來的東西是這樣分的:

這一層 它負責給的東西
frappe(框架) DocType、權限、排程、REST API、自動生成的後台介面
erpnext(ERP 業務層) 會計傳票、Payment Entry、Company、Holiday List,以及 Employee 主檔
hrms HR 跟 Payroll,hrms/modules.txt 全文就這兩行

這段歷史 README 自己交代了:HR 本來就長在 ERPNext 裡面,從 version 14 才拆成獨立產品。repo 的 created_at 是 2022-06-08,時間對得上。

所以它在 Frappe 的世界裡有個更精確的名字:app。

它願意換掉的,總共四個 class

不重造主檔,那要怎麼把別人的表改成自己要的樣子?答案在 hrms/hooks.py:158-163

1
2
3
4
5
6
override_doctype_class = {
"Employee": "hrms.overrides.employee_master.EmployeeMaster",
"Timesheet": "hrms.overrides.employee_timesheet.EmployeeTimesheet",
"Payment Entry": "hrms.overrides.employee_payment_entry.EmployeePaymentEntry",
"Project": "hrms.overrides.employee_project.EmployeeProject",
}

整個 controller class 換掉,資料表原封不動。這是最粗暴也最乾淨的擴充方式:行為歸我,schema 歸你。

比例值得記一下:

項目 數量
自己新增的 DocType 160 個
hrms/hr/doctype/ 117 個
hrms/payroll/doctype/ 43 個
其中只是 child table 的 54 個
其中是要送審的單據 53 個
換掉別人的 controller class 4 個

409 行的 hooks.py 就是這份「哪些自己來、哪些接上去」的接線總表。

會計整合更省,整段用宣告的:

1
2
3
4
# hrms/hooks.py:281-295
advance_payment_payable_doctypes = ["Leave Encashment", "Gratuity", "Employee Advance"]
invoice_doctypes = ["Expense Claim"]
period_closing_doctypes = ["Payroll Entry"]

翻成人話:報帳單在 ERPNext 眼裡就是一張 invoice,跑薪資要參與期末結帳,離職金走預付款科目。它沒有實作任何一條會計邏輯,只是把自己的單據登記進上游既有的分類裡。兩套系統對 API 要處理的認證、重試、資料不一致,這裡一件都不用處理,因為根本沒有兩套系統。

那它自己造了什麼

薪資公式那塊,它自己刻了一個 Python sandbox。

Salary Detailconditionformula 兩個欄位,fieldtype 是 Code、options 是 PythonExpression。使用者在後台填的 Python 運算式,直接存進資料庫,跑薪的時候逐列 eval。而且它刻意不用 Frappe 內建的 frappe.safe_eval,docstring 把理由寫得很白:有些國家的薪資公式又大又深,內建那套會撞遞迴上限,所以改用 AST 檢查屬性的輕量版沙箱,代價是它是黑名單而不是白名單,「safe only for admin-authored salary-structure formulas, not arbitrary or end-user input」。

1
2
3
4
5
6
7
8
# 節錄改寫自 hrms/payroll/utils.py:125-144
def _safe_eval(code, eval_globals=None, eval_locals=None):
code = unicodedata.normalize("NFKC", code) # 先正規化,堵全形字元繞過檢查
_check_attributes(code) # AST 走一遍,封鎖 lambda、海象、危險 attribute
eval_globals = eval_globals or {}
eval_globals["__builtins__"] = {}
eval_globals.update({"int": int, "float": float, "long": int, "round": round})
return eval(code, eval_globals, eval_locals) # nosemgrep

行尾那個 # nosemgrep 是跟靜態掃描器攤牌:我知道你要叫,我知道我在幹嘛。這種註解在正式產品裡不多見,多數專案會選擇繞路寫一個更笨但更好看的實作。

有一個細節讓整套設計立起來:算完的金額會用該項目的縮寫塞回 eval context(data[struct_row.abbr] = amount),所以下一條公式引用得到上一條的結果。整份薪資結構因此變成一張依序求值的試算表BS 先算完,SA = BS * 0.5 就有值可用。eval context 的組法也很小心:先把系統裡所有薪資項目的縮寫預設塞成 0,再疊上指派的 basevariable,最後疊上員工欄位。公式引用一個當期沒出現的項目時不會噴 NameError,會拿到 0。

另一個自己造的東西是請假餘額。它不存餘額,存帳本。

Leave Ledger Entry 是 append-only 的分錄表,每一筆額度增減寫一列,餘額用 SQL SUM 算出來。transaction_type 是 Link 到 DocType、transaction_name 是 Dynamic Link,所以同一張帳本收得下配發、請假、換現、調整四種來源。on_cancel 幾乎封死,只有到期分錄能撤,其餘直接 throw。沖銷而非修改,標準會計思路。

最能看出實務磨損的是這裡:程式碼把「帳面餘額」跟「可消耗餘額」分成兩個數。員工帳面上有 10 天,但額度明天就到期,實際只能消耗 1 天。這種區分不會出現在第一版設計裡,是被客訴磨出來的。

分界線在這裡:通用的基礎設施一行都不重寫,領域邏輯裡不講理的那些角落全部自己造。 記帳規則是別人的專業,休假到期怎麼算是它的專業。

這個賭注要付的帳單

把主檔押給上游,代價會在別的地方回來收。

required_apps 是硬相依,想「只要 HR 不要 ERP」做不到,整套會計科目表會跟著進來。三個 app 的大版號綁在一起,寫在 pyproject.toml[tool.bench.frappe-dependencies] 裡:要升就三個一起升,也不能從 v14 直接跳 v16,得照 Frappe 的升級路徑一版一版走。

台灣的部分要講清楚:完全沒有在地化。hrms/regional/ 只有 indiaunited_arab_emirates 兩個目錄,而且分量差很多。印度那份把 PF、Professional Tax、HRA 免稅額、Gratuity 規則、報表都做了,阿聯那份只有一個 57 行的 setup.py,內容是建三條離職金規則。勞保、健保、勞退 6%、二代健保補充保費、所得稅扣繳,一項都沒有,全部要自己用 Salary Component 加 formula 做,或另開 app 用 regional_overrides 抽換。介面也一樣,hrms/locale/zh_TW.po 的 3,033 條裡只有 702 條有翻譯,簡體那份是 2,252 條中的 2,101 條。用繁中介面會看到大量英文夾雜。

社群反應最多的那件事是沒有官方自架正式環境指南。官方文件只有 Frappe Cloud 與 Docker 開發環境,Nginx、SSL、Supervisor、DB 調校一概沒有;open issue #2818 就在要這份文件,截至 2026-08-26 還開著。repo 裡那份 docker/docker-compose.yml 只適合開發,root 密碼寫死 123developer_mode 開著,別拿去正式環境。

還有兩個會影響體感的:跑薪資超過 30 人自動丟背景 queue,UI 按下去不會馬上有結果,Redis worker 數量直接決定跑薪要多久;打卡轉出勤靠 hourly_long 排程,最長一小時延遲,不是即時。

新手最常卡的地雷倒不是這些,是 is_submittable。Leave Allocation、Salary Structure、Salary Structure Assignment、Salary Slip、Attendance 全都是要送審的單據,用 API 建完只 insert()submit(),資料看得到但完全不生效——假期餘額是 0,薪資單算不出來。這條來自 repo 內的測試 helper 與各 DocType 的必填欄位,我沒有在真站台上跑過,欄位名稱請以你安裝的版本為準。

什麼時候這個賭注划算

已經在跑 ERPNext 的話,這題沒什麼好想的。員工、部門、會計科目本來就是同一份資料,整合成本接近零,那 4 個 override 幫你把最後一哩接完。

沒在跑 ERPNext 的話,你買的不是一套 HR 系統,是一整套 ERP 的維運成本,外加一份還沒有人寫的正式環境部署指南。要走這條就得先接受養一個會 bench 的人。

會讓我改口的條件有兩個:官方補上自架正式環境的完整文件,或者 regional_overrides 出現 taiwan 目錄。前者解掉的是維運風險,後者解掉的是「勞健保要自己從零刻」這件事。目前這兩個都不在路線圖上看得到——2026-08-26 實查,open issues 450、open PRs 44,標 feature-request 的 246 筆是標 bug 的 148 筆的 1.66 倍,高反應數的前十名幾乎全是功能許願。這是個活著而且改很兇的專案(8,673 stars、當天還有 push、GPL-3.0),但它還在長,不是功能凍結的成熟品。

順帶一提,hooks.py 裡有一整段 doc_events 掛在 hrms.telemetry.*,正常使用會送使用事件與啟用漏斗。要關得從 Frappe Framework 那端設定,確切設定名稱我沒查到,這點未查證。

下次評估一個 repo,先翻接線檔

一個團隊的資源永遠不夠寫完所有東西,所以真正的設計決策落在另一個問題上:哪些東西你打死都不自己寫。Frappe HR 給的答案很極端:凡是別人已經做對的(記帳、權限、排程、API 生成),一行都不碰;凡是這個領域裡沒有標準答案、每家公司都不一樣的(薪資公式、假期到期規則),做到最深。

下次評估一個開源專案,可以先不看它的功能清單。翻它的 hooks.py,或任何一份等價的接線檔,看它把哪些東西登記成別人的責任。那份清單比 README 誠實得多。

參考來源:frappe/hrms官方文件 docs.frappe.io/hr。本文事實以 2026-08-26 的 develop 分支原始碼與 gh api 機器可讀元資料為準,版本號、issue 數量、翻譯完成度都會隨時間變動。