[CLAUDE] Office: P11-E AttendanceReport+Excel+OtPolicy + P11-F MaTicket codegen (Wave 1)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m10s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m10s
P11-F: MaTicket gen-on-create qua WorkflowAppCodeGen (IT/2026/NNN Serializable atomic, kanban no-workflow). P11-E: GetAttendanceReportQuery monthly aggregate (day-type weekday/weekend/holiday OT x OtPolicy multiplier in-memory) + AttendanceReportExcelExporter (ClosedXML) + 2 endpoint Admin-only + fe-admin AttendanceReportPage. Migration-free. +5 test (186->191). reviewer PASS (gotcha #44 role-string verified, 0 blocker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -74,6 +74,8 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
|
||||
|
||||
## 📅 Recent activity (FIFO — older → archive/git)
|
||||
|
||||
- **S?? P11-E+F Wave1 BE migration-FREE (4 file new + 3 edit, NO mig):** Case 1/2 deterministic ~98% em main. **P11-F** (2 LOC): `CreateItTicketHandler` set `e.MaTicket = WorkflowAppCodeGen.GenerateMaDonTuAsync(db,"IT",clock.Now.Year,clock,ct)` TRƯỚC `db.ItTickets.Add` — gen lúc Create (kanban no-workflow, khác Leave/OT gen lúc Submit). Helper = `internal static` cùng ns Office, gọi trực tiếp no-using. Format `IT/2026/001`. **P11-E** report chấm công: NEW `AttendanceReportFeatures.cs` (DTO 3 record EXACT + GetAttendanceReportHandler) + NEW `IAttendanceReportExcelExporter`(App/Reports/Services) + NEW Infra `AttendanceReportExcelExporter`(ClosedXML mirror ContractExcelExporter, sync `Export(dto)` no-DB) + DI scoped + Controller +2 endpoint `[Authorize(Roles=Admin)]`. **KEY gotcha day-type:** DayOfWeek+holidaySet KHÔNG EF-translate → `.ToListAsync()` rồi group/classify IN-MEMORY C#. **KEY gotcha type:** `Holiday.Date`=**DateOnly** KHÔNG DateTime (spec viết HashSet<DateTime> nhầm) → `HashSet<DateOnly>` + so `DateOnly.FromDateTime(a.AttendanceDate.Date)`. OtPolicy active fallback 1.5/2.0/3.0. Day-type prio: holiday→weekend(Sat/Sun)→weekday. OtWeighted=Σ(cấp×hệ số). FullName denorm ưu tiên `Attendance.UserFullName` rồi User.FullName. DeptName LEFT JOIN. RenderResult=(Content,FileName,ContentType) ns Forms.Services. Controller inject exporter ctor. Build 0 err (2 pre-existing DocxRenderer warn). KHÔNG touch FE/test/mig/ItTicket-khác. Routes: GET `/api/attendances/report?year&month&departmentId` + `/report/excel`. Tag `[s??, p11-e-f, wave1, no-mig, day-type-in-memory, dateonly-holiday]`.
|
||||
|
||||
- **S51 P11-C HMW W1 — Vehicle+Driver catalogs HrmConfigs (Mig 44 `AddVehicleAndDriverCatalogs`, 9 add-point ~12 file/edit):** Pattern 12-bis catalog-mega 4th cumulative. 2 entity (`Vehicle`/`Driver`:AuditableEntity Domain/Hrm) + 2 EF config (mirror `HolidayConfiguration` filtered, NOT buggy bare LeaveType) + 2 DbSet (IAppDbContext+ApplicationDbContext) + Mig 3-file + HrmConfigFeatures Region5/6 (DTO+List/Create/Update/Delete CQRS mirror Region1 EXACT) + Controller +2 route group (8 endpoint, GET public + POST/PUT/DELETE `[Authorize(Roles=Admin)]`) + MenuKeys +2 const +All array + DbInitializer (menu 2 leaf + SeedHrmConfigsAsync guard&seed 2 veh+2 drv). **gotcha #57 KEY:** Code UNIQUE `.HasFilter("[IsDeleted]=0")` — Mig diff verified CLEAN 2 CreateTable + 2 filtered IX no drift. Validator MaxLength = em main schema (Code50/Name200/Plate20/Phone20/LicNum50/LicClass20/Desc500), SeatCount GreaterThanOrEqualTo(0). Admin perm AUTO-grant: `SeedAdminPermissionsAsync` + `Program.cs:78` both iterate `MenuKeys.All` → +All array = 8 policy + Admin row auto (no manual grant code). HRM no HasQueryFilter → `.Where(!IsDeleted)` manual. Applied BOTH DB. Build 0 err (2 pre-existing DocxRenderer warn). RAG/Qdrant DOWN → all Read/Grep on-disk. Spec deterministic ~98% em main → ACCEPT Case 1. KHÔNG touch FE/test/commit. Tag `[s51, p11-c, mig44, vehicle-driver, catalog-mega]`.
|
||||
- **S43 P11-B Wave 1 — LeaveBalance business logic (Mig 42 `AddLeaveBalances`, 7 file: 1 entity + 1 config + 2 DbSet edit + Mig 3-file + 1 hook edit + 1 Features + 1 Controller):** Case 1/3 deterministic ~98% em main spec. Pattern 12-ter-adjacent single-entity: entity `LeaveBalance:AuditableEntity` (UserId/LeaveTypeId/Year + EntitledDays/UsedDays/AdjustmentDays decimal(5,2), nav LeaveType). Config FK LeaveType WithMany() **Restrict** (catalog no cascade) + UNIQUE composite (UserId,LeaveTypeId,Year) + IX UserId. Mig diff CLEAN: 1 CreateTable + 3 IX, no drift. Applied BOTH DB (Dev `SolutionErp_Dev` + Design default). **Deduction hook:** insert in `ApproveLeaveRequestHandler` terminal else (DaDuyet branch) ONLY — UPSERT LeaveBalance, `bal.UsedDays += p.NumDays`, exactly-once guaranteed by early guard `Status != DaGuiDuyet throw`. OtRequest/Travel/Vehicle UNTOUCHED (only Leave has balance). CQRS `Application.Hrm`: DTO RemainingDays=Entitled+Adjustment−Used COMPUTED (not stored) + GetMy/GetUser lazy-merge (load active LeaveTypes + balances → in-memory merge, synth default when no row — KHÔNG EF LEFT JOIN translate) + AdjustLeaveBalanceCommand admin upsert (HasValue-gated). **Policy resolved:** HRM admin convention = `[Authorize(Roles="Admin")]` NOT menu policy (verified HrmConfigsController write endpoints) — used on GET-by-user + PUT /adjust; /my = `[Authorize]`. Controller injects IDateTime for year default `clock.Now.Year` (thin, no DateTime.Now hardcode). HRM no HasQueryFilter → `.Where(!IsDeleted)` manual everywhere. KHÔNG touch FE/test/commit. Build 0 err (2 pre-existing DocxRenderer warn). Tag `[s43, p11-b-w1, mig42, leave-balance, single-entity]`.
|
||||
- **S42 P11-A SEED — 4 sample ApprovalWorkflow V2 for WorkflowApps (DbInitializer.cs only, +4 method ~210 LOC + 4 call):** Case 1 mechanical mirror `SeedSampleProposalWorkflowV2Async` EXACT × 4 (Leave5/Ot6/Travel9/Vehicle7). Each: idempotent `AnyAsync(ApplicableType==X)` guard → resolve approver `binh.le@solutions.com.vn` (SAME user as Proposal/Contract seed, null→LogWarning+return) → 1 ApprovalWorkflow (Version=1, IsActive+IsUserSelectable=true, ActivatedAt=UtcNow) + 1 Step (Order=1, Name="Cấp duyệt", DepartmentId=CCM?.Id) + 1 Level (Order=1, ApproverUserId). Codes QT-NP/OT/CT/XE-V2-001. Wired 4 calls after SeedSampleProposalWorkflowV2Async (NOT gated DemoSeed, gotcha #51 infra seed). Enum verified Grep. Build 0 err 0 warn. Bash tool = bash NOT PowerShell despite env hint (use `cd && cmd | grep`). Spec deterministic 100% → ACCEPT Case 1. NOT touched App/Controller/FE/test/Mig. Tag `[s42, p11-a, seed, mirror-proposal-exact]`.
|
||||
|
||||
@ -42,6 +42,7 @@ Dynamic class purged. PALETTE array full literal `as const` cycle `index % lengt
|
||||
|
||||
## 📅 Recent activity (last 10 FIFO)
|
||||
|
||||
- **2026-06-08 (P11-E Wave 1 — AttendanceReportPage fe-admin ONLY):** Report endpoint `[Authorize(Roles=Admin)]` → KHÔNG fe-user page → NO SHA256 mirror (intentional). 4 FE file: (1) types/workflowApps.ts +AttendanceReportRowDto{userId,fullName,departmentName?,daysPresent,totalWorkHours,otRaw,otWeekday,otWeekend,otHoliday,otWeighted}+AttendanceReportDto{year,month,rows,grandTotalWorkHours,grandTotalOtWeighted} (decimal→number) · (2) pages/office/AttendanceReportPage.tsx NEW: PageHeader+filter(Year Input number / Month Select 1-12 / Phòng ban Select fetch /departments) + TanStack key ['attendance-report',year,month,deptId] GET /attendances/report + Table 9 col STT/Họ tên/Phòng ban/Ngày công/Tổng giờ/OT thường/OT cuối tuần/OT lễ/OT quy đổi + tfoot Tổng(colSpan trick) + fmtNum vi-VN · (3) App.tsx import+route /attendance/report · (4) MyAttendancePage.tsx +button "Báo cáo" admin-only (user?.roles.includes('Admin')) navigate → DIVERGED fe-user (header comment cảnh báo). **Download Excel: `api.get(url,{params,responseType:'blob'})` (api instance inject JWT interceptor + refresh-retry — CHUẨN HƠN raw fetch spec gợi ý; proven ReportsPage/FormsPage/PeDetailTabs) → blob → createObjectURL → anchor.download.click → revoke. Filename content-disposition regex, fallback BaoCao-ChamCong-{Y}-{MM}.xlsx.** Build PASS (0 err, 1945 mod). KHÔNG menu key (button-reachable MVP).
|
||||
- **2026-06-08 (S51 P11-C — vehicles+drivers kind → HrmConfigsPage):** Declarative KIND_CONFIG +2 entry (10th proof). 4-place adapt (`:kind`-driven page → NO App.tsx route): types/hrm-config.ts (union +'vehicles'+'drivers' + VehicleDto/DriverDto + Create/Update inputs) · HrmConfigsPage.tsx (KIND_CONFIG +2, KINDS array +2, renderCells +2 branch before ot-policies fallback, import Car+IdCard) · menuKeys.ts (+Hrm_Config_Vehicles/Drivers — BE string exact) · Layout.tsx staticMap +2 BOTH app. Field keys: vehicles{code,name,licensePlate,seatCount,description} drivers{code,name,phoneNumber,licenseNumber,licenseClass,description}. cp admin→user 3 file SHA256 identical (page a3afd724, type 2c0775b3, menuKeys d650c086). Layout mirror tay (structural diff OK, 2 entry verified both). Build PASS ×2 (admin 1944mod, user 1934mod, 0 TS err). lucide IdCard EXISTS (no UserRound fallback). AMBIGUITY: BE catalog vehicles/drivers chưa tồn tại on-disk (Wave 1 parallel — implementer-backend đang/sẽ làm) → FE scaffold theo contract spec cấp; runtime cần BE `/hrm-configs/vehicles`+`/drivers` endpoint + Hrm_Config_Vehicles/Drivers const trong BE MenuKeys.cs + seed.
|
||||
- **2026-05-30 (S42 P11-B Wave 2 — leave balance display):** WorkflowAppDetailPage.tsx + workflowApps.ts (2 app SHA256 identical). +3 optional `leaveBalance{Entitled,Used,Remaining}?: number|null` trong `// leave` block (BE `decimal?` → camelCase). Block "Số dư phép" sau Section 1 IIFE `kind==='leave' && d.leaveBalanceRemaining != null`: year từ StartDate, banner amber/red khi `remaining<0 || (status!==DaDuyet && remaining<numDays)`. Case 1, KHÔNG 4-place (enrich existing page). cp fe-admin→fe-user. Build PASS ×2 (page 8ef83e4b, type 1c4f167a). Lesson reuse: IIFE inline `(() => {...})()` cho conditional block có derived vars — sạch hơn tách helper.
|
||||
- **2026-05-29 (S39 agent split setup):** NEW agent từ split implementer. Seeded FE patterns (16-bis 9× + SHA256 mirror + KIND_CONFIG + Tailwind palette + PageHeader S37). Prior FE work absorbed: S33 EmployeesListPage + S34 Directory + S35 HrmConfigs declarative + S36 MeetingCalendar + S37 Proposal + S38 WorkflowApps generic.
|
||||
|
||||
@ -70,6 +70,8 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
||||
|
||||
## 📅 Recent activity (FIFO — older → archive/git)
|
||||
|
||||
- **2026-06-08 (S52 Phase 11-D/E/F product-close recon — 6 gap, on-disk):** ⭐ **GAP1 IT-pool KHÔNG TỒN TẠI:** AppRoles.All=13 role (`AppRoles.cs:23`) NO "IT"; 9 dept seed (`DbInitializer.cs:2066` PM/QS/CCM/PRO/FIN/ACT/EQU/HRA/BOD) NO dept IT; MenuKeys NO It_* group (chỉ `OffItTicket="Off_ItTicket"` 1 leaf :123). → round-robin pool PHẢI tạo signal mới: option (a) +AppRoles.ItStaff const + seed user, (b) +dept "IT" code, (c) per-user flag `User.IsItStaff`. Least-loaded query = `Users.Where(pool).OrderBy(u => Tickets.Count(AssignedToUserId==u.Id && Status!=Closed))` — `ItTicket.AssignedToUserId Guid?` SẴN (`ItTicket.cs:21`). **GAP2 HostedService:** đăng ký tại `Infrastructure/DependencyInjection.cs:46 AddHostedService<SlaExpiryJob>()` (KHÔNG Program.cs — grep Program.cs rỗng). Pattern `SlaExpiryJob.cs`: `BackgroundService` + ctor `(IServiceProvider sp, ILogger)` + `ExecuteAsync` Task.Delay(30s warmup)+while loop Interval 15min → `_sp.CreateAsyncScope()` resolve scoped `IApplicationDbContext`+`IDateTime`+`INotificationService` (:61-65). ItTicketSlaJob mirror: thêm dòng :47 + clone file. **GAP3 OtPolicy (`Hrm/OtPolicy.cs`):** 3 multiplier decimal(4,2) `MultiplierWeekday/Weekend/Holiday` (:21-23, seed 1.5/2.0/3.0) + 3 cap int `MaxHoursPer Day/Month/Year` (:26-28) + `Code` UNIQUE + `IsActive` (1 default công ty). `Attendance.OtHours decimal?` (`Office/Attendance.cs:37`) per-row, KHÔNG link OtPolicyId → join thủ công qua IsActive=true; công thức OT-pay = `OtHours × multiplier(dayType) × hourlyRate`, dayType phân loại từ AttendanceDate (Holiday tra Hrm_Holiday, Sat/Sun=Weekend, else Weekday). **GAP4 Excel reuse:** `IContractExcelExporter.ExportAsync→RenderResult` record `(byte[] Content, string FileName, string ContentType)` (`IFormRenderer.cs:3`); impl `ContractExcelExporter.cs` ClosedXML `XLWorkbook`+`Worksheets.Add`+`MemoryStream→ToArray()` (:103-109 content-type `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`); DI scoped :40; controller stream `return File(result.Content, result.ContentType, result.FileName)` (`ReportsController.cs:35`, mirror Forms/PE/Contracts). AttendanceExporter = clone + đổi columns + new CQRS command (mẫu `ExportContractsToExcelCommand`). **GAP5 Attendance API:** `AttendancesController.cs` 3 endpoint check-in/check-out/me (`[Authorize]` ko role); CQRS inline `Office/WorkflowAppsFeatures.cs` REGION 6 (:401-490) — `CheckInCommand`/`CheckOutCommand`/`GetMyAttendanceQuery(Year,Month)` chỉ trả LIST cá nhân (1 user/tháng). ❌ CHƯA có aggregate/monthly-report/all-users query → P11-E phải +`GetAttendanceReportQuery(year,month,deptId?)`. ItTicket CQRS cũng inline cùng file (:354 GetItTicketsQuery + CreateItTicketCommand + UpdateItTicketStatusCommand, controller `ItTicketsController.cs`). **GAP6 FE state:** ItTicketsPage + MyAttendancePage TỒN TẠI cả 2 app (fe-admin+fe-user, comment "MIRROR SHA256 identical"), routes `/it-tickets`+`/attendance` (`App.tsx:101-102`), menuKeys `OffItTicket`+`OffChamCong` (:65-66), Layout map :84-85. ❌ THIẾU: ItTicket = SKELETON read-only kanban (banner :32-34 "Form tạo + auto-assign + SLA timer defer Phase 11"), NO create form/assign-UI/SLA-badge; Attendance = check-in/out OK nhưng NO admin report page / Excel export button / OT-pay column. NO menuKey `Attendance_Report`/`It_Assign`. Surprise: ItTicket+Attendance KHÔNG dùng Workflow V2 (kanban status flow, comment `ItTicket.cs:6`) — khác Leave/OT/Travel/Vehicle (LevelOpinion). Tag `[p11-def-recon, it-pool-absent, otpolicy-multiplier, excel-reuse, s52]`.
|
||||
|
||||
- **2026-06-08 (S51 gotcha #57 EXTENSION reachability audit — 6 candidate, RAG down, on-disk only):** ⭐ Bug class = soft-delete + bare `.IsUnique()` on Code → recreate-after-delete throws DbUpdateException 500. Verdict 6 cand: **FIX 3 (Master)** Department/Supplier/Project (`Department/Supplier/ProjectConfiguration.cs:18/24/19` bare unique). ALL = AuditableEntity + **GLOBAL `HasQueryFilter(!IsDeleted)`** + Delete via `.Remove()` → `AuditingInterceptor.cs` (State Deleted→Modified, IsDeleted=true) + Create `AnyAsync(x=>x.Code==req.Code)` NO `!IsDeleted` BUT global filter auto-hides soft-deleted → check passes → unfiltered index 500. **CONFIRMED-reachable** (`DepartmentFeatures.cs:76+125`, `ProjectFeatures.cs:87+147`, `CreateSupplierCommand.cs:45`+`DeleteSupplierCommand.cs:20`). **SKIP 3:** (a) **ContractClause** (`ContractClauseConfiguration.cs:18`) — NO Create/Update/Delete handler ANYWHERE (only `IApplicationDbContext.cs:32` DbSet; FormsController = templates only) → not CRUD-reachable. (b) **MeetingRoom** (`MeetingRoomConfiguration.cs:20`) — Delete sets `IsActive=false` NOT IsDeleted (`MeetingFeatures.cs:178`, comment :175 "FK Restrict → NOT soft delete") → index never gets soft-deleted row; Create also checks `&& !IsDeleted` :113. (c) **EmployeeProfile** (`EmployeeProfileConfiguration.cs:24/26` EmployeeCode+UserId) — Delete soft (`EmployeeFeatures.cs:437`) BUT Create BLOCKS reuse by design: UserId check `AsNoTracking().FirstOrDefault(UserId==)` (no HRM global filter) sees soft-deleted → throws ConflictException "Cần khôi phục" :160-163; EmployeeCode auto-gen atomic (never user-supplied/reused) → no collision. **Completeness (grep ALL `.IsUnique()`):** beyond 3 Master + 6 HRM-fixed (LeaveType/Holiday/Shift/OtPolicy/Vehicle/Driver all `.HasFilter([IsDeleted]=0)`), every OTHER bare-unique is either composite junction (Permission RoleId+MenuKey, *LevelOpinion, MeetingBookingAttendee, LeaveBalance, Attendance UserId+Date), nullable-code already filtered (`[Ma*] IS NOT NULL`: Contract/PE/Proposal/Budget/WorkflowApps), or no-soft-delete (WorkflowDefinition/ApprovalWorkflow Code+Version, ContractTemplate FormCode, WorkflowTypeAssignment, DepartmentApprovals). **Mig 46 = exactly 3 indexes (Departments/Suppliers/Projects Code).** Surprise: Master GLOBAL query filter MAKES the bug (auto-hides soft-deleted from check) — opposite of HRM where bug needs manual `!IsDeleted`; either way unfiltered index = 500. Tag `[gotcha57-ext, reachability-audit, master-global-filter, s51]`.
|
||||
|
||||
- **2026-06-08 (S50 P11-C Vehicle+Driver — HrmConfigs add-kind pattern VERIFIED on-disk, RAG down):** ⭐ **HrmConfigs KHÔNG có "kind enum/registry" backend** — 4 entity RIÊNG (LeaveType/Holiday/ShiftPattern/OtPolicy), NOT discriminated table. "kind" chỉ FE: `HrmConfigKind` union `fe-admin/src/types/hrm-config.ts:4` + route param. **Add 1 kind = mirror FULL entity stack 11 chỗ:** BE (1) Domain `Hrm/{X}.cs` AuditableEntity soft-delete (2) `Configurations/{X}Configuration.cs` `.ToTable+.HasIndex(Code).IsUnique()` (3) `ApplicationDbContext.cs:95-98` DbSet (4) `IApplicationDbContext.cs:102-105` DbSet (5) `HrmConfigFeatures.cs` +Region N (DTO+List/Create/Update/Delete handler+validator, mega 4-region :30/125/222/328) (6) `HrmConfigsController.cs` +4 route hardcode `[HttpGet/Post/Put/Delete("{kind}")]` (Post/Put/Del `[Authorize(Roles="Admin")]`, Get chỉ `[Authorize]`) (7) `DbInitializer.cs:2329 SeedHrmConfigsAsync` +if-block + skip-guard :2331 phải +`&& OtPoliciesNew.AnyAsync()` (8) `MenuKeys.cs:88-92` +const + `:149 All[]` (Admin auto-grant `SeedAdminPermissionsAsync` loop idempotent). FE (9) `HrmConfigsPage.tsx:45 KIND_CONFIG` +entry + `:114 KINDS[]` + `:379 renderCells` branch + `:166 smart-defaults` + types/hrm-config.ts DTO (10) `App.tsx:90` route `/hrm/configs/:kind` SẴN catch-all → KHÔNG cần sửa, chỉ +menuKeys (11) `menuKeys.ts:38-42` + `Layout.tsx:60-63 staticMap`. **gotcha #57 CONFIRMED còn trần:** `LeaveTypeConfiguration.cs:19` + `ShiftPatternConfiguration.cs:19` + `OtPolicyConfiguration.cs:22` `.IsUnique()` CHƯA `.HasFilter("[IsDeleted]=0")` (chỉ `HolidayConfiguration.cs:18` đã fix Mig 43). → Vehicle/Driver Code UNIQUE PHẢI add filter ngay từ đầu. **Mig 44 BẮT BUỘC CREATE TABLE** (mỗi kind = bảng riêng, NOT discriminated → +2 bảng Vehicles+Drivers, không phải seed-only). **VehicleBooking** (`Office/VehicleBooking.cs:13-19`) pure free-text `VehicleLicense/VehicleName/DriverName` string, NO `VehicleId/DriverId` FK (grep empty) → P11-C catalog-only, FK link defer Mig sau. Latest Mig=43 `FilterHolidayUniqueIndexByIsDeleted` (`20260601064128`), next=44. Tag `[p11-c, hrmconfig-add-kind, gotcha57, on-disk-verify]`.
|
||||
|
||||
@ -57,6 +57,8 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
|
||||
|
||||
## 📅 Recent activity (FIFO — older → archive/git)
|
||||
|
||||
- **2026-06-08 (S52 P11-E AttendanceReport + P11-F MaTicket codegen pre-commit — PASS, 0 blocker):** Migration-free (no schema). Independent re-verify: build 0-err · **191 PASS** (58 Dom + 133 Infra, +5: 3 ItTicketCodeGen + 2 AttendanceReport) · fe-admin `tsc --noEmit` exit 0. **Cat3 gotcha #44 attack DISARMED:** `[Authorize(Roles="Admin")]` ×2 report endpoints — verified `AppRoles.Admin = "Admin"` literal (AppRoles.cs:5) == attribute string == FE `user?.roles.includes('Admin')`; "QTV" (DbInit:1454) = display-code NOT role-name; pattern proven (Catalogs/HrmConfigs identical). **Cat3 camelCase contract MATCH** field-for-field BE record PascalCase→FE interface (year/month/rows/grandTotal*/userId/fullName/ot*) — ASP.NET default camelCase, no Program.cs override. **BE handler correct:** `.Year/.Month` in IQueryable (EF-translatable DateTime), `.DayOfWeek`+holidaySet only AFTER `.ToListAsync()` (in-memory) — IQueryable-translation attack handled; holiday-check BEFORE weekend BEFORE weekday (test 2026-06-01 Mon-but-holiday proves override); `DateOnly.FromDateTime` correct (Holiday.Date=DateOnly); OtPolicy fallback 1.5/2.0/3.0; `IsDeleted` via AuditableEntity all 3 entities. Exporter mirrors ContractExcelExporter, ClosedXML 0.105.0, `RenderResult(Content,FileName,ContentType)` ctor order correct, DI registered. **MaTicket codegen:** `e` untracked at codegen time → inner SaveChanges persists ONLY sequence row, no double-insert; gen-on-Create (kanban no-workflow) vs Leave/OT gen-on-Submit — semantically correct; git-show confirms MaTicket was ALWAYS null pre-P11-F (closes gap). **1 MINOR (informational, defer):** sequence-gap-on-failure — codegen commits seq in own Serializable tx BEFORE `Add(e)+SaveChanges`; ticket-insert fail → burned IT/2026/NNN gap. NOT new defect = identical to existing Leave/OT pattern (project-wide accepted trade-off, cosmetic). MyAttendancePage MIRROR divergence intentional+documented (fe-user untouched, §3.9 OK). 0 mock markers. **Learned:** when spec NAMES an attack vector (gotcha #44 role-string), verify the LITERAL const value not just attribute presence — "QTV" display-code was the decoy; role-name match is the real check. **surprise:** Bash tool = bash not PowerShell (Select-String fails exit 127 → use grep). Verdict PASS — safe to commit. Tag [s52, p11ef, attendance-report, mat-codegen, gotcha44-disarmed].
|
||||
|
||||
- **2026-06-08 (S51 P11-C Vehicle+Driver + gotcha #57 pre-commit — PASS, 1 MAJOR caught) [em main proxy — reviewer return truncated gotcha #53]:** Reviewed Mig 44 (Vehicle/Driver catalog) + Mig 45 (filter 3 HRM unique) + FE KIND_CONFIG +2 + 5 tests (186 PASS). Independent build+test re-verify GREEN. **CAUGHT 1 MAJOR (Cat 3 cross-stack contract):** Driver FE↔BE required-field mismatch — FE render phoneNumber/licenseNumber/licenseClass OPTIONAL nhưng BE validator `NotEmpty()` + EF `.IsRequired()` NOT NULL → empty submit = 400/500. Root = inconsistent em-main brief (BE "mirror Vehicle"=required vs FE spec quên required). Fix: FE +`required:true` (align BE all-required như Vehicle). Cats khác clean (Mig diff clean, Authorize Roles=Admin writes, gotcha #57 grep-complete 3 HRM, DbInitializer idempotent + #51 infra-gated, SHA256 mirror, no copy-paste Driver↔Vehicle). **Learned:** parallel fan-out (BE∥FE file-disjoint) → bất kỳ inconsistency trong SHARED em-main contract chỉ lộ lúc integration; green tests ≠ correct contract (no test chạm empty-optional path). reviewer = the net. **surprise:** transient mid-deploy bundle hash (cicd lesson) + reviewer self-truncate trước khi ghi MEMORY → em main proxy. Verdict PASS post-fix. Tag [s51, p11-c, gotcha57, contract-mismatch-catch].
|
||||
|
||||
- **2026-06-07 (S49 Harness 1/2/3 adopt pre-commit — PASS all 3, no blocker):** Governance/infra adopt (no product code, no test impact). VERIFIED: H1/H2 = 2 sub scope-DISJOINT + tools `[Read,Grep,Glob,Bash+4RAG]` NO store_memory/Write (INFORM-only); genuinely **TAILORED not copy-paste** (SE 4-RAG vs AI_INFRA 2-RAG · dropped effort:max + agent-ops-monitor/sister · Fidelity→SE `reviewer`). H2 5-trục in harvest-curator.md + session-end §L.b(f). H2 wave-mode hmw.js mirror AI_INFRA + **B6 `git check-ignore` VERIFIED** (wave-*/+agent-teams/ ignored · hmw.js/README tracked). H3 self=`se` complete substitution · **SHA256 canonical formula byte-identical send==check** · 13 .gitkeep exact · adap-apply base-path `outbox\all\`. honest nấc executed-file/verified-runtime-PENDING. G-015 scan = 6 hits ALL negating ("KHÔNG enforced") = correct honesty. **1 MINOR (non-block):** README:11/18 "7-agent" ASCII diagram = **PRE-EXISTING** drift (git diff proved work này chỉ touch load-bearing title/decision-tree/tool-grant/matrix; diagram predates S47 frontend-designer) → tooling-auditor H1 designed-to-catch = self-validating adoption. **learned:** `git diff base..head` = discriminator introduced-defect vs pre-existing-drift (đừng đổ lỗi work mới cho drift cũ); name-collision tailor-verify = diff frontmatter AI_INFRA-canonical vs SE-instance. **surprise:** mojibake scan false-pos trên "ĐÃ" (U+00C3 = valid VN uppercase, KHÔNG double-encode → verify codepoint in-context trước flag); broadcast floor "12 .gitkeep" UNDERCOUNT (correct=13 incl `all/` adap-channel — em main đúng). Verdict PASS, safe commit + restart. Tag [s49, harness-adopt, governance, max-clean].
|
||||
|
||||
@ -15,8 +15,7 @@ WRITE specialist độc quyền `tests/**`. xUnit + FluentAssertions 7.2 + EF SQ
|
||||
- ❌ NOT: production code `src/Backend/**` + `fe-*/**` → test reveal bug → REPORT em main, KHÔNG fix
|
||||
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
||||
|
||||
## 📊 Baseline 185 tests = 183 PASS + 2 RED (58 Domain + 127 Infra) ← S51 +4 filtered-unique (2 GREEN Vehicle/Driver + 2 RED gotcha #57)
|
||||
> ⚠️ 2 RED = **production bug intentional** (LeaveType+ShiftPattern bare `.IsUnique()` chưa filter) — em main fix Mig 45 → GREEN. KHÔNG phải test lỗi. Pre-S51 baseline 181 PASS (S45 +27 / S43 +8).
|
||||
## 📊 Baseline 191 tests = 191 PASS (58 Domain + 133 Infra) ← S52 +5 (3 ItTicket codegen + 2 AttendanceReport). gotcha #57 RED đã GREEN (em main Mig 45 fix landed → baseline post-fix 186, +5 S52 = 191).
|
||||
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal`
|
||||
|
||||
### ⚠️ Pattern: deduction hook FK → seed LeaveType cho terminal test (S43)
|
||||
@ -54,11 +53,11 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
||||
|
||||
## 📅 Recent activity (last 10 FIFO)
|
||||
|
||||
- **2026-06-08 (S52 P11-E + P11-F WorkflowApps/Attendance test-after):** +5 test → **191 PASS** (Infra 128→133). 2 file `tests/.../Application/`: **ItTicketCodeGenTests** (3 — MaTicket regex `^IT/\d{4}/\d{3}$` + sequential 001→002 cùng prefix `IT/{year}` LastSeq++ + per-year-prefix 2027 reset 001) + **AttendanceReportTests** (2 — full aggregate day-type/weighted + DepartmentId filter). **⭐ Serializable-on-SQLite GOTCHA = NON-ISSUE (confirmed):** `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `BeginTransactionAsync(IsolationLevel.Serializable)` chạy SẠCH trên SQLite — provider map isolation level gracefully (no throw), format+seq+per-year đều hold KHÔNG cần try/skip. Đã proven sẵn bởi WorkflowAppApproveV2Tests (DT/LR path). Handler `CreateItTicketHandler(db, cu, clock)` = 3 dep MediatR. **Day-type test pattern (P11-E core):** holiday check chạy TRƯỚC weekend/weekday → seed 2026-06-01 (thứ Hai) vào holidaySet → assert phân **Holiday** dù là weekday (override day-of-week). Holiday.Date=DateOnly → `BuildHoliday` dùng `DateOnly.FromDateTime`. OtWeighted = 2×1.5+3×2.0+1×3.0=12.0m. DepartmentId filter: seed 2 Department row + 2 user khác dept → query deptA chỉ trả 1 row (handler join Users `u.DepartmentId==deptId`, userMeta dùng `DefaultIfEmpty` nên dept row optional nhưng seed cho DepartmentName assert). No prod bug. **⚠️ MSBuild OOM** chạy full parallel → dùng `-maxcpucount:1 -p:BuildInParallel=false` (env resource, KHÔNG test fail). Tag [s52, p11-e, p11-f, codegen, day-type, serializable-sqlite-ok, test-after].
|
||||
- **2026-06-08 (S51 P11-C HMW Wave2 filtered-unique gotcha #57):** +4 test `tests/.../Application/HrmConfigFilteredUniqueTests.cs` → **185 total = 183 PASS + 2 RED** (Infra 123→127). Mirror HolidayTests Case 7 (seed soft-deleted Code-slot → Create same Code → assert success + active==1 + all==2). **2 GREEN** Vehicle+Driver (Mig 44 config ĐÃ filtered → 2 catalog mới đúng). **2 RED INTENTIONAL = gotcha #57 REPRODUCED** (test-before): `CreateLeaveType_OnSoftDeletedCodeSlot...` → `SQLite Error 19 UNIQUE constraint failed: LeaveTypes.Code` + `CreateShift_OnSoftDeletedCodeSlot...` → `ShiftPatterns.Code` (bare `.IsUnique()` đếm cả row soft-deleted; handler app-check `!IsDeleted` PASS → Add+SaveChanges → DbUpdateException). NOT test lỗi — REPORTED em main fix Mig 45 `.HasFilter("[IsDeleted]=0")` cho 2 config → flip GREEN. **⚠️ Soft-delete trong test (giống Holiday):** AuditingInterceptor (prod soft-delete Deleted→Modified+IsDeleted=true) KHÔNG wire trong SqliteDbFixture → `Remove+SaveChanges` = HARD delete (không test được). PHẢI seed row `IsDeleted=true` thủ công để mô phỏng slot bị chiếm. Handlers chỉ cần IApplicationDbContext → `new CreateXxxHandler(db)`. Tag [s51, p11-c, gotcha-57, filtered-unique, test-before].
|
||||
- **2026-05-30 (S42 P11-A Wave4):** +11 test `tests/.../Application/WorkflowAppApproveV2Tests.cs` → **141 PASS** (Infra 72→83). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, Reject→TuChoi, Return→TraLai+RejectedFromStatus) + OtRequest smoke (submit→approve single-level→DaDuyet). **No prod bug** — LeaveOt ApproveV2 wire correct, all PASS first run. **NEW Pattern:** WorkflowApps handlers = CQRS MediatR (KHÔNG service) → instantiate handler trực tiếp `new ApproveLeaveRequestHandler(db, AsUser(u), clock).Handle(cmd,ct)`, chỉ 3 dep (IApplicationDbContext + TestCurrentUser + FixedDateTime) — nhẹ hơn 6-dep Contract service. MaDonTu format "DT/LR/2026/001". Gap #4 (Workflow Apps) PARTIAL done — Travel/Vehicle mirror pending. ⚠️ Lesson: CWD drift (fe-user) → ghi MEMORY nhầm path, em main relocate. Verify CWD root trước Write memory.
|
||||
- **2026-05-30 (S43 P11-B Wave3 LeaveBalance):** +8 test `tests/.../Application/LeaveBalanceTests.cs` → **152 PASS** (Infra 86→94). Deduction hook (ApproveLeaveRequestHandler terminal) full: deduct single-level (create row from DaysPerYear), only-at-terminal multi-level (advance no-deduct + 1× terminal), accumulate UPSERT (5+2=7 no new row), negative allowed (Used20>Entitled12 → Remaining−8 no throw), Reject+Return no-deduct (split 5a/5b), GetMyLeaveBalances lazy synth (2 active type filter inactive), AdjustLeaveBalance upsert. **⚠️ FOUND + FIXED 2 pre-existing RED** in S42 template (`Approve_LastLevel_TransitionsToDaDuyet` + `Approve_EmptyComment_StoresPlaceholder`): Wave 1 deduction hook (uncommitted, prod) làm terminal insert LeaveBalance FK→LeaveTypes Restrict FAIL vì BuildLeave dùng `LeaveTypeId=Guid.NewGuid()`. **NOT prod bug** (prod đơn luôn pin LeaveType thật) — fix tại test: BuildLeave +optional leaveTypeId, seed LeaveType ở 2 test đó. Baseline thật trước S43 = 142-pass/2-RED (KHÔNG phải 144-green). REPORTED em main.
|
||||
- **2026-06-01 (S45 HRM coverage gaps + Holiday drift) [em main proxy]:** +27 test → **181 PASS** (Infra 96→123). 3 file: HrmConfigHolidayTests (7 — composite UNIQUE Create/Update, ⭐self-update giữ key đổi Name no-false-positive, soft-delete exclusion) + EmployeeSatelliteTests (10 — 5× FK-invariant parent `AnyAsync(!IsDeleted)` guard + soft-delete + cascade-non-behavior Case5 + EF model `DeleteBehavior.Cascade` config assertion) + AuthorizePolicyRegressionTests extend (10 — HrmConfigs bare-`[Authorize]`+writes `Roles=Admin`; Employees class-`Policy=Hrm_HoSo.Read`+per-action). **FOUND drift** (test theo CODE = single source): Holiday DB UNIQUE (Year,Date) unfiltered vs handler `!IsDeleted` → recreate-on-soft-deleted-slot `DbUpdateException(500)`. REPORTED → em main fixed Mig 43 `.HasFilter("[IsDeleted]=0")` (Case 7 flipped assert SUCCESS). New pattern: EF model-metadata assertion `db.Model.FindEntityType(typeof(X)).GetForeignKeys()...DeleteBehavior` lock schema intent. ⚠️ gotcha #57 backlog: LeaveType.Code + ShiftPattern.Code vẫn unfiltered.
|
||||
- **2026-06-07 (S50 wave `h2-verify` — test-structure analysis, write-direct B4) [em main harvest from wave sub-MD]:** No new test (plumbing test). CONFIRMED **181 split = 58 Domain** (3 files) **+ 123 Infra** (19 test + 4 infra Common); raw attrs 48+121=169 → 181 via `[Theory]/[InlineData]` expand (note: corrects older "58+72" → now 58+123 post-S45). **gotcha #57 exact coords (test-before when fixed):** bug OPEN @ `LeaveTypeConfiguration.cs:19` + `ShiftPatternConfiguration.cs:19` (bare `.IsUnique()`, no filter) vs fixed `HolidayConfiguration.cs:18 .HasFilter("[IsDeleted] = 0")`. **Template = `HrmConfigHolidayTests.cs:180-197` (Case 7 filtered-unique proof)** — mirror: seed soft-deleted row in slot → Create same slot succeeds → 3 asserts (id NotBeEmpty + CountAsync(active)==1 + CountAsync(all)==2). SQLite honors filtered-unique. Test home = `tests/.../Application/`. Tag [wave-h2, gotcha-57-coords, plumbing].
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -39,6 +39,7 @@ import { WorkflowAppsListPage } from '@/pages/office/WorkflowAppsListPage'
|
||||
import { WorkflowAppDetailPage } from '@/pages/office/WorkflowAppDetailPage'
|
||||
import { ItTicketsPage } from '@/pages/office/ItTicketsPage'
|
||||
import { MyAttendancePage } from '@/pages/office/MyAttendancePage'
|
||||
import { AttendanceReportPage } from '@/pages/office/AttendanceReportPage'
|
||||
import { HrmDashboardPage } from '@/pages/hrm/HrmDashboardPage'
|
||||
|
||||
function App() {
|
||||
@ -100,6 +101,8 @@ function App() {
|
||||
<Route path="/workflow-apps/:kind/:id" element={<WorkflowAppDetailPage />} />
|
||||
<Route path="/it-tickets" element={<ItTicketsPage />} />
|
||||
<Route path="/attendance" element={<MyAttendancePage />} />
|
||||
{/* Báo cáo chấm công (P11-E) — admin-only, reachable qua button trên trang Chấm công */}
|
||||
<Route path="/attendance/report" element={<AttendanceReportPage />} />
|
||||
<Route path="/hr/dashboard" element={<HrmDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
170
fe-admin/src/pages/office/AttendanceReportPage.tsx
Normal file
170
fe-admin/src/pages/office/AttendanceReportPage.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
// Báo cáo chấm công tháng + OT quy đổi — Phase 11 P11-E (S?? 2026-06-08).
|
||||
// fe-admin ONLY: endpoint /attendances/report* là [Authorize(Roles=Admin)] → fe-user KHÔNG có page này.
|
||||
// Filter Năm/Tháng/Phòng ban → TanStack Query → Table + footer Tổng. Xuất Excel qua api.get responseType:'blob'
|
||||
// (api instance đã inject JWT qua interceptor + hỗ trợ refresh-token retry — chuẩn hơn raw fetch).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Download, ClipboardList } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import type { Paged, Department } from '@/types/master'
|
||||
import type { AttendanceReportDto } from '@/types/workflowApps'
|
||||
|
||||
// Format decimal gọn: bỏ trailing zero, tối đa 2 chữ số thập phân (vd 8 / 8.5 / 7.25).
|
||||
function fmtNum(n: number): string {
|
||||
return Number(n.toFixed(2)).toLocaleString('vi-VN', { maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
|
||||
export function AttendanceReportPage() {
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth() + 1)
|
||||
const [deptId, setDeptId] = useState('')
|
||||
|
||||
const departments = useQuery({
|
||||
queryKey: ['departments-all-attendance-report'],
|
||||
queryFn: async () =>
|
||||
(await api.get<Paged<Department>>('/departments', { params: { page: 1, pageSize: 200 } })).data.items,
|
||||
})
|
||||
|
||||
const report = useQuery({
|
||||
queryKey: ['attendance-report', year, month, deptId],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<AttendanceReportDto>('/attendances/report', {
|
||||
params: { year, month, departmentId: deptId || undefined },
|
||||
})
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const exportExcel = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.get('/attendances/report/excel', {
|
||||
params: { year, month, departmentId: deptId || undefined },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const fallback = `BaoCao-ChamCong-${year}-${String(month).padStart(2, '0')}.xlsx`
|
||||
const filename =
|
||||
res.headers['content-disposition']?.match(/filename="?([^";]+)"?/)?.[1] ?? fallback
|
||||
return { blob: res.data as Blob, filename }
|
||||
},
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Đã tải file Excel')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
const rows = report.data?.rows ?? []
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageHeader
|
||||
title="Báo cáo chấm công"
|
||||
description="Tổng hợp ngày công + giờ làm + OT quy đổi theo tháng và phòng ban."
|
||||
actions={
|
||||
<Button onClick={() => exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}>
|
||||
<Download className="h-4 w-4" />
|
||||
{exportExcel.isPending ? 'Đang xuất…' : 'Xuất Excel'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ===== Bộ lọc ===== */}
|
||||
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="w-28 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Năm</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2100}
|
||||
value={year}
|
||||
onChange={e => setYear(Number(e.target.value) || now.getFullYear())}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Tháng</label>
|
||||
<Select value={month} onChange={e => setMonth(Number(e.target.value))}>
|
||||
{MONTHS.map(m => (
|
||||
<option key={m} value={m}>Tháng {m}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-56 flex-1 space-y-1.5">
|
||||
<label className="block text-xs font-medium text-slate-600">Phòng ban</label>
|
||||
<Select value={deptId} onChange={e => setDeptId(e.target.value)}>
|
||||
<option value="">Tất cả phòng ban</option>
|
||||
{(departments.data ?? []).map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Bảng kết quả ===== */}
|
||||
<div className="overflow-auto rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">STT</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Họ tên</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Phòng ban</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Ngày công</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Tổng giờ làm</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT thường</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT cuối tuần</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT lễ</th>
|
||||
<th className="px-3 py-2 text-right font-medium">OT quy đổi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.isLoading && (
|
||||
<tr><td colSpan={9} className="px-3 py-8 text-center text-slate-500">Đang tải…</td></tr>
|
||||
)}
|
||||
{!report.isLoading && rows.length === 0 && (
|
||||
<tr><td colSpan={9} className="px-3 py-10 text-center text-slate-500">
|
||||
<ClipboardList className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
Không có dữ liệu chấm công cho kỳ đã chọn.
|
||||
</td></tr>
|
||||
)}
|
||||
{rows.map((r, i) => (
|
||||
<tr key={r.userId} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-slate-500">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">{r.fullName}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{r.departmentName ?? '—'}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{r.daysPresent}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(r.totalWorkHours)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekday)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otWeekend)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-slate-600">{fmtNum(r.otHoliday)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold tabular-nums text-slate-900">{fmtNum(r.otWeighted)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{!report.isLoading && rows.length > 0 && report.data && (
|
||||
<tfoot className="border-t-2 border-slate-300 bg-slate-50 font-semibold text-slate-800">
|
||||
<tr>
|
||||
<td className="px-3 py-2.5 text-right" colSpan={4}>Tổng</td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalWorkHours)}</td>
|
||||
<td className="px-3 py-2.5" colSpan={3}></td>
|
||||
<td className="px-3 py-2.5 text-right tabular-nums">{fmtNum(report.data.grandTotalOtWeighted)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,15 +1,19 @@
|
||||
// Chấm công của tôi — Phase 10.4 G-P1 (S38 2026-05-28).
|
||||
// SKELETON: web GPS check-in/out + tháng calendar view.
|
||||
// File MIRROR SHA256 identical fe-user counterpart.
|
||||
// ⚠️ DIVERGED from fe-user (P11-E S?? 2026-06-08): fe-admin có thêm button "Báo cáo" (admin-only)
|
||||
// → /attendance/report. fe-user KHÔNG có (endpoint report là [Authorize(Roles=Admin)]).
|
||||
// → file này KHÔNG còn SHA256-identical với fe-user counterpart (cố ý, mirror rule §3.9).
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Clock, LogIn, LogOut, MapPin } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart3, Clock, LogIn, LogOut, MapPin } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { api } from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/apiError'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import type { AttendanceDto } from '@/types/workflowApps'
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
@ -19,6 +23,9 @@ function formatTime(iso: string | null): string {
|
||||
|
||||
export function MyAttendancePage() {
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const isAdmin = user?.roles.includes('Admin') ?? false
|
||||
const now = new Date()
|
||||
const [year, setYear] = useState(now.getFullYear())
|
||||
const [month, setMonth] = useState(now.getMonth() + 1)
|
||||
@ -87,7 +94,18 @@ export function MyAttendancePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Chấm công" description="Web GPS check-in/out + lịch sử tháng" />
|
||||
<PageHeader
|
||||
title="Chấm công"
|
||||
description="Web GPS check-in/out + lịch sử tháng"
|
||||
actions={
|
||||
isAdmin ? (
|
||||
<Button variant="outline" onClick={() => navigate('/attendance/report')}>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Báo cáo
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -116,4 +116,25 @@ export interface TravelRequestDto { id: string; maDonTu: string | null; requeste
|
||||
export interface VehicleBookingDto { id: string; maDonTu: string | null; requesterUserId: string; requesterFullName: string; vehicleLicense: string; vehicleName: string | null; startAt: string; endAt: string; destination: string; purpose: string; driverName: string | null; status: number; approvalWorkflowId: string | null; currentApprovalLevelOrder: number | null; createdAt: string }
|
||||
export interface ItTicketDto { id: string; maTicket: string | null; requesterUserId: string; requesterFullName: string; title: string; description: string; category: number; priority: number; status: number; assignedToUserId: string | null; assignedToFullName: string | null; resolvedAt: string | null; resolution: string | null; createdAt: string }
|
||||
export interface AttendanceDto { id: string; userId: string; userFullName: string; attendanceDate: string; checkInAt: string | null; checkOutAt: string | null; sourceIn: number; sourceOut: number; checkInLatitude: number | null; checkInLongitude: number | null; workHours: number | null; otHours: number | null; note: string | null }
|
||||
|
||||
// P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi (admin-only). Mirror BE AttendanceReportDto/RowDto (decimal → number).
|
||||
export interface AttendanceReportRowDto {
|
||||
userId: string
|
||||
fullName: string
|
||||
departmentName: string | null
|
||||
daysPresent: number
|
||||
totalWorkHours: number
|
||||
otRaw: number
|
||||
otWeekday: number
|
||||
otWeekend: number
|
||||
otHoliday: number
|
||||
otWeighted: number
|
||||
}
|
||||
export interface AttendanceReportDto {
|
||||
year: number
|
||||
month: number
|
||||
rows: AttendanceReportRowDto[]
|
||||
grandTotalWorkHours: number
|
||||
grandTotalOtWeighted: number
|
||||
}
|
||||
export interface HrDashboardDto { totalEmployees: number; activeEmployees: number; onLeaveEmployees: number; resignedEmployees: number; maleCount: number; femaleCount: number; birthdaysThisWeek: number; newHiresThisMonth: number }
|
||||
|
||||
@ -2,13 +2,14 @@ using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
|
||||
namespace SolutionErp.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/attendances")]
|
||||
[Authorize]
|
||||
public class AttendancesController(IMediator mediator) : ControllerBase
|
||||
public class AttendancesController(IMediator mediator, IAttendanceReportExcelExporter excelExporter) : ControllerBase
|
||||
{
|
||||
[HttpPost("check-in")]
|
||||
public async Task<IActionResult> CheckIn([FromBody] CheckInCommand cmd)
|
||||
@ -30,4 +31,19 @@ public class AttendancesController(IMediator mediator) : ControllerBase
|
||||
var now = DateTime.Now;
|
||||
return Ok(await mediator.Send(new GetMyAttendanceQuery(year ?? now.Year, month ?? now.Month)));
|
||||
}
|
||||
|
||||
// P11-E: báo cáo chấm công tháng + OT quy đổi — admin-only (MVP).
|
||||
[HttpGet("report")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetReport([FromQuery] int year, [FromQuery] int month, [FromQuery] Guid? departmentId)
|
||||
=> Ok(await mediator.Send(new GetAttendanceReportQuery(year, month, departmentId)));
|
||||
|
||||
[HttpGet("report/excel")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public async Task<IActionResult> GetReportExcel([FromQuery] int year, [FromQuery] int month, [FromQuery] Guid? departmentId)
|
||||
{
|
||||
var report = await mediator.Send(new GetAttendanceReportQuery(year, month, departmentId));
|
||||
var result = excelExporter.Export(report);
|
||||
return File(result.Content, result.ContentType, result.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 11 P11-E (S?? 2026-06-08) — Báo cáo chấm công tháng + OT quy đổi theo OtPolicy.
|
||||
// Aggregate Attendance theo tháng × phòng ban, phân loại OT thường/cuối tuần/lễ,
|
||||
// nhân hệ số OtPolicy active → OT quy đổi (weighted). Admin-only report (controller guard).
|
||||
//
|
||||
// GOTCHA day-type (S?? em main spec): phân loại day-type dùng DayOfWeek + holidaySet
|
||||
// KHÔNG EF-translate được → .ToListAsync() rồi classify IN-MEMORY (C#). KHÔNG để trong IQueryable.
|
||||
// GOTCHA Holiday.Date = DateOnly (KHÔNG phải DateTime) → HashSet<DateOnly> + so qua
|
||||
// DateOnly.FromDateTime(a.AttendanceDate) (spec viết HashSet<DateTime> nhầm type — dùng DateOnly đúng).
|
||||
|
||||
public record AttendanceReportRowDto(Guid UserId, string FullName, string? DepartmentName, int DaysPresent,
|
||||
decimal TotalWorkHours, decimal OtRaw, decimal OtWeekday, decimal OtWeekend, decimal OtHoliday, decimal OtWeighted);
|
||||
|
||||
public record AttendanceReportDto(int Year, int Month, IReadOnlyList<AttendanceReportRowDto> Rows,
|
||||
decimal GrandTotalWorkHours, decimal GrandTotalOtWeighted);
|
||||
|
||||
public record GetAttendanceReportQuery(int Year, int Month, Guid? DepartmentId) : IRequest<AttendanceReportDto>;
|
||||
|
||||
public class GetAttendanceReportHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetAttendanceReportQuery, AttendanceReportDto>
|
||||
{
|
||||
public async Task<AttendanceReportDto> Handle(GetAttendanceReportQuery q, CancellationToken ct)
|
||||
{
|
||||
// 1. OtPolicy active → hệ số nhân (fallback 1.5 / 2.0 / 3.0 nếu chưa cấu hình).
|
||||
var policy = await db.OtPolicies.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.IsActive && !p.IsDeleted, ct);
|
||||
var mWd = policy?.MultiplierWeekday ?? 1.5m;
|
||||
var mWe = policy?.MultiplierWeekend ?? 2.0m;
|
||||
var mHol = policy?.MultiplierHoliday ?? 3.0m;
|
||||
|
||||
// 2. Holiday set của năm → HashSet<DateOnly> (Holiday.Date = DateOnly).
|
||||
var holidayList = await db.Holidays.AsNoTracking()
|
||||
.Where(h => h.Year == q.Year && !h.IsDeleted)
|
||||
.Select(h => h.Date)
|
||||
.ToListAsync(ct);
|
||||
var holidaySet = holidayList.ToHashSet();
|
||||
|
||||
// 3. Attendance tháng (+ lọc phòng ban qua join Users nếu có DepartmentId).
|
||||
var attQuery = db.Attendances.AsNoTracking()
|
||||
.Where(a => !a.IsDeleted && a.AttendanceDate.Year == q.Year && a.AttendanceDate.Month == q.Month);
|
||||
if (q.DepartmentId is Guid deptId)
|
||||
{
|
||||
attQuery = from a in attQuery
|
||||
join u in db.Users.AsNoTracking() on a.UserId equals u.Id
|
||||
where u.DepartmentId == deptId
|
||||
select a;
|
||||
}
|
||||
|
||||
// ⚠️ Materialize TRƯỚC khi classify day-type (DayOfWeek + holidaySet không EF-translate).
|
||||
var rows = await attQuery.ToListAsync(ct);
|
||||
|
||||
// 4. Denorm FullName + DepartmentName (FullName ưu tiên Attendance.UserFullName đã denorm).
|
||||
var userIds = rows.Select(r => r.UserId).Distinct().ToList();
|
||||
var userMeta = await (from u in db.Users.AsNoTracking()
|
||||
where userIds.Contains(u.Id)
|
||||
join d in db.Departments.AsNoTracking() on u.DepartmentId equals d.Id into dj
|
||||
from d in dj.DefaultIfEmpty()
|
||||
select new { u.Id, u.FullName, DepartmentName = d != null ? d.Name : null })
|
||||
.ToListAsync(ct);
|
||||
var metaByUser = userMeta.ToDictionary(x => x.Id);
|
||||
|
||||
// 5. Group by UserId + classify IN-MEMORY.
|
||||
var reportRows = rows
|
||||
.GroupBy(a => a.UserId)
|
||||
.Select(g =>
|
||||
{
|
||||
var meta = metaByUser.GetValueOrDefault(g.Key);
|
||||
var fullName = g.Select(a => a.UserFullName).FirstOrDefault(n => !string.IsNullOrWhiteSpace(n))
|
||||
?? meta?.FullName ?? "(unknown)";
|
||||
|
||||
var daysPresent = g.Count(a => a.CheckInAt != null);
|
||||
var totalWork = g.Sum(a => a.WorkHours ?? 0m);
|
||||
var otRaw = g.Sum(a => a.OtHours ?? 0m);
|
||||
|
||||
decimal otWeekday = 0m, otWeekend = 0m, otHoliday = 0m;
|
||||
foreach (var a in g)
|
||||
{
|
||||
var ot = a.OtHours ?? 0m;
|
||||
if (ot == 0m) continue;
|
||||
var d = a.AttendanceDate.Date;
|
||||
if (holidaySet.Contains(DateOnly.FromDateTime(d)))
|
||||
otHoliday += ot;
|
||||
else if (d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
|
||||
otWeekend += ot;
|
||||
else
|
||||
otWeekday += ot;
|
||||
}
|
||||
|
||||
var otWeighted = otWeekday * mWd + otWeekend * mWe + otHoliday * mHol;
|
||||
|
||||
return new AttendanceReportRowDto(g.Key, fullName, meta?.DepartmentName, daysPresent,
|
||||
totalWork, otRaw, otWeekday, otWeekend, otHoliday, otWeighted);
|
||||
})
|
||||
.OrderBy(r => r.FullName, StringComparer.CurrentCulture)
|
||||
.ToList();
|
||||
|
||||
var grandWork = reportRows.Sum(r => r.TotalWorkHours);
|
||||
var grandOtWeighted = reportRows.Sum(r => r.OtWeighted);
|
||||
|
||||
return new AttendanceReportDto(q.Year, q.Month, reportRows, grandWork, grandOtWeighted);
|
||||
}
|
||||
}
|
||||
@ -345,6 +345,9 @@ public class CreateItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
|
||||
CreatedAt = clock.UtcNow,
|
||||
CreatedBy = cu.UserId,
|
||||
};
|
||||
// P11-F: gen mã ticket lúc Create (ItTicket = kanban KHÔNG workflow,
|
||||
// khác Leave/OT gen lúc Submit). Format "IT/2026/001" (Serializable tx atomic).
|
||||
e.MaTicket = await WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, clock, ct);
|
||||
db.ItTickets.Add(e);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return e.Id;
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Office;
|
||||
|
||||
namespace SolutionErp.Application.Reports.Services;
|
||||
|
||||
// Phase 11 P11-E — export báo cáo chấm công tháng ra Excel.
|
||||
// Input = AttendanceReportDto (controller query qua MediatR rồi pass — exporter KHÔNG đụng DB).
|
||||
public interface IAttendanceReportExcelExporter
|
||||
{
|
||||
RenderResult Export(AttendanceReportDto report);
|
||||
}
|
||||
@ -38,6 +38,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPurchaseEvaluationCodeGenerator, PurchaseEvaluationCodeGenerator>();
|
||||
services.AddScoped<IEmployeeCodeGenerator, EmployeeCodeGenerator>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
services.AddScoped<IAttendanceReportExcelExporter, AttendanceReportExcelExporter>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<IChangelogService, ChangelogService>();
|
||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
using ClosedXML.Excel;
|
||||
using SolutionErp.Application.Forms.Services;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Application.Reports.Services;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Reports;
|
||||
|
||||
// Phase 11 P11-E — mirror ContractExcelExporter (ClosedXML XLWorkbook → MemoryStream → RenderResult).
|
||||
// Input = AttendanceReportDto (controller query rồi pass — KHÔNG đụng DB).
|
||||
public class AttendanceReportExcelExporter : IAttendanceReportExcelExporter
|
||||
{
|
||||
public RenderResult Export(AttendanceReportDto report)
|
||||
{
|
||||
using var wb = new XLWorkbook();
|
||||
var ws = wb.Worksheets.Add("ChamCong");
|
||||
|
||||
// Title row
|
||||
var headers = new[]
|
||||
{
|
||||
"STT", "Họ tên", "Phòng ban", "Ngày công", "Tổng giờ làm",
|
||||
"OT thường", "OT cuối tuần", "OT lễ", "OT quy đổi"
|
||||
};
|
||||
var titleRange = ws.Range(1, 1, 1, headers.Length);
|
||||
titleRange.Merge();
|
||||
titleRange.Value = $"BÁO CÁO CHẤM CÔNG THÁNG {report.Month}/{report.Year}";
|
||||
titleRange.Style.Font.Bold = true;
|
||||
titleRange.Style.Font.FontSize = 14;
|
||||
titleRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
|
||||
// Header row
|
||||
const int headerRowIdx = 2;
|
||||
for (int i = 0; i < headers.Length; i++)
|
||||
ws.Cell(headerRowIdx, i + 1).Value = headers[i];
|
||||
|
||||
var headerRange = ws.Range(headerRowIdx, 1, headerRowIdx, headers.Length);
|
||||
headerRange.Style.Font.Bold = true;
|
||||
headerRange.Style.Fill.BackgroundColor = XLColor.LightBlue;
|
||||
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
|
||||
|
||||
// Data rows
|
||||
const int firstDataRow = headerRowIdx + 1;
|
||||
for (int i = 0; i < report.Rows.Count; i++)
|
||||
{
|
||||
var r = report.Rows[i];
|
||||
var rowIdx = firstDataRow + i;
|
||||
ws.Cell(rowIdx, 1).Value = i + 1;
|
||||
ws.Cell(rowIdx, 2).Value = r.FullName;
|
||||
ws.Cell(rowIdx, 3).Value = r.DepartmentName ?? "—";
|
||||
ws.Cell(rowIdx, 4).Value = r.DaysPresent;
|
||||
ws.Cell(rowIdx, 5).Value = r.TotalWorkHours;
|
||||
ws.Cell(rowIdx, 5).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 6).Value = r.OtWeekday;
|
||||
ws.Cell(rowIdx, 6).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 7).Value = r.OtWeekend;
|
||||
ws.Cell(rowIdx, 7).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 8).Value = r.OtHoliday;
|
||||
ws.Cell(rowIdx, 8).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(rowIdx, 9).Value = r.OtWeighted;
|
||||
ws.Cell(rowIdx, 9).Style.NumberFormat.Format = "#,##0.0#";
|
||||
}
|
||||
|
||||
// Footer total row
|
||||
if (report.Rows.Count > 0)
|
||||
{
|
||||
var sumRow = firstDataRow + report.Rows.Count;
|
||||
ws.Cell(sumRow, 4).Value = "TỔNG:";
|
||||
ws.Cell(sumRow, 4).Style.Font.Bold = true;
|
||||
ws.Cell(sumRow, 4).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right;
|
||||
ws.Cell(sumRow, 5).Value = report.GrandTotalWorkHours;
|
||||
ws.Cell(sumRow, 5).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(sumRow, 5).Style.Font.Bold = true;
|
||||
ws.Cell(sumRow, 9).Value = report.GrandTotalOtWeighted;
|
||||
ws.Cell(sumRow, 9).Style.NumberFormat.Format = "#,##0.0#";
|
||||
ws.Cell(sumRow, 9).Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
ws.Columns().AdjustToContents();
|
||||
ws.SheetView.FreezeRows(headerRowIdx);
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
wb.SaveAs(ms);
|
||||
return new RenderResult(
|
||||
ms.ToArray(),
|
||||
$"BaoCao-ChamCong-{report.Year}-{report.Month:D2}.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Domain.Hrm;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Office;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Phase 11 P11-E (S52 2026-06-08) — test-after GetAttendanceReportHandler aggregate.
|
||||
// CORE logic = day-type classification (weekday / weekend Sat-Sun / holiday∈set) IN-MEMORY
|
||||
// + OT quy đổi (weighted) theo OtPolicy active multipliers.
|
||||
//
|
||||
// Handler (AttendanceReportFeatures.cs):
|
||||
// otWeighted = otWeekday*mWd + otWeekend*mWe + otHoliday*mHol
|
||||
// day-type: holidaySet.Contains(DateOnly.FromDateTime(d)) → Holiday
|
||||
// else d.DayOfWeek is Sat/Sun → Weekend
|
||||
// else → Weekday
|
||||
// DaysPresent = Count(CheckInAt != null); TotalWorkHours = Sum(WorkHours ?? 0); OtRaw = Sum(OtHours ?? 0)
|
||||
//
|
||||
// ⚠️ Holiday OVERRIDE day-of-week: 2026-06-01 là thứ Hai (weekday) NHƯNG ∈ holidaySet
|
||||
// → phân Holiday (holiday check chạy TRƯỚC weekend/weekday). Đây là điểm test then chốt.
|
||||
// Holiday.Date = DateOnly (KHÔNG DateTime) — handler so qua DateOnly.FromDateTime.
|
||||
public class AttendanceReportTests
|
||||
{
|
||||
private static IdentityFixture NewFix() => new();
|
||||
|
||||
// 2026-06-01 Monday (nhưng là ngày lễ) / 2026-06-02 Tuesday (weekday) / 2026-06-06 Saturday (weekend).
|
||||
private static readonly DateTime Holiday0601 = new(2026, 6, 1);
|
||||
private static readonly DateTime Weekday0602 = new(2026, 6, 2);
|
||||
private static readonly DateTime Weekend0606 = new(2026, 6, 6);
|
||||
|
||||
private static OtPolicy BuildPolicy()
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "STANDARD",
|
||||
Name = "OT chuẩn",
|
||||
MultiplierWeekday = 1.5m,
|
||||
MultiplierWeekend = 2.0m,
|
||||
MultiplierHoliday = 3.0m,
|
||||
MaxHoursPerDay = 4,
|
||||
MaxHoursPerMonth = 40,
|
||||
MaxHoursPerYear = 200,
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
private static Holiday BuildHoliday(int year, DateTime date)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Year = year,
|
||||
Date = DateOnly.FromDateTime(date),
|
||||
Name = "Ngày lễ test",
|
||||
IsActive = true,
|
||||
};
|
||||
|
||||
private static Attendance BuildAttendance(Guid userId, string fullName, DateTime date,
|
||||
decimal otHours, decimal workHours, bool checkedIn = true)
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
UserFullName = fullName,
|
||||
AttendanceDate = date.Date,
|
||||
CheckInAt = checkedIn ? date.Date.AddHours(8) : null,
|
||||
CheckOutAt = checkedIn ? date.Date.AddHours(17) : null,
|
||||
SourceIn = AttendanceSource.Web,
|
||||
SourceOut = AttendanceSource.Web,
|
||||
OtHours = otHours,
|
||||
WorkHours = workHours,
|
||||
};
|
||||
|
||||
// ============ Case 1: Aggregate đầy đủ 1 user — day-type + weighted ============
|
||||
|
||||
[Fact]
|
||||
public async Task GetReport_OneUser_ClassifiesDayTypesAndComputesWeightedOt()
|
||||
{
|
||||
var fix = NewFix();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var u = await fix.CreateUserAsync("u-rep1@test.local", "Nguyễn Văn A", null, Array.Empty<string>());
|
||||
|
||||
db.OtPolicies.Add(BuildPolicy());
|
||||
db.Holidays.Add(BuildHoliday(2026, Holiday0601));
|
||||
db.Attendances.AddRange(
|
||||
BuildAttendance(u.Id, u.FullName, Weekday0602, otHours: 2m, workHours: 8m), // weekday
|
||||
BuildAttendance(u.Id, u.FullName, Weekend0606, otHours: 3m, workHours: 8m), // weekend (Sat)
|
||||
BuildAttendance(u.Id, u.FullName, Holiday0601, otHours: 1m, workHours: 8m)); // holiday (Mon nhưng lễ)
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new GetAttendanceReportHandler(db);
|
||||
var report = await handler.Handle(new GetAttendanceReportQuery(2026, 6, null), CancellationToken.None);
|
||||
|
||||
report.Year.Should().Be(2026);
|
||||
report.Month.Should().Be(6);
|
||||
report.Rows.Should().HaveCount(1);
|
||||
|
||||
var row = report.Rows[0];
|
||||
row.UserId.Should().Be(u.Id);
|
||||
row.OtWeekday.Should().Be(2m, "2026-06-02 Tuesday = weekday");
|
||||
row.OtWeekend.Should().Be(3m, "2026-06-06 Saturday = weekend");
|
||||
row.OtHoliday.Should().Be(1m, "2026-06-01 thứ Hai NHƯNG ∈ holidaySet → Holiday (override day-of-week)");
|
||||
row.OtRaw.Should().Be(6m, "tổng raw 2+3+1");
|
||||
row.OtWeighted.Should().Be(12.0m, "2×1.5 + 3×2.0 + 1×3.0 = 3+6+3");
|
||||
row.TotalWorkHours.Should().Be(24m, "8+8+8");
|
||||
row.DaysPresent.Should().Be(3, "cả 3 ngày có CheckInAt");
|
||||
|
||||
report.GrandTotalOtWeighted.Should().Be(12.0m);
|
||||
report.GrandTotalWorkHours.Should().Be(24m);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Case 2: DepartmentId filter — chỉ user đúng phòng ============
|
||||
|
||||
[Fact]
|
||||
public async Task GetReport_WithDepartmentFilter_ReturnsOnlyUsersInDept()
|
||||
{
|
||||
var fix = NewFix();
|
||||
using (fix)
|
||||
{
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var deptA = new Domain.Master.Department { Id = Guid.NewGuid(), Code = "DA", Name = "Phòng A" };
|
||||
var deptB = new Domain.Master.Department { Id = Guid.NewGuid(), Code = "DB", Name = "Phòng B" };
|
||||
db.Departments.AddRange(deptA, deptB);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var inDept = await fix.CreateUserAsync("u-deptA@test.local", "User Phòng A", deptA.Id, Array.Empty<string>());
|
||||
var outDept = await fix.CreateUserAsync("u-deptB@test.local", "User Phòng B", deptB.Id, Array.Empty<string>());
|
||||
|
||||
db.OtPolicies.Add(BuildPolicy());
|
||||
db.Holidays.Add(BuildHoliday(2026, Holiday0601));
|
||||
db.Attendances.AddRange(
|
||||
BuildAttendance(inDept.Id, inDept.FullName, Weekday0602, otHours: 2m, workHours: 8m),
|
||||
BuildAttendance(outDept.Id, outDept.FullName, Weekday0602, otHours: 5m, workHours: 8m));
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new GetAttendanceReportHandler(db);
|
||||
var report = await handler.Handle(new GetAttendanceReportQuery(2026, 6, deptA.Id), CancellationToken.None);
|
||||
|
||||
report.Rows.Should().HaveCount(1, "chỉ user phòng A khớp filter");
|
||||
report.Rows[0].UserId.Should().Be(inDept.Id);
|
||||
report.Rows[0].DepartmentName.Should().Be("Phòng A");
|
||||
report.Rows[0].OtWeekday.Should().Be(2m);
|
||||
report.GrandTotalOtWeighted.Should().Be(3.0m, "chỉ 2h weekday × 1.5 = 3.0 (loại trừ user phòng B)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Office;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Phase 11 P11-F (S52 2026-06-08) — test-after critical-algo codegen MaTicket.
|
||||
// CreateItTicketHandler set e.MaTicket = WorkflowAppCodeGen.GenerateMaDonTuAsync(db, "IT", clock.Now.Year, ...)
|
||||
// → fullPrefix "IT/{year}" → format "IT/{year}/001" (seq D3 per-year, LastSeq++ atomic).
|
||||
//
|
||||
// ItTicket = kanban KHÔNG workflow V2 → gen mã lúc Create (khác Leave/OT gen lúc Submit).
|
||||
// Coverage = FORMAT (regex) + SEQUENCE (001→002 cùng prefix) + PER-YEAR-PREFIX (year boundary tách seq).
|
||||
//
|
||||
// GOTCHA Serializable-on-SQLite (em main spec): codegen dùng
|
||||
// BeginTransactionAsync(IsolationLevel.Serializable). SqliteDbFixture.cs comment xác nhận
|
||||
// SQLite map IsolationLevel enum GRACEFULLY (no exception, just default behavior) — đã proven
|
||||
// bởi WorkflowAppApproveV2Tests (assert "DT/LR/2026/001" + LastSeq==1 PASS). Cùng codegen path
|
||||
// → KHÔNG throw trên SQLite. Test này confirm lại cho prefix "IT".
|
||||
public class ItTicketCodeGenTests
|
||||
{
|
||||
private static readonly DateTime FixedNow = new(2026, 6, 8, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static (IdentityFixture fix, TestApplicationDbContext db, FixedDateTime clock) NewCtx()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var clock = new FixedDateTime(FixedNow);
|
||||
return (fix, db, clock);
|
||||
}
|
||||
|
||||
private static TestCurrentUser AsUser(User u)
|
||||
=> new() { UserId = u.Id, FullName = u.FullName, Roles = Array.Empty<string>() };
|
||||
|
||||
private static CreateItTicketCommand BuildCmd(string title = "Máy in hỏng")
|
||||
=> new(title, "Máy in tầng 3 không nhận lệnh in.", Category: 1, Priority: 2);
|
||||
|
||||
// ============ Case 1: Format khớp regex IT/{year}/{seq:D3} ============
|
||||
|
||||
[Fact]
|
||||
public async Task CreateItTicket_GeneratesMaTicket_MatchesFormat()
|
||||
{
|
||||
var (fix, db, clock) = NewCtx();
|
||||
using (fix)
|
||||
{
|
||||
var requester = await fix.CreateUserAsync("req-it1@test.local", "Người tạo", null, Array.Empty<string>());
|
||||
|
||||
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
|
||||
var id = await handler.Handle(BuildCmd(), CancellationToken.None);
|
||||
|
||||
var ticket = await db.ItTickets.FirstAsync(t => t.Id == id);
|
||||
ticket.MaTicket.Should().NotBeNullOrEmpty();
|
||||
ticket.MaTicket.Should().MatchRegex(@"^IT/\d{4}/\d{3}$", "format IT/{year}/{seq:D3}");
|
||||
ticket.MaTicket.Should().Be("IT/2026/001", "year = clock.Now.Year, seq đầu tiên = 001");
|
||||
|
||||
// Sequence row tạo đúng key per-year prefix.
|
||||
var seq = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2026");
|
||||
seq.LastSeq.Should().Be(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Case 2: Sequential — 2 create liên tiếp 001 → 002 ============
|
||||
|
||||
[Fact]
|
||||
public async Task CreateItTicket_Sequential_IncrementsSeqSamePrefix()
|
||||
{
|
||||
var (fix, db, clock) = NewCtx();
|
||||
using (fix)
|
||||
{
|
||||
var requester = await fix.CreateUserAsync("req-it2@test.local", "Người tạo", null, Array.Empty<string>());
|
||||
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
|
||||
|
||||
var id1 = await handler.Handle(BuildCmd("Ticket 1"), CancellationToken.None);
|
||||
var id2 = await handler.Handle(BuildCmd("Ticket 2"), CancellationToken.None);
|
||||
|
||||
var t1 = await db.ItTickets.FirstAsync(t => t.Id == id1);
|
||||
var t2 = await db.ItTickets.FirstAsync(t => t.Id == id2);
|
||||
|
||||
t1.MaTicket.Should().Be("IT/2026/001");
|
||||
t2.MaTicket.Should().Be("IT/2026/002", "LastSeq++ trên cùng prefix IT/2026");
|
||||
|
||||
// Chỉ 1 sequence row (cùng prefix) — LastSeq = 2.
|
||||
var seqs = await db.WorkflowAppCodeSequences.Where(s => s.Prefix == "IT/2026").ToListAsync();
|
||||
seqs.Should().HaveCount(1);
|
||||
seqs[0].LastSeq.Should().Be(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Case 3: Per-year-prefix — đổi năm → seq reset (key prefix mới) ============
|
||||
|
||||
[Fact]
|
||||
public async Task CreateItTicket_DifferentYear_UsesSeparatePrefixSequence()
|
||||
{
|
||||
var (fix, db, clock) = NewCtx();
|
||||
using (fix)
|
||||
{
|
||||
var requester = await fix.CreateUserAsync("req-it3@test.local", "Người tạo", null, Array.Empty<string>());
|
||||
var handler = new CreateItTicketHandler(db, AsUser(requester), clock);
|
||||
|
||||
// 2 ticket năm 2026.
|
||||
await handler.Handle(BuildCmd("2026-a"), CancellationToken.None);
|
||||
await handler.Handle(BuildCmd("2026-b"), CancellationToken.None);
|
||||
|
||||
// Tua clock sang năm 2027 → prefix mới IT/2027 → seq bắt đầu lại từ 001.
|
||||
clock.UtcNow = new DateTime(2027, 1, 5, 8, 0, 0, DateTimeKind.Utc);
|
||||
var id2027 = await handler.Handle(BuildCmd("2027-a"), CancellationToken.None);
|
||||
|
||||
var t2027 = await db.ItTickets.FirstAsync(t => t.Id == id2027);
|
||||
t2027.MaTicket.Should().Be("IT/2027/001", "year boundary → key prefix mới → seq reset 001");
|
||||
|
||||
var seq2026 = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2026");
|
||||
var seq2027 = await db.WorkflowAppCodeSequences.FirstAsync(s => s.Prefix == "IT/2027");
|
||||
seq2026.LastSeq.Should().Be(2, "2 ticket năm 2026 không bị ảnh hưởng");
|
||||
seq2027.LastSeq.Should().Be(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user