Appearance
Nguồn sự thật chuẩn (Source of Truth) — Chấm công đa đơn vị v3.1.4
File này là nguồn duy nhất cho tất cả decisions, scope, và facts. Session mới dùng file này + codebase thực tế để viết lại 6 spec files sạch.
Ngày: 21/04/2026
Changelog
| Version | Date | Author | Thay đổi |
|---|---|---|---|
| 3.1.4 | 21/04/2026 | PO/BA | Khóa lại canonical Day-1 cho break_mode = flex, missing_break và remote: break linh hoạt chỉ là metadata, không auto trừ công vì thiếu mốc giữa ca, và nhóm nhỏ tiếp tục dùng flow remote legacy đã approved |
| 3.1.3 | 15/04/2026 | PO/BA | Chuyển model Công chuẩn sang nhóm áp dụng công chuẩn (standard_workday_scope_key) + map department -> scope, thay cho proposal cũ bám branch_label_id |
| 3.0 | 07/04/2026 | PO/BA | Initial source of truth cho multi-unit timekeeping |
| 3.0.1 | 07/04/2026 | PO/BA | Đồng bộ canonical package về break_mode, annual leave Day-1, remote nhóm nhỏ và QA/go-live wording |
| 3.1 | 13/04/2026 | PO/BA | Bổ sung canonical decisions cho primary_department_id, soft-disable, readiness checklist, tab Triển khai và vocabulary vận hành |
| 3.1.1 | 13/04/2026 | PO/BA | Làm rõ GPS source-of-truth: branch coordinates tiếp tục dùng branch master data hiện có; gps_radius_meters là override per-unit và fallback global chỉ dùng khi unit chưa cấu hình |
| 3.1.2 | 14/04/2026 | PO/BA | Chuyển cách tính công từ cấp timekeeping_unit xuống time_slot_template: mỗi ca tự quyết định Theo ngày công hoặc Theo giờ, reuse field workday hiện có làm công tối đa của ca; đồng thời chuyển yêu cầu GPS của ca xuống cấp shift dưới dạng gps_required = true/false |
1. Bài toán
PN (247 NV, 4 cơ sở) và Daisy (150 NV, 9 cơ sở) cần chấm công trên app Diva. Mỗi bên quy định riêng. Diva đang chạy ổn định — KHÔNG được ảnh hưởng.
2. Giải pháp
timekeeping_unit + Settings Module toàn diện. unit_id = NULL → legacy code nguyên vẹn.
3. Inputs (đã có sẵn — đọc TRƯỚC khi viết)
| File | Đường dẫn | Nội dung |
|---|---|---|
| Logic codebase chấm công | /Users/sontho/Downloads/timekeeping-flow.md | 4 module: Work-Shift, Working-Schedule, Working-Sheet, Approver |
| Khảo sát Daisy | /Users/sontho/Downloads/Daisy - Phiếu khảo sát yêu cầu chấm công.pdf | 10 trang, 25 câu |
| Khảo sát PN | /Users/sontho/Downloads/Phương Nam - Phiếu khảo sát yêu cầu chấm công.pdf | 10 trang, 25 câu |
| Phản hồi Daisy | /Users/sontho/Downloads/Câu hỏi Daisy.pdf | 4 câu trả lời |
| Phản hồi PN | /Users/sontho/Downloads/Câu hỏi Phương Nam.pdf | 4 câu trả lời |
4. Quyết định chuẩn (Canonical decisions) — CHỐT, không thay đổi
Scope & Architecture
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-001 | Daisy + PN dùng app Diva chấm công | Locked |
| DEC-002 | Timekeeping-only scope, không retrofit company boundary | Locked |
| DEC-003 | Dùng timekeeping_unit (không business_unit) | Locked |
| DEC-004 | timekeeping_unit không đổi branch/department/report/dashboard/salary global | Locked |
| DEC-005 | Tách approver timekeeping sang scope riêng | Locked |
| DEC-006 | time_slot_time_keeping = source of truth, runtime dùng time_slot_user_id + segment_index | Locked |
| DEC-008 | Báo cáo = vận hành chấm công scope | Locked |
| DEC-009 | GPS on-site bắt buộc Day-1 | Locked |
| DEC-028 | Department có branch_id → tạo dept per branch, mapping qua timekeeping_unit_department | Locked |
| DEC-029 | Assignment user vào timekeeping_unit phải lưu primary_branch_id + primary_department_id + effective_from/effective_to để khớp filter/export/approver scope Day-1 | Locked |
| DEC-030 | Mapping timekeeping_unit_branch + timekeeping_unit_department dùng soft-disable (disabled = true), không hard delete Day-1 | Locked |
| DEC-031 | SCR-00 có Readiness Checklist + tab Triển khai; Bật rollout chỉ khả dụng khi checklist không còn badge ❌ | Locked |
| DEC-032 | GPS Day-1 không build màn master riêng: tọa độ chi nhánh tiếp tục quản lý ở branch master data hiện có; gps_radius_meters nằm ở SCR-00 > Quy định, và nếu unit chưa override thì fallback global AppSettings.Position.Value.Distance | Locked |
| DEC-033 | Cách tính công không cấu hình ở timekeeping_unit. Day-1 đặt tại time_slot_template để mỗi ca tự chọn fixed hoặc hourly; field workday hiện có được reuse làm công tối đa của ca, thêm standard_hours ở cấp ca để hỗ trợ mode hourly | Locked |
| DEC-034 | GPS bypass không cấu hình ở timekeeping_unit. Day-1 đặt tại time_slot_template dưới dạng gps_required = true/false; timekeeping_unit chỉ giữ radius mặc định. Flow remote_onetime / remote_weekly vẫn là runtime/request logic riêng, không expose thành option cấu hình trong màn Ca làm việc | Locked |
Rollout
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-007 | Phase 1A: Foundation (schema + canonical_key + Settings Module). Phase 1B: PN + Daisy đồng thời. Phase 1C: Hardening | Locked |
Chấm công 2 mốc / 4 mốc
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-011 | Rule nghỉ giữa ca nằm per shift template, reuse field sẵn có break_time, start_break_time, end_break_time; bổ sung break_clocking_required, break_mode = fixed/flex, break_flex_minutes (không config ở cấp unit). Day-1 với break_mode = flex: không hard-validate mốc nghỉ theo khung giờ, chỉ yêu cầu đủ 4 mốc và tính actual_hours theo mốc thực tế | Updated v5 |
| DEC-014 | PN ca gãy = 4 mốc (xác nhận bởi PN: cần track trễ đầu ca sáng + chiều) | Confirmed |
| DEC-019 | Daisy BS/Phụ tá break mặc định 12:00–14:00, break_flex_minutes = 60 chỉ lưu metadata tham chiếu cho Day-1. Ca "xuyên trưa" = 2 mốc. Với ca 4 mốc linh hoạt, hệ thống không block/phạt theo khung nghỉ; chỉ yêu cầu đủ 4 mốc, tính actual_hours theo mốc thực tế và export đủ 4 mốc cho HR đối soát | Confirmed |
| DEC-024 | Daisy Tạp vụ: 6 ca cố định mới, 7 NV | Confirmed |
Settings Module toàn diện
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-012 | Penalty engine Day-1 — timekeeping_penalty_rule per unit. HR KHÔNG cần Excel | Updated v3 |
| DEC-013 | Công chuẩn Day-1 — timekeeping_standard_workday_rule mapping theo nhóm áp dụng công chuẩn (standard_workday_scope_key), và map department -> scope để phản ánh đúng xác nhận HCNS của PN/Daisy | Updated v4 |
| DEC-022 | PN tính công theo giờ (actual_hours / standard_hours) nhưng áp dụng ở cấp ca làm việc, không khóa cứng toàn đơn vị. Có đơn approved → full công. Daisy mặc định fixed nhưng vẫn cho phép mở rộng mixed-mode về sau | Confirmed |
| DEC-026 | Settings Module toàn diện: tất cả config per unit trên UI, backend đọc DB, không hardcode | Locked v3 |
| DEC-027 | Bảng timekeeping_penalty_rule. exempt_pool = 'individual' (PN) / 'shared' (Daisy) | Locked v3 |
Remote
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-010 | Remote hỗ trợ nhóm nhỏ Day-1: Daisy Ca 3 Marketing + PN 3-4 NV | Updated |
| DEC-016 | Reuse logic remote_onetime hiện có — code log_time_keeping.go đã có bypass GPS cho remote_onetime + remote_weekly. Day-1 giữ flow này như request/runtime logic riêng, không build feature remote mới và không đưa thành option riêng trong cấu hình ca | Locked |
Approval
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-015 | Daisy + PN = 2 cấp duyệt | Confirmed |
| DEC-020 | Count đơn scoped per timekeeping_unit | Locked |
| DEC-021 | Daisy max 60 phút per đơn trễ/sớm. PN không giới hạn | Locked |
Phép năm
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-023 | Báo cáo phép năm bắt buộc Day-1 cho PN. Reuse AnnualLeaveReport.tsx + thêm filter unit | Confirmed |
Khác
| ID | Quyết định | Trạng thái (Status) |
|---|---|---|
| DEC-017 | Daisy ca xoay → HR import Excel, cron generate_working_shift skip auto_schedule_disabled | Locked |
| DEC-018 | Daisy Tạp vụ giờ linh động → HR tạo ca riêng đúng giờ NV | Locked |
| DEC-025 | Daisy "hạn chế deploy" = gộp release | Confirmed |
5. Hiện trạng code THỰC TẾ (chưa đổi gì)
Tất cả dưới đây là code hiện tại — chưa có bất kỳ thay đổi nào cho feature mới.
Schema hiện tại (CHƯA có timekeeping_unit_id, segment_index)
| Bảng | Columns liên quan | Ghi chú |
|---|---|---|
time_slot_template | id, name, start_time, end_time, break_time, start_break_time, end_break_time, workday, roles, disabled | workday đã có sẵn và nên reuse làm công tối đa của ca. CHƯA CÓ: timekeeping_unit_id, canonical_key, break_clocking_required, break_mode, break_flex_minutes, workday_calculation_mode, standard_hours, gps_required |
time_slot_user | id, user_id, name, start_time, end_time, break_time, workday, branch_id, time_keeping_id | CHƯA CÓ: timekeeping_unit_id |
time_slot_time_keeping | id, created_by, clock_in, clock_out, position_in/out, branch_id, late_arrival, leave_early, off, workday, overtime | CHƯA CÓ: timekeeping_unit_id, time_slot_user_id, segment_index |
request_working_schedule | id, code, type, behavior, status_id, from, to, reason, workday, num_approved | CHƯA CÓ: timekeeping_unit_id, segment_index |
request_approver | id, department_id, branch_id, user_id, request_type, level_id | CHƯA CÓ: timekeeping_unit_id (approver resolve theo dept/branch, KHÔNG theo unit) |
department | id, name, branch_id, code, disabled | CÓ branch_id — dept scoped per branch |
branch | id, name, code, branch_label_id, company_fullname, tax_code | CÓ branch_label_id — metadata branch hiện có, nhưng Day-1 công chuẩn mới KHÔNG dùng làm business scope |
employee_profile | remaining_leave_days, prev_remain_leave | Phép năm balance |
annual_leave_logs | id, account_id, leave_days, request_id | CHƯA CÓ: timekeeping_unit_id |
general_salary | basic_salary, standard_workday, actual_workday, paid_day_off, overtime_hour | Salary data |
Backend logic hiện tại
| File | Logic | Key points |
|---|---|---|
log_time_keeping.go | Action chấm công | limit: 1 query → toggle clock_in/clock_out (SINGLE pair). GPS: CalculateDistance() check ALL branches. Remote bypass: remote_onetime + remote_weekly ĐÃ CÓ |
log_time_keeping_flag.go | Cronjob 23:15 | Late > 1p → flag. Late > 60p → trừ 0.5 công. late_early_fine = phút × 10,000 (HARDCODE). 15 workers, timeout 355s. Salary: GenerateSalary() tự động |
time_slot_template_update.go | Event trigger | Match time_slot_user bằng name (KHÔNG phải ID/key) — BLOCKER phải fix trước |
generate_working_shift.go | Cron CN 23:00 | Copy tuần trước → tuần sau. Match template bằng mapTemplateByName[item.Name] — cũng dùng name |
request_working_schedule_insert.go | Event trigger | Resolve reviewers bằng GetReviewersNextstepWithConfig() → query request_approver WHERE department_id + request_type + level_id. KHÔNG có dimension unit |
user_salary.go | Init salary | Hiện hardcode branchLabelID == "general"/"office" → countNonSundayDays(), else → 26.0. Cần replace bằng resolve qua primary_department_id -> standard_workday_scope_key |
count_remaining_* | PostgreSQL functions | 2 functions đếm đơn trễ/sớm + quên chấm. Đọc max từ hrm_master_data (GLOBAL, không per unit) |
app_setting | Config | request_leave.value: min_working_day, reset_date, leave_expiry_date (GLOBAL) |
FE hiện tại
| File | Có gì | CHƯA CÓ gì |
|---|---|---|
WorkingTimeSheet.tsx | Filter: DepartmentSelect, ShiftGroupSelect, DateRange, search. Cell: clock_in/out, late/early badge, request overlay. Export: 3 loại | CHƯA CÓ: filter timekeeping_unit, filter chi nhánh, multi-record day (4 mốc) |
WorkingSchedule.tsx | Filter: DepartmentSelect, ShiftGroupSelect, DateRange, search. Import Excel: WorkingScheduleImport.tsx | CHƯA CÓ: filter unit, filter chi nhánh |
ExportWorkingTimeSheetByDay.tsx | 14 cột đã có: STT, Ngày, Họ tên, Mã NV, Phòng ban, CN vào/ra, Ca, Giờ vào/ra, Trễ phút, Sớm phút, Đơn, OT. Logic: getTimeLateArrival(), getTimeEarlyLeave(), getOverTime() | CHƯA CÓ: filter unit, 4 cột ca 4 mốc, cột tiền phạt |
ExportTotalWorkingTimeSheetByDay.tsx | 5 cột đã có: STT, Họ tên, Mã NV, Phòng ban, Tổng ngày công. Logic: calculateWorkDays() | CHƯA CÓ: filter unit |
AnnualLeaveReport.tsx | Hoàn chỉnh: filter năm/phòng ban/chi nhánh, export Excel, DB view report_annual_leave | CHƯA CÓ: filter unit |
ApproverPage.tsx | 15 tab (clock_in_out, change_shift, forget_clock, late_arrival_early_leave, leave, overtime, remote...). SettingApprover*.tsx components | CHƯA CÓ: filter unit, scope per unit |
ShiftSetting.tsx | 2 tab: Work Shift + Shift Group. CRUD templates | CHƯA CÓ: filter unit, contract 2 mốc/4 mốc, mode nghỉ giữa ca fixed/flex, field break_clocking_required, break_mode, break_flex_minutes |
BranchForm.tsx | Đã có latitude / longitude trong branch master data | CHƯA CÓ: bán kính GPS per-unit; đây không phải màn timekeeping settings |
attendance_bloc.dart | State machine 2 mốc: CheckIn → CheckOut. GPS check. Calendar history | CHƯA CÓ: state machine 4 mốc, quota hiển thị, running total |
6. Phân loại effort THỰC TẾ
Phải build MỚI hoàn toàn (code chưa có gì)
| Feature | Lý do | Effort estimate |
|---|---|---|
Bảng timekeeping_unit + schema mới | Concept chưa tồn tại | BE 2-3d |
| Settings Module UI (SCR-00, 6 tab + Tab Quy định) | Chưa có | FE 4-5d |
timekeeping_penalty_rule table + engine | Chưa có penalty config per unit | BE 2d |
timekeeping_standard_workday_rule + timekeeping_standard_workday_scope_department | Chưa có (hiện hardcode trong user_salary.go) | BE 1-1.5d |
Multi-segment runtime (4 mốc) trong logTimeKeeping | Hiện chỉ toggle clock_in/clock_out. Cần state machine mới | BE 2-3d |
Multi-segment cronjob trong log_time_keeping_flag | Hiện xử lý 1 record/user/day. Cần xử lý 2 records/user/day | BE 2-3d |
| Mobile 4 mốc state machine | Hiện chỉ CheckIn/CheckOut. Cần thêm BreakOut/BreakIn | FE Mobile 2-3d |
| Tính công theo giờ (PN) | Logic actual_hours / standard_hours chưa có | BE 1-2d |
| Export bảng công tháng (17 cột) | Hiện chỉ 3 cột + grid. Cần rewrite column mapping | FE 2d |
Phải MIGRATE / FIX trước (Phase 0 blocker)
| Task | Hiện trạng code | Target | Blocker? |
|---|---|---|---|
canonical_key backfill + đổi propagation | time_slot_template_update.go match bằng name. generate_working_shift.go dùng mapTemplateByName | Match bằng canonical_key + timekeeping_unit_id | BLOCKER — fix trước khi seed template PN/Daisy |
ADD COLUMN timekeeping_unit_id (nullable) | 4 bảng chưa có column này | ALTER TABLE + nullable | BLOCKER — schema phải có trước khi code mới chạy |
ADD COLUMN segment_index (nullable) | time_slot_time_keeping + request_working_schedule chưa có | ALTER TABLE + nullable | BLOCKER cho 4 mốc |
| Approver resolve thêm dimension unit | Hiện resolve bằng dept/branch + type + level. KHÔNG có unit | Thêm timekeeping_request_approver table hoặc extend request_approver | BLOCKER — không có thì đơn PN chuyển sang approver Daisy |
Assignment lưu primary_department_id | UI Day-1 đã filter/list/export theo phòng ban áp dụng, nhưng schema hiện tại chưa có field tương ứng | Thêm cột vào timekeeping_unit_user | BLOCKER — không có thì FE/BE phải tự suy diễn department |
| Mapping branch/department giữ lịch sử | UI Day-1 dùng Ngưng áp dụng/Xóa khỏi đơn vị, nhưng schema hiện tại chưa có disabled | Thêm disabled vào bảng mapping + contract reactivate | BLOCKER — không có thì trạng thái UI không thể persist đúng |
| Readiness checklist + tab Triển khai | UI Day-1 đã có checklist/bật rollout, nhưng chưa có aggregate contract trong code hiện tại | Thêm payload aggregate + persistence rollout snapshot | BLOCKER — không có thì rollout tab chỉ là mock |
Reuse ĐƯỢC (chỉ cần extend filter/scope)
| Feature | Codebase hiện có | Delta thực tế |
|---|---|---|
| Export chi tiết ngày | ExportWorkingTimeSheetByDay.tsx — 14 cột + logic trễ/sớm/OT | Thêm filter unit + 4 cột ca 4 mốc |
| Export tổng công | ExportTotalWorkingTimeSheetByDay.tsx — 5 cột | Thêm filter unit |
| Phép năm report | AnnualLeaveReport.tsx — hoàn chỉnh | Thêm filter unit |
| Import Excel lịch | WorkingScheduleImport.tsx — hoàn chỉnh | Scope theo unit |
| Remote bypass GPS | log_time_keeping.go — remote_onetime + remote_weekly logic | KHÔNG cần build mới. Daisy Ca 3 tạo đơn remote → logic bypass đã có |
| Notification tạo đơn | Event trigger request_working_schedule_insert → resolve reviewers → push | Verify route đúng per-unit approver (cần extend resolver, không chỉ "verify") |
| Count requests 3/tháng | 2 PostgreSQL functions | ALTER thêm WHERE timekeeping_unit_id + đọc max từ timekeeping_unit thay vì global |
| Late/early detection | Cronjob: 1p flag, 60p trừ 0.5 | Logic 2 mốc KHỚP 100%. Delta CHỈ là multi-segment |
| GPS check | CalculateDistance() + radius | Thay query ALL branches → chỉ branch trong unit |
| Công chuẩn logic | user_salary.go đang dùng branchLabelID | Extend: thêm nhóm áp dụng công chuẩn + mapping department -> scope per unit |
7. Phản hồi đã xác nhận
Daisy
| Câu | Kết quả |
|---|---|
| Break BS/Phụ tá | Mặc định 12:00–14:00, break_flex_minutes = 60 chỉ là metadata Day-1. Ca "xuyên trưa" = 2 mốc |
| Tạp vụ | 6 ca cố định mới (chi tiết giờ + giờ nghỉ trưa cố định). 7 NV. CN Nha Trang 2 ca 4h riêng |
| "Hạn chế update" | = Hạn chế deploy phần mềm quá nhiều |
| Tính phạt thủ công | OK 2-3 tháng đầu (nhưng Settings toàn diện → không cần thủ công) |
Phương Nam
| Câu | Kết quả |
|---|---|
| Ca gãy | 4 mốc — cần track trễ đầu ca sáng + đầu ca chiều |
| Tính công theo giờ | Tất cả NV. Có đơn → full công. Không đơn → actual/standard. DV 8h, VP 7.5h |
| Phép năm | Bắt buộc Day-1 |
| Tính phạt thủ công | OK 2-3 tháng đầu. Cần bảng công 4 lần vào/ra khối DV |
8. Rollout & Timeline
Tuần 1: TL review + chốt spec
Tuần 2-3: Phase 1A — Foundation (1.5 tuần)
Schema + canonical_key fix + Settings Module + non-regression Diva
Gate: Diva pass ✓
Tuần 3-5: Phase 1B — 3 track song song (3 tuần)
BE + FE Admin + FE Mobile
Tuần 6-7: Pilot (2 tuần)
Gate: Pilot + Diva NR pass ✓
GO-LIVE đầu tháng 6/2026
Sau: Phase 1C — HardeningResource: 1 BE senior + 1 FE admin + 1 FE mobile + QA
9. Quy tắc cho session viết lại
- Đọc Source of Truth + codebase TRƯỚC khi viết
- Phân biệt rõ "hiện tại code" vs "target design" — dùng thì khác nhau
- Mỗi feature ghi Reuse / Extend / Build mới + file path + delta thực tế
- Wording phản ánh effort thật — không nhẹ hoá
- 1 quyết định = 1 nội dung canonical — không cho phép 2 files nói khác
- Sweep cross-file trước khi deliver
- Blocker = blocker — không ghi "delta nhỏ" cho schema change
- Vocabulary mới là canonical — dùng
Chi nhánh áp dụng,Phòng ban áp dụng,Nhân viên áp dụng,Triển khai; không quay lạiTab Branch/Department/User/Rollout