本章目標與前置條件
讀完後,你應能分辨假別目錄、假別政策、年度餘額、已核准紀錄與待審申請,知道本人查詢與 HR 查詢的授權差異,也知道哪些假別規則目前只保存、尚未執行。前置條件是理解 employee scope、年度快照、Asia/Taipei 日期區間與表單 workflow。
功能現況
| 區塊 | 後端已存在的能力 | 前端現況 |
|---|---|---|
| 假別治理 | /v1/attendance/leave-types list/get/create/update 與 /v1/attendance/leave-type-form-options;create 需 Idempotency-Key,會建立 local_ 開頭、以天計的本地假別;update 需 expected_version | /workspace/leave-policy 經 features/leave-types 讀寫政策與 form options |
| 員工目錄 | /v1/leave/types 只列 item 節點,名稱與啟停已套用本地政策,回應不含 id | 目前沒有前端呼叫;表單的假別選項走 GET /v1/forms/data-sources/leave_types,value 是假別 UUID |
| 餘額 | /v1/leave/balances 必填 year、選填 employee_id(需 data scope 涵蓋),回 decimal-preserving 年度快照;員工不是 active 或 probation 時回 409 employee_ineligible | /myleave 的 useLeaveBalances 先確認 identity 與 attendance.leave.read 權限點才發請求,reserved 與 remaining 分開顯示 |
| 本人紀錄 | /v1/leave/records 必填 from/to,只查 authenticated employee;status 只接受 approved/cancelled/corrected | /myleave 的 useMyLeaveRecords 顯示日期/狀態;「全部狀態」會拆成三次查詢 |
| HR 紀錄 | /v1/hr/employees/:employeeId/leave-records 再檢查 HR 與 leave read scope,status 可篩 pending | employeeLeaveApi、EmployeeLeaveRecords 接 detail drawer |
| 表單 effect | capability attendance.leave、attendance.leave_cancellation 經 outbox 由 worker 的 attendance effect 套用 | /myleave 以 leave-request 模板開 /forms?template=…;features/forms 顯示 workflow,effectStatus 只在 schema 解析、畫面尚未呈現 |
| 請假引擎 | 000067 只有空帳務表;OpenAPI 另有 4 條 contract-first 路徑未註冊(見下文) | 無 |
核心概念
Balance 是年度快照,不是 UI 加總
LeaveBalance 以 decimal 字串保存 granted、used、reserved、remaining(wire 同名),domain 以 canonical decimal 驗證,不能使用浮點數自行加總。remaining 恆等於 granted − used(DB CHECK),不扣 reserved;待審期間的可用額度要以 remaining − reserved 表示。跨年請假整筆記在開始時間(Asia/Taipei)所屬的年度。
目前 live 路徑沒有餘額充足檢查:該年度沒有 balance 列時,effect 會先建一筆 granted 為 0 的本地列再扣,核准後 used 可以超過 granted,remaining 變成負數(tests/integration/postgres/attendance_effects_test.go 就斷言 -6.125001)。
表單回應的 quotaState 是表單模板的「送單次數」配額(none/reserved/consumed/released,額滿回 409 quota_exhausted),不是假別餘額。假別的預扣與扣抵看 /v1/leave/balances;核准後 effect 是否落地看表單的 effectStatus(pending 之後變 applied 或 failed)。
Record 是可追溯事實
LeaveRecord 的狀態詞彙是 pending、approved、cancelled、corrected,但目前的寫入路徑只產生兩種:核准 effect 寫 approved(source=local,form_instance_id 指回表單)、eHRMS 匯入寫 approved(source=ehrms),銷假 effect 再把本地紀錄改成 cancelled。本人 endpoint 預設 approved 並拒絕 pending。日期查詢以 Asia/Taipei 的 inclusive local date 轉成半開區間 [from, to 的隔日),以區間重疊比對,最多 366 天。
假別名稱是查詢時 join 目前的本地政策名或目錄名(COALESCE(p.name, t.name)):停用假別仍讀得到名稱,但改名會同步改變歷史畫面,紀錄本身不保存名稱快照。
假別政策與表單是兩個 owner
/v1/attendance/leave-types 回傳 canonical 假別與本地政策合成的 read model:code、unit、requires_balance、source 來自假別目錄;政策設定包含 name、chip、color、status、validity、quota_mode(unlimited/fixed/seniority_tier/form_field)、fixed_days、cycle_start、pay_mode、applicability(性別)、minimum_tenure_months、prerequisite_leave_type_id、linked_form_template_id/linked_form_version、form_field_id/form_field_unit、hours_per_day、tiers/increment;讀回另附 effective_status、linked_form_state、prerequisite_state 與 enforcement。
週期規則(000069 起):seniority_tier 必須有 cycle_start;fixed 只有在 validity 不是 unlimited 時才必填;unlimited 可選填;form_field 禁止週期,且必須有 form_field_id、form_field_unit=hours、hours_per_day 與 linked form。hours_per_day 同時是以小時請天制假別時的換算係數。
form capability 只宣告哪些欄位對應能力欄位:attendance.leave 必須綁定 leave_type、start_at、end_at、hours、reason,請假數量取 hours、單位固定為小時。表單負責輸入與流程,attendance service 負責 canonical effect,不能在前端直接扣 balance。
送審准入、預扣與銷假
- 送出前,表單服務先以獨立 attendance 交易寫 leave admission(000059 的
attendance_leave_admissions),凍結政策版本與hours_per_day:假別或政策停用回 409leave_type_inactive(40300),以小時請天制假別但未設hours_per_day回 409leave_unit_conversion_unconfigured(40303)。表單交易失敗會補償刪除 admission;commit 結果不明時保留。 - 表單交易寫 outbox:送出發
form.leave.reservation_requested,核准發form.leave.approved,拒絕/退回/撤回/管理員取消發form.leave.reservation_released。worker 的 attendance effect 據此做 reserve(reserved增加)、consume(轉成used,並寫 approved leave record、approved interval 與每日投影)與 release。送出 API 回 200 的當下,reserved可能還沒更新。 - 銷假(000066):銷假單以
attendance.leave_cancellation綁定從my_leave_records選出的本人紀錄;核准後 effect 只處理source=local的 approved 紀錄,改為cancelled、取消 approved interval、扣回used並寫一筆refund分錄。
eHRMS 來源
- HR 的 eHRMS 同步寫假別目錄與年度餘額(
source=ehrms);remaining一律重算為granted − used,不採用上游的 remaining。worker 排程只在初始化與每日維護輪次同步這兩段。 - 考勤同步把 eHRMS 請假明細寫成
source=ehrms、unit=minutes、status=approved的 canonical record(依員工、假別與起訖產生穩定 ID),不改本地表單或餘額;跨日假先依歷史出勤班表拆成每日片段再寫入。
請假引擎尚未上線
- 000067 只建立 8 張 opt-in 的空帳務表(
leave_engines、leave_cycles、leave_grant_lots、leave_ledger等),db/drafts/leave_engine_accounting.sql.draft保留未完成設計;LeaveAccountingService等引擎服務沒有被 API 或 worker 組裝。 api/openapi.yaml的/v1/attendance/leave-types/{leaveTypeId}/engine、/v1/forms/{formInstanceId}/leave/preview、/v1/leave/balance-cycles、/v1/leave/balance-ledger標為L0 contract-first; route not yet available,不在internal/route/testdata/registry.golden。- 錯誤碼 40304–40311(
insufficient_balance、leave_eligibility_denied、leave_overlap等)已登錄在api/error-codes.md,但 live 程式碼不會回傳。
請求鏈與範圍
正在繪製架構圖…
查看圖表原始碼
flowchart LR Submit["表單送出(attendance.leave)"] --> Admission["leave admission 交易"] Admission --> Tx["表單交易 + outbox"] Tx -- "reservation_requested" --> Effect["worker:attendance effect"] Tx --> Decision["簽核決策"] Decision -- "approved/reservation_released" --> Effect Effect --> Balance[(年度 balance)] Effect --> Record[(canonical leave record)] Sync["eHRMS 來源同步"] --> Balance Sync --> Record Balance --> Bal["GET /v1/leave/balances"] Record --> Self["GET /v1/leave/records"] Record --> HR["GET /v1/hr/employees/:employeeId/leave-records"]
本人 balance 可在 data scope 涵蓋時指定 employee_id,否則回 403 data_scope_denied;本人 records 沒有 employee_id 參數,帶入會得到 400。HR detail 查詢先由 hr.employee.read 的 target scope 判斷並以 HR 範圍重新查員工,接著 handler 以同一 target 向 PDP 要 attendance.leave.read、再比對一次 data scope(PDP 出錯一律拒絕),避免把 HR profile 權限當成完整假勤權限。路由權限點以 internal/route/testdata/registry.golden 為準。
實作步驟與示例
- 先查
api/openapi.yaml的 Leave schemas、日期與狀態 enum(標L0 contract-first的請假引擎路徑尚未註冊),權限點看internal/route/testdata/registry.golden,再讀internal/api/v1/attendance_leave.go、internal/api/v1/attendance_leave_records.go與internal/api/v1/hr_leave_records.go。 - 若改 balance 或 record,先確認是 snapshot、append-only entry 還是 canonical record;現行扣抵只在 attendance effect(
internal/service/attendance/effects_leave.go),不要新增第二套扣款計算,也不要把尚未接線的引擎帳務當成現行邏輯。 - 若改表單 capability,更新 form schema、種子 definition 的 binding(
internal/domain/form/seed_catalog.go)、後端 validation 與 workflow effect contract,再做 revision/quota 負向測試。 - 若改假別政策欄位,同步 domain 驗證、DB CHECK、OpenAPI、
features/leave-typesadapter 與/workspace/leave-policy編輯器。部署定稿設定依deploy/APPROVED-CONFIG.md使用tools/approved_config.py:先唯讀產生差異計畫,再帶--apply與核對過的 plan hash 套用,最後跑--verify-only。 - 前端用
features/leave的 zod schema 解析資料;查詢 key 要帶 identity,切換 employee 或 year 時重新讀取,不能保留上一個人的結果。
GET /v1/leave/balances?year=2026&page=1&page_size=20&sort=leave_type_code%20asc
GET /v1/leave/records?from=2026-09-01&to=2026-09-30&status=approved&page=1&page_size=20上例只展示合法的 endpoint 與必要 query;不包含 token 或真實 employee id。預期驗證應包括未綁定 employee、scope 外 employee、from/to 缺失、超過 366 天、未知 status、本人端點帶 status=pending,以及停用假別仍可讀取名稱。
驗證矩陣
| 驗證問題 | 程式碼/測試證據 | 仍需另外做的驗收 |
|---|---|---|
| balance scope 與 decimal | internal/service/attendance/leave_test.go、features/leave/api.test.tsx | 本地 DB 的年度快照與權限範圍 |
| record 日期/狀態 | internal/api/v1/attendance_leave_records_test.go、internal/api/v1/hr_leave_records_test.go、internal/service/attendance/leave_records_test.go | Asia/Taipei 邊界與實際歷史資料 |
| leave policy contract | internal/api/v1/leave_type_policy_test.go、internal/domain/attendance/leave_policy_cycle_test.go、features/leave-types/adapter.test.ts | policy revision、disabled 與 linked form |
| 表單 effect 與餘額 | internal/service/attendance/effects_test.go、tests/integration/postgres/attendance_effects_test.go、tests/integration/postgres/attendance_cancellation_test.go、tests/unit/forminstance/events_test.go | 拒絕/退回/撤回的 release、其他假別與正式環境 |
| HR view 的雙重授權 | internal/api/v1/hr_leave_records_test.go、features/hr-employees/employeeLeaveApi.contract.test.tsx | 不同資料範圍角色的 API 負向案例 |
docs/reports/2026-09-22-attendance-live-flow-verification.md 記錄了一次本機真實 API、資料庫、簽核與 worker 的請假實測:送出後 reserved 由 0 變 1,核准且 effect applied 後 used 由 13 變 14、reserved 回到 0。該報告沒有驗拒絕、退回、撤回與全部假別,也不代表正式環境或瀏覽器自動刷新已通過。
常見錯誤
- 用浮點數呈現或計算餘額,導致
0.1類值與 server decimal 不一致。 - 把
remaining當成待審期間的可用額度,忘了再扣reserved;或把表單的quotaState(送單次數)當成假別餘額。 - 以為假別政策的額度、資格、前置假已經生效;目前只有啟停會擋送審。
- 把 pending form 當 approved leave record,讓未核准申請提前進入出勤投影。
- 表單假別欄位送 code;
attendance.leave只接受 lookup 回傳的假別 UUID,送 code 會 422。 - 只做 route permission,沒有在 service/repository 檢查 target employee 與 tenant。
- 使用瀏覽器 UTC 日期截斷
from/to,在 Asia/Taipei 午夜附近查錯一天。 - 將 fixture 的 quota 或表單 effect 當成正式資料庫結果;本機實測只涵蓋送出與核准路徑。