本章目標與前置條件
讀完後,你應能區分原始打卡證據與 daily projection,知道哪一些端點屬於本人、哪一些是 tenant-wide 管理操作,並能為一次考勤改動安排驗證。前置條件是了解日期區間、時區、分頁、授權與 Idempotency-Key。
功能現況
| 區塊 | 已在後端路由/服務中出現 | 前端入口與現況 |
|---|---|---|
| 本人打卡 | GET /v1/attendance/clock-status、POST /v1/attendance/clock | 打卡卡片 app/(platform)/_components/AttendanceClock.tsx(首頁、/tasks、/myleave 共用),經 features/gps 處理 location、risk remark、status 與 clock flow |
| 證據與投影 | GET /v1/attendance/clock-records、/daily-records、/days、/monthly-summary | /workspace/clock(ClockTimeView:clock-records 與 daily-records)、/workspace/attendance(WorkHoursPanel:/days)、/myleave 月出勤(/days 與 clock-records);/monthly-summary 目前沒有前端呼叫端 |
| 制度與地點 | policy、policy/options、worksites、calendar-integration(含 verify) | /workspace/leave-policy 的 AttendanceSettingsPanel:版本編輯、初始制度設定、worksite picker 與 Google integration UI |
| 行事曆 | calendar、years、days(新增/刪除租戶覆寫)、import(JSON 與 CSV) | 同頁的 AttendanceCalendarPanel:年度讀取、覆寫、刪除,政府年度與 CSV 匯入都先預覽再確認;沒有匯出 |
| digest/同步 | digest-rules、/attendance/ehrms/sync-runs(建立、查詢、items、retry)與 worker | 沒有前端 UI;定期同步由 HR source-sync worker 觸發,digest job 要 ATTENDANCE_DIGEST_ENABLED=true(預設 false)才建立 |
權限以 internal/route/testdata/registry.golden 為準:clock-status 與 clock 只作用於呼叫者本人;clock-records、days、daily-records、monthly-summary 可帶 employee_id,必須落在 decision scope 內;policy、worksites、calendar、calendar-integration、digest-rules 與 sync-runs 都是 tenant-wide 管理操作。/workspace/clock 與 /workspace/attendance 的員工關鍵字搜尋透過 hooks/api/useEmployeeNameMatches.ts 呼叫 GET /v1/hr/employees?q=,同時比對中文名與英文名(見組織與員工)。
核心概念
原始證據與讀模型
clock 寫入的是帶方向、時間、位置及風險結果的原始 evidence(public.attendance_clock_records);days、daily-records 與 summary 是供頁面讀取的投影(public.attendance_day_projections)。daily-records 另外合併已同步的 eHRMS 日資料(public.attendance_external_days),讀取時不呼叫 eHRMS,並以 effective_source、differing_fields 標示來源差異。投影不是讓前端補算規則的邀請,資料缺失、敏感欄位 null 與「零」必須依契約分辨。
日狀態與投影更新
flexible 制的出勤時間包含午休,達每日應出勤時數就不判遲到或早退(原始 evidence 與 geofence 風險仍保留);fixed 制與「請假日固定班表」(leave_day_fixed_schedule_enabled)仍以扣除午休的淨工時計算。eHRMS 日資料若上下班卡涵蓋整個排班(含午休)也判為正常,但不改寫來源工時。
投影有三條更新路徑:
- 同步寫入:
POST /v1/attendance/clock在同一 transaction 寫 evidence、重算當日 projection 並寫 audit。 - 讀時重算:
/days、/monthly-summary與 digest 讀取時會重算已過預期下班時間仍沒有下班卡的 pending 日、行事曆已變更的日子,以及 flexible 制的遲到/早退日(eHRMS 排班日除過期 pending 外以來源為準);結果只回應、不回寫,所以資料庫裡的 projection 列可能落後於 API 回應。 - outbox/worker:表單核准的補卡、請假、加班及其撤銷(
form.leave_cancellation.approved、form.overtime_cancellation.approved)由internal/worker/attendance.go消費後重算投影;撤銷只在attendance_approved_intervals.cancelled_at留痕並從投影排除(migration000066_attendance_cancellations.sql)。eHRMS sync run 由 sync worker 寫入外部日資料與投影,地標解析也走 outbox。
打卡的冪等與風險確認
POST /v1/attendance/clock 必須帶 Idempotency-Key,但伺服器只檢查它存在;真正的去重鍵是 body 的 client_event_id,比對的是「actor 加完整 body」的 SHA-256 指紋。同一 client_event_id 且指紋相同會回放原結果(200;低精度紀錄重送仍回 422);改 body 會得到 409 idempotency_conflict。風險確認保留相同 client_event_id 與原座標,依契約改用新的 Idempotency-Key(前端以 client_event_id 加 :risk 後綴),伺服器會重新檢查時窗與 policy。風險挑戰與時窗拒絕都不會消耗 client_event_id。
policy、calendar 與 worksite
policy 的時間選項只接受每半小時(HH:00 或 HH:30),版本更新使用 base_version compare-and-set(不符回 409 stale_version);跨午夜的標準/break interval 回 422 policy_overnight_not_supported。租戶還沒有制度時,GET /v1/attendance/policy 回 404(10050 not_found),clock-status 的 blocked_reason 是 policy_unavailable,打卡回 503;設定頁會顯示「尚未建立出勤制度」,持有 attendance.policy.update 的人檢查前端預設草稿後,以 base_version 為 0 明確發布第一版,系統不會自動建立。
tenant calendar override(POST /v1/attendance/calendar/days)不修改 government row:同日已有租戶列回 409 calendar_day_exists,刪除 government 列回 409 calendar_day_not_tenant。停用 worksite(status=disabled,沒有 delete)會保留歷史引用,不再出現在 clock-status 的 worksites 與新的 geofence 判定。
Google Calendar 設定是租戶層級:PUT /v1/attendance/calendar-integration 保存 enabled、workspace_domain、delegated_admin_email,service_account_json 只寫不讀、以 ENCRYPTION_KEY 加密保存(未設定時帶 JSON 會回 503),GET 只回安全的中繼資料。啟用且已有憑證時,設定一有變更狀態就回到 error(calendar_unverified),要再呼叫 POST /v1/attendance/calendar-integration/verify(需 Idempotency-Key);原樣再存一次則保留上次檢查結果。
行事曆匯入
POST /v1/attendance/calendar/import 需要 Idempotency-Key,三種 body 的 dry_run 都預設 true:
- JSON(
year、dry_run):只匯入程式內嵌的政府行事曆(目前 2026、2027 年),正式匯入只替換該年 government 列。 multipart/form-data(year、source、選填dry_run、一個file)或 rawtext/csv(year、source、dry_run放 query):source為government或tenant,正式匯入只替換該 source 的列。CSV 必須是 UTF-8、表頭恰為date,kind,name、上限 256 KiB,日期不可重複且須屬於該年,空檔會被拒;tenant 匯入不會建立 government 年度覆蓋。
前端不論 JSON 或 CSV 都先以 dry-run 預覽 diff,確認時才送 dry_run=false,每次請求使用新的冪等鍵。目前沒有行事曆匯出。
來源同步與 digest
- 手動:
POST /v1/attendance/ehrms/sync-runs需Idempotency-Key(最多 160 字),dry_run與include_offboarded都預設true,日期是[start_date,end_date)半開區間、最多 366 天,employee_ids可帶 1–500 人,回202;再以GET .../sync-runs/:runId、.../items追蹤,POST .../retry重試。 - 定期:HR 的 source-sync 開啟後,worker 每 30 分鐘一輪,在 HR 同步之後為當年整年(含未來已核准的請假)建立並執行 sync run;開關與初始化見組織與員工。
- digest:
GET/PUT /v1/attendance/digest-rules,PUT 以revision做樂觀鎖;排程 job 只在ATTENDANCE_DIGEST_ENABLED=true時建立,但規則寫入不受開關影響。
一條請求鏈
正在繪製架構圖…
查看圖表原始碼
flowchart LR
Card["打卡卡片"] --> Status["GET clock-status"]
Card --> Punch["POST clock"]
Punch --> Gate["replay、時窗、policy、worksite、risk"]
Gate --> Evidence[("clock evidence")]
Evidence -->|"同一 transaction"| Projection[("day projection")]
Forms["表單核准與撤銷 outbox"] --> Projection
Sync["eHRMS sync run worker"] --> Projection
Sync --> External[("eHRMS 日資料")]
Projection --> Read["days、daily-records、monthly-summary"]
External --> Read前端在 /v1/me 完成後才建立 SWR key,team 查詢還要判斷 hr.employee.read 等明確權限。後端會在 route、service、Repository transaction 再判斷 tenant/employee 範圍;不要只靠 UI 把 employee selector 隱藏。讀取端的重算只影響回應內容,不會回寫投影。
實作步驟與示例
- 先在
api/openapi.yaml確認日期、sort、範圍與 response status,權限 point 與tenant_wide則對internal/route/testdata/registry.golden。handler 都在internal/api/v1/:attendance.go(policy、worksites、calendar 與/leave/*)、attendance_calendar_csv.go、attendance_clock.go(clock-status、clock、clock-records、days)、attendance_sync.go(sync-runs、daily-records)、attendance_digest.go(digest-rules、monthly-summary)與attendance_integration.go。 - 將 policy/calendar/worksite 寫入留在 attendance service 的單一 transaction;打卡的投影在同一 transaction 同步寫入,跨域效果(表單核准、撤銷、地標)走 outbox/worker,讀取端的重算不得回寫。
- 打卡寫入前檢查 idempotency、hard window、accuracy、worksite 與 risk;不要在前端先判定「一定成功」。
- 查詢頁使用 server projection,並對上限做明確 UI 提示:
/days必填日期、含首尾最多 62 天;/clock-records日期選填、含首尾最多 366 天;/daily-records是[start_date,end_date)半開區間、最多 62 天;/monthly-summary單月超過 6200 列時要帶employee_id縮小範圍。
GET /v1/attendance/daily-records?start_date=2026-09-01&end_date=2026-09-08&page=1&page_size=50&sort=work_date%20asc,employee_id%20ascdaily-records 的 end_date 不含當天,上例讀的是 9/1 到 9/7;同一週改打 /v1/attendance/days 時要帶 end_date=2026-09-07(含首尾),sort 固定為 work_date desc,employee_id asc。正式打卡 request 的欄位與 status 以 OpenAPI AttendanceClockRequest 為準;本文不填入座標、帳號或 token。驗證時應分別測成功 evidence、風險確認、低精度 GPS、時窗外 409、同鍵 replay 與改 body conflict。
驗證矩陣
| 驗證問題 | 程式碼/測試證據 | 仍需另外做的驗收 |
|---|---|---|
| clock status 與 GPS flow | internal/api/v1/attendance_clock.go、features/gps/clockFlow.test.ts、features/gps/clockStatus.test.tsx | 真實位置服務與 policy 時窗 |
| idempotency/risk | internal/service/attendance/punch_test.go、internal/service/attendance/geofence_test.go、tests/integration/postgres/attendance_punch_test.go、features/gps/useAttendancePunchFlow.test.tsx | 受控 DB 的 replay、conflict、audit |
| daily/hours projection | internal/api/v1/attendance_sync_test.go、internal/service/attendance/day_status_credit_test.go、internal/service/attendance/listed_days_deadline_test.go、tests/integration/postgres/attendance_calendar_refresh_test.go、features/attendance-daily-records/api.test.ts、features/work-hours/WorkHoursPanel.test.tsx | 跨日、時區、敏感欄位 field policy |
| policy/worksite/integration | internal/service/attendance/policy_test.go、internal/service/attendance/integration_update_test.go、tests/integration/postgres/attendance_test.go、tests/integration/postgres/attendance_integration_test.go、features/attendance-settings/AttendanceSettingsPanel.test.tsx | 真實 Google credential 與受控連線檢查 |
| calendar import | internal/api/v1/attendance_calendar_csv_test.go、tests/integration/postgres/calendar_csv_test.go、features/attendance-calendar/api.test.tsx | dry-run diff、正式替換與資料量上限 |
| 表單效果與撤銷 | internal/service/attendance/effects_cancellation_test.go、tests/integration/postgres/attendance_cancellation_test.go | 受控環境核准/撤銷後的投影 |
| sync run 與 source-sync | internal/api/v1/attendance_sync_test.go、internal/service/attendance/sync_worker_test.go、cmd/worker/ehrms_sync_test.go | 受控上游的整年同步結果 |
常見錯誤
- 用
clock-status的最新紀錄自己算下一步,忽略 server policy、late evidence 與employee_ineligible。 - 重送風險確認仍沿用舊
Idempotency-Key;確認 request 需要新的 transport key,但 businessclient_event_id不變。伺服器目前不會因沿用舊 key 報錯,這仍是契約要求。 - 把低精度 GPS 的拒絕當作「沒有紀錄」;契約會保存 rejected raw evidence,
422 gps_accuracy_insufficient另有語意。 - 把
calendar imported=false當成錯誤;未匯入年仍可成功回應,summary 需以calendar_covered標示 calendar coverage。 - 以為
daily-records的end_date含當天;它是半開區間,/days與/clock-records才含首尾。 - 以為
public.attendance_day_projections的列一定等於 API 回應;過期 pending、行事曆覆寫與 flexible 遲到/早退是讀時重算,不回寫。 - 在新租戶直接測打卡卻沒先發布制度;
GET /v1/attendance/policy會回404、打卡回503,要先由有權限的人在設定頁建立第一版。 - 把 CSV 的
source=tenant匯入當成已建立政府年度覆蓋;只有 government 匯入會寫imported_at。 - 把
dry_run或 fixture UI 當成正式資料庫驗收;目前本文未啟動 attendance service,也未操作真實位置/行事曆。