Skip to content

Đặ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 đổiSectionẢ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ứcBE, 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ốtC3) Quy tắc và công thứcBE, 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 enforceC4) Mô hình dữ liệu, C11) Danh sách công việcBE, QA

Tham chiếu (Ref): PRD v3.1.4 | Ngày (Date): 21/04/2026

Changelog

VersionDateAuthorThay đổi
3.1.421/04/2026PO/BAChố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.315/04/2026PO/BAChuyể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.007/04/2026PO/BAInitial dev spec cho schema/runtime/export multi-unit
3.0.107/04/2026PO/BAĐồng bộ break_mode, annual leave Day-1, remote nhóm nhỏ và non-regression wording
3.113/04/2026PO/BAThê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.113/04/2026PO/BALà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.214/04/2026PO/BAChuyể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

ModuleLoạiThay đổi
diva-admin/src/modules/timekeepingFEFilter timekeeping_unit, multi-record day, export vận hành
diva-admin/src/modules/settings/pages/InternalSetting.tsxFEThêm menu entry Đơn vị chấm công trong Internal Settings
diva-admin/src/modules/settings/pages/TimekeepingUnitSetting.tsxFEMàn mới SCR-00: quản lý unit, branch, department, user assignment, rollout
diva-admin/src/modules/settings/pages/ShiftSetting.tsxFEThêm filter/unit scope cho shift
diva-admin/src/modules/settings/pages/ApproverPage.tsxFEThêm filter/unit scope cho approver timekeeping
diva-flutter/staff/lib/presentation/modules/main/attendanceFE MobileState 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.goBERuntime multi-clock
diva-backend/services/ecommerce-api/action/check_attendance.goBEGiữ legacy app check-in ngoài scope HR timekeeping
diva-backend/services/ecommerce-api/event/request_working_schedule_*BEOverlay request theo runtime mới
diva-backend/services/ecommerce-api/event/time_slot_template_update.goBEBỏ propagation theo name
diva-backend/services/controller/metadata/databases/hrm/*DB/HasuraThê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/dashboard
  • diva-backend/services/export-api/action/report_salary.go
  • salary, payroll, general_salary, user_salary
  • Refactor global branch_user, department_user
  • Không thêm field timekeeping_unit vào UserCreate.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

  1. timekeeping_unit chỉ là boundary của module chấm công.
  2. Không dùng timekeeping_unit để thay company boundary của toàn hệ thống.
  3. Không thêm filter timekeeping_unit vào dashboard/report legacy.
  4. Không sửa pipeline payroll.
  5. Day-1 chỉ mở UI on-site attendance có GPS; full remote attendance là deferred scope. Ngoại lệ nhóm nhỏ dùng lại flow remote_onetime hiện có, không build UI remote mới.

Impact Map

SurfaceMức độGhi chú
Timekeeping admin webCaoCó thay đổi thật
Mobile attendanceCaoCó thay đổi thật
Shift settingsTrung bìnhThêm filter/unit
Approver settings timekeepingTrung bìnhChỉ request type timekeeping
Dashboard/report legacyKhông thuộc scopeKhông sửa
Salary/payroll legacyKhông thuộc scopeKhô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'
END

FORMULA-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_minutes

Ca 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_early

PN 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ính actual_hours theo 2 segment thực tế.

Phân biệt: PN fixed dùng break window để tính violation ở giữa ca. Daisy flex Day-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ính WorkdaySalary, OvertimeHour, LateEarlyMinutes

Ca 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'. Khi hourly, workday đã tính qua actual_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_out

Ca 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 form

FORMULA-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 typeMulti-segment impact
forget_clock_in_outCầ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_outCần field segment_index: điều chỉnh giờ cho segment cụ thể
late_arrival_early_leaveAuto-detect: late = seg 0 clock_in, early = seg 1 clock_out
change_shiftKhông cần segment — thay cả ca
overtimeKhông cần segment — OT tính ngoài ca
leaveKhô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_out

FORMULA-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_unit row thay vì global hrm_master_data khi unit_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ệu

FORMULA-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 decimal

Edge cases:

  • actual_hours > shift_template.standard_hours → cap tại shift_template.workday
  • actual_hours <= 0workday = 0
  • clock_out IS NULL (quên chấm ra) → actual_hours = NULL, status = missing_end. Cronjob KHÔNG gán workday = 0. Giữ pending cho đến khi HR xử lý đơn quên chấm
  • clock_in IS NULL → tương tự, status = missing_start
  • Ca 4 mốc thiếu 1 segment → tính actual_hours chỉ 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'. Khi hourly, 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ùng branchLabelID
  • Quy ước Day-1 mới: resolve từ timekeeping_unit_user.primary_department_idtimekeeping_standard_workday_scope_departmenttimekeeping_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_value

Ví 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 = 0

Ví 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

  • branch
  • department
  • branch_user
  • department_user
  • request_approver dù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

RuleMô tả
R1Một user chỉ có 1 active timekeeping_unit tại một thời điểm
R2Khi đổi unit giữa tháng, record cũ không hồi tố
R3timekeeping_unit_id là bắt buộc cho record rollout mới
R4branch/department chỉ được map, không bị ownership bởi timekeeping_unit ở global scope
R5Day-1 punch chỉ hợp lệ khi GPS resolve được vào branch đã map trong đúng timekeeping_unit
R6Rollout Day-1 không mở UI remote mới; nhóm nhỏ chỉ reuse flow remote_onetime hiện có để bypass GPS khi đã approved
R7Ca 4 mốc tạo 2 record time_slot_time_keeping per day (segment_index 0 và 1)
R8generate_working_shift cron skip user thuộc unit có auto_schedule_disabled = true
R9request_working_schedule.segment_index nullable: NULL cho ca 2 mốc, 0/1 cho ca 4 mốc
R10OT request trong unit có ot_min_threshold_minutes > 0 → reject nếu duration < threshold
R11Backend đọ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)
R13Mỗi lần save config → ghi audit log (CFG-006)
R14timekeeping_unit_branchtimekeeping_unit_department dùng soft-disable (disabled = true) khi user chọn Xóa khỏi đơn vị; không hard delete Day-1
R15Re-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
R16timekeeping_unit_user phải lưu primary_branch_id + primary_department_id để khớp list/filter/export/approver scope ở Day-1
R17Chờ 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
R18Readiness 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_idUnit chấm công đang thao tác
X-Hasura-Branch-IdGiữ 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_unit filter vào dashboard/report/salary
  • remote request/punch path của rollout unit không expose trong Day-1 UI/API mới
  • HR chỉ query được data trong timekeeping_unit mình thuộc; Hasura permission row-level filter theo timekeeping_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()count_remaining_forget_clock_in_out_requests() cần thêm param timekeeping_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

PathDelta
logTimeKeepingResolve timekeeping_unit_id, time_slot_user_id, segment_index
checkAttendanceKhông dùng làm source of truth HR timekeeping
request_working_schedule_*Overlay request theo timekeeping_unit_id
time_slot_template_update.goJoin theo canonical_key, không join theo name

Admin actions — SCR-00

ActionInput tối thiểuOutput tối thiểuGhi chú
upsertTimekeepingUnitBranchtimekeeping_unit_id, branch_ids[]Danh sách branch active sau khi lưuNếu branch đã có row disabled thì reactivate
disableTimekeepingUnitBranchtimekeeping_unit_id, branch_iddisabled = true + impact summaryKhông hard delete Day-1
upsertTimekeepingUnitDepartmenttimekeeping_unit_id, department_ids[]Danh sách department active sau khi lưuNếu department đã có row disabled thì reactivate
disableTimekeepingUnitDepartmenttimekeeping_unit_id, department_iddisabled = true + impact summaryKhông hard delete Day-1
bulkUpsertTimekeepingUnitUsertimekeeping_unit_id, items[{user_id, primary_branch_id, primary_department_id}], effective_from, effective_to, reasoncreated_count, reassigned_count, skipped[]Hỗ trợ create mới + re-assignment
bulkDisableTimekeepingUnitUserassignment_ids[] hoặc user_ids[], effective_to, reasonupdated_count, skipped[]Dùng cho action Xóa nhân viên khỏi đơn vị
previewTimekeepingUnitUserImporttimekeeping_unit_id, staff_codes[]matched[], unmatched[], invalid_scope[]Dùng cho cả upload file và paste mã NV
getTimekeepingUnitReadinesstimekeeping_unit_idChecklist counts + status badgesDù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 = count timekeeping_unit_branch WHERE disabled = false
  • department.actual = count timekeeping_unit_department WHERE disabled = false
  • user.actual = count timekeeping_unit_user WHERE disabled = false AND (effective_to IS NULL OR effective_to >= today)
  • approver.actual = số request type Day-1 có approver hợp lệ trong timekeeping_request_approver
  • shift_template.actual = count time_slot_template active thuộc unit
  • rule_config.status = ready khi các field bắt buộc trong timekeeping_unit + rule tables đã có giá trị hợp lệ
  • export_smoke_test.status = ready khi 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_branch của active_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_branch củ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_meters per timekeeping_unit (Settings Module). Nếu NULL → fallback global AppSettings.Position.Value.Distance
  • GPS Day-1: resolve từ shift_template.gps_required
    • true: check GPS như bình thường
    • false: bỏ qua GPS check cho ca đó
  • remote_onetime / remote_weekly tiế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.dart

Mobile edge cases (Flutter)

ScenarioBehavior
Network timeout khi punchHiệ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 punchKhô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 <5sReject tap thứ 2 với message "Vui lòng đợi"
NV không có ca hôm nayHiện message "Không có ca làm việc hôm nay" — không hiện CTA
NV chưa assign vào unitGiữ 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 reset branch, department, shift group
  • ApproverPage chỉ enable delta cho request type Day-1; không enable remote

C7) Chiến lược migration (Migration Strategy)

Order

  1. Tạo bảng timekeeping_unit*
  2. Add nullable timekeeping_unit_id, segment_index vào bảng timekeeping
  3. 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)
  4. Backfill canonical_key cho time_slot_template hiện dùng trong PN/Daisy rollout
  5. Thay event/template propagation từ name sang canonical_key
  6. Backfill mapping cho Daisy/PN pilot users
  7. Update log_time_keeping_flag cronjob: multi-segment logic (FORMULA-003)
  8. Update logTimeKeeping action: state machine cho 4 mốc (FORMULA-004)
  9. Update generate_working_shift cron: skip unit có auto_schedule_disabled = true
  10. Mở admin filter
  11. Mở PN mobile 2 mốc (ca thường) + 4 mốc (ca gãy) + GPS
  12. Mở Daisy 4 mốc (DV) + 2 mốc (VP)

Seed shift templates

Phương Nam (16 ca):

canonical_keyTênStartEndBreakBreak startBreak endbreak_clocking_requiredbreak_modebreak_flex_minutes
pn_hcCa hành chính08:0017:00true12:0013:30falsenone0
pn_ca1Ca 106:0014:00falsefalsenone0
pn_ca2Ca 207:0015:30falsefalsenone0
pn_ca3Ca 307:3016:00falsefalsenone0
pn_ca4Ca 407:0018:00falsefalsenone0
pn_ca5Ca 507:3018:00falsefalsenone0
pn_ca6Ca 608:0017:00falsefalsenone0
pn_ca7Ca 709:0018:00falsefalsenone0
pn_ca8Ca 810:0019:00falsefalsenone0
pn_toiCa tối15:0023:00falsefalsenone0
pn_gay_7_14Ca gãy 7:00 14:0007:0018:00true11:0014:00truefixed0
pn_gay_730_1330Ca gãy 7:30 13:3007:3017:30true11:3013:30truefixed0
pn_gay_730_1400Ca gãy 7:30 14:0007:3018:00true11:3014:00truefixed0
pn_gay_730_1300Ca gãy 7:30 13:0007:3017:00true11:3013:00truefixed0
pn_gay_730_1700Ca gãy 7:30 17:0007:3017:00true11:3013:30truefixed0
pn_gay_800Ca gãy 8:0008:0018:00true11:3013:30truefixed0

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_keyTênStartEndBreakBreak startBreak endbreak_clocking_requiredbreak_modebreak_flex_minutes
ds_mkt_ca1Marketing Ca 107:3015:00falsefalsenone0
ds_mkt_ca2Marketing Ca 210:3018:00falsefalsenone0
ds_mkt_ca3Marketing Ca 315:0022:30falsefalsenone0
ds_teleTelesales/CSKH07:4517:30true12:0013:30falsenone0
ds_bs_ca1Bác sĩ Ca 1 (xuyên trưa)08:0017:00falsefalsenone0
ds_bs_ca2Bác sĩ Ca 208:0019:00true12:0014:00trueflex60
ds_phuta_ca1Phụ tá Ca 1 (xuyên trưa)08:0017:00falsefalsenone0
ds_phuta_ca2Phụ tá Ca 208:0019:00true12:0014:00trueflex60
ds_baoveBảo vệ08:0019:00truefalsenone0
ds_tapvu_nt_sangTạp vụ NT Sáng06:3010:30falsefalsenone0
ds_tapvu_nt_chieuTạp vụ NT Chiều12:3016:30falsefalsenone0
ds_tapvu_0630Tạp vụ 06:3006:3015:30true10:3011:30falsenone0
ds_tapvu_0700Tạp vụ 07:0007:0016:00true11:0012:00falsenone0
ds_tapvu_0730Tạp vụ 07:3007:3016:30true11:3012:30falsenone0
ds_tapvu_0800Tạp vụ 08:0008:0017:00true12:0013:00falsenone0
ds_ketoanKế toán08:0017:00true12:0013:00falsenone0
ds_laboLabo 5D08:0018:00true12:0013:30falsenone0

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 theo name
  • 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)

RuleMô tả
ISO-001User chỉ thấy dữ liệu timekeeping của active_timekeeping_unit_id
ISO-002Request timekeeping chỉ resolve approver trong cùng unit
ISO-003Branch/department ngoài unit không hiện trong picker
ISO-004GPS ngoài branch hợp lệ của unit thì reject punch

C9) NFR

NFRTarget
Query working sheet<= 2s ở pilot dataset
Mobile clock action<= 1s response
Export tháng<= 30s

C10) Quan sát vận hành (Observability)

MetricMục đích
timekeeping_multi_clock_write_totalTheo dõi usage runtime mới
timekeeping_scope_mismatch_totalPhát hiện sai scope unit
timekeeping_export_generated_totalTheo dõi export vận hành

C11) Danh sách công việc (Tasks)

Phase 1A — Foundation (schema + canonical_key + Settings Module)

IDCông việc (Task)Người phụ trách (Owner)
P1-01Tạo bảng timekeeping_unit* (gồm auto_schedule_disabled, ot_min_threshold_minutes, penalty, workday rules)BE
P1-02Add timekeeping_unit_id, segment_index vào bảng timekeeping + requestBE
P1-03Chặn overlap assignment bằng exclusion constraint + validationBE
P1-04Backfill canonical_key và chuyển propagation bỏ nameBE
P1-05Seed shift templates PN 16 ca + Daisy ~17 caBE + PO
P1-06Shift setting filter + contract giờ nghỉ giữa ca (break_time, break_clocking_required, break_mode, break_flex_minutes)FE
P1-07Approver setting timekeeping scopeFE + BE
P1-14SCR-00 Tab "Quy định" — Settings Module UI (tính công, đơn từ, ca, báo cáo)FE
P1-14bHasura row-level permission: HR chỉ thấy data unit mìnhBE
P1-15FE 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)

IDCông việc (Task)Người phụ trách (Owner)
P1-08Working schedule/sheet filter theo unit + reset filter conFE
P1-09Update 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-10Update logTimeKeeping action: state machine 4 mốc (FORMULA-004)BE
P1-11Update log_time_keeping_flag cronjob: multi-segment + ca gãy (FORMULA-003)BE
P1-12OT request validation: reject < ot_min_threshold_minutesBE
P1-13PN mobile self-service 2 mốc (ca thường) + 4 mốc (ca gãy) + GPSFE Mobile + BE
P1-16Working Sheet thêm filter Chi nhánh scoped per unitFE
P1-17Request 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-19Mobile: hiển thị quota "X/3 lần" khi tạo đơnFE Mobile
P1-20Mobile: lịch sử chấm công cá nhân (history, running total)FE Mobile
P2-01Runtime 4 mốc cho Daisy — integrate state machineBE
P2-02Daisy mobile 4 CTA (chỉ cho ca break_clocking_required = true)FE Mobile
P2-03Daisy VP mobile 2 CTA (ca break_clocking_required = false)FE Mobile
P2-04Daisy admin multi-record dayFE
P2-05Export chi tiết ngày 4 mốc (column spec mới)FE
P2-06Request handler adapt multi-segment (FORMULA-005)BE
P2-07Daisy 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-08Export bảng công tháng (tổng hợp) column spec mớiFE
P2-09Export báo cáo trễ/sớm với cột "có đơn xin phép" + running countFE
P2-10PN tính công theo giờ — FORMULA-009, resolve từ shift_template.workday_calculation_mode = 'hourly'BE
P2-11Báo cáo phép năm cho PN — reuse AnnualLeaveReport.tsx + thêm filter unit + annual_leave_logs thêm timekeeping_unit_idFE 0.5d + BE 0.5d
P2-12Daisy Tạp vụ 6 ca cố định mới theo phản hồi (DEC-024)BE seed
P2-13Công chuẩn: bảng timekeeping_standard_workday_rule + timekeeping_standard_workday_scope_department + FORMULA-010 + UI mapping theo nhóm áp dụngFE 0.5d + BE 1-1.5d
P2-14Penalty engine: bảng timekeeping_penalty_rule + cronjob logic (FORMULA-011) + UI CRUD rulesFE 1d + BE 1.5d
P2-15OT rate: ot_rate_default + ot_rate_doctor + FORMULA-012 + export tự tính tiền OTFE 0.5d + BE 0.5d

Phase 1C — Hardening

IDCông việc (Task)Người phụ trách (Owner)
P3-01Hardening, export, bugfix, penalty engine fine-tune, công chuẩn tự độngFE + BE + QA

C12) Truy vết yêu cầu (Traceability)

FRFEBE/DB
FR-001SCR-00timekeeping_unit, mapping tables
FR-001ASCR-00timekeeping_unit_user, timekeeping_unit_branch, timekeeping_unit_department
FR-001BSCR-00readiness aggregate + rollout flag save
FR-002SCR-00C, SCR-05time_slot_template.break_time, start_break_time, end_break_time, break_clocking_required, break_mode, break_flex_minutes
FR-003SCR-02, SCR-05log_time_keeping.go, time_slot_time_keeping
FR-004SCR-01, SCR-02, SCR-04working sheet projection
FR-005SCR-05mobile attendance runtime
FR-006SCR-00D, SCR-03timekeeping_request_approver, request overlay
FR-007SCR-06export actions chỉ trong scope timekeeping
FR-008rollout flagsmigration + rollback