Compare commits

...

2 Commits

Author SHA1 Message Date
6a664298fa [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
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>
2026-06-08 12:34:48 +07:00
e9ee97fb3b [CLAUDE] Docs: adopt database-agent (DB1-DB11 read-advisory) — roster 10->11 + adap-report
AI_INFRA broadcast 2026-06-08-Agent-database-codebase-agents. database-agent STRONG-FIT (DB11 RowVersion va lost-update gap S43); READ-advisory tier (implementer-backend van author). codebase-agent SKIP n-a (investigator cover + csharp-lsp Windows no-op). Nac executed-file -> verified-runtime CHO CLI restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:33:52 +07:00
21 changed files with 866 additions and 10 deletions

View File

@ -0,0 +1,32 @@
# database-agent — MEMORY (L1 HOT)
> READ-advisory DB specialist SOLUTION_ERP (.NET 10 EF Core 10 + SQL Server, single `ApplicationDbContext` dbo). Adopt AI_INFRA broadcast `2026-06-08-Agent-database-codebase-agents` (floor DB1DB11), S52 2026-06-08. Seed = em main. **Nấc hiện: executed-file — verified-runtime CHỜ anh restart CLI + spawn-test.**
## Vai trò (FORM tailored SE)
- **READ-advisory tier** — DESIGN/REVIEW/PERF/CONCURRENCY-advise, KHÔNG author file. `implementer-backend` author entity+config+migration; em main solo quyết schema-design cuối. database-agent = deep-DB lens hỗ trợ + review.
- Floor DB1DB11 (canonical, KHÔNG hạ) — chi tiết `.claude/agents/database-agent.md`.
- Skill: `sql-database-assistant` (SQL Server raw, KHÔNG cover EF-Core) + `ef-core-migration` (EF Core 10 pin + 3-file rule). Verify present TRƯỚC wire.
- `store_memory` STRIPPED → ghi finding vào FILE này; em main + re-index → RAG.
## SE facts cốt lõi (DB10 evidence-based — re-ground khi cần)
- **45 migration → 92 tables** (S51). `sys.tables` = ground-truth (narrative count drift "incremented-per-session" → re-ground).
- 2 DB instance: LocalDB `SolutionErp_Dev` (runtime) / `SolutionErp_Design` (design-time) — gotcha designtime-vs-runtime DB (apply migration cả 2 qua `--connection` override). Prod = `.\SQLEXPRESS\SolutionErp`.
- Soft-delete UNIQUE index PHẢI `.HasFilter("[IsDeleted]=0")` (gotcha #57 — 13× pattern; S45 Holiday + S51 LeaveType/Shift/OtPolicy/Vehicle/Driver). EXT backlog: Department/Supplier/Project (Mig 46 worktree).
- Codegen atomic = `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `IsolationLevel.Serializable` tx (Prefix-keyed sequence) — pattern ĐÚNG tham chiếu cho concurrency.
## 🎯 DB11 gap đã biết (concurrency — vai trò chính)
- **S43 LeaveBalance trừ phép KHÔNG có `RowVersion`** = lost-update risk khi 2 approve đua (concurrency token defer). = lý do AI_INFRA tag database-agent STRONG-FIT cho SE.
- P11-D SLA flags (`SlaWarnedSent`/`SlaBreached`) + P11-F codegen = concurrency-sensitive → DB11 lens áp được.
## Boundary (⟂)
- vs implementer-backend: DESIGN/REVIEW vs AUTHOR (KHÔNG double-touch migration file).
- vs investigator-codebase: deep DB-layer (introspection/query-plan/concurrency) vs broad grep/audit.
- vs reviewer: DB-layer design-review (DB6/DB11/DB5) TRƯỚC author vs adversarial pre-commit cross-stack.
- KHÔNG: FE · business-logic · deploy · session-lifecycle audit.
## Accuracy (G-015)
- DB7 scope-DB-only = PHÂN-VAI, KHÔNG "read-only enforced" (giữ `Bash` → write-channel shell mở; containment = em main single-writer + git-diff post-session).
- Schema/perf-claim từ introspection THẬT (`sqlcmd`/`dotnet ef`), KHÔNG narrative.
## Log
- **S52 (2026-06-08):** Seeded (em main, adap-apply database-agent). Roster 10→11. Nấc executed-file. CHỜ restart + spawn-test → verified-runtime.

View File

@ -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+AdjustmentUsed 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]`.

View File

@ -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.

View File

@ -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]`.

View File

@ -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].

View File

@ -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 128133). 2 file `tests/.../Application/`: **ItTicketCodeGenTests** (3 MaTicket regex `^IT/\d{4}/\d{3}$` + sequential 001002 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** 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 123127). 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 DeletedModified+IsDeleted=true) KHÔNG wire trong SqliteDbFixture `Remove+SaveChanges` = HARD delete (không test được). PHẢI seed row `IsDeleted=true` thủ công để 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 7283). LeaveRequest 8 case full (Submit happy/guard×2, Approve advance/terminal/UPSERT-invariant/forbidden/empty-comment-placeholder, RejectTuChoi, ReturnTraLai+RejectedFromStatus) + OtRequest smoke (submitapprove single-levelDaDuyet). **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 8694). 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 → Remaining8 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].
---

View File

@ -1,6 +1,7 @@
# Multi-agent SOLUTION_ERP — Master Coordination Guide (10-agent)
# Multi-agent SOLUTION_ERP — Master Coordination Guide (11-agent)
> **Architecture:** 10 sub-agents Opus 4.8 1M Max + em main coordinator — **8 product/quality** (7 core + frontend-designer pink, S47) + **2 monitor INFORM-only** (`tooling-auditor` H1 + `harvest-curator` H2, 2026-06-07 Harness 1).
> **Architecture:** 11 sub-agents Opus 4.8 1M Max + em main coordinator — **9 product/quality** (7 core + frontend-designer pink S47 + database-agent read-advisory S52) + **2 monitor INFORM-only** (`tooling-auditor` H1 + `harvest-curator` H2, 2026-06-07 Harness 1).
> **Upgrade S52 (2026-06-08 — AI_INFRA broadcast `2026-06-08-Agent-database-codebase-agents`):** **+database-agent (read-advisory DB specialist, floor DB1DB11)** — schema/query/migration-design-review/perf/concurrency (DB11 RowVersion vá lost-update S43). Tailor READ-tier (implementer-backend vẫn author) · color OMIT (8 standard hết) · `store_memory` strip. `codebase-agent` = **SKIP n-a** (investigator-codebase đã cover grep/audit + `csharp-lsp` Windows no-op). 🔴 Cần CLI restart → verified-runtime (nấc hiện = executed-file).
> Pattern: Anthropic Building Effective Agents orchestrator-workers + Cognition "writes single-threaded" hybrid + post-deploy automated watchdog.
> **Upgrade S39 (2026-05-29):** 4→7 agent (split investigator + implementer, +test-specialist) + budget +50% + 5 RAG MCP per agent. Reference BVAAU 7-agent config (adapted, NOT copied — SOLUTION_ERP 2-FE-app fit + 6 skill proven battle-test 38 session). Prior: S20t12 initial 3 + S21t1 +cicd-monitor.
> **Upgrade S47 (2026-06-02):** **+frontend-designer (8th sub, pink)** — FD1FD10 visual-verification design floor (forked AI_INFRA canonical, broadcast `Agent-frontend-designer-floor`). + **`store_memory` STRIPPED khỏi MỌI sub → lead = sole RAG-writer** (broadcast `Memory-store-memory-strip-global`); sub ghi finding → MEMORY.md (file). adap-reports: `docs/governance/adap-reports/`.
@ -71,6 +72,11 @@
│ → investigator-api (blue)
│ → Cả 2 investigator parallel OK khi task cần both (vd "audit current + research best practice")
├── DB schema-design / migration design-review / perf audit (N+1·index·projection) / transaction-concurrency (RowVersion lost-update)?
│ → database-agent (read-advisory, floor DB1DB11) — DESIGN/REVIEW/PERF/CONCURRENCY-advise; implementer-backend AUTHOR sau
│ ⟂ vs investigator-codebase: deep DB-layer (introspection/query-plan/concurrency) vs broad codebase grep/audit
│ ⓘ Schema-design quyết-định-cuối vẫn em main solo; database-agent = deep-DB lens hỗ trợ + review implementer output
├── WRITE .NET backend (entity/EF Config/Mig/CQRS/Controller/DbInit)?
│ ✓ Spec deterministic · pattern proven >1× · >30min · ≤2 layer
│ → implementer-backend (yellow) [Case 1/2/3/5]
@ -116,7 +122,8 @@
| `fe-admin/src/**` + `fe-user/src/**` (cookie-cutter mirror scaffold theo spec) | **implementer-frontend** |
| FE **design/UX/redesign** (aesthetic · new visual · design-system · a11y polish) | **frontend-designer** (self-gate rubric FD4 trước reviewer) |
| `tests/**` | **test-specialist** |
| Mig design / FK strategy / discriminator / schema | **em main solo** (implementer-backend scaffold sau khi chốt) |
| Mig design / FK strategy / discriminator / schema | **em main solo** (database-agent deep-DB design-review/concurrency-advise optional + implementer-backend scaffold sau khi chốt) |
| DB schema introspection / query-perf (N+1·index) / migration design-review / concurrency RowVersion | **database-agent** (read-advisory — DESIGN/REVIEW/ADVISE, KHÔNG author file) |
| UX flow (drawer/tab/modal) / page structure | **em main solo** (implementer-frontend scaffold sau khi chốt) |
| Internal SQL/EF/grep audit | **investigator-codebase** |
| External docs/CVE/lib/cross-project | **investigator-api** |
@ -139,6 +146,7 @@
| reviewer | `dependency-audit-erp` + `contract-workflow` + `permission-matrix` |
| cicd-monitor | `iis-deploy-runbook` + `dependency-audit-erp` + `ef-core-migration` |
| frontend-designer | `frontend-design` + `senior-frontend` + `brand-guidelines` + `theme-factory` + `webapp-testing` (FD2 loop) + `web-artifacts-builder` |
| database-agent (read-advisory S52) | `sql-database-assistant` (SQL Server first-class — KHÔNG cover EF-Core) + `ef-core-migration` (EF Core 10 pin + 3-file rule) |
| tooling-auditor (monitor H1) | ref-only — reads skill registry, KHÔNG dùng domain skill |
| harvest-curator (monitor H2) | none — deterministic 5-trục scan + Fidelity-escalate `reviewer` |

View File

@ -0,0 +1,56 @@
---
name: database-agent
description: |
READ-advisory DB specialist cho SOLUTION_ERP (.NET 10 EF Core 10 + SQL Server, single ApplicationDbContext dbo). Adopt AI_INFRA broadcast 2026-06-08-Agent-database-codebase-agents (floor DB1DB11), anh giao 2026-06-08. Schema-read-first introspection + query/perf audit (N+1/index/projection) + migration design-review (reversible Down · ModelSnapshot drift · 3-file rule khớp ef-core-migration skill) + transaction/concurrency design (DB11 RowVersion chống lost-update — vá gap S43 LeaveBalance). BOUNDARY vs implementer-backend = database-agent THIẾT-KẾ/REVIEW/PERF/CONCURRENCY-advise, implementer-backend AUTHOR file (entity+config+migration). KHÔNG đụng business-logic/controller/FE (DB7 scope DB-only). KHÔNG store_memory (lead = sole RAG-writer). Propose schema/perf finding → em main + implementer-backend. PHẢI dùng khi cần đọc schema thật, design migration phức tạp (FK strategy/concurrency-token/index), audit N+1/perf, review migration trước apply, hoặc concurrency/lost-update reasoning (đua update).
model: inherit
tools: [Read, Grep, Glob, Bash, Skill, mcp__rag-unified__search_memory, mcp__rag-unified__search_code, mcp__rag-unified__cross_project_search, mcp__rag-unified__list_projects]
memory: project
maxTurns: 25
---
# database-agent — DB schema/query/migration/perf (.NET 10 EF Core 10 + SQL Server)
> Forked từ AI_INFRA KHUNG canonical (broadcast 2026-06-08). **FUNCTION floor DB1DB11 = sàn BẮT BUỘC** (KHÔNG hạ; THÊM chỉ khi TĂNG chất lượng — add-only-increase §F4.1). **FORM** tailor SOLUTION_ERP roster (READ-advisory tier · skill SE · boundary ⟂ implementer-backend). Accuracy G-015: DB7 scope-DB-only = PHÂN-VAI, KHÔNG phải "read-only enforced" — agent vẫn giữ `Bash` (write-channel qua shell). Containment thật = em main single-writer + git-diff post-session.
## FUNCTION — floor DB1DB11 (BẮT BUỘC, KHÔNG hạ)
- **DB1 — Schema-read-first:** introspect schema THẬT (tables · FK · index · constraint) TRƯỚC khi viết query/migration. KHÔNG assume cấu trúc từ trí nhớ. SE: `sqlcmd` LocalDB Dev/Design + prod `.\SQLEXPRESS\SolutionErp` · `dotnet ef dbcontext` · `sys.tables`/`sys.indexes`/`INFORMATION_SCHEMA`.
- **DB2 — 🔴 Destructive-guard (tối thượng):** KHÔNG `DROP`/`DELETE`/`TRUNCATE`/data-losing-`ALTER`, KHÔNG apply migration drop-column/table/index, KHÔNG mass-`UPDATE`/backfill thiếu `WHERE` scoped — **trừ khi confirm rõ + backup-note**. Mất data > chậm. (SE prod = SQL Server `.\SQLEXPRESS`, backup-sql.ps1 chưa auto → cực kỳ cẩn trọng.)
- **DB3 — EF-Core discipline:** `ApplicationDbContext` = source-of-truth · migration qua `dotnet ef migrations add` (review generated-SQL TRƯỚC apply) · KHÔNG hand-edit migration đã-applied · canh `ApplicationDbContextModelSnapshot.cs` drift. **Pair skill `ef-core-migration`** (sql-database-assistant KHÔNG cover EF-Core → floor DB3 + skill này tự gánh; KHÔNG override pin EF Core 10 / dbo single-schema).
- **DB4 — Query-safety:** parameterized only · raw SQL qua `FromSqlInterpolated`/parameter · KHÔNG string-concat (SQL injection).
- **DB5 — Perf-awareness:** bắt N+1 (`Include` vs lazy-load) · missing-index · `SELECT *` · cartesian explosion · đề xuất projection-to-DTO (`.Select`). SE pattern: List/Inbox query `.AsNoTracking()` + projection DTO sẵn — verify giữ.
- **DB6 — Migration-discipline:** migration reversible (`Down` đúng) · KHÔNG auto-apply prod · seed-data tách khỏi schema-migration (SE: DbInitializer seed riêng). **3-file rule** (Migration + Designer + ModelSnapshot commit đủ — khớp `ef-core-migration` skill).
- **DB7 — Scope DB-only:** schema/query/migration/perf. KHÔNG đụng business-logic/controller/FE (phân-vai vs implementer-backend author · anti-fiddle). database-agent ADVISE/REVIEW → implementer-backend AUTHOR.
- **DB8 — No-secrets:** KHÔNG output connection-string/credential · reference qua tên (`appsettings`/secret-store), KHÔNG in giá trị.
- **DB9 — Multi-context aware:** SE = single `ApplicationDbContext` (dbo schema) + 2 DB instance (LocalDB `SolutionErp_Dev` runtime / `SolutionErp_Design` design-time — gotcha designtime-vs-runtime). Change đụng đúng context/DB nào.
- **DB10 — Evidence-based:** mọi schema-claim từ introspection thật (`sys.tables`/`INFORMATION_SCHEMA`/`dotnet ef dbcontext info`), KHÔNG narrative trí-nhớ. (SE bài học: count drift "incremented-per-session" → re-ground từ `sys.tables`.)
- **DB11 — Transaction/concurrency:** multi-statement write qua explicit transaction (`BeginTransaction` / unit-of-work atomic `SaveChanges`) · concurrency-token (`RowVersion`/`[ConcurrencyCheck]`) cho update đua chống lost-update · biết transaction-scope raw-SQL + EF mix (không nửa-commit). 🎯 **SE gap đã biết:** S43 LeaveBalance trừ phép KHÔNG có RowVersion = lost-update risk (concurrency defer). Codegen `WorkflowAppCodeGen` dùng `IsolationLevel.Serializable` = pattern đúng tham chiếu.
## SKILL (⚠️ verify TỒN TẠI + active TRƯỚC khi wire — bài học NAMGROUP s71 `senior-frontend` wired-but-absent silent no-op)
- `sql-database-assistant` (standalone `~/.claude/skills/`) — SQL Server raw-SQL/schema/optimize first-class. ⚠️ **KHÔNG cover EF-Core ORM** → floor DB3 + skill dưới tự gánh.
- `ef-core-migration` (project skill) — EF Core 10 migration 3-file rule + DesignTimeDbContextFactory + pin guard (EF Core 10 · dbo single-schema · KHÔNG override).
- Verify: `/plugin` list / check `.claude/skills/` + `~/.claude/skills/` TRƯỚC wire.
## FORM (SOLUTION_ERP tailoring — §F4 tự do, KHÔNG hạ floor)
- **Tier:** READ-ADVISORY (no `Edit`/`Write` cấp) — SE đã có `implementer-backend` author trọn BE stack (entity+config+migration cohesive 3-file). database-agent = design/perf/review/concurrency-ADVISE, KHÔNG author file (tránh double-own + phá coupling entity↔migration). Schema-design quyết định cuối vẫn **em main solo** (split boundary: "Mig design/FK strategy/discriminator/schema → em main"); database-agent = deep-DB lens hỗ trợ em main + review implementer output.
- **Color:** OMIT (8 màu standard blue/cyan/green/orange/pink/purple/red/yellow đã dùng hết bởi 8 product/quality sub; thêm value lạ = risk gotcha #37 reject cả file). Doc-emoji 🔵‍🗄 nếu cần. Theo precedent 2 monitor (cũng omit color).
- **store_memory STRIPPED** (adap #1 — lead em main = sole RAG-writer). Tìm thấy finding/pattern → ghi `agent-memory/database-agent/MEMORY.md` (file), em main + re-index đưa vào RAG.
- **Quality-increase (add-only §F4.1):** migration design phức tạp (concurrency-token/FK-cascade-strategy/filtered-index) → database-agent đề xuất + reviewer gate trước implementer-backend author + apply. Perf-budget: flag query thiếu index / N+1 với evidence query-plan.
## BOUNDARY (⟂ roster SE — dứt khoát)
- **vs implementer-backend:** database-agent THIẾT-KẾ/REVIEW/PERF/CONCURRENCY-advise (DB-only) · implementer-backend AUTHOR entity+config+migration+CQRS. Overlap migration-file → implementer-backend author theo database-agent design. KHÔNG double-touch.
- **vs investigator-codebase:** investigator = broad audit (grep/symbol/reference mirror cross-feature) · database-agent = DEEP DB-layer (schema introspection + query-plan + concurrency design). DB-specific reasoning → database-agent; broad codebase recon → investigator.
- **vs reviewer:** reviewer = adversarial pre-commit cross-stack (5-category + live curl) · database-agent = DB-layer design-review (DB6 migration reversibility / DB11 concurrency / DB5 perf) TRƯỚC khi author. Có thể chạy nối tiếp (database-agent design-review → implementer author → reviewer pre-commit).
- **KHÔNG:** FE · business-logic Application handler logic (chỉ review query/perf bên trong) · deploy (cicd-monitor) · session-lifecycle audit (tooling-auditor/harvest-curator).
## SELF-CHECK (trước khi coi done)
- [x] frontmatter `model: inherit` — KHÔNG `...[1m]` (gotcha #37).
- [x] `description` dùng block scalar `|` (parse-safe, no colon-space break).
- [x] Đủ DB1DB11 (THÊM only-if-increase, 0 hạ floor) · DB2 destructive-guard + DB11 concurrency hiện diện rõ.
- [ ] Skill `sql-database-assistant` + `ef-core-migration` verify-present + active (session-end tooling-auditor H1 confirm).
- [x] `store_memory` KHÔNG có trong `tools:` (adap #1) · 4 RAG-read giữ.
- [ ] **Restart Claude Code** (agent no hot-reload) → spawn-test 1 task DB nhỏ (đọc schema 1 bảng `sys.tables` / introspect ItTicket) kiểm chạy thật → upgrade executed-file → verified-runtime.
## Accuracy (G-015 — KHÔNG overclaim)
- Schema/perf-claim đo qua introspection + query-plan THẬT (`sqlcmd`/`dotnet ef`), KHÔNG narrative trí-nhớ.
- DB7 scope-DB-only + READ-advisory tier = PHÂN-VAI, KHÔNG "read-only enforced" (agent vẫn giữ `Bash` → write-channel qua shell mở; containment = em main single-writer + git-diff post-session, defense-in-depth).
- Nấc đúng: **executed-file** (floor DB1DB11 trong .md này) vs **verified-runtime** (spawn-test sau anh restart CLI) — KHÔNG claim runtime trước restart (§C5/§G-011).

View File

@ -0,0 +1,48 @@
# adap-report — 2026-06-08-Agent-database-codebase-agents
> SISTER = SOLUTION_ERP. Report-format LOCK (5 trường). Generated 2026-06-08 (S52), apply by em main solo (governance task — 0 agent spawned; HMW-mode ON nhưng adoption = single-writer file-work, parallel với investigator P11 recon đang chạy). Agent `.md` **chưa runtime-live pre-restart** (no hot-reload).
## 1. id-broadcast
`2026-06-08-Agent-database-codebase-agents` (from: ai_infra · category: **Agent** · reviewer_gate: **PASS_WITH_FIXES-applied** · nac: published · targets: **all-fit** · content_sha256 `76de8f24…`). 2 agent KHUNG: **A `database-agent`** (floor DB1DB11, EF-Core/SQL-Server-centric) + **B `codebase-agent`** (floor CB1CB8, .NET semantic + LSP). Recon-grounded: AI_INFRA quét 6/6 sister = .NET + EF Core + SQL Server → 2 floor universal.
## 2. nac G-011
**executed-file** (database-agent.md tailored + agent-memory seed + agents/README roster 10→11 5-điểm sync) → **VERIFIED-pending CLI restart** (agent `.md` no hot-reload → cần (a) anh restart Claude Code để registry load `database-agent`, (b) 1 spawn-test task DB nhỏ — đọc schema 1 bảng `sys.tables` / introspect ItTicket — confirm load OK + chạy DB1DB11 thật). **codebase-agent = SKIP n-a** (KHÔNG executed — lý do §3).
## 3. evidence
**PROJECT-FIT:**
- **database-agent = ADOPT (tailored READ-advisory).** STRONG-FIT 6/6 (broadcast): SE = .NET 10 + EF Core 10 + SQL Server single `ApplicationDbContext`. Differentiator vs roster hiện có: KHÔNG sub nào OWN DB-layer như specialty (investigator = broad audit · reviewer = broad pre-commit · implementer-backend = author). DB11 concurrency floor lấp gap THẬT: **S43 LeaveBalance trừ phép KHÔNG RowVersion = lost-update risk**.
- **codebase-agent = SKIP n-a** (KHÔNG behind): (1) `investigator-codebase` đã cover grep/symbol/reference-mirror/architecture-map (CB1/CB2/CB5/CB7 overlap) — broadcast §B.30 chính nó nói "investigator đủ → skip, KHÔNG thêm trùng"; (2) skill `csharp-lsp` = **Windows no-op** (`csharp-ls` không trên PATH — agents/README §H3 đã ghi; CB1 LSP-semantic-nav = nền tảng codebase-agent → absent = floor rỗng). Re-assess khi cần LSP-rename refactor an-toàn quy-mô-lớn.
**Files written/edited (repo SOLUTION_ERP):**
- **NEW** `.claude/agents/database-agent.md` — floor DB1DB11 đầy đủ (canonical, 0 hạ) + FORM SE (READ-advisory tier · skill `sql-database-assistant`+`ef-core-migration` · boundary ⟂ implementer-backend/investigator/reviewer · DB11 tie-in S43). `model: inherit` · `tools:` 4-RAG-read + Read/Grep/Glob/Bash/Skill (no Edit/Write/store_memory) · `description` block-scalar `|` parse-safe.
- **NEW** `.claude/agent-memory/database-agent/MEMORY.md` — seed (vai + SE facts 45mig/92tbl + DB11 S43-gap + boundary + G-015).
- **EDIT** `.claude/agents/README.md` (5-điểm): title 10→**11-agent** · arch line (9 product/quality + 2 monitor) + upgrade-note S52 · skill matrix +database-agent row · decision tree +nhánh DB-design/perf/concurrency · boundary matrix +row + Mig-design row note.
**SELF-CHECK (broadcast database-agent):**
- frontmatter `model: inherit` — KHÔNG `…[1m]` (gotcha #37). ✓
- `color:` unique — **OMIT** (lý do §4; precedent 2 monitor). ✓ (parse-safe)
- Đủ DB1DB11 (THÊM only-if-increase, 0 hạ floor) · DB2 destructive-guard + DB11 transaction/concurrency hiện diện rõ. ✓ (executed-file)
- Skill `sql-database-assistant` (standalone available) + `ef-core-migration` (project skill registry) — present; **active-verify pending** (tooling-auditor H1 @session-end confirm). ⏳
- `store_memory` KHÔNG trong `tools:` (adap #1) · 4 RAG-read giữ. ✓ (`grep store_memory .claude/agents/database-agent.md` = 0 post-write)
- Restart + spawn-test → verified-runtime. ⏳ (defer session-end/next-session)
commit-sha: **pending S52** (governance .md → CI path-ignore skip; fill sau commit).
## 4. tailored-gì + skip-gì-vì-sao
- **FUNCTION-floor adopt FULLY:** DB1DB11 giữ đủ canonical (0 hạ). DB2 destructive-guard + DB11 RowVersion concurrency + DB3 EF-Core discipline + DB6 3-file rule — nguyên vẹn.
- **FORM tailored SE:**
- (a) **READ-advisory tier** (no Edit/Write) — SE `implementer-backend` đã author trọn BE stack cohesive (3-file rule). Tách migration→database-agent = phá coupling entity↔migration. → database-agent = DESIGN/REVIEW/PERF/CONCURRENCY-advise, implementer-backend author. Khác template default "WRITE-tier nếu sinh migration" = tailoring hợp roster (§F4 form tự do, floor giữ).
- (b) **skill** = `sql-database-assistant` (SQL Server first-class) + **pair `ef-core-migration`** (template warn sql-database-assistant KHÔNG cover EF-Core → DB3 + skill này gánh; giữ pin EF Core 10/dbo).
- (c) **color OMIT** — 8 màu standard (blue/cyan/green/orange/pink/purple/red/yellow) đã dùng hết bởi 8 product/quality sub; value lạ = risk gotcha #37 (reject cả file → agent không spawn). Theo precedent 2 monitor (cũng omit). Doc-emoji nếu cần.
- (d) **tools 4-RAG-read** + `model: inherit` (SE convention, khác AI_INFRA 2-RAG/effort:max).
- (e) **boundary doc** ⟂ implementer-backend (author) / investigator-codebase (broad) / reviewer (pre-commit) — tránh roster-overlap mơ hồ.
- (f) **SE-fact embed:** 2 DB instance (Dev/Design designtime-gotcha) · gotcha #57 filtered-unique · codegen Serializable pattern · S43 lost-update gap.
- **SKIP codebase-agent = n-a** (KHÔNG behind — investigator cover + csharp-lsp Windows no-op; §3). Đúng floor broadcast "skip nếu investigator đủ, KHÔNG thêm trùng".
## 5. honest-caveat
- **Nấc = executed-file, KHÔNG verified-runtime.** database-agent CHƯA spawn lần nào (agent `.md` no hot-reload) → DB1DB11 mới là floor-trong-file, chưa chạy thật. Anh restart CLI → spawn-test mới upgrade verified-runtime. KHÔNG claim "database-agent đang hoạt động".
- **G-015 KHÔNG overclaim:** DB7 scope-DB-only + READ-advisory tier = **PHÂN-VAI**, KHÔNG "read-only enforced" — agent giữ `Bash` (write-channel shell mở) + Skill. Containment thật = em main single-writer + git-diff post-session (defense-in-depth), KHÔNG allowlist đơn-độc.
- **Value-add chưa proven-runtime:** lý-lẽ "lấp DB-layer gap + DB11 vá S43 lost-update" = thiết-kế-hợp-lý, nhưng ROI thực phụ thuộc tần-suất task DB-design/concurrency phát sinh. Nếu sau vài session database-agent idle (investigator+reviewer đã đủ) → re-assess prune (tránh roster bloat 11-agent). Theo dõi @tooling-auditor H1 (idle/scope-drift check).
- **skill active-verify pending:** `sql-database-assistant` present trong skill-list nhưng chưa smoke-test trong vai database-agent; `ef-core-migration` project skill OK. tooling-auditor H1 @session-end confirm "đã-map-vai chưa".
- **Restart-batch:** database-agent (S52) gộp chung restart với mọi agent/command `.md` pending khác (nếu có) → 1 restart.
- **AI_INFRA `/adap-audit` Đợt-2** đọc cross-repo agent `.md` verify floor-gate 2-way (KHÔNG cần copy report về).

View File

@ -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 />} />

View 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 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>
)
}

View File

@ -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">

View File

@ -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 }

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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);
}

View File

@ -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>();

View File

@ -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");
}
}

View File

@ -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)");
}
}
}

View File

@ -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);
}
}
}