Appearance
Đặc tả kỹ thuật (Dev Spec) — Chấm công đa đơn vị trên shared tenant
v3.1.4 — 21/04/2026
| Thay đổi | Section | Ảnh hưởng |
|---|---|---|
Chuyển break_mode = flex Day-1 thành metadata tham chiếu: không dùng break window để phạt/block ở mốc 2 và 3, chỉ tính actual_hours theo 2 segment thực tế | C3) Quy tắc và công thức | BE, FE, QA |
Bỏ rule auto suy diễn missing_break -> workday = 0.5; runtime chỉ flag trạng thái để HR xử lý theo policy đã chốt | C3) Quy tắc và công thức | BE, FE, QA |
Đồng bộ comment schema/task wording để break_flex_minutes được hiểu đúng là metadata Day-1, không phải tolerance đang enforce | C4) Mô hình dữ liệu, C11) Danh sách công việc | BE, QA |
Tham chiếu (Ref): PRD v3.1.4 | Ngày (Date): 21/04/2026
Changelog
| Version | Date | Author | Thay đổi |
|---|---|---|---|
| 3.1.4 | 21/04/2026 | PO/BA | Chốt runtime Day-1 cho break_mode = flex theo hướng metadata-only, bỏ auto-deduct missing_break, và đồng bộ task/schema wording để team không hiểu break_flex_minutes như tolerance đang enforce |
| 3.1.3 | 15/04/2026 | PO/BA | Chuyển Công chuẩn sang resolve theo primary_department_id -> standard_workday_scope_key -> rule, thêm bảng map timekeeping_standard_workday_scope_department |
| 3.0 | 07/04/2026 | PO/BA | Initial dev spec cho schema/runtime/export multi-unit |
| 3.0.1 | 07/04/2026 | PO/BA | Đồng bộ break_mode, annual leave Day-1, remote nhóm nhỏ và non-regression wording |
| 3.1 | 13/04/2026 | PO/BA | Thêm primary_department_id, disabled cho mapping tables, persistence rules soft-disable/reactivate, readiness aggregate và action contract cho bulk assignment/import/unassign |
| 3.1.1 | 13/04/2026 | PO/BA | Làm rõ nguồn GPS: branch coordinates tiếp tục dùng branch master data hiện có; gps_radius_meters là override per-unit và fallback global AppSettings.Position.Value.Distance chỉ dùng khi unit chưa cấu hình |
| 3.1.2 | 14/04/2026 | PO/BA | Chuyển mode tính công xuống shift: reuse time_slot_template.workday làm max_workday, thêm workday_calculation_mode + standard_hours ở shift để hỗ trợ mixed-mode trong cùng một unit; đồng thời thêm gps_required ở shift để quyết định Bắt buộc / Không bắt buộc GPS theo từng ca |
C1) Phạm vi kỹ thuật (Scope)
Modules ảnh hưởng
| Module | Loại | Thay đổi |
|---|---|---|
diva-admin/src/modules/timekeeping | FE | Filter timekeeping_unit, multi-record day, export vận hành |
diva-admin/src/modules/settings/pages/InternalSetting.tsx | FE | Thêm menu entry Đơn vị chấm công trong Internal Settings |
diva-admin/src/modules/settings/pages/TimekeepingUnitSetting.tsx | FE | Màn mới SCR-00: quản lý unit, branch, department, user assignment, rollout |
diva-admin/src/modules/settings/pages/ShiftSetting.tsx | FE | Thêm filter/unit scope cho shift |
diva-admin/src/modules/settings/pages/ApproverPage.tsx | FE | Thêm filter/unit scope cho approver timekeeping |
diva-flutter/staff/lib/presentation/modules/main/attendance | FE Mobile | State machine 4 mốc (PN ca gãy + Daisy DV), 2 mốc (PN thường + Daisy VP) |
diva-backend/services/ecommerce-api/action/log_time_keeping.go | BE | Runtime multi-clock |
diva-backend/services/ecommerce-api/action/check_attendance.go | BE | Giữ legacy app check-in ngoài scope HR timekeeping |
diva-backend/services/ecommerce-api/event/request_working_schedule_* | BE | Overlay request theo runtime mới |
diva-backend/services/ecommerce-api/event/time_slot_template_update.go | BE | Bỏ propagation theo name |
diva-backend/services/controller/metadata/databases/hrm/* | DB/Hasura | Thêm timekeeping_unit_id cho bảng timekeeping |
Không thuộc scope
diva-admin/src/modules/report(ngoại trừAnnualLeaveReport.tsx— cần thêm filter unit)diva-admin/src/modules/dashboarddiva-backend/services/export-api/action/report_salary.gosalary,payroll,general_salary,user_salary- Refactor global
branch_user,department_user - Không thêm field
timekeeping_unitvàoUserCreate.tsx/EmployeeProfileCreate.tsxở Day-1
C2) Kiến trúc thu gọn
Quy ước chung
- Timezone: Tất cả timestamp so sánh dùng
Asia/Ho_Chi_Minh. Không hỗ trợ multi-timezone Day-1. - Segment naming: Code dùng
segment_index = 0(vào ca → ra nghỉ) vàsegment_index = 1(vào lại → ra về). PRD gọi "Segment 1/2" = code "segment_index 0/1".
Nguyên tắc
timekeeping_unitchỉ là boundary của module chấm công.- Không dùng
timekeeping_unitđể thay company boundary của toàn hệ thống. - Không thêm filter
timekeeping_unitvào dashboard/report legacy. - Không sửa pipeline payroll.
- Day-1 chỉ mở UI
on-site attendancecó GPS; fullremote attendancelà deferred scope. Ngoại lệ nhóm nhỏ dùng lại flowremote_onetimehiện có, không build UI remote mới.
Impact Map
| Surface | Mức độ | Ghi chú |
|---|---|---|
| Timekeeping admin web | Cao | Có thay đổi thật |
| Mobile attendance | Cao | Có thay đổi thật |
| Shift settings | Trung bình | Thêm filter/unit |
| Approver settings timekeeping | Trung bình | Chỉ request type timekeeping |
| Dashboard/report legacy | Không thuộc scope | Không sửa |
| Salary/payroll legacy | Không thuộc scope | Không sửa |
C3) Quy tắc và công thức (Rules & Formulas)
FORMULA-001: Trạng thái ngày công
- Ref: PRD A10 FORMULA-001
- Implementation: aggregate events theo
time_slot_user_id
sql
CASE
WHEN break_clocking_required = false AND clock_in IS NOT NULL AND clock_out IS NOT NULL THEN 'complete'
WHEN break_clocking_required = true AND event_mask = '1111' THEN 'complete'
WHEN break_clocking_required = true AND missing_break = true THEN 'missing_break'
WHEN missing_start = true THEN 'missing_start'
WHEN missing_end = true THEN 'missing_end'
ELSE 'partial'
ENDFORMULA-002: Tổng phút vi phạm
- Ref: PRD A10 FORMULA-002
Ca 2 mốc (PN thường, Daisy VP):
sql
late_minutes = GREATEST(0, EXTRACT(EPOCH FROM (clock_in - start_working_time)) / 60)
early_minutes = GREATEST(0, EXTRACT(EPOCH FROM (end_working_time - clock_out)) / 60)
total_violation_minutes = late_minutes + early_minutesCa 4 mốc (Daisy DV + PN ca gãy) — cùng logic multi-segment:
sql
-- segment 0: vào ca → ra nghỉ
seg0_late = GREATEST(0, EXTRACT(EPOCH FROM (seg0.clock_in - scheduled_start)) / 60)
seg0_early = GREATEST(0, EXTRACT(EPOCH FROM (scheduled_break_start - seg0.clock_out)) / 60)
-- segment 1: vào lại → ra về
seg1_late = GREATEST(0, EXTRACT(EPOCH FROM (seg1.clock_in - scheduled_break_end)) / 60)
seg1_early = GREATEST(0, EXTRACT(EPOCH FROM (scheduled_end - seg1.clock_out)) / 60)
total_violation_minutes = seg0_late + seg0_early + seg1_late + seg1_earlyPN ca gãy = 4 mốc (DEC-014 confirmed). Break cố định trong template (VD: 11:00-14:00).
break_mode = 'fixed',break_flex_minutes = 0. Dùng cùng multi-segment logic ở trên: tính late cho clock_in đầu mỗi segment, tính early cho clock_out cuối mỗi segment.Daisy BS/Phụ tá Ca 2 = 4 mốc (DEC-019). Break mặc định 12:00-14:00,
break_mode = 'flex',break_flex_minutes = 60. Day-1 không dùng break window này để phạt/block ở mốc 2 và 3; chỉ yêu cầu đủ 4 mốc và tínhactual_hourstheo 2 segment thực tế.Phân biệt: PN
fixeddùng break window để tính violation ở giữa ca. DaisyflexDay-1 chỉ dùng break window như metadata tham chiếu, chưa dùng làm hard validation/penalty.
FORMULA-003: Cronjob log_time_keeping_flag — multi-segment logic
- Ref: PRD A10 FORMULA-001, FORMULA-002
- File:
ecommerce-api/scheduler/log_time_keeping_flag.go
Reuse: Logic late/early detection ca 2 mốc KHỚP 100% code hiện tại:
- Late > 1 phút:
earliestTime.Add(time.Minute).Before(clockIn)→LateArrival = true(line ~753)- Late > 60 phút:
earliestTime.Add(60 * time.Minute).Before(clockIn)→ trừworkday/2(line ~750)- Early > 1 phút:
latestTime.Add(-1 * time.Minute).After(clockOut)(line ~775)- Early > 60 phút: trừ
workday/2(line ~771)- OT hours:
store.DiffHours(r.From, r.To)(line ~794)- Salary:
GenerateSalary()tự động tínhWorkdaySalary,OvertimeHour,LateEarlyMinutesCa 2 mốc: KHÔNG cần sửa logic cũ. Delta CHỈ là thêm nhánh multi-segment cho ca 4 mốc.
Delta cho multi-segment (ca 4 mốc — build mới):
Với mỗi NV có lịch (time_slot_user) hôm nay:
1. Resolve timekeeping_unit_id từ user assignment
2. IF timekeeping_unit_id IS NULL → chạy logic hiện tại (Diva legacy)
3. IF timekeeping_unit_id IS NOT NULL:
a. Load shift template qua time_slot_user → canonical_key → time_slot_template
b. IF break_clocking_required = false → logic hiện tại (1 record, clock_in/clock_out)
c. IF break_clocking_required = true → multi-segment logic:
records = SELECT * FROM time_slot_time_keeping
WHERE time_slot_user_id = ? AND segment_index IN (0, 1)
segment_0 = records WHERE segment_index = 0
segment_1 = records WHERE segment_index = 1
-- Tính status
IF segment_0 complete AND segment_1 complete → 'complete'
IF segment_0 missing → 'missing_start'
IF segment_0 complete AND segment_1 missing → 'missing_break' (thiếu vào lại + ra về)
IF segment_0 complete AND segment_1.clock_in exists AND segment_1.clock_out missing → 'missing_end'
ELSE → 'partial'
-- Tính late/early per segment
segment_0: late nếu clock_in > scheduled_start + grace_minutes
IF shift.break_mode = 'fixed':
segment_1: late nếu clock_in > scheduled_break_end
segment_0: early nếu clock_out < scheduled_break_start
IF shift.break_mode = 'flex':
-- Day-1: không tính late/early ở mốc giữa ca
-- chỉ ghi nhận đủ 4 mốc + tính actual_hours theo mốc thực tế
segment_1: early nếu clock_out < scheduled_end - 1min
-- Tính workday
IF shift_template.workday_calculation_mode = 'fixed':
IF complete → workday = shift_template.workday
IF missing_break → giữ nguyên theo policy hiện hành, KHÔNG auto trừ cứng chỉ vì thiếu mốc giữa ca
IF late > late_deduct_threshold_minutes (segment 0) → workday -= shift_template.workday / 2
IF early > late_deduct_threshold_minutes (segment 1) → workday -= shift_template.workday / 2
IF shift_template.workday_calculation_mode = 'hourly':
→ dùng FORMULA-009 (actual_hours / shift_template.standard_hours × shift_template.workday)
→ KHÔNG áp dụng logic "late >60min → trừ 0.5 workday" (tránh double-deduction)Quan trọng: Logic "late >60min → trừ 0.5 workday" CHỈ áp dụng khi
shift_template.workday_calculation_mode = 'fixed'. Khihourly, workday đã tính quaactual_hours / standard_hours × max_workday, KHÔNG trừ thêm 0.5 để tránh double-deduction.
FORMULA-004: logTimeKeeping state machine — multi-clock
- Ref: PRD FR-003
- File:
ecommerce-api/action/log_time_keeping.go
Ca 2 mốc (hiện tại — không đổi):
State: no_record → Action: INSERT clock_in (segment_index = NULL)
State: has_clock_in → Action: UPDATE clock_outCa 4 mốc (break_clocking_required = true):
Query: SELECT * FROM time_slot_time_keeping
WHERE time_slot_user_id = ? ORDER BY segment_index
State machine:
┌─────────────────────┬────────────────────────────────────────────┐
│ State │ Action │
├─────────────────────┼────────────────────────────────────────────┤
│ no_record │ INSERT seg_0 {clock_in, segment_index=0} │
│ seg_0.clock_in only │ UPDATE seg_0 {clock_out} (= Ra nghỉ) │
│ seg_0 complete │ INSERT seg_1 {clock_in, segment_index=1} │
│ seg_1.clock_in only │ UPDATE seg_1 {clock_out} (= Ra về) │
│ seg_0+seg_1 complete│ REJECT "Đã chấm đủ mốc" │
├─────────────────────┼────────────────────────────────────────────┤
│ GPS validation │ Resolve branch chỉ trong │
│ │ timekeeping_unit_branch của user's unit │
│ │ Resolve gps_required từ shift_template: │
│ │ - true → check GPS bình thường │
│ │ - false → skip GPS check cho ca đó │
│ │ Nếu ngoài bán kính → REJECT │
└─────────────────────┴────────────────────────────────────────────┘
Guard rules:
- Double-tap <5s → REJECT "Vui lòng đợi"
- User không có time_slot_user hôm nay → REJECT "Không có ca hôm nay"
- GPS resolve vào branch ngoài timekeeping_unit → REJECT "Ngoài phạm vi"
- `remote_onetime` / `remote_weekly` approved tiếp tục là flow runtime riêng; Day-1 không render thành option cấu hình trong shift formFORMULA-005: Request handlers — multi-segment adaptation
- Ref: PRD FR-006
- Files:
ecommerce-api/event/request_working_schedule_*.go
Đơn cần biết segment:
| Request type | Multi-segment impact |
|---|---|
forget_clock_in_out | Cần field segment_index trong request: quên đầu ca (seg 0), quên giữa trưa (seg 0 clock_out hoặc seg 1 clock_in), quên cuối ca (seg 1) |
clock_in_out | Cần field segment_index: điều chỉnh giờ cho segment cụ thể |
late_arrival_early_leave | Auto-detect: late = seg 0 clock_in, early = seg 1 clock_out |
change_shift | Không cần segment — thay cả ca |
overtime | Không cần segment — OT tính ngoài ca |
leave | Không cần segment nếu nghỉ cả ngày; leave_shift_1/leave_shift_2 map sang segment 0/1 |
Auto-detect logic cho forget_clock_in_out ca 4 mốc:
IF behavior = 'forget_clock_in':
→ target = segment_0.clock_in (nếu null) HOẶC segment_1.clock_in (nếu seg_0 complete)
IF behavior = 'forget_clock_out':
→ target = segment_1.clock_out (nếu seg_1.clock_in exists) HOẶC segment_0.clock_outFORMULA-006: Request count scoped per unit
- Ref: PRD FR-012, FR-013, DEC-020
Reuse: 2 PostgreSQL functions ĐÃ CÓ SẴN:
count_remaining_late_early_requests(user_id, start, end)— đọc max từhrm_master_data.max_amount_late_arrival_leave_early_form(= 3)count_remaining_forget_clock_in_out_requests(user_id, start, end)— đọc max từhrm_master_data.max_amount_forget_clock_in_out_form(= 3)Delta: ALTER 2 functions thêm param
timekeeping_unit_id, thêm WHERE clause. Đọc max từtimekeeping_unitrow thay vì globalhrm_master_datakhiunit_id IS NOT NULL.
SQL delta:
sql
-- Late/early count scoped per unit
SELECT COUNT(*) FROM request_working_schedule
WHERE created_by = $user_id
AND type = 'late_arrival_early_leave'
AND status_id IN ('approved', 'pending')
AND timekeeping_unit_id = $unit_id
AND created_at >= $month_start
AND created_at < $month_end;FORMULA-007: Max duration validation cho đơn đi trễ/về sớm
- Ref: PRD FR-012, DEC-021
IF timekeeping_unit.late_early_max_duration_minutes IS NOT NULL:
IF request.duration_minutes > late_early_max_duration_minutes:
REJECT "Thời gian xin phép vượt quá {max} phút"- Daisy:
late_early_max_duration_minutes = 60 - PN:
late_early_max_duration_minutes = NULL(không giới hạn) - Diva: giữ nguyên logic hiện tại
FORMULA-008: Break window cố định / linh hoạt cho ca 4 mốc
- Ref: PRD FR-014, DEC-019
-- break_mode = 'fixed' (PN ca gãy)
-- scheduled_break_start / scheduled_break_end là hard validation
seg0_early = GREATEST(0, scheduled_break_start - seg0.clock_out) / 60
seg1_late = GREATEST(0, seg1.clock_in - scheduled_break_end) / 60
-- break_mode = 'flex' (Daisy BS/Phụ tá Day-1)
-- scheduled_break_start / scheduled_break_end + break_flex_minutes
-- chỉ lưu metadata tham chiếu, KHÔNG dùng để phạt/block ở mốc 2 và 3
morning_hours = EXTRACT(EPOCH FROM (seg0.clock_out - seg0.clock_in)) / 3600
afternoon_hours = EXTRACT(EPOCH FROM (seg1.clock_out - seg1.clock_in)) / 3600
actual_hours = morning_hours + afternoon_hours
-- Nếu thiếu seg0 hoặc seg1:
-- thiếu mốc 2 hoặc 3 -> mark missing_break
-- actual_hours chỉ tính trên segment đủ dữ liệuFORMULA-009: Tính công theo giờ thực tế — PN (DEC-022)
- Ref: PRD A10 FORMULA-005
- Áp dụng: Chỉ khi
shift_template.workday_calculation_mode = 'hourly' - File:
ecommerce-api/scheduler/log_time_keeping_flag.go(nhánh mới)
sql
-- Resolve standard_hours trực tiếp từ shift_template.standard_hours
-- Ví dụ:
-- PN ca DV full ngày: standard_hours = 8.0, workday = 1.0
-- PN ca VP full ngày: standard_hours = 7.5, workday = 1.0
-- PN part-time: standard_hours = 4.0, workday = 0.5
-- Ca 2 mốc:
actual_hours = EXTRACT(EPOCH FROM (clock_out - clock_in)) / 3600
- COALESCE(break_duration_hours, 0)
-- Ca 4 mốc:
actual_hours = (seg0.clock_out - seg0.clock_in) + (seg1.clock_out - seg1.clock_in)
-- break tự trừ vì 2 segment tách biệt
-- Check đơn trễ/sớm approved trong ngày
has_approved_late_early = EXISTS(
SELECT 1 FROM request_working_schedule
WHERE created_by = $user_id
AND type = 'late_arrival_early_leave'
AND status_id = 'approved'
AND DATE(from) = $today
AND timekeeping_unit_id = $unit_id
)
-- Tính workday
IF has_approved_late_early:
workday = shift_template.workday -- full công
ELSE:
workday = LEAST(
(actual_hours / shift_template.standard_hours) * shift_template.workday,
shift_template.workday -- cap tại max
)
workday = ROUND(workday, 2) -- 2 decimalEdge cases:
actual_hours > shift_template.standard_hours→ cap tạishift_template.workdayactual_hours <= 0→workday = 0clock_out IS NULL(quên chấm ra) →actual_hours = NULL, status =missing_end. Cronjob KHÔNG gánworkday = 0. Giữ pending cho đến khi HR xử lý đơn quên chấmclock_in IS NULL→ tương tự, status =missing_start- Ca 4 mốc thiếu 1 segment → tính
actual_hourschỉ từ segment có đủ (VD: seg0 = 4h, thiếu seg1 → actual_hours = 4h) - Round: 2 decimal
Ghi chú quan trọng:
- Logic "late >60min → trừ 0.5 workday" CHỈ áp dụng khi
shift_template.workday_calculation_mode = 'fixed'. Khihourly, workday đã phản ánh qua actual_hours — KHÔNG trừ double timekeeping_unit_id IS NULL(Diva native) → legacy code
FORMULA-010: Công chuẩn per unit — resolve theo department -> standard_workday_scope_key
- Ref: PRD DEC-013 v3
- Hiện trạng cần thay:
user_salary.gođang dùngbranchLabelID - Quy ước Day-1 mới: resolve từ
timekeeping_unit_user.primary_department_id→timekeeping_standard_workday_scope_department→timekeeping_standard_workday_rule
// Cronjob hoặc salary init:
IF timekeeping_unit_id IS NULL:
// DIVA LEGACY — code cũ nguyên vẹn
IF branchLabelID IN ('general', 'office') → countNonSundayDays(month)
ELSE → 26.0
IF timekeeping_unit_id IS NOT NULL:
// PN / DAISY — đọc từ Settings Module
scope = SELECT standard_workday_scope_key
FROM timekeeping_standard_workday_scope_department
WHERE timekeeping_unit_id = $unit_id
AND department_id = $primary_department_id
rule = SELECT formula, fixed_value
FROM timekeeping_standard_workday_rule
WHERE timekeeping_unit_id = $unit_id
AND standard_workday_scope_key = scope.standard_workday_scope_key
IF rule NOT FOUND → fallback: 26.0
SWITCH rule.formula:
'days_minus_sun' → countNonSundayDays(month)
'days_minus_sun_half_sat' → countNonSundayDays(month) - 0.5 * countSaturdays(month)
'fixed_26' → 26.0
'fixed_custom' → rule.fixed_valueVí dụ PN tháng 4/2026 (30 ngày, 4 CN, 4 T7):
- NV thuộc scope
PN_SERVICE→ rule =days_minus_sun→ 30 − 4 = 26 ngày - NV thuộc scope
PN_OFFICE→ rule =days_minus_sun_half_sat→ 30 − 4 − 0.5×4 = 24 ngày
Diva chuyển sang (khi tạo unit DIVA):
- Seed rule mapping khớp 1:1 logic cũ → kết quả KHÔNG ĐỔI
FORMULA-011: Penalty engine — tính phạt vi phạm per unit
- Ref: DEC-012 v3, DEC-027
- Table:
timekeeping_penalty_rule - File:
ecommerce-api/scheduler/log_time_keeping_flag.go(nhánh penalty)
// Chạy cuối mỗi ngày (trong cronjob flag) hoặc khi generate salary
FOR EACH user IN unit:
rules = SELECT * FROM timekeeping_penalty_rule
WHERE timekeeping_unit_id = $unit_id
AND disabled = false
ORDER BY sort_order
-- Đếm số lần vi phạm trong tháng per violation_type
FOR EACH rule IN rules:
IF rule.exempt_pool = 'individual':
-- PN style: đếm riêng per violation_type
count = COUNT violations WHERE type = rule.violation_type
AND user_id = $user_id
AND month = $current_month
IF rule.exempt_pool = 'shared':
-- Daisy style: đếm CHUNG tất cả violation_type
count = COUNT violations WHERE type IN ('late_early', 'forget_start', 'forget_end', 'forget_break')
AND user_id = $user_id
AND month = $current_month
-- Áp dụng miễn
IF count <= rule.exempt_count:
penalty = 0 -- miễn
-- Tính phạt cho các lần vượt exempt
IF count > rule.exempt_count:
billable_count = count - rule.exempt_count
SWITCH rule.penalty_mode:
'per_minute':
-- VD: PN đi trễ 10k/phút
penalty = SUM(violation_minutes) * rule.penalty_amount
-- Chỉ tính violation_minutes của lần thứ (exempt_count+1) trở đi
'fixed_amount':
-- VD: PN quên chấm 30k/lần
penalty = billable_count * rule.penalty_amount
'deduct_workday':
-- VD: Daisy quên chấm trừ 0.5 công/lần
workday -= billable_count * rule.penalty_workday
-- Output: penalty_amount (tiền), penalty_workday_deduction (ngày công bị trừ)Ví dụ PN:
- NV trễ 5 lần trong tháng, exempt_count = 3, penalty_mode = per_minute
- 3 lần đầu: miễn
- Lần 4 (trễ 15 phút): 15 × 10,000 = 150,000đ
- Lần 5 (trễ 8 phút): 8 × 10,000 = 80,000đ
Ví dụ Daisy (shared pool):
- NV quên chấm vào 1 lần + quên chấm ra 2 lần + trễ 1 lần = tổng 4 lần (shared pool)
- exempt_count = 3: 3 lần đầu miễn (bất kể loại nào)
- Lần 4 (trễ 10 phút): áp rule
late_early→ 10 × 10,000 = 100,000đ
FORMULA-012: OT rate — tính tiền tăng ca per unit
- Ref: DEC-022 (PN), timekeeping_unit config
- Fields:
timekeeping_unit.ot_rate_default,timekeeping_unit.ot_rate_doctor
// Xác định rate dựa trên role NV
IF employee.is_doctor = true OR employee.department IN (doctor_departments):
rate = timekeeping_unit.ot_rate_doctor
ELSE:
rate = timekeeping_unit.ot_rate_default
// Tính tiền OT
ot_amount = ot_hours * rate
// Threshold (PN = 30 phút, Daisy = 0)
IF timekeeping_unit.ot_min_threshold_minutes > 0:
IF ot_duration_minutes < ot_min_threshold_minutes:
ot_hours = 0 -- skip OT < threshold
ot_amount = 0Ví dụ PN:
ot_rate_default = 50,000 đ/giờ,ot_rate_doctor = 150,000 đ/giờot_min_threshold_minutes = 30- NV thường OT 2 giờ: 2 × 50,000 = 100,000đ
- Bác sĩ OT 1.5 giờ: 1.5 × 150,000 = 225,000đ
- NV OT 20 phút (< 30p threshold): skip, ot_amount = 0
Ví dụ Daisy:
ot_rate_default = 35,000 đ/giờ,ot_rate_doctor = 150,000 đ/giờot_min_threshold_minutes = 0(không threshold)- NV OT 20 phút: 0.33 × 35,000 = 11,667đ (vẫn tính)
C4) Mô hình dữ liệu (Data Model)
Bảng mới
sql
CREATE TABLE hrm.public.timekeeping_unit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
rollout_phase TEXT NOT NULL DEFAULT 'OFF',
allow_admin_timekeeping BOOLEAN NOT NULL DEFAULT false,
allow_mobile_self_service BOOLEAN NOT NULL DEFAULT false,
auto_schedule_disabled BOOLEAN NOT NULL DEFAULT false, -- true cho Daisy (ca xoay, HR import manual)
ot_min_threshold_minutes SMALLINT NOT NULL DEFAULT 0, -- PN = 30, Daisy = 0
late_early_max_duration_minutes SMALLINT NULL, -- Daisy = 60, PN = NULL (no limit)
late_grace_minutes SMALLINT NOT NULL DEFAULT 1, -- trễ <X phút không tính
late_deduct_threshold_minutes SMALLINT NOT NULL DEFAULT 60, -- trễ >X phút trừ 0.5 công (fixed mode only)
-- Nhóm Công chuẩn: dùng nhóm áp dụng công chuẩn + map department
-- Nhóm Đơn từ
max_late_early_requests_per_month SMALLINT NOT NULL DEFAULT 3,
max_forget_clock_requests_per_month SMALLINT NOT NULL DEFAULT 3,
-- Nhóm Tăng ca
ot_rate_default INTEGER NOT NULL DEFAULT 0, -- đ/giờ mặc định
ot_rate_doctor INTEGER NOT NULL DEFAULT 0, -- đ/giờ Bác sĩ
-- Nhóm GPS
gps_radius_meters INTEGER NULL, -- bán kính GPS per unit; NULL = fallback global AppSettings.Position.Value.Distance
disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Reuse time_slot_template.workday hiện có làm max_workday.
-- Day-1 chỉ ALTER thêm 3 cột để shift tự quyết định mode tính công + yêu cầu GPS của ca.
ALTER TABLE hrm.public.time_slot_template
ADD COLUMN IF NOT EXISTS workday_calculation_mode TEXT NOT NULL DEFAULT 'fixed',
ADD COLUMN IF NOT EXISTS standard_hours NUMERIC(4,1) NULL,
ADD COLUMN IF NOT EXISTS gps_required BOOLEAN NOT NULL DEFAULT true;
-- Công chuẩn — mapping theo nhóm áp dụng công chuẩn
CREATE TABLE hrm.public.timekeeping_standard_workday_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL REFERENCES timekeeping_unit(id),
standard_workday_scope_key TEXT NOT NULL,
scope_name TEXT NOT NULL,
formula TEXT NOT NULL DEFAULT 'days_minus_sun',
-- enum: 'days_minus_sun' = tổng ngày − CN
-- 'days_minus_sun_half_sat' = tổng ngày − CN − 0.5×T7
-- 'fixed_26' = 26 ngày cố định
-- 'fixed_custom' = giá trị tuỳ chỉnh
fixed_value NUMERIC(4,1) NULL, -- chỉ dùng khi formula = 'fixed_custom'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(timekeeping_unit_id, standard_workday_scope_key)
);
CREATE TABLE hrm.public.timekeeping_standard_workday_scope_department (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL REFERENCES timekeeping_unit(id),
standard_workday_scope_key TEXT NOT NULL,
department_id UUID NOT NULL REFERENCES department(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(timekeeping_unit_id, department_id),
UNIQUE(timekeeping_unit_id, standard_workday_scope_key, department_id)
);
-- Seed Phương Nam
INSERT INTO timekeeping_standard_workday_rule
(timekeeping_unit_id, standard_workday_scope_key, scope_name, formula, fixed_value)
VALUES
('PN_ID', 'PN_SERVICE', 'PN - Khối Dịch vụ', 'days_minus_sun', NULL),
('PN_ID', 'PN_OFFICE', 'PN - Khối Văn phòng', 'days_minus_sun_half_sat', NULL);
-- Seed Daisy
INSERT INTO timekeeping_standard_workday_rule
(timekeeping_unit_id, standard_workday_scope_key, scope_name, formula, fixed_value)
VALUES
('DS_ID', 'DAISY_OFFICE_ACCOUNTING', 'Daisy - VP Kế toán', 'fixed_custom', 24.0),
('DS_ID', 'DAISY_OFFICE_TELE_CSKH_PAGE_BRANCH', 'Daisy - VP Tele/CSKH/Page/Chi nhánh', 'fixed_26', NULL),
('DS_ID', 'DAISY_SERVICE', 'Daisy - Khối Dịch vụ', 'fixed_26', NULL);
-- Vi phạm & Phạt — bảng con riêng per unit (DEC-027)
CREATE TABLE hrm.public.timekeeping_penalty_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL REFERENCES timekeeping_unit(id),
violation_type TEXT NOT NULL,
-- enum: 'late_early', 'forget_start', 'forget_end', 'forget_break'
penalty_mode TEXT NOT NULL,
-- enum: 'per_minute' (VD: 10k/phút), 'fixed_amount' (VD: 30k/lần), 'deduct_workday' (VD: 0.5 công)
penalty_amount INTEGER NOT NULL DEFAULT 0,
penalty_workday NUMERIC(3,1) NOT NULL DEFAULT 0,
exempt_count SMALLINT NOT NULL DEFAULT 0, -- 0 = không miễn, 3 = miễn 3 lần đầu
exempt_pool TEXT NOT NULL DEFAULT 'individual',
-- 'individual': đếm riêng per violation_type (PN style)
-- 'shared': đếm chung tất cả violation_type (Daisy style)
sort_order SMALLINT NOT NULL DEFAULT 0,
disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(timekeeping_unit_id, violation_type)
);
-- Seed penalty rules
-- Phương Nam
INSERT INTO timekeeping_penalty_rule
(timekeeping_unit_id, violation_type, penalty_mode, penalty_amount, penalty_workday, exempt_count, exempt_pool, sort_order)
VALUES
('PN_ID', 'late_early', 'per_minute', 10000, 0, 3, 'individual', 1),
('PN_ID', 'forget_start', 'fixed_amount', 30000, 0, 0, 'individual', 2), -- KHÔNG miễn
('PN_ID', 'forget_end', 'fixed_amount', 30000, 0, 0, 'individual', 3), -- KHÔNG miễn
('PN_ID', 'forget_break', 'fixed_amount', 30000, 0, 3, 'individual', 4); -- miễn 3, pool riêng
-- Daisy
INSERT INTO timekeeping_penalty_rule
(timekeeping_unit_id, violation_type, penalty_mode, penalty_amount, penalty_workday, exempt_count, exempt_pool, sort_order)
VALUES
('DS_ID', 'late_early', 'per_minute', 10000, 0, 3, 'individual', 1),
('DS_ID', 'forget_start', 'deduct_workday', 0, 0.5, 3, 'shared', 2), -- pool CHUNG
('DS_ID', 'forget_end', 'deduct_workday', 0, 0.5, 0, 'shared', 3), -- pool CHUNG
('DS_ID', 'forget_break', 'fixed_amount', 50000, 0, 0, 'shared', 4); -- pool CHUNG
-- Seed data timekeeping_unit
-- Cột liệt kê đầy đủ, VALUES map 1:1 theo thứ tự
INSERT INTO timekeeping_unit (
code, -- 1
name, -- 2
rollout_phase, -- 3
allow_admin_timekeeping, -- 4
allow_mobile_self_service, -- 5
auto_schedule_disabled, -- 6
ot_min_threshold_minutes, -- 7
late_early_max_duration_minutes, -- 8
late_grace_minutes, -- 9
late_deduct_threshold_minutes, -- 10
max_late_early_requests_per_month, -- 11
max_forget_clock_requests_per_month, -- 12
ot_rate_default, -- 13
ot_rate_doctor, -- 14
gps_radius_meters -- 15
) VALUES
-- Phương Nam (15 values)
(
'PN', -- 1 code
'Phương Nam', -- 2 name
'1B', -- 3 rollout_phase
true, -- 4 allow_admin_timekeeping
true, -- 5 allow_mobile_self_service
false, -- 6 auto_schedule_disabled (cron tự sinh lịch)
30, -- 7 ot_min_threshold_minutes (skip OT < 30p)
NULL, -- 8 late_early_max_duration_minutes (không giới hạn)
1, -- 9 late_grace_minutes
60, -- 10 late_deduct_threshold_minutes
3, -- 11 max_late_early_requests_per_month
3, -- 12 max_forget_clock_requests_per_month
50000, -- 13 ot_rate_default (50k đ/giờ)
150000, -- 14 ot_rate_doctor (150k đ/giờ)
200 -- 15 gps_radius_meters
),
-- Daisy (15 values)
(
'DS', -- 1 code
'Daisy', -- 2 name
'1B', -- 3 rollout_phase
true, -- 4 allow_admin_timekeeping
true, -- 5 allow_mobile_self_service
true, -- 6 auto_schedule_disabled (ca xoay, HR import)
0, -- 7 ot_min_threshold_minutes (không threshold)
60, -- 8 late_early_max_duration_minutes (DEC-021)
1, -- 9 late_grace_minutes
60, -- 10 late_deduct_threshold_minutes
3, -- 11 max_late_early_requests_per_month
3, -- 12 max_forget_clock_requests_per_month
35000, -- 13 ot_rate_default (35k đ/giờ)
150000, -- 14 ot_rate_doctor (150k đ/giờ)
200 -- 15 gps_radius_meters
);
-- Công chuẩn: dùng bảng timekeeping_standard_workday_rule (seed ở trên)
-- KHÔNG dùng fields trên timekeeping_unit cho công chuẩn
-- Nghỉ giữa ca cố định / linh hoạt: dùng `time_slot_template.break_mode` + `break_flex_minutes`
CREATE TABLE hrm.public.timekeeping_unit_branch (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL,
branch_id UUID NOT NULL,
disabled BOOLEAN NOT NULL DEFAULT false,
UNIQUE(timekeeping_unit_id, branch_id)
);
CREATE TABLE hrm.public.timekeeping_unit_department (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL,
department_id UUID NOT NULL,
disabled BOOLEAN NOT NULL DEFAULT false,
UNIQUE(timekeeping_unit_id, department_id)
);
CREATE TABLE hrm.public.timekeeping_unit_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
timekeeping_unit_id UUID NOT NULL,
primary_branch_id UUID NULL,
primary_department_id UUID NULL,
effective_from DATE NOT NULL,
effective_to DATE NULL,
disabled BOOLEAN NOT NULL DEFAULT false,
UNIQUE(user_id, timekeeping_unit_id, effective_from)
);
CREATE TABLE hrm.public.timekeeping_request_approver (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
timekeeping_unit_id UUID NOT NULL,
request_type TEXT NOT NULL,
scope_type TEXT NOT NULL,
scope_id UUID NOT NULL,
user_id TEXT NOT NULL,
level_id TEXT NOT NULL
);Bảng hiện có cần mở rộng
sql
ALTER TABLE hrm.public.time_slot_template
ADD COLUMN timekeeping_unit_id UUID,
ADD COLUMN canonical_key TEXT,
ADD COLUMN break_clocking_required BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN break_mode TEXT NOT NULL DEFAULT 'none',
ADD COLUMN break_flex_minutes SMALLINT NOT NULL DEFAULT 0;
-- PHÂN BIỆT: break_time (đã có) ≠ break_clocking_required (mới)
-- break_time = true: ca CÓ giờ nghỉ trong schedule (VD: Kế toán 12:00-13:00). NV KHÔNG cần chấm ra/vào trưa.
-- break_clocking_required = true: NV PHẢI chấm 4 mốc (vào ca, ra nghỉ, vào lại, ra về).
-- break_mode = 'fixed' hoặc 'flex' chỉ dùng khi break_clocking_required = true.
-- break_flex_minutes là metadata tham chiếu quanh break window của template, KHÔNG thay thế start_break_time/end_break_time.
-- Cả 2 field cần tồn tại song song. VD: Kế toán Daisy: break_time=true + break_clocking_required=false (có break nhưng chỉ 2 mốc).
ALTER TABLE hrm.public.time_slot_user
ADD COLUMN timekeeping_unit_id UUID;
ALTER TABLE hrm.public.time_slot_time_keeping
ADD COLUMN timekeeping_unit_id UUID,
ADD COLUMN time_slot_user_id UUID,
ADD COLUMN segment_index SMALLINT;
ALTER TABLE hrm.public.request_working_schedule
ADD COLUMN timekeeping_unit_id UUID,
ADD COLUMN segment_index SMALLINT; -- NULL cho ca 2 mốc, 0 hoặc 1 cho ca 4 mốc
ALTER TABLE hrm.public.annual_leave_logs
ADD COLUMN timekeeping_unit_id UUID; -- audit trail per unit, nullable (Diva = NULL)Không sửa trực tiếp
branchdepartmentbranch_userdepartment_userrequest_approverdùng chung cho flow ngoài timekeeping
Constraints
sql
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE hrm.public.time_slot_time_keeping
ADD CONSTRAINT uq_tstk_slot_segment UNIQUE (time_slot_user_id, segment_index);
ALTER TABLE hrm.public.time_slot_template
ADD CONSTRAINT uq_tst_template_unit_key UNIQUE (timekeeping_unit_id, canonical_key);
ALTER TABLE hrm.public.timekeeping_unit_user
ADD CONSTRAINT ex_tku_user_active_period
EXCLUDE USING gist (
user_id WITH =,
daterange(effective_from, COALESCE(effective_to, 'infinity'::date), '[]') WITH &&
)
WHERE (disabled = false);FK + Index cho column mới
sql
-- FK constraints
ALTER TABLE time_slot_template ADD CONSTRAINT fk_tst_unit
FOREIGN KEY (timekeeping_unit_id) REFERENCES timekeeping_unit(id);
ALTER TABLE time_slot_user ADD CONSTRAINT fk_tsu_unit
FOREIGN KEY (timekeeping_unit_id) REFERENCES timekeeping_unit(id);
ALTER TABLE time_slot_time_keeping ADD CONSTRAINT fk_tstk_unit
FOREIGN KEY (timekeeping_unit_id) REFERENCES timekeeping_unit(id);
ALTER TABLE request_working_schedule ADD CONSTRAINT fk_rws_unit
FOREIGN KEY (timekeeping_unit_id) REFERENCES timekeeping_unit(id);
-- Indexes (cronjob + admin query heavy)
CREATE INDEX idx_tstk_unit ON time_slot_time_keeping(timekeeping_unit_id) WHERE timekeeping_unit_id IS NOT NULL;
CREATE INDEX idx_tsu_unit ON time_slot_user(timekeeping_unit_id) WHERE timekeeping_unit_id IS NOT NULL;
CREATE INDEX idx_rws_unit ON request_working_schedule(timekeeping_unit_id) WHERE timekeeping_unit_id IS NOT NULL;
-- timekeeping_request_approver UNIQUE constraint
ALTER TABLE timekeeping_request_approver
ADD CONSTRAINT uq_tra_config UNIQUE (timekeeping_unit_id, request_type, scope_type, scope_id, user_id, level_id);Persistence rules
| Rule | Mô tả |
|---|---|
| R1 | Một user chỉ có 1 active timekeeping_unit tại một thời điểm |
| R2 | Khi đổi unit giữa tháng, record cũ không hồi tố |
| R3 | timekeeping_unit_id là bắt buộc cho record rollout mới |
| R4 | branch/department chỉ được map, không bị ownership bởi timekeeping_unit ở global scope |
| R5 | Day-1 punch chỉ hợp lệ khi GPS resolve được vào branch đã map trong đúng timekeeping_unit |
| R6 | Rollout Day-1 không mở UI remote mới; nhóm nhỏ chỉ reuse flow remote_onetime hiện có để bypass GPS khi đã approved |
| R7 | Ca 4 mốc tạo 2 record time_slot_time_keeping per day (segment_index 0 và 1) |
| R8 | generate_working_shift cron skip user thuộc unit có auto_schedule_disabled = true |
| R9 | request_working_schedule.segment_index nullable: NULL cho ca 2 mốc, 0/1 cho ca 4 mốc |
| R10 | OT request trong unit có ot_min_threshold_minutes > 0 → reject nếu duration < threshold |
| R11 | Backend đọc config từ timekeeping_unit row — KHÔNG hardcode trong code (CFG-001) |
| R12 | Đổi config chỉ ảnh hưởng record từ ngày hôm sau, không hồi tố (CFG-004) |
| R13 | Mỗi lần save config → ghi audit log (CFG-006) |
| R14 | timekeeping_unit_branch và timekeeping_unit_department dùng soft-disable (disabled = true) khi user chọn Xóa khỏi đơn vị; không hard delete Day-1 |
| R15 | Re-map branch/department đã từng bị disable phải reactivate row cũ (disabled = false), không insert row mới nếu vi phạm unique |
| R16 | timekeeping_unit_user phải lưu primary_branch_id + primary_department_id để khớp list/filter/export/approver scope ở Day-1 |
| R17 | Chờ hiệu lực chỉ là derived state của timekeeping_unit_user khi effective_from > today; không dùng state này cho branch/department mapping |
| R18 | Readiness checklist chỉ tính trên dữ liệu active (disabled = false) và assignment ở trạng thái Đang áp dụng hoặc Chờ hiệu lực |
C5) API / Hasura / Phân quyền (Permissions)
Session contract
| Trường (Field) | Ý nghĩa |
|---|---|
active_timekeeping_unit_id | Unit chấm công đang thao tác |
X-Hasura-Branch-Id | Giữ nguyên contract cũ nếu có |
Resolve active_timekeeping_unit_id
1. Query timekeeping_unit_user WHERE user_id = current_user
AND disabled = false
AND effective_from <= today
AND (effective_to IS NULL OR effective_to >= today)
2. Nếu có đúng 1 row → active_timekeeping_unit_id = row.timekeeping_unit_id
3. Nếu có 0 row → active_timekeeping_unit_id = NULL (Diva native, legacy behavior)
4. Nếu System Admin → active_timekeeping_unit_id = từ filter UI (bắt buộc chọn)Permission rules
- Chỉ bảng timekeeping rollout mới cần filter theo
timekeeping_unit_id - Không thêm
timekeeping_unitfilter vào dashboard/report/salary remoterequest/punch path của rollout unit không expose trong Day-1 UI/API mới- HR chỉ query được data trong
timekeeping_unitmình thuộc; Hasura permission row-level filter theotimekeeping_unit_id - Manager chỉ query data trong unit mình + department/branch mình quản lý
- System Admin bypass unit filter — nhưng FE bắt buộc chọn unit trước
- Diva native user: mọi query timekeeping không filter theo
timekeeping_unit_id(legacy)
Request count scoped per unit
count_remaining_late_early_requests()vàcount_remaining_forget_clock_in_out_requests()cần thêm paramtimekeeping_unit_id- Nếu
timekeeping_unit_id IS NULL→ fallback global count (legacy Diva behavior) - Mobile app gọi count trước khi hiện form tạo đơn → hiển thị "X/3 lần trong tháng"
Action/Event impact
| Path | Delta |
|---|---|
logTimeKeeping | Resolve timekeeping_unit_id, time_slot_user_id, segment_index |
checkAttendance | Không dùng làm source of truth HR timekeeping |
request_working_schedule_* | Overlay request theo timekeeping_unit_id |
time_slot_template_update.go | Join theo canonical_key, không join theo name |
Admin actions — SCR-00
| Action | Input tối thiểu | Output tối thiểu | Ghi chú |
|---|---|---|---|
upsertTimekeepingUnitBranch | timekeeping_unit_id, branch_ids[] | Danh sách branch active sau khi lưu | Nếu branch đã có row disabled thì reactivate |
disableTimekeepingUnitBranch | timekeeping_unit_id, branch_id | disabled = true + impact summary | Không hard delete Day-1 |
upsertTimekeepingUnitDepartment | timekeeping_unit_id, department_ids[] | Danh sách department active sau khi lưu | Nếu department đã có row disabled thì reactivate |
disableTimekeepingUnitDepartment | timekeeping_unit_id, department_id | disabled = true + impact summary | Không hard delete Day-1 |
bulkUpsertTimekeepingUnitUser | timekeeping_unit_id, items[{user_id, primary_branch_id, primary_department_id}], effective_from, effective_to, reason | created_count, reassigned_count, skipped[] | Hỗ trợ create mới + re-assignment |
bulkDisableTimekeepingUnitUser | assignment_ids[] hoặc user_ids[], effective_to, reason | updated_count, skipped[] | Dùng cho action Xóa nhân viên khỏi đơn vị |
previewTimekeepingUnitUserImport | timekeeping_unit_id, staff_codes[] | matched[], unmatched[], invalid_scope[] | Dùng cho cả upload file và paste mã NV |
getTimekeepingUnitReadiness | timekeeping_unit_id | Checklist counts + status badges | Dùng cho SCR-00 và tab Triển khai |
Readiness aggregate contract
Checklist trong SCR-00 và tab Triển khai đọc từ 1 payload thống nhất:
json
{
"timekeeping_unit_id": "uuid",
"branch": { "actual": 6, "target": 6, "status": "ready" },
"department": { "actual": 6, "target": 6, "status": "ready" },
"user": { "actual": 148, "target": 150, "status": "warning" },
"approver": { "actual": 5, "target": 6, "status": "warning" },
"shift_template": { "actual": 17, "target": 17, "status": "ready" },
"rule_config": { "status": "ready" },
"export_smoke_test": { "status": "missing" }
}Nguồn tính toán tối thiểu:
branch.actual= counttimekeeping_unit_branchWHEREdisabled = falsedepartment.actual= counttimekeeping_unit_departmentWHEREdisabled = falseuser.actual= counttimekeeping_unit_userWHEREdisabled = falseAND (effective_to IS NULL OR effective_to >= today)approver.actual= số request type Day-1 có approver hợp lệ trongtimekeeping_request_approvershift_template.actual= counttime_slot_templateactive thuộc unitrule_config.status=readykhi các field bắt buộc trongtimekeeping_unit+ rule tables đã có giá trị hợp lệexport_smoke_test.status=readykhi có evidence row/log cho smoke test mới nhất của unit
GPS contract
- Runtime chỉ tìm candidate branch trong
timekeeping_unit_branchcủaactive_timekeeping_unit_id - Nếu không có branch hợp lệ trong bán kính cho phép, action reject với message rõ ràng
- Không fallback sang branch ngoài unit dù GPS hợp lệ theo khoảng cách
Bán kính GPS:
Reuse:
log_time_keeping.gođã có GPS check hoàn chỉnh:
CalculateDistance(branch, user, "K")— tính khoảng cách (line ~78)distance * 1000 <= AppSettings.Position.Value.Distance— so sánh radius (line ~98)- Remote bypass: check
remote_onetime+remote_weekly→ bypass nếu có đơn approved (line ~88-103)Delta: Thay query ALL branches → chỉ branch trong
timekeeping_unit_branchcủa user's unit.
- Branch coordinates Day-1: tiếp tục đọc từ branch master data hiện có (
reference_address.position/latitude/longitude), không tạo màn GPS master mới - Radius Day-1: dùng
gps_radius_meterspertimekeeping_unit(Settings Module). Nếu NULL → fallback globalAppSettings.Position.Value.Distance - GPS Day-1: resolve từ
shift_template.gps_requiredtrue: check GPS như bình thườngfalse: bỏ qua GPS check cho ca đó
remote_onetime/remote_weeklytiếp tục là flow request/runtime riêng đang có; không encode thành option kỹ thuật trong shift form
Branch overlap giữa 2 unit:
- Constraint: 1 branch physical có thể được map vào nhiều
timekeeping_unit(VD: cùng toà nhà) - GPS resolve flow:
candidate_branches = SELECT branch_id FROM timekeeping_unit_branch WHERE timekeeping_unit_id = user's active unit→ chỉ check distance với branches trong set này - Nếu NV thuộc unit A đứng gần branch của unit B → KHÔNG match (vì branch B không nằm trong candidate set)
- Validation khi map branch: UI cảnh báo nếu branch đã thuộc unit khác (soft warning, không block — vì có thể cùng toà nhà hợp lệ)
C6) Thành phần giao diện (Frontend Components)
File Structure
text
diva-admin/src/modules/timekeeping/
├── pages/
│ ├── WorkingSchedule.tsx
│ └── WorkingTimeSheet.tsx
├── components/
│ ├── WorkingTimeSheetItem.tsx
│ ├── NoClockCell.tsx
│ ├── ExportWorkingTimeSheet.tsx
│ └── ExportWorkingTimeSheetByDay.tsx
diva-admin/src/modules/settings/pages/
├── ShiftSetting.tsx
└── ApproverPage.tsx
diva-admin/src/modules/user/components/employee/
└── EmployeeProfileTimekeepingHistory.tsx
diva-flutter/staff/lib/presentation/modules/main/attendance/
├── bloc/attendance_bloc.dart
└── views/attendance_screen.dartMobile edge cases (Flutter)
| Scenario | Behavior |
|---|---|
| Network timeout khi punch | Hiện error "Không kết nối được máy chủ. Vui lòng thử lại." + nút Retry |
| GPS timeout (>10s) | Hiện error "Không xác định được vị trí. Vui lòng bật GPS và thử lại." — phân biệt rõ với "ngoài phạm vi" |
| App bị kill giữa chừng punch | Không có local queue Day-1. Khi re-open, load lại state từ server. Nếu punch đã ghi → hiện state mới. Nếu chưa ghi → hiện state cũ, NV bấm lại |
| Double-tap <5s | Reject tap thứ 2 với message "Vui lòng đợi" |
| NV không có ca hôm nay | Hiện message "Không có ca làm việc hôm nay" — không hiện CTA |
| NV chưa assign vào unit | Giữ behavior legacy hiện có (nếu có), hoặc hiện empty state |
FE rules
- Không sửa list/report/dashboard module ngoài scope trên.
- Không mở rộng user management global ngoài field/select cần cho timekeeping assignment.
- Khi đổi filter
timekeeping_unit, FE phải resetbranch,department,shift group ApproverPagechỉ enable delta cho request type Day-1; không enableremote
C7) Chiến lược migration (Migration Strategy)
Order
- Tạo bảng
timekeeping_unit* - Add nullable
timekeeping_unit_id,segment_indexvào bảng timekeeping - Seed shift templates: PN 16 ca (gồm 6 ca gãy 4 mốc), Daisy ~17 ca (gồm 4-punch cho khối DV)
- Backfill
canonical_keychotime_slot_templatehiện dùng trong PN/Daisy rollout - Thay event/template propagation từ
namesangcanonical_key - Backfill mapping cho Daisy/PN pilot users
- Update
log_time_keeping_flagcronjob: multi-segment logic (FORMULA-003) - Update
logTimeKeepingaction: state machine cho 4 mốc (FORMULA-004) - Update
generate_working_shiftcron: skip unit cóauto_schedule_disabled = true - Mở admin filter
- Mở PN mobile 2 mốc (ca thường) + 4 mốc (ca gãy) + GPS
- Mở Daisy 4 mốc (DV) + 2 mốc (VP)
Seed shift templates
Phương Nam (16 ca):
canonical_key | Tên | Start | End | Break | Break start | Break end | break_clocking_required | break_mode | break_flex_minutes |
|---|---|---|---|---|---|---|---|---|---|
pn_hc | Ca hành chính | 08:00 | 17:00 | true | 12:00 | 13:30 | false | none | 0 |
pn_ca1 | Ca 1 | 06:00 | 14:00 | false | — | — | false | none | 0 |
pn_ca2 | Ca 2 | 07:00 | 15:30 | false | — | — | false | none | 0 |
pn_ca3 | Ca 3 | 07:30 | 16:00 | false | — | — | false | none | 0 |
pn_ca4 | Ca 4 | 07:00 | 18:00 | false | — | — | false | none | 0 |
pn_ca5 | Ca 5 | 07:30 | 18:00 | false | — | — | false | none | 0 |
pn_ca6 | Ca 6 | 08:00 | 17:00 | false | — | — | false | none | 0 |
pn_ca7 | Ca 7 | 09:00 | 18:00 | false | — | — | false | none | 0 |
pn_ca8 | Ca 8 | 10:00 | 19:00 | false | — | — | false | none | 0 |
pn_toi | Ca tối | 15:00 | 23:00 | false | — | — | false | none | 0 |
pn_gay_7_14 | Ca gãy 7:00 14:00 | 07:00 | 18:00 | true | 11:00 | 14:00 | true | fixed | 0 |
pn_gay_730_1330 | Ca gãy 7:30 13:30 | 07:30 | 17:30 | true | 11:30 | 13:30 | true | fixed | 0 |
pn_gay_730_1400 | Ca gãy 7:30 14:00 | 07:30 | 18:00 | true | 11:30 | 14:00 | true | fixed | 0 |
pn_gay_730_1300 | Ca gãy 7:30 13:00 | 07:30 | 17:00 | true | 11:30 | 13:00 | true | fixed | 0 |
pn_gay_730_1700 | Ca gãy 7:30 17:00 | 07:30 | 17:00 | true | 11:30 | 13:30 | true | fixed | 0 |
pn_gay_800 | Ca gãy 8:00 | 08:00 | 18:00 | true | 11:30 | 13:30 | true | fixed | 0 |
Ghi chú PN: 6 ca gãy dùng
break_clocking_required = true→ 4 mốc (DEC-014 confirmed). PN xác nhận cần track trễ đầu ca sáng + đầu ca chiều.break_mode = fixed,break_flex_minutes = 0. Ca thường (Ca HC, Ca 1–8, Ca tối) vẫn 2 mốc.
Daisy (~17 ca):
canonical_key | Tên | Start | End | Break | Break start | Break end | break_clocking_required | break_mode | break_flex_minutes |
|---|---|---|---|---|---|---|---|---|---|
ds_mkt_ca1 | Marketing Ca 1 | 07:30 | 15:00 | false | — | — | false | none | 0 |
ds_mkt_ca2 | Marketing Ca 2 | 10:30 | 18:00 | false | — | — | false | none | 0 |
ds_mkt_ca3 | Marketing Ca 3 | 15:00 | 22:30 | false | — | — | false | none | 0 |
ds_tele | Telesales/CSKH | 07:45 | 17:30 | true | 12:00 | 13:30 | false | none | 0 |
ds_bs_ca1 | Bác sĩ Ca 1 (xuyên trưa) | 08:00 | 17:00 | false | — | — | false | none | 0 |
ds_bs_ca2 | Bác sĩ Ca 2 | 08:00 | 19:00 | true | 12:00 | 14:00 | true | flex | 60 |
ds_phuta_ca1 | Phụ tá Ca 1 (xuyên trưa) | 08:00 | 17:00 | false | — | — | false | none | 0 |
ds_phuta_ca2 | Phụ tá Ca 2 | 08:00 | 19:00 | true | 12:00 | 14:00 | true | flex | 60 |
ds_baove | Bảo vệ | 08:00 | 19:00 | true | — | — | false | none | 0 |
ds_tapvu_nt_sang | Tạp vụ NT Sáng | 06:30 | 10:30 | false | — | — | false | none | 0 |
ds_tapvu_nt_chieu | Tạp vụ NT Chiều | 12:30 | 16:30 | false | — | — | false | none | 0 |
ds_tapvu_0630 | Tạp vụ 06:30 | 06:30 | 15:30 | true | 10:30 | 11:30 | false | none | 0 |
ds_tapvu_0700 | Tạp vụ 07:00 | 07:00 | 16:00 | true | 11:00 | 12:00 | false | none | 0 |
ds_tapvu_0730 | Tạp vụ 07:30 | 07:30 | 16:30 | true | 11:30 | 12:30 | false | none | 0 |
ds_tapvu_0800 | Tạp vụ 08:00 | 08:00 | 17:00 | true | 12:00 | 13:00 | false | none | 0 |
ds_ketoan | Kế toán | 08:00 | 17:00 | true | 12:00 | 13:00 | false | none | 0 |
ds_labo | Labo 5D | 08:00 | 18:00 | true | 12:00 | 13:30 | false | none | 0 |
Ghi chú Daisy:
- BS/Phụ tá Ca 2:
break_clocking_required = true, break mặc định 12:00–14:00,break_mode = flex,break_flex_minutes = 60(DEC-019 confirmed)- BS/Phụ tá Ca 1 "xuyên trưa":
break_clocking_required = false→ chấm 2 mốc (trực trưa tại PK)- Tạp vụ: 6 ca cố định mới theo đề xuất Daisy (DEC-024), 7 NV. CN Nha Trang 2 ca 4h riêng
Canonical key backfill
- Chỉ backfill cho shift template thuộc scope rollout PN/Daisy trong wave đầu
canonical_keyđược sinh ổn định từ mã ca hiện có; nếu thiếu mã ca thì tạo mapping table tạm để tránh join theoname- Chỉ bật unique constraint sau khi backfill xong và đã verify không collision trong từng
timekeeping_unit
Rollback
- Tắt rollout flag theo
timekeeping_unit - Giữ schema mới nhưng không expose UI/API path mới
- Không cần rollback dashboard/report/salary vì không nằm trong scope
C8) Bảo mật / cô lập dữ liệu (Security / Isolation)
| Rule | Mô tả |
|---|---|
| ISO-001 | User chỉ thấy dữ liệu timekeeping của active_timekeeping_unit_id |
| ISO-002 | Request timekeeping chỉ resolve approver trong cùng unit |
| ISO-003 | Branch/department ngoài unit không hiện trong picker |
| ISO-004 | GPS ngoài branch hợp lệ của unit thì reject punch |
C9) NFR
| NFR | Target |
|---|---|
| Query working sheet | <= 2s ở pilot dataset |
| Mobile clock action | <= 1s response |
| Export tháng | <= 30s |
C10) Quan sát vận hành (Observability)
| Metric | Mục đích |
|---|---|
timekeeping_multi_clock_write_total | Theo dõi usage runtime mới |
timekeeping_scope_mismatch_total | Phát hiện sai scope unit |
timekeeping_export_generated_total | Theo dõi export vận hành |
C11) Danh sách công việc (Tasks)
Phase 1A — Foundation (schema + canonical_key + Settings Module)
| ID | Công việc (Task) | Người phụ trách (Owner) |
|---|---|---|
| P1-01 | Tạo bảng timekeeping_unit* (gồm auto_schedule_disabled, ot_min_threshold_minutes, penalty, workday rules) | BE |
| P1-02 | Add timekeeping_unit_id, segment_index vào bảng timekeeping + request | BE |
| P1-03 | Chặn overlap assignment bằng exclusion constraint + validation | BE |
| P1-04 | Backfill canonical_key và chuyển propagation bỏ name | BE |
| P1-05 | Seed shift templates PN 16 ca + Daisy ~17 ca | BE + PO |
| P1-06 | Shift setting filter + contract giờ nghỉ giữa ca (break_time, break_clocking_required, break_mode, break_flex_minutes) | FE |
| P1-07 | Approver setting timekeeping scope | FE + BE |
| P1-14 | SCR-00 Tab "Quy định" — Settings Module UI (tính công, đơn từ, ca, báo cáo) | FE |
| P1-14b | Hasura row-level permission: HR chỉ thấy data unit mình | BE |
| P1-15 | FE auto-select default unit theo user assignment (PERM-001→005) | FE |
Phase 1B — PN + Daisy đồng thời (3 track song song: BE + FE Admin + FE Mobile)
| ID | Công việc (Task) | Người phụ trách (Owner) |
|---|---|---|
| P1-08 | Working schedule/sheet filter theo unit + reset filter con | FE |
| P1-09 | Update generate_working_shift cron: skip auto_schedule_disabled unit + chuyển match template từ name sang canonical_key (reuse generate_working_shift.go) | BE |
| P1-10 | Update logTimeKeeping action: state machine 4 mốc (FORMULA-004) | BE |
| P1-11 | Update log_time_keeping_flag cronjob: multi-segment + ca gãy (FORMULA-003) | BE |
| P1-12 | OT request validation: reject < ot_min_threshold_minutes | BE |
| P1-13 | PN mobile self-service 2 mốc (ca thường) + 4 mốc (ca gãy) + GPS | FE Mobile + BE |
| P1-16 | Working Sheet thêm filter Chi nhánh scoped per unit | FE |
| P1-17 | Request count scoped per unit (late/early + forget clock) | BE |
| P1-18 | Đơn trễ/sớm: max duration validation per unit (late_early_max_duration_minutes) | BE |
| P1-19 | Mobile: hiển thị quota "X/3 lần" khi tạo đơn | FE Mobile |
| P1-20 | Mobile: lịch sử chấm công cá nhân (history, running total) | FE Mobile |
| P2-01 | Runtime 4 mốc cho Daisy — integrate state machine | BE |
| P2-02 | Daisy mobile 4 CTA (chỉ cho ca break_clocking_required = true) | FE Mobile |
| P2-03 | Daisy VP mobile 2 CTA (ca break_clocking_required = false) | FE Mobile |
| P2-04 | Daisy admin multi-record day | FE |
| P2-05 | Export chi tiết ngày 4 mốc (column spec mới) | FE |
| P2-06 | Request handler adapt multi-segment (FORMULA-005) | BE |
| P2-07 | Daisy BS/Phụ tá ca 4 mốc linh động: lưu metadata break_flex_minutes, tính actual hours theo mốc thực tế, export đủ 4 mốc (DEC-019) | BE |
| P2-08 | Export bảng công tháng (tổng hợp) column spec mới | FE |
| P2-09 | Export báo cáo trễ/sớm với cột "có đơn xin phép" + running count | FE |
| P2-10 | PN tính công theo giờ — FORMULA-009, resolve từ shift_template.workday_calculation_mode = 'hourly' | BE |
| P2-11 | Báo cáo phép năm cho PN — reuse AnnualLeaveReport.tsx + thêm filter unit + annual_leave_logs thêm timekeeping_unit_id | FE 0.5d + BE 0.5d |
| P2-12 | Daisy Tạp vụ 6 ca cố định mới theo phản hồi (DEC-024) | BE seed |
| P2-13 | Công chuẩn: bảng timekeeping_standard_workday_rule + timekeeping_standard_workday_scope_department + FORMULA-010 + UI mapping theo nhóm áp dụng | FE 0.5d + BE 1-1.5d |
| P2-14 | Penalty engine: bảng timekeeping_penalty_rule + cronjob logic (FORMULA-011) + UI CRUD rules | FE 1d + BE 1.5d |
| P2-15 | OT rate: ot_rate_default + ot_rate_doctor + FORMULA-012 + export tự tính tiền OT | FE 0.5d + BE 0.5d |
Phase 1C — Hardening
| ID | Công việc (Task) | Người phụ trách (Owner) |
|---|---|---|
| P3-01 | Hardening, export, bugfix, penalty engine fine-tune, công chuẩn tự động | FE + BE + QA |
C12) Truy vết yêu cầu (Traceability)
| FR | FE | BE/DB |
|---|---|---|
| FR-001 | SCR-00 | timekeeping_unit, mapping tables |
| FR-001A | SCR-00 | timekeeping_unit_user, timekeeping_unit_branch, timekeeping_unit_department |
| FR-001B | SCR-00 | readiness aggregate + rollout flag save |
| FR-002 | SCR-00C, SCR-05 | time_slot_template.break_time, start_break_time, end_break_time, break_clocking_required, break_mode, break_flex_minutes |
| FR-003 | SCR-02, SCR-05 | log_time_keeping.go, time_slot_time_keeping |
| FR-004 | SCR-01, SCR-02, SCR-04 | working sheet projection |
| FR-005 | SCR-05 | mobile attendance runtime |
| FR-006 | SCR-00D, SCR-03 | timekeeping_request_approver, request overlay |
| FR-007 | SCR-06 | export actions chỉ trong scope timekeeping |
| FR-008 | rollout flags | migration + rollback |