本章目標與前置條件
讀完後,你應能找到員工、組織、職位、上游同步與在職分析的程式碼,並知道每個操作的資料範圍。前置條件是能閱讀 REST API、UUID、分頁與基本組織樹;不需要先接觸 eHRMS 帳號。
功能現況
| 區塊 | 目前來源可核實的功能 | 前端入口與現況 |
|---|---|---|
| 員工 | list(q、status、include_offboarded、org_unit_id、category)、create、detail、update、profile、stats、leave-records、transitions、password-reset、activate-accounts、bulk-reassign;沒有 delete,離職與復聘走 transitions | /workspace/employees → features/hr-employees:列表、詳情、profile、請假紀錄、帳號開通、CSV 匯出與 source-sync 開關,附多組元件測試 |
| 組織 | org chart(含上游 source_hierarchy)、org units 的 list/create/update(沒有 delete,以 closed 關閉),以及員工掛接與批次改派 | /hr/org → features/hr-organization:樹狀轉換、上游層級顯示與 SWR mutation |
| 職位 | positions 的 list/get/create/update/delete;停用透過 status=disabled | /hr/positions → features/hr-positions:表格、編輯、刪除、cache revalidation,以及送出 dry_run=false 的 eHRMS 正式同步按鈕 |
| 上游同步 | 手動 POST /v1/hr/ehrms/sync(預設 dry-run);租戶排程開關 GET/PUT /v1/hr/source-sync 與 POST /v1/hr/source-sync/initialize(正式寫入) | /workspace/employees 的同步開關與初始化、/hr/positions 的正式同步;真實上游連線仍需受控環境 |
| 分析 | /hr/turnover/monthly 與 /hr/turnover/annual | /workspace/turnover → features/hr-turnover:月份/年度查詢與未來期間防呆 |
HR 路由都註冊在 internal/api/v1/routes.go 的 registerHRRoutes。每條路由的 permission point、risk、tenant_wide 與 target_employee 以 internal/route/testdata/registry.golden 為準;api/openapi.yaml 只有部分端點在描述文字提到權限,不能當依據。HR 路由分成兩類:
- tenant-wide:org chart、org units、positions、turnover、
/hr/ehrms/sync、source-sync,以及bulk-reassign(它沒有逐員工的範圍判定,只能以 tenant-wide 收斂)。 - 依資料範圍過濾(
tenant_wide=false):員工 list、stats、create、activate-accounts,以及所有帶:employeeId的路由;單筆路由另外帶TargetEmployee。因此不能把「持有hr.employee.read」誤讀成可以讀全租戶。
核心概念
組織與職位是租戶治理資料
hr.org_unit、hr.position、hr.employee 是授權資源鍵;實際資料表是 public.org_units、public.positions、public.employees,都在 tenant transaction 內讀寫。組織樹的 parent_id 與推導出的 path 決定 department/department subtree 資料範圍,重掛節點不是單純的 UI 排序。
PUT /v1/hr/org-units/:orgUnitId 是整筆替換:name、closed、show_in_org_chart 必填,其餘欄位可為 null,未宣告的鍵會被拒。它在 org tree lock 下於同一 transaction 改寫整棵子樹的 path,沒有版本欄位或 CAS。組織沒有 delete,closed=true 會向下傳染,也不能把節點搬到自己的後代之下。職位同樣沒有版本:刪除是 high-risk,帶上游 source(如 eHRMS)或仍被組織的 manager_position_id 引用時會被拒;停用(status=disabled)則保留歷史引用。HR 裡有樂觀鎖的只有 employee profile 與 source-sync 開關(兩者都用 expected_version)。
員工資料分成 identity、profile 與 assignment
Employee 是 HR 聚合的主體,profile 端點處理可編輯欄位與 profile contract(PUT .../profile 必須帶 expected_version),assignment 及生命週期操作則由 service 的 transition 規則處理;離職、復聘都走 POST .../transitions,復聘會建立新員工列並回 201。
帳號開通不等於建立員工。activate-accounts 一次最多 200 人,逐筆套用資料範圍並回 outcome(not_found、offboarded、already_linked、company_email_missing、email_in_use、accepted),全部被拒仍回 200;accepted 只代表受理,帳號建立與邀請信由 worker 在交易外完成。bulk-reassign 同樣最多 200 人,在一筆交易內執行,任一員工失敗就整批回滾。
GET /v1/hr/employees/:employeeId/leave-records 除了 hr.employee.read,還會對同一 target 再檢查 attendance.leave.read,兩個範圍取交集。員工 CSV 匯出沒有後端端點:前端 features/hr-employees/employeeExportApi.ts 逐員工先以 POST /v1/authz/check 檢查 hr.employee/export,再讀 detail、profile、/v1/leave/balances 與 leave-records 組成報表;入口需要 hr.employee.export 與 hr.employee.read。
搜尋與上游組織層級
GET /v1/hr/employees?q= 修剪後為空視為未帶、超過 64 字回 400,以不分大小寫子字串比對 display_name、員工檔案的 english_name、company_email 與 employee_no(%、_、\ 視為字面)。GET /v1/hr/org-chart?q= 比對 display_name、english_name、employee_no,只過濾回傳的員工,不影響主管解析。前端 hooks/api/useEmployeeNameMatches.ts 以 q 加 include_offboarded=true 取回全部分頁,組織頁、/workspace/attendance 與 /workspace/clock 的關鍵字搜尋靠它補上英文名比對(需要 hr.employee.read)。
Migration 000068_org_source_hierarchy.sql 在 public.org_units 保存上游的 source_depth、source_parent_code、source_manager_employee_no(以 source_hierarchy_present 標示)。只有 source sync 會寫入,手動 PUT 會保留既有快照;遷移前的列要等下一次成功的 source sync 才有值。GET /v1/hr/org-chart 的每個 unit 以 source_hierarchy 回傳這些事實與 manager_resolution(not_assigned/resolved/unresolved;同租戶、同 source 的外部員工編號恰好對到一人才算 resolved)。這是呈現用的上游事實,與授權用的 parent_id/path 分開,不能拿來判斷資料範圍。
eHRMS 同步是來源重建,不是名稱比對
SyncEHRMS 先載入本地 positions、org units、employees,再依 position code、unit code、external employee id 或 employee number 做確定性比對。同步會依序處理職位、開放組織、員工、manager,再處理來源不再出現的資料;source-owned 資料與手動資料不可混為一談。
入口有兩條,行為不同:
- 手動
POST /v1/hr/ehrms/sync:同步執行、上限 30 分鐘;空物件 body 等於只 dry-run HR 目錄。選項為dry_run(預設true)、include_resigned、leave、balances_year(2000–2100)與activate_accounts(dry-run 時忽略)。回應只有計數、row_errors與問題摘要,不含上游欄位值;/hr/positions的同步按鈕送的是dry_run=false,屬正式同步。 - 租戶排程 source-sync:
PUT /v1/hr/source-sync必須同時帶enabled與expected_version(不符回409 stale_version);POST /v1/hr/source-sync/initialize要求開關已開(否則409 source_sync_disabled)並帶 1–160 字的Idempotency-Key,回202排入首次匯入;已成功初始化或仍有工作執行中時會被拒。開啟後 worker 每 30 分鐘一輪:eHRMS HR 同步 →(EHRMS_SYNC_ACTIVATE_ACCOUNTS=true時)帳號開通 → 在初始化與每日維護輪(當日第一輪或上一輪未成功)加跑假別、請假與年度餘額 → 當年整年的出勤同步(初始化用排入時的年度)。這條路徑沒有 dry-run,全部是正式寫入;EHRMS_SYNC_ENABLED、EHRMS_SYNC_FULL_ON_START只是相容舊設定。
程式位置:手動路由 internal/api/v1/hr_ehrms.go → 編排 internal/wiring/ehrms.go(fetch → HR → accounts → leave → balances)→ internal/service/hr/ehrms_sync.go 的 SyncEHRMS;上游 client 在 internal/platform/ehrms;排程開關在 internal/api/v1/source_sync.go 與 internal/service/sourcesync,worker 在 cmd/worker/ehrms_sync.go。
一條請求鏈
正在繪製架構圖…
查看圖表原始碼
flowchart LR
UI["Next page"] --> SWR["feature api.ts + SWR"]
SWR --> ME["/v1/me identity"]
SWR --> Gin["Gin /v1/hr/*"]
Gin --> Auth["route point + target scope"]
Auth --> Service["service/hr"]
Service --> Repo["Repository + tenant tx"]
Repo --> DB[("PostgreSQL + RLS")]前端的 cache key 會包含 identity;例如員工 detail 的查詢要等 /v1/me 得到 tenant/account 後才發送。後端仍需重新判斷 scope,不能把前端是否顯示按鈕當作授權。
實作步驟與示例
- 先在
api/openapi.yaml確認 operation、欄位與錯誤,權限與資料範圍則對internal/route/testdata/registry.golden。handler 都在internal/api/v1/:員工hr_employees.go、hr_employee_*.go(stats、bulk-reassign、帳號開通、password reset)、hr_profile*.go、hr_leave_records.go;組織hr_org.go、hr_org_chart.go;職位hr_positions.go;同步hr_ehrms.go、source_sync.go;分析hr_turnover.go。 - 若是新欄位,按 migration、query/sqlc、Repository、service、handler、測試的順序落地;不要直接在 handler 寫 SQL。
- 針對單筆員工操作確認
TargetEmployee與資料範圍;跨租戶或 scope 外的結果不能透過改寫 ID 繞過。 - 前端在
features/hr-employees/api.ts以 schema 解析 wire response,再讓頁面元件呈現;欄位新增要同步 adapter、表單與測試。
GET /v1/hr/employees?status=active&q=chen&page=1&page_size=20
Authorization: Bearer <由執行環境注入>上例只展示契約形狀,不提供 token,也不代表本輪已打 API。q 會同時比對中文姓名、英文名、公司信箱與員編;status 與 include_offboarded 不能同時帶(400)。要驗證,先在有授權的本地環境讀回 tenant 內資料,再檢查分頁 total、欄位策略與 audit;不要用別人的 .env 或共用資料庫做試驗。
驗證矩陣
| 驗證問題 | 程式碼/測試證據 | 仍需另外做的驗收 |
|---|---|---|
| 員工 CRUD 與欄位 contract | internal/api/v1/hr_employees_test.go、features/hr-employees/api.test.tsx | 有授權帳號的 API happy path 與負向 scope |
| 雙名搜尋 | tests/integration/postgres/hr_employee_name_search_test.go、hooks/api/useEmployeeNameMatches.test.tsx | 真實資料在組織頁與出勤頁的搜尋結果 |
| 組織樹 mapping 與改派 | internal/service/hr/org_chart_test.go、internal/service/hr/org_source_test.go、tests/integration/postgres/hr_org_test.go、tests/integration/postgres/hr_employee_bulk_reassign_test.go、features/hr-organization/tree.test.ts | 真實租戶樹與跨租戶阻擋 |
| 職位生命週期 | internal/api/v1/hr_positions_filter_test.go、tests/integration/postgres/hr_org_test.go、features/hr-positions/api.test.ts | 停用後的歷史引用與被引用時刪除被拒 |
| eHRMS row isolation | internal/service/hr/ehrms_sync_rows_test.go、internal/api/v1/hr_ehrms_test.go | 受控上游快照、dry-run 影響數與正式同步 |
| source-sync 開關與排程 | internal/api/v1/source_sync_test.go、cmd/worker/ehrms_sync_test.go、tests/integration/postgres/source_sync_test.go | 受控上游的首次初始化與 30 分鐘排程 |
| turnover 月/年計算 | internal/service/hr/turnover_metrics_test.go、features/hr-turnover/api.test.tsx | runtime 期間、時區與實際資料核對 |
常見錯誤
- 看到
/v1/hr/employees/:employeeId就直接在 SQL 以 ID 查詢:應先完成 principal、target 與 scope 判斷。 - 把 employee profile、登入 account、外部 eHRMS row 當成一張表;它們的 ownership 與生命週期不同。
- 用姓名匹配同步資料;同步實作使用 code/external id,姓名只可作顯示與搜尋。
- 把
dry_run=true的計數當成已寫入;dry-run 必須確認沒有 mutation,再按授權執行正式同步。source-sync 的開關與初始化沒有 dry-run,一律是正式寫入。 - 以為 org unit 或 position 的
PUT有版本保護;兩者都是整筆替換、沒有 CAS,送出前要以最新資料為基礎。 - 把
source_hierarchy當成授權階層;資料範圍只看本地parent_id/path。 - 以為只給
hr.org_unit.read就看不到員工:GET /v1/hr/org-chart是 tenant-wide,會回傳全租戶(預設不含離職)的員工名冊與主管關係,不受hr.employee範圍限制。 - 用前端 fixture 或單元測試結果宣稱真實 HR 已可用;目前尚未在本文執行後端 API、eHRMS 或部署驗證。