From 6a664298fa50a3bc929caeb21c90e7b960c549d6 Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Mon, 8 Jun 2026 12:34:48 +0700 Subject: [PATCH] [CLAUDE] Office: P11-E AttendanceReport+Excel+OtPolicy + P11-F MaTicket codegen (Wave 1) 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) --- .../implementer-backend/MEMORY.md | 2 + .../implementer-frontend/MEMORY.md | 1 + .../investigator-codebase/MEMORY.md | 2 + .claude/agent-memory/reviewer/MEMORY.md | 2 + .../agent-memory/test-specialist/MEMORY.md | 5 +- fe-admin/src/App.tsx | 3 + .../src/pages/office/AttendanceReportPage.tsx | 170 ++++++++++++++++++ .../src/pages/office/MyAttendancePage.tsx | 24 ++- fe-admin/src/types/workflowApps.ts | 21 +++ .../Controllers/AttendancesController.cs | 18 +- .../Office/AttendanceReportFeatures.cs | 107 +++++++++++ .../Office/WorkflowAppsFeatures.cs | 3 + .../IAttendanceReportExcelExporter.cs | 11 ++ .../DependencyInjection.cs | 1 + .../Reports/AttendanceReportExcelExporter.cs | 87 +++++++++ .../Application/AttendanceReportTests.cs | 150 ++++++++++++++++ .../Application/ItTicketCodeGenTests.cs | 119 ++++++++++++ 17 files changed, 719 insertions(+), 7 deletions(-) create mode 100644 fe-admin/src/pages/office/AttendanceReportPage.tsx create mode 100644 src/Backend/SolutionErp.Application/Office/AttendanceReportFeatures.cs create mode 100644 src/Backend/SolutionErp.Application/Reports/Services/IAttendanceReportExcelExporter.cs create mode 100644 src/Backend/SolutionErp.Infrastructure/Reports/AttendanceReportExcelExporter.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/AttendanceReportTests.cs create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/ItTicketCodeGenTests.cs diff --git a/.claude/agent-memory/implementer-backend/MEMORY.md b/.claude/agent-memory/implementer-backend/MEMORY.md index abb5ac7..0472327 100644 --- a/.claude/agent-memory/implementer-backend/MEMORY.md +++ b/.claude/agent-memory/implementer-backend/MEMORY.md @@ -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 nhầm) → `HashSet` + 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]`. diff --git a/.claude/agent-memory/implementer-frontend/MEMORY.md b/.claude/agent-memory/implementer-frontend/MEMORY.md index e2df908..f03426b 100644 --- a/.claude/agent-memory/implementer-frontend/MEMORY.md +++ b/.claude/agent-memory/implementer-frontend/MEMORY.md @@ -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 {...})()` 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. diff --git a/.claude/agent-memory/investigator-codebase/MEMORY.md b/.claude/agent-memory/investigator-codebase/MEMORY.md index 98d47a5..3a1afe9 100644 --- a/.claude/agent-memory/investigator-codebase/MEMORY.md +++ b/.claude/agent-memory/investigator-codebase/MEMORY.md @@ -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()` (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]`. diff --git a/.claude/agent-memory/reviewer/MEMORY.md b/.claude/agent-memory/reviewer/MEMORY.md index d1756f2..982224f 100644 --- a/.claude/agent-memory/reviewer/MEMORY.md +++ b/.claude/agent-memory/reviewer/MEMORY.md @@ -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]. diff --git a/.claude/agent-memory/test-specialist/MEMORY.md b/.claude/agent-memory/test-specialist/MEMORY.md index c4a29ff..5f0432f 100644 --- a/.claude/agent-memory/test-specialist/MEMORY.md +++ b/.claude/agent-memory/test-specialist/MEMORY.md @@ -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]. --- diff --git a/fe-admin/src/App.tsx b/fe-admin/src/App.tsx index cdab029..2c17df1 100644 --- a/fe-admin/src/App.tsx +++ b/fe-admin/src/App.tsx @@ -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() { } /> } /> } /> + {/* Báo cáo chấm công (P11-E) — admin-only, reachable qua button trên trang Chấm công */} + } /> } /> } /> } /> diff --git a/fe-admin/src/pages/office/AttendanceReportPage.tsx b/fe-admin/src/pages/office/AttendanceReportPage.tsx new file mode 100644 index 0000000..1749be0 --- /dev/null +++ b/fe-admin/src/pages/office/AttendanceReportPage.tsx @@ -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>('/departments', { params: { page: 1, pageSize: 200 } })).data.items, + }) + + const report = useQuery({ + queryKey: ['attendance-report', year, month, deptId], + queryFn: async () => { + const res = await api.get('/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 ( +
+ exportExcel.mutate()} disabled={exportExcel.isPending || report.isLoading}> + + {exportExcel.isPending ? 'Đang xuất…' : 'Xuất Excel'} + + } + /> + + {/* ===== Bộ lọc ===== */} +
+
+ + setYear(Number(e.target.value) || now.getFullYear())} + /> +
+
+ + +
+
+ + +
+
+ + {/* ===== Bảng kết quả ===== */} +
+ + + + + + + + + + + + + + + + {report.isLoading && ( + + )} + {!report.isLoading && rows.length === 0 && ( + + )} + {rows.map((r, i) => ( + + + + + + + + + + + + ))} + + {!report.isLoading && rows.length > 0 && report.data && ( + + + + + + + + + )} +
STTHọ tênPhòng banNgày côngTổng giờ làmOT thườngOT cuối tuầnOT lễOT quy đổi
Đang tải…
+ + Không có dữ liệu chấm công cho kỳ đã chọn. +
{i + 1}{r.fullName}{r.departmentName ?? '—'}{r.daysPresent}{fmtNum(r.totalWorkHours)}{fmtNum(r.otWeekday)}{fmtNum(r.otWeekend)}{fmtNum(r.otHoliday)}{fmtNum(r.otWeighted)}
Tổng{fmtNum(report.data.grandTotalWorkHours)}{fmtNum(report.data.grandTotalOtWeighted)}
+
+
+ ) +} diff --git a/fe-admin/src/pages/office/MyAttendancePage.tsx b/fe-admin/src/pages/office/MyAttendancePage.tsx index c8f5fae..a02c057 100644 --- a/fe-admin/src/pages/office/MyAttendancePage.tsx +++ b/fe-admin/src/pages/office/MyAttendancePage.tsx @@ -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 (
- + navigate('/attendance/report')}> + + Báo cáo + + ) : undefined + } + />
diff --git a/fe-admin/src/types/workflowApps.ts b/fe-admin/src/types/workflowApps.ts index cdd78a1..1bbf2d1 100644 --- a/fe-admin/src/types/workflowApps.ts +++ b/fe-admin/src/types/workflowApps.ts @@ -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 } diff --git a/src/Backend/SolutionErp.Api/Controllers/AttendancesController.cs b/src/Backend/SolutionErp.Api/Controllers/AttendancesController.cs index bc30826..614f904 100644 --- a/src/Backend/SolutionErp.Api/Controllers/AttendancesController.cs +++ b/src/Backend/SolutionErp.Api/Controllers/AttendancesController.cs @@ -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 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 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 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); + } } diff --git a/src/Backend/SolutionErp.Application/Office/AttendanceReportFeatures.cs b/src/Backend/SolutionErp.Application/Office/AttendanceReportFeatures.cs new file mode 100644 index 0000000..31fbb7c --- /dev/null +++ b/src/Backend/SolutionErp.Application/Office/AttendanceReportFeatures.cs @@ -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 + so qua +// DateOnly.FromDateTime(a.AttendanceDate) (spec viết HashSet 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 Rows, + decimal GrandTotalWorkHours, decimal GrandTotalOtWeighted); + +public record GetAttendanceReportQuery(int Year, int Month, Guid? DepartmentId) : IRequest; + +public class GetAttendanceReportHandler(IApplicationDbContext db) + : IRequestHandler +{ + public async Task 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 (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); + } +} diff --git a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs index f9a1f5c..f5eb722 100644 --- a/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs +++ b/src/Backend/SolutionErp.Application/Office/WorkflowAppsFeatures.cs @@ -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; diff --git a/src/Backend/SolutionErp.Application/Reports/Services/IAttendanceReportExcelExporter.cs b/src/Backend/SolutionErp.Application/Reports/Services/IAttendanceReportExcelExporter.cs new file mode 100644 index 0000000..7692f8b --- /dev/null +++ b/src/Backend/SolutionErp.Application/Reports/Services/IAttendanceReportExcelExporter.cs @@ -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); +} diff --git a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs index d7a221e..398a749 100644 --- a/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs +++ b/src/Backend/SolutionErp.Infrastructure/DependencyInjection.cs @@ -38,6 +38,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); diff --git a/src/Backend/SolutionErp.Infrastructure/Reports/AttendanceReportExcelExporter.cs b/src/Backend/SolutionErp.Infrastructure/Reports/AttendanceReportExcelExporter.cs new file mode 100644 index 0000000..34f07fe --- /dev/null +++ b/src/Backend/SolutionErp.Infrastructure/Reports/AttendanceReportExcelExporter.cs @@ -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"); + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/AttendanceReportTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/AttendanceReportTests.cs new file mode 100644 index 0000000..4a8c9fe --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/AttendanceReportTests.cs @@ -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(); + var u = await fix.CreateUserAsync("u-rep1@test.local", "Nguyễn Văn A", null, Array.Empty()); + + 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(); + + 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()); + var outDept = await fix.CreateUserAsync("u-deptB@test.local", "User Phòng B", deptB.Id, Array.Empty()); + + 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)"); + } + } +} diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketCodeGenTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketCodeGenTests.cs new file mode 100644 index 0000000..0eedb11 --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Application/ItTicketCodeGenTests.cs @@ -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(); + 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() }; + + 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()); + + 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()); + 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()); + 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); + } + } +}