[CLAUDE] App: golive harden — LeaveBalance concurrency + ItTicket authz-order + DocxRenderer + Travel/Vehicle tests
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m19s
Pre-golive verification (S56) surfaced 4 issues; all fixed + verified (228 test pass, 0 build warning). #3 LeaveBalance lost-update (DB11 concurrency): terminal-approve deduction was an in-memory read-modify-write (UsedDays += NumDays) under a bare SaveChanges, so two concurrent terminal approvals of the same (user,type,year) lost an update. Fix: atomic server-side ExecuteUpdateAsync (UsedDays = UsedDays + n) inside an explicit Serializable transaction (matches the codegen/Proposal/TravelVehicle convention; serializes the auto-create-row race too). Exactly-once guard (Status != DaGuiDuyet) intact. No migration. #5 ItTicket reassign existence-oracle: AssignItTicketHandler checked ticket-NotFound before the Admin-OR-dept-IT Forbidden guard. Reordered so authorization runs first -> fail-closed (a non-IT/non-admin caller gets Forbidden for any ticketId, existent or not). #6 DocxRenderer CS8602: null-guard MainDocumentPart + Document with clear exceptions (cleared 2 build warnings -> 0). #4 Travel/Vehicle ApproveV2: added smoke tests (Submit->Approve terminal + outsider-Forbidden) — previously zero coverage. Tests 216 -> 228 (+12). database-agent DB-layer review PASS; em-main cross-stack review clean (reviewer workflow stage did not emit StructuredOutput -> em-main covered the cross-stack review by reading every diff). Bundles agent-memory harvest (S56). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -68,6 +68,8 @@ BE (test+build) ~90s · FE × 2 ~60s/app · deploy ~30s · **total ~3min code /
|
|||||||
|
|
||||||
## 📅 Recent runs (FIFO — older → archive/git)
|
## 📅 Recent runs (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **2026-06-09 (S56 pre-golive verify — NO deploy, read-only audit):** Re-verified prod truth at golive gate (HEAD `bef5825` docs-only → prod correctly = Run #378). build SolutionErp.slnx 0-err + fe-admin/fe-user npm build 0-TS each + test **216** (58D+158I) exact. Prod health live+ready 200; admin root serves `4SUwDLD8` / eoffice `XdKzt9LL` (== baseline, NO drift). `__EFMigrationsHistory` top = Mig 48 == repo; 92 tables. Master-data prod spot: Projects=70 (62 real+8 demo), CAL01.Investor=N'Công ty TNHH Calofic' exact, WorkItems real=71 (VT16/TP30/MEP9/TB16) of 86, Suppliers 3/3. **LESSON — local-vs-prod FE hash divergence is EXPECTED, not ship-fail:** fresh local `npm build` produces a DIFFERENT content-hash than CI-built prod artifact (node_modules/timestamp inputs not byte-reproducible) → load-bearing check is `prod-hash == documented-baseline`, NOT `== my-local-rebuild`. Don't false-alarm on local≠prod when HEAD unchanged. Tag [s56, pre-golive-verify, prod-truth-pass, local-vs-prod-hash-lesson].
|
||||||
|
|
||||||
- **2026-06-09 Run #378 (run_number 264) sha=`7feb53e` PASS ~4m24s (S55 Phase-1 FE-Admin VISUAL redesign density-first design-system NAMGROUP-ref keep brand #1F7DC1 — FE-ADMIN-ONLY, ZERO BE/Mig/fe-user):** Push `84fa638..7feb53e` 1 commit 15 files: 13 fe-admin (`index.css` design tokens + 6 ui primitives Button/Dialog/Input/Label/Select/Textarea + 6 shell DataTable/EmptyState/Layout/PageHeader/PhaseBadge/TopBar + DashboardPage) + 2 agent-memory `.md` (frontend-designer/reviewer). NO fe-user, NO `.cs`, NO Mig. `.tsx`/`.css` present → NOT docs-skip, pipeline RAN. **Run IN-PROGRESS at first check (status=running 11:51) — correctly did NOT FAIL, polled to terminal** (started 11:51:06 → updated 11:55:30 ≈4m24s status=success; updated_at froze 11:55:30 across 3 poll iters = terminal signal before status field parsed). **THE KEY PROOF — admin bundle ROTATE `B-d6893W→4SUwDLD8`** (✓ redesign shipped, verified AFTER status=success; pre-success snapshot 11:51 still showed OLD `B-d6893W` = anti-pattern #3 timing confirmed AGAIN; re-confirm +3s post-success = stable `4SUwDLD8`, NO transient this run). **fe-user bundle UNCHANGED `XdKzt9LL`** (= #377; untouched ✓ NOT ship-fail — correct, no fe-user file in commit). Admin root **200 text/html** + serves `<title>Solutions ERP · Admin</title>` + `<div id="root">` (app loads ✓). **NO migration** — prod `__EFMigrationsHistory` top = `20260609020759_AddProjectMasterFields` (Mig 48) == repo latest, GIỮ NGUYÊN ✓ (FE-only, BE/Domain untouched). Health live+ready **200/200** (both pre- and post-deploy). Test gate **216** (CI both proj pre-deploy ⟹ success=passed; `tasks` endpoint reports terminal as `status:success`, `conclusion` NOT populated — trust CI conclusion). 0 regression. **LESSON (single-app FE redesign — asymmetric bundle verify):** when ONLY fe-admin changes, the PASS criteria is asymmetric — admin hash MUST rotate (proof shipped) AND user hash MUST stay frozen (proof scope-correct, no accidental fe-user redeploy). User-unchanged is a POSITIVE signal here (mirror of BE-only Run #243/#368 where admin+user both stay frozen). Visual-only redesign (CSS tokens + className) rotates bundle exactly like logic change — Vite content-hash byte-sensitive. Status-grep gotcha: greedy `.*?` regex failed to isolate `"status"` field mid-poll → use `grep -oE '\\{"id":378,[^}]*\\}'` to capture full object then sub-grep status. Tag `[s55, run378, pass, fe-admin-only-redesign, asymmetric-bundle-verify, no-mig]`.
|
- **2026-06-09 Run #378 (run_number 264) sha=`7feb53e` PASS ~4m24s (S55 Phase-1 FE-Admin VISUAL redesign density-first design-system NAMGROUP-ref keep brand #1F7DC1 — FE-ADMIN-ONLY, ZERO BE/Mig/fe-user):** Push `84fa638..7feb53e` 1 commit 15 files: 13 fe-admin (`index.css` design tokens + 6 ui primitives Button/Dialog/Input/Label/Select/Textarea + 6 shell DataTable/EmptyState/Layout/PageHeader/PhaseBadge/TopBar + DashboardPage) + 2 agent-memory `.md` (frontend-designer/reviewer). NO fe-user, NO `.cs`, NO Mig. `.tsx`/`.css` present → NOT docs-skip, pipeline RAN. **Run IN-PROGRESS at first check (status=running 11:51) — correctly did NOT FAIL, polled to terminal** (started 11:51:06 → updated 11:55:30 ≈4m24s status=success; updated_at froze 11:55:30 across 3 poll iters = terminal signal before status field parsed). **THE KEY PROOF — admin bundle ROTATE `B-d6893W→4SUwDLD8`** (✓ redesign shipped, verified AFTER status=success; pre-success snapshot 11:51 still showed OLD `B-d6893W` = anti-pattern #3 timing confirmed AGAIN; re-confirm +3s post-success = stable `4SUwDLD8`, NO transient this run). **fe-user bundle UNCHANGED `XdKzt9LL`** (= #377; untouched ✓ NOT ship-fail — correct, no fe-user file in commit). Admin root **200 text/html** + serves `<title>Solutions ERP · Admin</title>` + `<div id="root">` (app loads ✓). **NO migration** — prod `__EFMigrationsHistory` top = `20260609020759_AddProjectMasterFields` (Mig 48) == repo latest, GIỮ NGUYÊN ✓ (FE-only, BE/Domain untouched). Health live+ready **200/200** (both pre- and post-deploy). Test gate **216** (CI both proj pre-deploy ⟹ success=passed; `tasks` endpoint reports terminal as `status:success`, `conclusion` NOT populated — trust CI conclusion). 0 regression. **LESSON (single-app FE redesign — asymmetric bundle verify):** when ONLY fe-admin changes, the PASS criteria is asymmetric — admin hash MUST rotate (proof shipped) AND user hash MUST stay frozen (proof scope-correct, no accidental fe-user redeploy). User-unchanged is a POSITIVE signal here (mirror of BE-only Run #243/#368 where admin+user both stay frozen). Visual-only redesign (CSS tokens + className) rotates bundle exactly like logic change — Vite content-hash byte-sensitive. Status-grep gotcha: greedy `.*?` regex failed to isolate `"status"` field mid-poll → use `grep -oE '\\{"id":378,[^}]*\\}'` to capture full object then sub-grep status. Tag `[s55, run378, pass, fe-admin-only-redesign, asymmetric-bundle-verify, no-mig]`.
|
||||||
- **2026-06-09 Run #377 (run_number 263) sha=`69cb393` PASS ~4m33s (S55 HMW-P4 real master-data seed from Excel + Project +4 cols Mig 48 — cross-stack BE+FE×2+Mig+seed):** Push `f8640d6..69cb393` 1 commit 18 files: Mig 48 `20260609020759_AddProjectMasterFields` (3-file) + Domain `Project.cs` (+Year/Investor/Location/Package nullable) + `ProjectConfiguration.cs` + App `ProjectFeatures.cs` + **`DbInitializer.cs` NEW `SeedRealMasterDataAsync` (UNGATED, per-code idempotent)** + FE×2 `master/ProjectsPage.tsx` + `types/master.ts` + 1 test `MasterCatalogFilteredUniqueTests.cs` + 4 agent-memory .md. `.cs`+`.tsx`+Mig present → full pipeline RAN. **Run was IN-PROGRESS at first check (status=running 09:28) — correctly did NOT FAIL, polled to terminal** (started 09:27:19 → updated 09:31:52 ≈4m33s status=success). ⚠️ `jq` NOT in Bash-tool bash (env is bash not PS despite shell=PowerShell env-line) — parse JSON via `grep -oE '\"id\":377[^}]*'` fallback; the working first call only used `head -c` not jq. **Bundle ROTATE admin `DmjI8Cmn→B-d6893W` + user `YxL_MljK→XdKzt9LL`** (BOTH changed ✓ FE shipped both apps, verified AFTER status=success; pre-success snapshot 09:28 still showed OLD DmjI8Cmn/YxL_MljK = anti-pattern #3 timing confirmed again; re-confirm +3s post-success = stable, no transient). **Mig 48 applied prod** (`__EFMigrationsHistory` top = `...AddProjectMasterFields` ✓ + `COL_LENGTH('Projects','Investor')` EXISTS). **THE KEY CHECK — real master-data landed (4/4 spot PASS):** Projects spot6 (APVN01/ZOTE01/CAL01/MIDEA01/SAM01/TLB01)=**6** · WorkItems VT-/TP-/MEP-/TB-=**71** · Suppliers (TRUONGGIANG/TANPHU/TGN)=**3** · `CAL01.Investor` =EXACT match N'Công ty TNHH Calofic' (console showed `C<>ng` = sqlcmd codepage mangle of `ô`, NOT data corruption — confirmed via `WHERE Investor=N'...'` EXACT). Totals: Projects=**70** (62 real + 8 demo coexist ✓ ungated seed idempotent), WorkItems=**86**, 5 Projects carry Investor / 23 carry Year (Excel sparse-fill, only some rows enriched — expected). Health live+ready **200/200**. `GET /api/projects` unauth=**401** (route wired, auth gates ✓). 0 regression. **LESSON (ungated prod seed verify = count spot-checks NOT schema):** `SeedRealMasterDataAsync` runs unconditionally on every prod startup (NOT inside `if(!demoSeedDisabled)` — correct per gotcha #51 INFRASTRUCTURE-seed rule); verify = sqlcmd COUNT spot-checks of real Codes + N-literal EXACT match for unicode fields (console codepage will mangle Vietnamese diacritics → always re-assert via `=N'...'`, never trust raw sqlcmd console render). Tag `[s55, run377, pass, mig48-master-fields, real-seed-ungated, sqlcmd-codepage-lesson]`.
|
- **2026-06-09 Run #377 (run_number 263) sha=`69cb393` PASS ~4m33s (S55 HMW-P4 real master-data seed from Excel + Project +4 cols Mig 48 — cross-stack BE+FE×2+Mig+seed):** Push `f8640d6..69cb393` 1 commit 18 files: Mig 48 `20260609020759_AddProjectMasterFields` (3-file) + Domain `Project.cs` (+Year/Investor/Location/Package nullable) + `ProjectConfiguration.cs` + App `ProjectFeatures.cs` + **`DbInitializer.cs` NEW `SeedRealMasterDataAsync` (UNGATED, per-code idempotent)** + FE×2 `master/ProjectsPage.tsx` + `types/master.ts` + 1 test `MasterCatalogFilteredUniqueTests.cs` + 4 agent-memory .md. `.cs`+`.tsx`+Mig present → full pipeline RAN. **Run was IN-PROGRESS at first check (status=running 09:28) — correctly did NOT FAIL, polled to terminal** (started 09:27:19 → updated 09:31:52 ≈4m33s status=success). ⚠️ `jq` NOT in Bash-tool bash (env is bash not PS despite shell=PowerShell env-line) — parse JSON via `grep -oE '\"id\":377[^}]*'` fallback; the working first call only used `head -c` not jq. **Bundle ROTATE admin `DmjI8Cmn→B-d6893W` + user `YxL_MljK→XdKzt9LL`** (BOTH changed ✓ FE shipped both apps, verified AFTER status=success; pre-success snapshot 09:28 still showed OLD DmjI8Cmn/YxL_MljK = anti-pattern #3 timing confirmed again; re-confirm +3s post-success = stable, no transient). **Mig 48 applied prod** (`__EFMigrationsHistory` top = `...AddProjectMasterFields` ✓ + `COL_LENGTH('Projects','Investor')` EXISTS). **THE KEY CHECK — real master-data landed (4/4 spot PASS):** Projects spot6 (APVN01/ZOTE01/CAL01/MIDEA01/SAM01/TLB01)=**6** · WorkItems VT-/TP-/MEP-/TB-=**71** · Suppliers (TRUONGGIANG/TANPHU/TGN)=**3** · `CAL01.Investor` =EXACT match N'Công ty TNHH Calofic' (console showed `C<>ng` = sqlcmd codepage mangle of `ô`, NOT data corruption — confirmed via `WHERE Investor=N'...'` EXACT). Totals: Projects=**70** (62 real + 8 demo coexist ✓ ungated seed idempotent), WorkItems=**86**, 5 Projects carry Investor / 23 carry Year (Excel sparse-fill, only some rows enriched — expected). Health live+ready **200/200**. `GET /api/projects` unauth=**401** (route wired, auth gates ✓). 0 regression. **LESSON (ungated prod seed verify = count spot-checks NOT schema):** `SeedRealMasterDataAsync` runs unconditionally on every prod startup (NOT inside `if(!demoSeedDisabled)` — correct per gotcha #51 INFRASTRUCTURE-seed rule); verify = sqlcmd COUNT spot-checks of real Codes + N-literal EXACT match for unicode fields (console codepage will mangle Vietnamese diacritics → always re-assert via `=N'...'`, never trust raw sqlcmd console render). Tag `[s55, run377, pass, mig48-master-fields, real-seed-ungated, sqlcmd-codepage-lesson]`.
|
||||||
- **2026-06-08 Run #376 (run_number 262) sha=`ca4b602` PASS ~4m18s (S54 IT-staff self-reassign ticket — authz Admin-OR-IT + scoped capability endpoint, cross-stack, NO migration):** Push `18d397f..ca4b602` 1 commit 13 files: BE `WorkflowAppsFeatures.cs` (NEW `GetAssignableItStaffQuery` capability + `AssignItTicketHandler` authz Admin-OR-IT) + `ItTicketsController.cs` (NEW `GET /it-tickets/assignable-staff` + `/assign` LOWERED Authorize-Roles) + FE×2 `ItTicketsPage.tsx` (SHA256-identical) + `workflowApps.ts`×2 (+2 type) + `ItTicketReassignAuthzTests.cs` (+13 → 203→**216**) + 6 agent-memory `.md`. `.cs`+`.tsx` present → NOT docs-skip, full pipeline RAN. Poll iter5 status=success (started 16:12:23 → updated 16:16:41 ≈4m18s). **Bundle ROTATE admin `DfCfHUE9→DmjI8Cmn` + user `_3S0BPJ2→YxL_MljK`** (BOTH changed ✓ FE shipped, verified AFTER status=success; pre-deploy iter0 still showed OLD DfCfHUE9/_3S0BPJ2 — correct timing anti-pattern #3). **NO migration** — prod `__EFMigrationsHistory` top = `...FilterMasterCatalogUniqueIndexesByIsDeleted` (Mig 47) == repo latest, GIỮ NGUYÊN ✓ (DepartmentId reuse). sys.tables=**93** stable (no new table). Test gate **216** (CI both proj pre-deploy ⟹ success=passed; grep undercounts InlineData — trust CI). Health live+ready 200 + admin/eoffice root 200. **Smoke NEW endpoint:** `GET /api/it-tickets/assignable-staff` unauth=**401** (route wired, [Authorize] gates) · `PUT /api/it-tickets/{guid}/assign` unauth bare=**411** then **WITH body `-d '{}'`=401** (IIS demands Content-Length before auth eval; 411 is pre-auth Length-check NOT routing-miss) · control fake route `/it-tickets/zzz`=**404** (proves 401s are real auth gates not catch-all). 0 regression. **LESSON (411 vs 401 on bodyless PUT/POST):** unauth bodyless PUT/POST to a JSON-body endpoint returns **411 Length Required** from IIS BEFORE the [Authorize] filter runs — NOT a 404/route-miss. Re-send with `-d '{}'` to force auth eval → real 401. Consistent w/ Run #367 `PUT /adjust=411` + #364 `POST /approve=411` (same pattern, now explained). Tag `[s54, run376, pass, it-reassign-authz, no-mig, 411-precheck-lesson]`.
|
- **2026-06-08 Run #376 (run_number 262) sha=`ca4b602` PASS ~4m18s (S54 IT-staff self-reassign ticket — authz Admin-OR-IT + scoped capability endpoint, cross-stack, NO migration):** Push `18d397f..ca4b602` 1 commit 13 files: BE `WorkflowAppsFeatures.cs` (NEW `GetAssignableItStaffQuery` capability + `AssignItTicketHandler` authz Admin-OR-IT) + `ItTicketsController.cs` (NEW `GET /it-tickets/assignable-staff` + `/assign` LOWERED Authorize-Roles) + FE×2 `ItTicketsPage.tsx` (SHA256-identical) + `workflowApps.ts`×2 (+2 type) + `ItTicketReassignAuthzTests.cs` (+13 → 203→**216**) + 6 agent-memory `.md`. `.cs`+`.tsx` present → NOT docs-skip, full pipeline RAN. Poll iter5 status=success (started 16:12:23 → updated 16:16:41 ≈4m18s). **Bundle ROTATE admin `DfCfHUE9→DmjI8Cmn` + user `_3S0BPJ2→YxL_MljK`** (BOTH changed ✓ FE shipped, verified AFTER status=success; pre-deploy iter0 still showed OLD DfCfHUE9/_3S0BPJ2 — correct timing anti-pattern #3). **NO migration** — prod `__EFMigrationsHistory` top = `...FilterMasterCatalogUniqueIndexesByIsDeleted` (Mig 47) == repo latest, GIỮ NGUYÊN ✓ (DepartmentId reuse). sys.tables=**93** stable (no new table). Test gate **216** (CI both proj pre-deploy ⟹ success=passed; grep undercounts InlineData — trust CI). Health live+ready 200 + admin/eoffice root 200. **Smoke NEW endpoint:** `GET /api/it-tickets/assignable-staff` unauth=**401** (route wired, [Authorize] gates) · `PUT /api/it-tickets/{guid}/assign` unauth bare=**411** then **WITH body `-d '{}'`=401** (IIS demands Content-Length before auth eval; 411 is pre-auth Length-check NOT routing-miss) · control fake route `/it-tickets/zzz`=**404** (proves 401s are real auth gates not catch-all). 0 regression. **LESSON (411 vs 401 on bodyless PUT/POST):** unauth bodyless PUT/POST to a JSON-body endpoint returns **411 Length Required** from IIS BEFORE the [Authorize] filter runs — NOT a 404/route-miss. Re-send with `-d '{}'` to force auth eval → real 401. Consistent w/ Run #367 `PUT /adjust=411` + #364 `POST /approve=411` (same pattern, now explained). Tag `[s54, run376, pass, it-reassign-authz, no-mig, 411-precheck-lesson]`.
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
- Codegen atomic = `WorkflowAppCodeGen.GenerateMaDonTuAsync` dùng `IsolationLevel.Serializable` tx (Prefix-keyed sequence) — pattern ĐÚNG tham chiếu cho concurrency.
|
- 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)
|
## 🎯 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.
|
- **S43/S56 LeaveBalance lost-update — FIX DESIGNED S56 (approach A, NO migration).** `ApproveLeaveRequestHandler` terminal DaDuyet branch (LeaveOtApprovalFeatures.cs:355-386) đọc `bal.UsedDays` in-memory + `+= p.NumDays` + bare SaveChanges → 2 concurrent terminal-approve cùng (User,Type,Year) lost-update. **Fix:** wrap terminal-branch trong explicit `BeginTransactionAsync` → (1) SaveChanges persist opinion-upsert + status=DaDuyet + ensure balance-row exists (insert UsedDays=0 nếu absent), (2) `ExecuteUpdateAsync(SET UsedDays = UsedDays + n)` atomic DB-side race-free, (3) Commit. Atomic-with-approval preserved (1 tx all-or-nothing). Exactly-once untouched (early `Status != DaGuiDuyet` guard :296). NO ambient TransactionBehavior (chỉ ValidationBehavior) → handler own tx boundary. **Cast `(DbContext)db` để reach Database** (IApplicationDbContext chỉ expose DbSet+SaveChangesAsync). Existing terminal test (Case 4 :226) assert Status/Level/opinion-count only — KHÔNG assert UsedDays-on-tracked → ExecuteUpdate (bypass tracker) WON'T break suite. Spec authoritative → implementer-backend author.
|
||||||
|
- OtRequest terminal KHÔNG trừ phép (chỉ status) → no lost-update bên OT.
|
||||||
- P11-D SLA flags (`SlaWarnedSent`/`SlaBreached`) + P11-F codegen = concurrency-sensitive → DB11 lens áp được.
|
- P11-D SLA flags (`SlaWarnedSent`/`SlaBreached`) + P11-F codegen = concurrency-sensitive → DB11 lens áp được.
|
||||||
|
|
||||||
## Boundary (⟂)
|
## Boundary (⟂)
|
||||||
@ -30,3 +31,4 @@
|
|||||||
|
|
||||||
## Log
|
## 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.
|
- **S52 (2026-06-08):** Seeded (em main, adap-apply database-agent). Roster 10→11. Nấc executed-file. CHỜ restart + spawn-test → verified-runtime.
|
||||||
|
- **S56 (2026-06-09) — pre-golive verify (2nd real spawn, verified-runtime ✓):** Schema/Mig integrity P11 + S55 master-data = SOLID. Mig 42-48 applied IDENTICALLY Dev+Design+prod `.\SQLEXPRESS` (48 mig / 92 tables, **NO S53-style unapplied-local drift**); 0 pending model-snapshot diff; **9 gotcha-#57 filtered-unique** confirmed BOTH EF config (HasFilter×13) AND DB (`filter_definition=([IsDeleted]=(0))`) incl Vehicle/Driver day-1; FK đúng (LeaveBalance→LeaveType Restrict + UNIQUE(User,Type,Year); 4× LevelOpinions Cascade(parent)/Restrict(Level)+UNIQUE composite; ItTicket SLA nullable). **DB11 FAIL (1 major, fast-follow KHÔNG blocker):** `LeaveBalance.UsedDays += NumDays` @ `LeaveOtApprovalFeatures.cs:361-386` chạy bare SaveChanges (no tx / no RowVersion / READ COMMITTED) → concurrent double-approve lost-update. **S43 gap STILL OPEN.** Fix-pattern sẵn repo: Serializable tx (codegen `:34`) HOẶC RowVersion+retry (1 mig). Low-prob ~30 user, recoverable Admin Adjustment. Tag [s56, pre-golive-verify, schema-pass, db11-lostupdate-open].
|
||||||
|
|||||||
@ -74,6 +74,7 @@ UI `disabled={!canX}` + BE helper `EnsureCanXAsync(id, userId)` throw 403 (NOT i
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **S56 GOLIVE-HARDEN 3 BE fix (NO mig, 3 file edit, em-main spec deterministic 100% → ACCEPT Case 1):** **#3 LeaveBalance lost-update** `LeaveOtApprovalFeatures.cs` terminal DaDuyet branch → atomic-executeupdate-tx (spec chosen, KHÔNG RowVersion/Mig). Replaced in-mem `bal.UsedDays += NumDays` với: set p.Status/Updated* → `(DbContext)db` cast + `BeginTransactionAsync(ct)` (plain, NO IsolationLevel — READ COMMITTED đủ vì increment atomic) → STEP1 ensure-row (FirstOrDefault, auto-create UsedDays=0 via tracker) + SaveChanges (opinion+status+insert trong tx) → STEP2 `db.LeaveBalances.Where(...).ExecuteUpdateAsync(s=>s.SetProperty(b=>b.UsedDays, b=>b.UsedDays+p.NumDays), ct)` server-side row-lock race-free → `tx.CommitAsync` + **`return;`** (skip trailing shared SaveChanges). **STALE-TRACKED caveat (load-bearing):** ExecuteUpdate bypass tracker → tracked `bal` giữ pre-increment value; SAFE vì không đọc lại + handler return ngay; KHÔNG thêm `bal.UsedDays +=` (double-count). `using System.Data` + EF Core đã import. **#5 AssignItTicketHandler existence-oracle** `WorkflowAppsFeatures.cs:493` → moved Admin-OR-dept-IT Forbidden guard (itDeptId+isAdmin+myDeptId resolve) TRƯỚC ticket NotFound lookup → fail-closed (non-IT nhận Forbidden cho MỌI ticketId). assignee-must-be-IT Conflict + reassign giữ nguyên. **#6 DocxRenderer.cs:30,40 CS8602** → hoist `mainPart = doc.MainDocumentPart ?? throw InvalidOperationException` + `document = mainPart.Document ?? throw` (Document cũng nullable — KEY: 1st hoist chỉ fix part, vẫn còn 1 warn ở `.Document.Body`); deref qua local non-null. Build SolutionErp.slnx **0 err 0 warn** (DocxRenderer warn CLEARED — thực tế 1 warn không phải 2 như MEMORY ghi). Test 58 Domain PASS + 154/158 Infra: **4 FAIL `LeaveBalanceTests` (Approve_LastLevel_DeductsLeave.../AccumulatesExisting.../OverEntitled.../MultiLevel_NoDeductAtIntermediate)** = EXPECTED #3 stale-tracked (re-query trả tracked instance pre-increment, DB row đúng) → tests_to_update cho test-specialist (add AsNoTracking/ChangeTracker.Clear). ItTicket authz tests #5 PASS (Case5 đã expect Forbidden, NotFound case dùng Admin caller). KHÔNG touch tests/FE/commit. #4 (Travel/Vehicle smoke test) = test-specialist next stage. Tag `[s56, golive-harden, executeupdate-atomic, fail-closed-authz, cs8602, no-mig]`.
|
||||||
- **S55 master-data import (Mig 48 `AddProjectMasterFields` 4 AddColumn no-table + `SeedRealMasterDataAsync` 62 Project+71 WorkItem+3 Supplier) [proxy by em main — agent return truncated gotcha #53 before MEMORY step]:** Project entity +4 prop (`Year int?`, `Investor/Location/Package string?`, maxlen 250/500/300 ProjectConfiguration). `ProjectFeatures.cs` DTO+CreateCmd+UpdateCmd+validators+handlers+List/Get projections +4 (all nullable, appended end). **`SeedRealMasterDataAsync`** = 3 tuple-loop per-code idempotent (mirror `SeedDemoMasterDataAsync:2185` `existingCodes.Contains→skip`) wired UNGATED line 118 AFTER `SeedCatalogsAsync` → reaches prod (DemoSeed:Disabled=true KHÔNG gate, by-design như SeedDemoMasterData/Catalogs). Project Name=Code khi Excel blank. WorkItem 4 Category (Vật tư16/Thầu phụ30/MEP9/Thiết bị16, gen Code VT/TP/MEP/TB-NN; divider "THIẾT BỊ" dropped). Supplier NTP→NhaThauPhu/NCC→NhaCungCap, extras→Note. **FLOCK01 collision** demo↔real → per-code skip (demo thắng, real code+year only, OK). Compile-fix `MasterCatalogFilteredUniqueTests.cs` +4 null args CreateProjectCommand (necessary build-green). **Runtime Dev proof (em main):** app-start seeded 62proj/71wi/3sup landed, CAL01.Investor col populates, 0 overflow/dup. Build 0/0, test 216. Data spec `scripts/master-import-data.generated.md`. Tag `[s55, master-import, mig48, seed-real-ungated, project-4field]`.
|
- **S55 master-data import (Mig 48 `AddProjectMasterFields` 4 AddColumn no-table + `SeedRealMasterDataAsync` 62 Project+71 WorkItem+3 Supplier) [proxy by em main — agent return truncated gotcha #53 before MEMORY step]:** Project entity +4 prop (`Year int?`, `Investor/Location/Package string?`, maxlen 250/500/300 ProjectConfiguration). `ProjectFeatures.cs` DTO+CreateCmd+UpdateCmd+validators+handlers+List/Get projections +4 (all nullable, appended end). **`SeedRealMasterDataAsync`** = 3 tuple-loop per-code idempotent (mirror `SeedDemoMasterDataAsync:2185` `existingCodes.Contains→skip`) wired UNGATED line 118 AFTER `SeedCatalogsAsync` → reaches prod (DemoSeed:Disabled=true KHÔNG gate, by-design như SeedDemoMasterData/Catalogs). Project Name=Code khi Excel blank. WorkItem 4 Category (Vật tư16/Thầu phụ30/MEP9/Thiết bị16, gen Code VT/TP/MEP/TB-NN; divider "THIẾT BỊ" dropped). Supplier NTP→NhaThauPhu/NCC→NhaCungCap, extras→Note. **FLOCK01 collision** demo↔real → per-code skip (demo thắng, real code+year only, OK). Compile-fix `MasterCatalogFilteredUniqueTests.cs` +4 null args CreateProjectCommand (necessary build-green). **Runtime Dev proof (em main):** app-start seeded 62proj/71wi/3sup landed, CAL01.Investor col populates, 0 overflow/dup. Build 0/0, test 216. Data spec `scripts/master-import-data.generated.md`. Tag `[s55, master-import, mig48, seed-real-ungated, project-4field]`.
|
||||||
- **S54 ItTicket reassign cross-stack — IT-staff self-service (NO migration, 2 BE file edit):** NEW `GetAssignableItStaffQuery`+`AssignableStaffResult(CanReassign,Staff)`+`AssignableStaffDto(Id,FullName)` capability endpoint (REGION 5 WorkflowAppsFeatures.cs) + MODIFIED `AssignItTicketHandler`: authz Admin-OR-dept-IT → `ForbiddenException`; assignee-must-be-IT → `ConflictException`. Controller `/assign` hạ `[Authorize(Roles="Admin")]`→`[Authorize]` (handler enforce fine-grained data-driven) + NEW `GET /assignable-staff`. Predicate IT = reuse round-robin S52 `Departments.Where(Code=="IT" && !IsDeleted)`. `ICurrentUser` KHÔNG có DepartmentId → query `db.Users.Where(Id==cu.UserId).Select(DepartmentId)`. 2 pattern split (em main reconciled từ stray src/Backend/.claude — cwd-relative Write mishap): [[pattern-controller-lower-authorize-handler-finegrained]] + [[pattern-scoped-capability-endpoint-anti-silent-403]]. Build 0/0, test 203→216 (test-specialist +13 authz), reviewer PASS (role-string "Admin" chain-verified real: AppRoles→SeedRoles→JWT ClaimTypes.Role→cu.Roles). Tag `[s54, it-ticket-reassign, capability-endpoint, authz-handler, no-mig]`.
|
- **S54 ItTicket reassign cross-stack — IT-staff self-service (NO migration, 2 BE file edit):** NEW `GetAssignableItStaffQuery`+`AssignableStaffResult(CanReassign,Staff)`+`AssignableStaffDto(Id,FullName)` capability endpoint (REGION 5 WorkflowAppsFeatures.cs) + MODIFIED `AssignItTicketHandler`: authz Admin-OR-dept-IT → `ForbiddenException`; assignee-must-be-IT → `ConflictException`. Controller `/assign` hạ `[Authorize(Roles="Admin")]`→`[Authorize]` (handler enforce fine-grained data-driven) + NEW `GET /assignable-staff`. Predicate IT = reuse round-robin S52 `Departments.Where(Code=="IT" && !IsDeleted)`. `ICurrentUser` KHÔNG có DepartmentId → query `db.Users.Where(Id==cu.UserId).Select(DepartmentId)`. 2 pattern split (em main reconciled từ stray src/Backend/.claude — cwd-relative Write mishap): [[pattern-controller-lower-authorize-handler-finegrained]] + [[pattern-scoped-capability-endpoint-anti-silent-403]]. Build 0/0, test 203→216 (test-specialist +13 authz), reviewer PASS (role-string "Admin" chain-verified real: AppRoles→SeedRoles→JWT ClaimTypes.Role→cu.Roles). Tag `[s54, it-ticket-reassign, capability-endpoint, authz-handler, no-mig]`.
|
||||||
- **S54 Task D BE — promote AttendanceReport to sidebar menu leaf (NO migration, 2 file edit):** Case 1 mechanical, menu = DbInitializer idempotent seed (not schema). 3 insert: (1) MenuKeys.cs const `OffAttendanceReport = "Off_AttendanceReport"` after OffChamCong:124 · (2) MenuKeys.cs All[] Off-group line +`OffAttendanceReport` after OffChamCong:158-159 · (3) DbInitializer.cs menu tuple `(OffAttendanceReport, "Báo cáo chấm công", Off, 8, "FileBarChart")` after OffChamCong:1787 (Order 8, parent Off, mirror Vehicle/Driver S51). **adminPermAutoViaAll=TRUE verified 2-point:** `SeedAdminPermissionsAsync` DbInitializer:1916 iterates `MenuKeys.All` → full-CRUD Permission row per missing key (idempotent `existingMenuKeys.Contains`); `Program.cs:78` iterates All × Actions → policy registration. +All[] = both auto, NO manual grant. **Idempotent-add verified:** menu upsert loop DbInitializer:1845-1862 `existingItems.TryGetValue(key)` miss → `MenuItems.Add` (existing prod gets leaf on restart, existing rows only Order-reconciled — same as S51). Build 0 err (2 pre-existing DocxRenderer warn). KHÔNG touch FE (menuKeys.ts/Layout = implementer-frontend) / tests / commit. Tag `[s54, task-d, menu-leaf, no-mig, admin-perm-via-all]`.
|
- **S54 Task D BE — promote AttendanceReport to sidebar menu leaf (NO migration, 2 file edit):** Case 1 mechanical, menu = DbInitializer idempotent seed (not schema). 3 insert: (1) MenuKeys.cs const `OffAttendanceReport = "Off_AttendanceReport"` after OffChamCong:124 · (2) MenuKeys.cs All[] Off-group line +`OffAttendanceReport` after OffChamCong:158-159 · (3) DbInitializer.cs menu tuple `(OffAttendanceReport, "Báo cáo chấm công", Off, 8, "FileBarChart")` after OffChamCong:1787 (Order 8, parent Off, mirror Vehicle/Driver S51). **adminPermAutoViaAll=TRUE verified 2-point:** `SeedAdminPermissionsAsync` DbInitializer:1916 iterates `MenuKeys.All` → full-CRUD Permission row per missing key (idempotent `existingMenuKeys.Contains`); `Program.cs:78` iterates All × Actions → policy registration. +All[] = both auto, NO manual grant. **Idempotent-add verified:** menu upsert loop DbInitializer:1845-1862 `existingItems.TryGetValue(key)` miss → `MenuItems.Add` (existing prod gets leaf on restart, existing rows only Order-reconciled — same as S51). Build 0 err (2 pre-existing DocxRenderer warn). KHÔNG touch FE (menuKeys.ts/Layout = implementer-frontend) / tests / commit. Tag `[s54, task-d, menu-leaf, no-mig, admin-perm-via-all]`.
|
||||||
|
|||||||
@ -70,6 +70,10 @@ Bearer từ `POST api.solutions.com.vn/api/auth/login` → status matrix expecte
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **2026-06-09 (S56 pre-golive verify — 4 logic streams, all PASS):** Audited P11-B/D/E/F + ApproveV2 + catalogs + S55 master-wiring. **LeaveBalance** deduction exactly-once (terminal DaDuyet, guard `Status!=DaGuiDuyet` :296 blocks re-approve), FK guard Create+UpdateDraft→Conflict; **AttendanceReport** classify day-type IN-MEMORY (Holiday DateOnly HashSet), OtPolicy multiplier; **MaTicket** gen-on-Create Serializable IT/2026/NNN. Tests cover (LeaveBalance 9 + AttReport 2 + codegen 3 = 29 green). ApproveV2 4-module flatten Steps→Levels correct; **Travel/Vehicle ApproveV2 = 0 test** (cookie-cutter of tested Leave/OT — add 2 smoke post-golive). master-data **idempotency PROVEN** (DbInitializer re-run → counts identical, per-code guard :2310/:2404/:2422). **⚠️ PROD FACT (corrects stale S52 mem):** dept "IT"/Phòng CNTT DOES exist (Id 65CC6307…) but has **0 active users** on prod → ItTicket auto-assign no-ops, reassign dropdown empty, SLA job no notify-target. Pre-golive ops fix: assign ≥1 real user to dept IT (1 UPDATE, no code). Tag [s56, pre-golive-verify, logic-pass, dept-IT-empty-prod, travel-vehicle-untested].
|
||||||
|
|
||||||
|
- **2026-06-09 (S56 Phase 2 FE-redesign RECON — 25 page audit, on-disk):** ⭐ **NOT a rewrite** — S55 already redesigned ui-primitives + DataTable + shell → any page importing `ui/{Button,Input}`+`DataTable` AUTO-inherits density. **Hover-hidden quick-win nearly absent:** repo-wide grep `opacity-0`×`group-hover` = **only 1 real site** `ContractCreatePage.tsx:196` (EXCLUDED scope) + DataTable.tsx:15 is a comment forbidding it (good). In-scope = **0 hover-hidden fixes**. **DataTable adoption split:** only 5/25 use `<DataTable>` (Suppliers/Projects/Departments/Users/Forms); 12 pages roll RAW `<table>` (MeetingRooms/Catalogs/HrmConfigs/EmployeesList/Proposals/WorkflowApps/Attendance×2/MenuVisibility/Roles) → custom density pass needed. **Drawer ≥8-field candidates = 3:** Suppliers (9 fld, Dialog), Projects (10 fld, Dialog), Users-CREATE (~8 fld w/ roles multiselect, 4 Dialogs total but only create is big). All currently big-Dialog → convert. **NO `Drawer.tsx` exists** (`ui/` = Button/Dialog/Input/Label/Select/Textarea only) → build first. **Bậc-thang reference ALREADY EXISTS:** `EmployeesListPage.tsx` (1200L) = canonical inline add/edit-row for 5 satellites (`setEditing{X}Id` + `addingX` mutex, :256-356) → extract `InlineEditRow` pattern from here, reuse for Catalogs/HrmConfigs/MeetingRooms (these 3 currently edit via Dialog :251/:316/:232, ≤7 cols → bậc-thang candidates). **Modal-detail = NONE:** ProposalDetail:275 + WorkflowAppDetail:424 Dialogs are tiny action-confirm (just `<Textarea>` ý kiến), detail body already inline 2-col grid (:181) → NOT convert. **fe-user mirrors** office/master/hrm (SHA256-identical per comments) but NO system/forms/reports mirror. **Custom-layout heavy:** InternalDirectory (card-grid :124 not table), MeetingCalendar (693L FullCalendar), EmployeesList (2-panel), HrmConfigs (declarative KIND_CONFIG :45). Effort: Master 3×Drawer=M, Catalogs/HrmConfigs/MeetingRooms bậc-thang=M, Users Drawer=M, rest S (auto-inherit polish). Surprise: hover-hidden NAMGROUP-win essentially pre-solved (team already avoids opacity-0 pattern, DataTable comment enforces) → quick-win section nearly empty. Tag `[fe-redesign-p2, recon, drawer-3, basc-thang-ref-exists, s56]`.
|
||||||
|
|
||||||
- **2026-06-09 (S55 master-data Excel-import recon — 3 master + seed mechanism, on-disk):** ⭐ **"Hạng mục"/WorkItem master TỒN TẠI** — `Domain/Master/Catalogs/WorkItem.cs:6-14` (Code(50)UNIQUE-filtered/Name(200)/Category(100,idx)/DefaultUnit(50)/Description/IsActive), config `CatalogsConfiguration.cs:60-74`, full CRUD `CatalogsFeatures.cs:260-324` → group(VẬT TƯ/THẦU PHỤ/MEP)→Category, "1 Mat"→Code, item→Name. KHÔNG cần table/migration mới. **PE detail = pure free-text** (`PurchaseEvaluationDetail.cs` GroupCode/GroupName/ItemCode/NoiDung strings, NO FK→WorkItem) → load WorkItems non-breaking. **Project** (`Project.cs:5-14`, cfg `:14-21`): Code(50,UNIQUE `[IsDeleted]=0` Mig47)+Name(200) REQUIRED, StartDate/EndDate/BudgetTotal(18,2)/Note(1000)/ManagerUserId optional. ❌ **THIẾU Year/Investor/Location/Package** — chỉ Note free-text catch-all. Create cmd `ProjectFeatures.cs:67` dup-check `:87 AnyAsync(Code==)`. **Supplier** (`Supplier.cs:5-16`, cfg `:14-27`): Code/Name req + Type enum + TaxCode(20)/Phone/Email/Address/ContactPerson/Note. `SupplierType.cs`: NhaCungCap=1/NhaThauPhu=2/ToDoi=3/DonViDichVu=4/ChuDauTu=5. ❌ **THIẾU Status/TinhTrang (KHÔNG có field/enum nào)** + bank-acct + legal-rep (≠ContactPerson) + quality-score; "Cả hai" PHÂN LOẠI unmappable (Type single-valued). Create `CreateSupplierCommand.cs:10` dup `:45`. **Seed = idempotent `existingCodes.Contains→skip`** (`DbInitializer.SeedDemoMasterDataAsync:2149`, today 18 supplier `:2155` + 8 project `:2222`; WorkItems 15 rows tuple-loop `SeedCatalogsAsync:576-599`). **NO bulk import** — Master chỉ single CRUD; Import/Upload hits = Forms/PE/Employees attachment only; POST one-at-a-time. **Seed→prod:** `DbInitializer.InitializeAsync` chạy MỌI startup (`Program.cs:197` unless `--no-db-init`) → `MigrateAsync` THEN seed; demo gated `config.GetValue<bool>("DemoSeed:Disabled")` (`:80`) NHƯNG SeedDemoMasterData+SeedCatalogs chạy BẤT KỂ flag (ngoài if-block :108/:115) → seed method mới auto-reach prod next deploy. Rec: idempotent DbInitializer mirror (NOT API loop). Surprise: real+demo data sẽ trộn chung Suppliers/Projects/WorkItems (18/8/15 demo rows) → cân nhắc gate demo off prod. Tag `[master-import, workitem-exists, seed-idempotent, s55]`.
|
- **2026-06-09 (S55 master-data Excel-import recon — 3 master + seed mechanism, on-disk):** ⭐ **"Hạng mục"/WorkItem master TỒN TẠI** — `Domain/Master/Catalogs/WorkItem.cs:6-14` (Code(50)UNIQUE-filtered/Name(200)/Category(100,idx)/DefaultUnit(50)/Description/IsActive), config `CatalogsConfiguration.cs:60-74`, full CRUD `CatalogsFeatures.cs:260-324` → group(VẬT TƯ/THẦU PHỤ/MEP)→Category, "1 Mat"→Code, item→Name. KHÔNG cần table/migration mới. **PE detail = pure free-text** (`PurchaseEvaluationDetail.cs` GroupCode/GroupName/ItemCode/NoiDung strings, NO FK→WorkItem) → load WorkItems non-breaking. **Project** (`Project.cs:5-14`, cfg `:14-21`): Code(50,UNIQUE `[IsDeleted]=0` Mig47)+Name(200) REQUIRED, StartDate/EndDate/BudgetTotal(18,2)/Note(1000)/ManagerUserId optional. ❌ **THIẾU Year/Investor/Location/Package** — chỉ Note free-text catch-all. Create cmd `ProjectFeatures.cs:67` dup-check `:87 AnyAsync(Code==)`. **Supplier** (`Supplier.cs:5-16`, cfg `:14-27`): Code/Name req + Type enum + TaxCode(20)/Phone/Email/Address/ContactPerson/Note. `SupplierType.cs`: NhaCungCap=1/NhaThauPhu=2/ToDoi=3/DonViDichVu=4/ChuDauTu=5. ❌ **THIẾU Status/TinhTrang (KHÔNG có field/enum nào)** + bank-acct + legal-rep (≠ContactPerson) + quality-score; "Cả hai" PHÂN LOẠI unmappable (Type single-valued). Create `CreateSupplierCommand.cs:10` dup `:45`. **Seed = idempotent `existingCodes.Contains→skip`** (`DbInitializer.SeedDemoMasterDataAsync:2149`, today 18 supplier `:2155` + 8 project `:2222`; WorkItems 15 rows tuple-loop `SeedCatalogsAsync:576-599`). **NO bulk import** — Master chỉ single CRUD; Import/Upload hits = Forms/PE/Employees attachment only; POST one-at-a-time. **Seed→prod:** `DbInitializer.InitializeAsync` chạy MỌI startup (`Program.cs:197` unless `--no-db-init`) → `MigrateAsync` THEN seed; demo gated `config.GetValue<bool>("DemoSeed:Disabled")` (`:80`) NHƯNG SeedDemoMasterData+SeedCatalogs chạy BẤT KỂ flag (ngoài if-block :108/:115) → seed method mới auto-reach prod next deploy. Rec: idempotent DbInitializer mirror (NOT API loop). Surprise: real+demo data sẽ trộn chung Suppliers/Projects/WorkItems (18/8/15 demo rows) → cân nhắc gate demo off prod. Tag `[master-import, workitem-exists, seed-idempotent, s55]`.
|
||||||
|
|
||||||
- **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 (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]`.
|
||||||
|
|||||||
@ -57,6 +57,8 @@ Adversarial pre-commit reviewer SOLUTION_ERP. Read-only verify + live curl prod
|
|||||||
|
|
||||||
## 📅 Recent activity (FIFO — older → archive/git)
|
## 📅 Recent activity (FIFO — older → archive/git)
|
||||||
|
|
||||||
|
- **2026-06-09 (S56 pre-golive authz live-curl — PASS, 0 blocker):** Live prod curl 8 new endpoints. **8/8 return 401 unauth**; admin-authed: hrm-configs/vehicles(2)+drivers(2), leave-balances/my(5 lazy), attendances/report+excel(200, 6797B xlsx) all 200; non-admin Drafter correctly 403 on the 2 Admin-only attendance endpoints. **gotcha #44 silent-403 sweep CLEAN:** capability GET /it-tickets/assignable-staff returns HTTP 200 `{canReassign:false,staff:[]}` for non-IT Drafter (NOT swallowed 403) + `{true,[]}` admin — handler returns flag, doesn't throw (`WorkflowAppsFeatures.cs:466`). assign-mutation guard fail-closed (:504). E2E: GET /projects payload has all +4 fields (70/70), CAL01 Investor live. Off_AttendanceReport menu key in admin /menus/me. **1 MINOR (non-block, defense-in-depth):** PUT /it-tickets/{id}/assign checks NotFound BEFORE Admin-OR-IT Forbidden (`WorkflowAppsFeatures.cs:496-508`) → existence-oracle leak; mutation itself fail-closed → post-golive hardening only. Tag [s56, pre-golive-verify, authz-clean, gotcha44-clean, notfound-before-forbidden-minor].
|
||||||
|
|
||||||
- **2026-06-09 (S55 Phase-1 FE visual redesign pre-commit — PASS, 0 blocker, verdict-first survived):** 14 fe-admin files VISUAL/CSS-only (NAMGROUP density + SOLUTION brand). **Independent re-verify GREEN:** `npm run build` fe-admin = ✓ 607ms, 1945 modules, 0 TS err (only PRE-EXISTING warns: CSS @import-order + >500KB chunk + INEFFECTIVE_DYNAMIC_IMPORT realtime.ts — git-confirmed none introduced, @import lines untouched in diff). **Regression Cat1 ALL preserved:** Button cva variant keys (primary/secondary/outline/ghost/danger) + size (sm/md/lg) STABLE — only Tailwind class VALUES swapped, defaultVariants intact (51 call-sites safe); Input/Select/Textarea/Label = `forwardRef`+`...props`+`className` passthrough unchanged, only `cn()` literal; Dialog `{open,onClose,title,children,footer,size}` destructure + sm/md/lg→max-w map intact (+aria-label="Đóng" = a11y GAIN); DataTable `Column<T>` type UNCHANGED (diff starts after type def) — render/sortable/align/width + sort + Pagination props intact, RowActions/RowActionButton purely ADDITIVE; Layout MenuLeaf className-only (brand left-rail via before:), nav/resolver/permission-filter/routing untouched; PhaseBadge phase→ContractPhaseColor/Label map intact; PageHeader/EmptyState/TopBar pure class. DashboardPage data-flow (useQuery/navigate/fmtMoney/BarChart/PhaseBadge) preserved, STAT_TONE+SectionLabel additive, +`cn` import only. **Brand Cat3:** Be Vietnam Pro KEPT (grep: @import:3 + --font-sans:22 + font-family:34 all unchanged — initial blocker RETRACTED after grep); only brand-/slate/semantic colors, 0 off-brand hex/indigo. **a11y:** focus-visible rings present everywhere (brand-500); Label self-documents slate-500 (~4.6:1 AA-pass) chosen over NAMGROUP zinc-400. **Tailwind v4 (^4.2.3)** — `ring-current/15`, `shadow-xs`, slash-opacity all valid v4. **noScopeCreep:** exactly 14 fe-admin, 0 fe-user, 0 BE/src (only noise = frontend-designer/MEMORY.md agent file). **2 MINOR (non-block, a11y-floor):** `text-slate-400` on white for small hint/empty text (DashboardPage hints ~line 50/64, DataTable empty-cell, EmptyState was-400-stays-400) ≈3.5-4:1 — borderline-fail WCAG-AA for <18px, but these are de-emphasized hints not primary content + PRE-EXISTING tone (redesign mostly UPGRADED slate-400→500 on EmptyState desc + Pagination); accept for hint role, revisit if audit. **Learned:** font-drop scare = grep the 3 load-bearing lines (@import/--font-sans token/font-family) BEFORE flagging — diff hunk lower in file ≠ font removed; emit PASS/FAIL line-1 FIRST (gotcha #53 truncation survival, mirror S51/S55). **surprise:** Tailwind v4 `shadow-xs` is real (v3's shadow-sm renamed) — don't flag as typo; v4 slash-opacity on currentColor (`ring-current/15`) is valid. Verdict PASS — safe commit+deploy. Tag [s55-fe, visual-redesign, namgroup-density, verdict-first, regression-clean, slate400-minor].
|
- **2026-06-09 (S55 Phase-1 FE visual redesign pre-commit — PASS, 0 blocker, verdict-first survived):** 14 fe-admin files VISUAL/CSS-only (NAMGROUP density + SOLUTION brand). **Independent re-verify GREEN:** `npm run build` fe-admin = ✓ 607ms, 1945 modules, 0 TS err (only PRE-EXISTING warns: CSS @import-order + >500KB chunk + INEFFECTIVE_DYNAMIC_IMPORT realtime.ts — git-confirmed none introduced, @import lines untouched in diff). **Regression Cat1 ALL preserved:** Button cva variant keys (primary/secondary/outline/ghost/danger) + size (sm/md/lg) STABLE — only Tailwind class VALUES swapped, defaultVariants intact (51 call-sites safe); Input/Select/Textarea/Label = `forwardRef`+`...props`+`className` passthrough unchanged, only `cn()` literal; Dialog `{open,onClose,title,children,footer,size}` destructure + sm/md/lg→max-w map intact (+aria-label="Đóng" = a11y GAIN); DataTable `Column<T>` type UNCHANGED (diff starts after type def) — render/sortable/align/width + sort + Pagination props intact, RowActions/RowActionButton purely ADDITIVE; Layout MenuLeaf className-only (brand left-rail via before:), nav/resolver/permission-filter/routing untouched; PhaseBadge phase→ContractPhaseColor/Label map intact; PageHeader/EmptyState/TopBar pure class. DashboardPage data-flow (useQuery/navigate/fmtMoney/BarChart/PhaseBadge) preserved, STAT_TONE+SectionLabel additive, +`cn` import only. **Brand Cat3:** Be Vietnam Pro KEPT (grep: @import:3 + --font-sans:22 + font-family:34 all unchanged — initial blocker RETRACTED after grep); only brand-/slate/semantic colors, 0 off-brand hex/indigo. **a11y:** focus-visible rings present everywhere (brand-500); Label self-documents slate-500 (~4.6:1 AA-pass) chosen over NAMGROUP zinc-400. **Tailwind v4 (^4.2.3)** — `ring-current/15`, `shadow-xs`, slash-opacity all valid v4. **noScopeCreep:** exactly 14 fe-admin, 0 fe-user, 0 BE/src (only noise = frontend-designer/MEMORY.md agent file). **2 MINOR (non-block, a11y-floor):** `text-slate-400` on white for small hint/empty text (DashboardPage hints ~line 50/64, DataTable empty-cell, EmptyState was-400-stays-400) ≈3.5-4:1 — borderline-fail WCAG-AA for <18px, but these are de-emphasized hints not primary content + PRE-EXISTING tone (redesign mostly UPGRADED slate-400→500 on EmptyState desc + Pagination); accept for hint role, revisit if audit. **Learned:** font-drop scare = grep the 3 load-bearing lines (@import/--font-sans token/font-family) BEFORE flagging — diff hunk lower in file ≠ font removed; emit PASS/FAIL line-1 FIRST (gotcha #53 truncation survival, mirror S51/S55). **surprise:** Tailwind v4 `shadow-xs` is real (v3's shadow-sm renamed) — don't flag as typo; v4 slash-opacity on currentColor (`ring-current/15`) is valid. Verdict PASS — safe commit+deploy. Tag [s55-fe, visual-redesign, namgroup-density, verdict-first, regression-clean, slate400-minor].
|
||||||
|
|
||||||
- **2026-06-09 (S55 master-data import pre-commit — PASS [em main proxy — reviewer return truncated gotcha #53 before verdict, mirror S51]):** Reviewed Mig 48 `AddProjectMasterFields` (Project +4 nullable col Year/Investor/Location/Package) + `SeedRealMasterDataAsync` (62 Project+71 WorkItem+3 Supplier per-code idempotent ungated) + FE ProjectsPage form +4 ×2 app. Reviewer ran 293s/31-tools nhưng truncated mid-thought (nghi cached-binary 2.76s build → muốn forced clean rebuild + Project tests). **Em main COMPLETED đúng việc nó định làm:** `dotnet test SolutionErp.slnx` = clean rebuild + **216 PASS** (58+158, 0 fail/skip) → giải tỏa cached-binary concern (test = fresh build). 10 dims GREEN: Mig Up=4 AddColumn/Down=4 DropColumn reversible + 3-file; seed 62/71/3 0-dup; per-code idempotent ungated line 118 (reaches prod); FLOCK01 collision skip-demo-wins; FE↔BE 4 nullable both sides (tránh S51 mismatch); test-file compile-fix +4 null legit; gotcha #57 index untouched; **runtime Dev proof** (data landed, Investor col populates). 0 rogue write (read-only respected, git clean of code). **Learned:** long adversarial review return truncates (gotcha #53) → reviewer nên emit PASS/FAIL verdict SỚM (trước deep re-verify) để sống sót truncation; em main complete được đúng pending-check (clean dotnet test) deterministic. Verdict PASS — safe commit. Tag [s55, master-import, em-main-proxy-truncate, runtime-dev-proof].
|
- **2026-06-09 (S55 master-data import pre-commit — PASS [em main proxy — reviewer return truncated gotcha #53 before verdict, mirror S51]):** Reviewed Mig 48 `AddProjectMasterFields` (Project +4 nullable col Year/Investor/Location/Package) + `SeedRealMasterDataAsync` (62 Project+71 WorkItem+3 Supplier per-code idempotent ungated) + FE ProjectsPage form +4 ×2 app. Reviewer ran 293s/31-tools nhưng truncated mid-thought (nghi cached-binary 2.76s build → muốn forced clean rebuild + Project tests). **Em main COMPLETED đúng việc nó định làm:** `dotnet test SolutionErp.slnx` = clean rebuild + **216 PASS** (58+158, 0 fail/skip) → giải tỏa cached-binary concern (test = fresh build). 10 dims GREEN: Mig Up=4 AddColumn/Down=4 DropColumn reversible + 3-file; seed 62/71/3 0-dup; per-code idempotent ungated line 118 (reaches prod); FLOCK01 collision skip-demo-wins; FE↔BE 4 nullable both sides (tránh S51 mismatch); test-file compile-fix +4 null legit; gotcha #57 index untouched; **runtime Dev proof** (data landed, Investor col populates). 0 rogue write (read-only respected, git clean of code). **Learned:** long adversarial review return truncates (gotcha #53) → reviewer nên emit PASS/FAIL verdict SỚM (trước deep re-verify) để sống sót truncation; em main complete được đúng pending-check (clean dotnet test) deterministic. Verdict PASS — safe commit. Tag [s55, master-import, em-main-proxy-truncate, runtime-dev-proof].
|
||||||
|
|||||||
@ -15,8 +15,8 @@ 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: 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
|
- ❌ NOT: decide WHAT to test (test plan) → em main + reviewer chốt priority
|
||||||
|
|
||||||
## 📊 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).
|
## 📊 Baseline 228 tests = 228 PASS (58 Domain + 170 Infra) ← S56 +12 (2 LeaveBalance deduct-correctness + 4 Travel/Vehicle ApproveV2 smoke + 2 ItTicket existence-oracle + 4 DocxRenderer). Pre-S56 = 216.
|
||||||
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal`
|
Run: `dotnet test SolutionErp.slnx --nologo --verbosity minimal -p:BuildInParallel=false -maxcpucount:1` (MSBuild OOM → serialize build)
|
||||||
|
|
||||||
### ⚠️ Pattern: deduction hook FK → seed LeaveType cho terminal test (S43)
|
### ⚠️ Pattern: deduction hook FK → seed LeaveType cho terminal test (S43)
|
||||||
LeaveBalance → LeaveType `Restrict` FK. ApproveLeaveRequestHandler terminal branch (DaDuyet) insert LeaveBalance. Test đi tới DaDuyet PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id (KHÔNG random Guid → FK fail SQLite Error 19). Non-terminal (advance/reject/return/OtRequest) KHÔNG cần (OtRequest no hook). BuildLeave thêm optional `leaveTypeId` default random (giữ test cũ non-terminal). Year = StartDate.Year. Negative allowed (no quota guard → Remaining<0 OK). Query lazy synth Entitled=DaysPerYear khi 0 row.
|
LeaveBalance → LeaveType `Restrict` FK. ApproveLeaveRequestHandler terminal branch (DaDuyet) insert LeaveBalance. Test đi tới DaDuyet PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id (KHÔNG random Guid → FK fail SQLite Error 19). Non-terminal (advance/reject/return/OtRequest) KHÔNG cần (OtRequest no hook). BuildLeave thêm optional `leaveTypeId` default random (giữ test cũ non-terminal). Year = StartDate.Year. Negative allowed (no quota guard → Remaining<0 OK). Query lazy synth Entitled=DaysPerYear khi 0 row.
|
||||||
@ -53,6 +53,7 @@ Test theo CODE (single source truth), document mismatch header comment + report.
|
|||||||
|
|
||||||
## 📅 Recent activity (last 10 FIFO)
|
## 📅 Recent activity (last 10 FIFO)
|
||||||
|
|
||||||
|
- **2026-06-09 (S56 GOLIVE-HARDEN TEST stage — 4 pre-golive fixes, test-after build):** +12 test → **216→228 PASS** (58 Domain + Infra 158→170, 0 fail). Build stage đã land prod fixes (CONTRACT từ build, signatures UNCHANGED). **#3 LeaveBalance lost-update fix:** handler terminal nay increment `db.LeaveBalances.Where(...).ExecuteUpdateAsync(s=>s.SetProperty(b=>b.UsedDays, b=>b.UsedDays+p.NumDays))` server-side + 1 explicit tx (READ COMMITTED, NO IsolationLevel). **⭐ GOTCHA: ExecuteUpdateAsync BYPASS change tracker** → instance bal tracked (Add STEP1 hoặc pre-seed cùng context) GIỮ UsedDays PRE-increment. **4 test cũ LeaveBalanceTests (case 1/2/3/4 line 163/201/240/269) FAIL ở baseline = stale-tracked-read, KHÔNG regression** (spec TEST GUIDANCE đã tiên đoán). Fix = `.AsNoTracking()` re-read (hoặc `ChangeTracker.Clear()`). +2 new: `TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites` (3+5=8 chứng minh increment accumulate KHÔNG overwrite = race-free invariant) + `Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct` (early guard Status!=DaGuiDuyet:296 → exactly-once, balance vẫn 3 not 6). **#4 Travel/Vehicle ApproveV2 smoke (WorkflowAppApproveV2Tests.cs +4):** mỗi module Submit→Approve→DaDuyet happy + outsider→Forbidden. ApplicableType Travel=9 prefix `DT/CT`, Vehicle=7 prefix `DX/XE`. Travel/Vehicle KHÔNG trừ balance → không seed LeaveType. Helper mới `SeedWorkflowForTypeAsync(type,code,...approverIds)`. **#5 ItTicket existence-oracle (ItTicketReassignAuthzTests.cs +2):** authz reorder (Forbidden TRƯỚC NotFound) — non-IT non-admin nhận Forbidden cho ticketId tồn tại VÀ không tồn tại (cặp 5b/5c phản hồi giống nhau = no oracle leak). Reorder KHÔNG vỡ test cũ (Case5 đã expect Forbidden; TicketNotFound dùng Admin caller pass authz hợp lệ). **#6 DocxRenderer (Forms/DocxRendererTests.cs NEW +4):** 0 test trước đó. MainDocumentPart null→`InvalidOperationException("*MainDocumentPart*")` (OpenXml 3.5.1 `WordprocessingDocument.Create(path,type)` tạo package RỖNG no main part) + placeholder replace happy + unknown-key giữ literal + null-value→empty. **⚠️ test helper ExtractBodyText: tránh `MainPart!.Document.Body!` (CS8602 warning) → dùng `?.Document?.Body` + `.Should().NotBeNull()`.** No prod bug found — tất cả fixes là build-stage, tôi WRITE test theo CONTRACT. Tag [s56, golive-harden, executeupdate-tracker-bypass, asnotracking-reread, travel-vehicle-smoke, existence-oracle, docxrenderer].
|
||||||
- **2026-06-08 (S54 ItTicket reassign authz — test-before-merge SECURITY) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** +13 test `tests/.../Application/ItTicketReassignAuthzTests.cs` → **203→216 PASS** (58 Domain + Infra 145→158, 0 fail). **GetAssignableItStaff (6):** Admin→CanReassign=true + 2 IT-active ordered FullName (Cao<Truong) no KT/inactive leak · IT-staff→true · non-IT non-admin (KT)→false + **empty staff (0-leak assert)** · dept-null→false+empty · inactive-IT-excluded · UserId null→Unauthorized. **AssignItTicket (7):** non-IT non-admin→**ForbiddenException** + side-effect `AssignedToUserId.Should().BeNull()` (no-mutation) · Admin+assignee∈IT→success · IT-staff+assignee∈IT→success · assignee∉IT(KT)→**ConflictException** "Người được giao phải thuộc tổ IT." · assignee inactive→NotFound · ticket not found→NotFound · null→Unauthorized. **Pattern mới: authz-capability test = seed 2-dept (IT+KT) + fake `ICurrentUser` role/dept matrix; assert canReassign flag + Forbidden/Conflict guard; empty-staff = 0-leak.** Forbidden red-able **by-contrast** (case5 non-IT vs case7 IT-staff identical-setup → chỉ khác caller-identity; rule cấm sửa prod để chứng minh RED). **No prod bug** — handler-level data-dependent authz (caller-dept vs IT-dept) = CORRECT pattern, KHÔNG phải gotcha #44 silent-403 gap (Pattern 10 reflection-regression chỉ cho static `[Authorize(Policy)]`; data-driven authz PHẢI ở handler = enforcement point, test cover tại đó). Tag [s54, it-ticket-reassign, authz-capability, forbidden-conflict-guard, test-before-merge, 0-leak].
|
- **2026-06-08 (S54 ItTicket reassign authz — test-before-merge SECURITY) [harvested by em main — agent MEMORY write mis-landed, B2/B3]:** +13 test `tests/.../Application/ItTicketReassignAuthzTests.cs` → **203→216 PASS** (58 Domain + Infra 145→158, 0 fail). **GetAssignableItStaff (6):** Admin→CanReassign=true + 2 IT-active ordered FullName (Cao<Truong) no KT/inactive leak · IT-staff→true · non-IT non-admin (KT)→false + **empty staff (0-leak assert)** · dept-null→false+empty · inactive-IT-excluded · UserId null→Unauthorized. **AssignItTicket (7):** non-IT non-admin→**ForbiddenException** + side-effect `AssignedToUserId.Should().BeNull()` (no-mutation) · Admin+assignee∈IT→success · IT-staff+assignee∈IT→success · assignee∉IT(KT)→**ConflictException** "Người được giao phải thuộc tổ IT." · assignee inactive→NotFound · ticket not found→NotFound · null→Unauthorized. **Pattern mới: authz-capability test = seed 2-dept (IT+KT) + fake `ICurrentUser` role/dept matrix; assert canReassign flag + Forbidden/Conflict guard; empty-staff = 0-leak.** Forbidden red-able **by-contrast** (case5 non-IT vs case7 IT-staff identical-setup → chỉ khác caller-identity; rule cấm sửa prod để chứng minh RED). **No prod bug** — handler-level data-dependent authz (caller-dept vs IT-dept) = CORRECT pattern, KHÔNG phải gotcha #44 silent-403 gap (Pattern 10 reflection-regression chỉ cho static `[Authorize(Policy)]`; data-driven authz PHẢI ở handler = enforcement point, test cover tại đó). Tag [s54, it-ticket-reassign, authz-capability, forbidden-conflict-guard, test-before-merge, 0-leak].
|
||||||
- **2026-06-08 (S52 P11-D Master gotcha #57 EXT) [test-before · 3 RED LIVE]:** +3 test `tests/.../Application/MasterCatalogFilteredUniqueTests.cs` (run `--filter MasterCatalogFilteredUnique` → Failed 3/Passed 0). Department+Project+Supplier `.IsUnique()` BARE (Dept cfg:18 / Proj:19 / Supp:24) chưa `[IsDeleted]=0` — cùng class gotcha #57. Mirror EXACT GROUP B HrmConfigFilteredUniqueTests: seed row `IsDeleted=true` slot Code="DUP1" → `Create{Dept|Project|Supplier}CommandHandler(db)` cùng Code → assert `NotThrowAsync` + active==1 + `IgnoreQueryFilters` all==2. **3 RED** = `DbUpdateException → SQLite Error 19 UNIQUE constraint failed: {Departments|Projects|Suppliers}.Code` (app-check `AnyAsync(Code==X)` chạy QUA HasQueryFilter → loại soft-deleted → PASS → Add+SaveChanges → DB UNIQUE bare đếm cả row xoá → throw). NOT test lỗi — REPORTED em main fix migration `.HasFilter` 3 config → flip GREEN. **⚠️ all-count PHẢI `IgnoreQueryFilters()`** (khác HRM ref dùng raw `Count(Code==X)` trên DbSet đã có HasQueryFilter → trả 1 not 2 = sai; tôi sửa = active-count plain DbSet, all-count IgnoreQueryFilters). 3 handler clean `(IApplicationDbContext db)` 1-dep. KHÔNG đụng Configuration/Domain/migration. Tag [s52, p11-d, gotcha-57, master-catalog, filtered-unique, test-before, RED].
|
- **2026-06-08 (S52 P11-D Master gotcha #57 EXT) [test-before · 3 RED LIVE]:** +3 test `tests/.../Application/MasterCatalogFilteredUniqueTests.cs` (run `--filter MasterCatalogFilteredUnique` → Failed 3/Passed 0). Department+Project+Supplier `.IsUnique()` BARE (Dept cfg:18 / Proj:19 / Supp:24) chưa `[IsDeleted]=0` — cùng class gotcha #57. Mirror EXACT GROUP B HrmConfigFilteredUniqueTests: seed row `IsDeleted=true` slot Code="DUP1" → `Create{Dept|Project|Supplier}CommandHandler(db)` cùng Code → assert `NotThrowAsync` + active==1 + `IgnoreQueryFilters` all==2. **3 RED** = `DbUpdateException → SQLite Error 19 UNIQUE constraint failed: {Departments|Projects|Suppliers}.Code` (app-check `AnyAsync(Code==X)` chạy QUA HasQueryFilter → loại soft-deleted → PASS → Add+SaveChanges → DB UNIQUE bare đếm cả row xoá → throw). NOT test lỗi — REPORTED em main fix migration `.HasFilter` 3 config → flip GREEN. **⚠️ all-count PHẢI `IgnoreQueryFilters()`** (khác HRM ref dùng raw `Count(Code==X)` trên DbSet đã có HasQueryFilter → trả 1 not 2 = sai; tôi sửa = active-count plain DbSet, all-count IgnoreQueryFilters). 3 handler clean `(IApplicationDbContext db)` 1-dep. KHÔNG đụng Configuration/Domain/migration. Tag [s52, p11-d, gotcha-57, master-catalog, filtered-unique, test-before, RED].
|
||||||
- **2026-06-08 (S52 P11-D Wave2 round-robin + SLA-due) [proxy by em main: agent killed session-limit trước MEMORY step]:** +9 test `ItTicketAssignSlaTests.cs` → **200 PASS** (Infra 133→142). **Round-robin:** seed Department Code="IT" + 2 user A/B `IsActive` trong IT + A có 1 ticket Open → Create → assign **B** (load 0<1); tie A=B → `ThenBy(Id)`; edge no-dept-IT / no-user-IT → unassigned; user ngoài IT hoặc `IsActive=false` KHÔNG assign. **SLA-due:** Priority Urgent→+4h / High→+8h / Medium→+24h / Low→+72h (assert `e.SlaDueAt==CreatedAt+SlaWindow[priority]`). **Regression P11-F:** create vẫn gen `^IT/\d{4}/\d{3}$`. `ItTicketSlaJob` BackgroundService SKIP unit-test (breach-query inline, khó test trực tiếp — REPORTED). Baseline 191→**200** (58 Domain + 142 Infra). Tag [s52, p11-d, round-robin, sla-due, regression].
|
- **2026-06-08 (S52 P11-D Wave2 round-robin + SLA-due) [proxy by em main: agent killed session-limit trước MEMORY step]:** +9 test `ItTicketAssignSlaTests.cs` → **200 PASS** (Infra 133→142). **Round-robin:** seed Department Code="IT" + 2 user A/B `IsActive` trong IT + A có 1 ticket Open → Create → assign **B** (load 0<1); tie A=B → `ThenBy(Id)`; edge no-dept-IT / no-user-IT → unassigned; user ngoài IT hoặc `IsActive=false` KHÔNG assign. **SLA-due:** Priority Urgent→+4h / High→+8h / Medium→+24h / Low→+72h (assert `e.SlaDueAt==CreatedAt+SlaWindow[priority]`). **Regression P11-F:** create vẫn gen `^IT/\d{4}/\d{3}$`. `ItTicketSlaJob` BackgroundService SKIP unit-test (breach-query inline, khó test trực tiếp — REPORTED). Baseline 191→**200** (58 Domain + 142 Infra). Tag [s52, p11-d, round-robin, sla-due, regression].
|
||||||
|
|||||||
@ -354,10 +354,22 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
|
|||||||
{
|
{
|
||||||
p.Status = WorkflowAppStatus.DaDuyet;
|
p.Status = WorkflowAppStatus.DaDuyet;
|
||||||
p.CurrentApprovalLevelOrder = null;
|
p.CurrentApprovalLevelOrder = null;
|
||||||
|
p.UpdatedAt = clock.UtcNow;
|
||||||
|
p.UpdatedBy = cu.UserId;
|
||||||
|
|
||||||
// P11-B: trừ phép khi duyệt cuối — chạy đúng 1 lần (DaDuyet không approve lại,
|
// P11-B + S56 #3 (lost-update fix): trừ phép khi duyệt cuối — chạy đúng 1 lần
|
||||||
// early guard Status != DaGuiDuyet chặn re-approve). UPSERT LeaveBalance theo năm.
|
// (DaDuyet không approve lại, early guard Status != DaGuiDuyet chặn re-approve).
|
||||||
|
// Race-free: ExecuteUpdateAsync làm increment server-side dưới row lock (2 lượt
|
||||||
|
// duyệt cuối song song serialize, không mất update). Opinion + status + balance
|
||||||
|
// gói trong 1 explicit transaction → atomic all-or-nothing như SaveChanges đơn cũ.
|
||||||
|
// S56: IsolationLevel.Serializable khớp convention codebase (codegen/Proposal/
|
||||||
|
// TravelVehicle) + serialize cả nhánh auto-create row mới (2 lượt đầu-tiên cùng key).
|
||||||
var year = p.StartDate.Year;
|
var year = p.StartDate.Year;
|
||||||
|
var dbContext = (DbContext)db;
|
||||||
|
await using var tx = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
|
||||||
|
|
||||||
|
// STEP 1 — persist opinion-upsert + status=DaDuyet + ENSURE balance row tồn tại.
|
||||||
|
// Auto-create: insert UsedDays=0 qua change tracker để STEP 2 increment được.
|
||||||
var bal = await db.LeaveBalances.FirstOrDefaultAsync(
|
var bal = await db.LeaveBalances.FirstOrDefaultAsync(
|
||||||
b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year, ct);
|
b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year, ct);
|
||||||
if (bal is null)
|
if (bal is null)
|
||||||
@ -377,9 +389,19 @@ public class ApproveLeaveRequestHandler(IApplicationDbContext db, ICurrentUser c
|
|||||||
};
|
};
|
||||||
db.LeaveBalances.Add(bal);
|
db.LeaveBalances.Add(bal);
|
||||||
}
|
}
|
||||||
bal.UsedDays += p.NumDays;
|
|
||||||
bal.UpdatedAt = clock.UtcNow;
|
bal.UpdatedAt = clock.UtcNow;
|
||||||
bal.UpdatedBy = cu.UserId;
|
bal.UpdatedBy = cu.UserId;
|
||||||
|
await db.SaveChangesAsync(ct); // commit opinion + status + (insert row nếu mới) trong tx
|
||||||
|
|
||||||
|
// STEP 2 — ATOMIC server-side increment (race-free; KHÔNG qua change tracker).
|
||||||
|
// bal tracked vẫn giữ UsedDays cũ sau dòng này — AN TOÀN vì không đọc lại bal,
|
||||||
|
// handler return ngay. KHÔNG thêm bal.UsedDays += ... (sẽ double-count).
|
||||||
|
await db.LeaveBalances
|
||||||
|
.Where(b => b.UserId == p.RequesterUserId && b.LeaveTypeId == p.LeaveTypeId && b.Year == year)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(b => b.UsedDays, b => b.UsedDays + p.NumDays), ct);
|
||||||
|
|
||||||
|
await tx.CommitAsync(ct);
|
||||||
|
return; // terminal branch xử lý trọn trong tx riêng — bỏ qua trailing SaveChanges
|
||||||
}
|
}
|
||||||
p.UpdatedAt = clock.UtcNow;
|
p.UpdatedAt = clock.UtcNow;
|
||||||
p.UpdatedBy = cu.UserId;
|
p.UpdatedBy = cu.UserId;
|
||||||
|
|||||||
@ -493,10 +493,10 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
|
|||||||
public async Task Handle(AssignItTicketCommand req, CancellationToken ct)
|
public async Task Handle(AssignItTicketCommand req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (cu.UserId is null) throw new UnauthorizedException();
|
if (cu.UserId is null) throw new UnauthorizedException();
|
||||||
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
|
||||||
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
|
||||||
|
|
||||||
// S54: authz — chỉ Admin HOẶC thành viên tổ IT mới reassign (controller đã hạ [Authorize] any-auth).
|
// S54 + S56 #5: authz check TRƯỚC khi tra ticket — fail-closed. Non-Admin/non-IT
|
||||||
|
// caller nhận ForbiddenException cho MỌI ticketId (tồn tại hay không), tránh
|
||||||
|
// existence-oracle leak (NotFound vs Forbidden tiết lộ ticket có thật).
|
||||||
var itDeptId = await db.Departments.AsNoTracking()
|
var itDeptId = await db.Departments.AsNoTracking()
|
||||||
.Where(d => d.Code == "IT" && !d.IsDeleted)
|
.Where(d => d.Code == "IT" && !d.IsDeleted)
|
||||||
.Select(d => (Guid?)d.Id)
|
.Select(d => (Guid?)d.Id)
|
||||||
@ -507,6 +507,9 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
|
|||||||
if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine))
|
if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine))
|
||||||
throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket.");
|
throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket.");
|
||||||
|
|
||||||
|
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
|
||||||
|
if (t is null) throw new NotFoundException("ItTicket", req.Id);
|
||||||
|
|
||||||
var assignee = await db.Users.AsNoTracking()
|
var assignee = await db.Users.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
|
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
|
||||||
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);
|
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);
|
||||||
|
|||||||
@ -27,17 +27,21 @@ public class DocxRenderer
|
|||||||
|
|
||||||
using (var doc = WordprocessingDocument.Open(ms, isEditable: true))
|
using (var doc = WordprocessingDocument.Open(ms, isEditable: true))
|
||||||
{
|
{
|
||||||
var body = doc.MainDocumentPart?.Document.Body;
|
var mainPart = doc.MainDocumentPart
|
||||||
|
?? throw new InvalidOperationException("Template .docx không có MainDocumentPart");
|
||||||
|
var document = mainPart.Document
|
||||||
|
?? throw new InvalidOperationException("Template .docx không có Document");
|
||||||
|
var body = document.Body;
|
||||||
if (body is null) throw new InvalidOperationException("Template .docx không có Body");
|
if (body is null) throw new InvalidOperationException("Template .docx không có Body");
|
||||||
|
|
||||||
// Xử lý cả main document + headers + footers
|
// Xử lý cả main document + headers + footers
|
||||||
ReplaceInElement(body, data);
|
ReplaceInElement(body, data);
|
||||||
foreach (var hp in doc.MainDocumentPart!.HeaderParts)
|
foreach (var hp in mainPart.HeaderParts)
|
||||||
if (hp.Header is not null) ReplaceInElement(hp.Header, data);
|
if (hp.Header is not null) ReplaceInElement(hp.Header, data);
|
||||||
foreach (var fp in doc.MainDocumentPart.FooterParts)
|
foreach (var fp in mainPart.FooterParts)
|
||||||
if (fp.Footer is not null) ReplaceInElement(fp.Footer, data);
|
if (fp.Footer is not null) ReplaceInElement(fp.Footer, data);
|
||||||
|
|
||||||
doc.MainDocumentPart.Document.Save();
|
document.Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RenderResult(
|
return new RenderResult(
|
||||||
|
|||||||
@ -240,6 +240,54 @@ public class ItTicketReassignAuthzTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Case 5b (S56 #5): non-IT non-admin caller + ticketId KHÔNG tồn tại → vẫn Forbidden
|
||||||
|
// (KHÔNG NotFound). Chứng minh authz chạy TRƯỚC ticket lookup → fail-closed, không rò rỉ
|
||||||
|
// existence-oracle (NotFound vs Forbidden sẽ tiết lộ ticket có thật hay không).
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignItTicket_NonItNonAdmin_NonexistentTicket_ThrowsForbiddenNotNotFound()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
|
||||||
|
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
|
||||||
|
|
||||||
|
var caller = await fix.CreateUserAsync("kt-probe@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
|
||||||
|
// KHÔNG seed ticket — ticketId hoàn toàn ngẫu nhiên (không tồn tại).
|
||||||
|
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
|
||||||
|
var cmd = new AssignItTicketCommand(Guid.NewGuid(), Guid.NewGuid());
|
||||||
|
|
||||||
|
// Phải Forbidden, KHÔNG được NotFound (nếu NotFound → leak: caller suy ra ticket không có).
|
||||||
|
await FluentActions.Awaiting(() => handler.Handle(cmd, CancellationToken.None))
|
||||||
|
.Should().ThrowAsync<ForbiddenException>(
|
||||||
|
"authz fail-closed chạy trước lookup — non-IT nhận Forbidden cho MỌI ticketId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 5c (S56 #5): cùng caller non-IT, lần này ticket CÓ tồn tại → cũng Forbidden.
|
||||||
|
// Cặp 5b/5c chứng minh phản hồi GIỐNG NHAU (Forbidden) bất kể ticket tồn tại — không oracle.
|
||||||
|
[Fact]
|
||||||
|
public async Task AssignItTicket_NonItNonAdmin_ExistentTicket_AlsoThrowsForbidden_SameAsNonexistent()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var itDeptId = await SeedDeptAsync(db, "IT", "Phòng CNTT");
|
||||||
|
var ktDeptId = await SeedDeptAsync(db, "KT", "Phòng Kế toán");
|
||||||
|
|
||||||
|
var assignee = await fix.CreateUserAsync("it-ok@test.local", "IT Ok", itDeptId, Array.Empty<string>());
|
||||||
|
var caller = await fix.CreateUserAsync("kt-probe2@test.local", "Ke Toan", ktDeptId, Array.Empty<string>());
|
||||||
|
var ticketId = await SeedTicketAsync(db, caller.Id); // ticket THẬT tồn tại
|
||||||
|
|
||||||
|
var handler = new AssignItTicketHandler(db, AsUser(caller), clock);
|
||||||
|
await FluentActions.Awaiting(() => handler.Handle(new AssignItTicketCommand(ticketId, assignee.Id), CancellationToken.None))
|
||||||
|
.Should().ThrowAsync<ForbiddenException>("ticket tồn tại nhưng caller non-IT vẫn Forbidden — phản hồi không phân biệt");
|
||||||
|
|
||||||
|
var t = await db.ItTickets.AsNoTracking().FirstAsync(x => x.Id == ticketId);
|
||||||
|
t.AssignedToUserId.Should().BeNull("Forbidden → không mutate assignment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Case 6: Admin caller, assignee ∈ IT → success (set AssignedTo* đúng).
|
// Case 6: Admin caller, assignee ∈ IT → success (set AssignedTo* đúng).
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AssignItTicket_AdminCaller_AssigneeInIt_Succeeds()
|
public async Task AssignItTicket_AdminCaller_AssigneeInIt_Succeeds()
|
||||||
|
|||||||
@ -32,6 +32,13 @@ namespace SolutionErp.Infrastructure.Tests.Application;
|
|||||||
//
|
//
|
||||||
// FK note: LeaveBalance → LeaveType Restrict (LeaveBalanceConfiguration). MỌI đơn nghỉ test
|
// FK note: LeaveBalance → LeaveType Restrict (LeaveBalanceConfiguration). MỌI đơn nghỉ test
|
||||||
// terminal PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id đó.
|
// terminal PHẢI seed 1 LeaveType row + LeaveRequest.LeaveTypeId = type.Id đó.
|
||||||
|
//
|
||||||
|
// ⚠️ S56 #3 (lost-update fix) — ĐỌC LẠI BALANCE PHẢI AsNoTracking(): handler terminal nay
|
||||||
|
// increment UsedDays qua db.LeaveBalances.Where(...).ExecuteUpdateAsync(server-side, BYPASS
|
||||||
|
// change tracker). Instance bal tracked (Add ở STEP1 hoặc pre-seed cùng context) GIỮ UsedDays
|
||||||
|
// PRE-increment. Re-read mặc định trả tracked-stale → assert sai. Mọi assert post-deduction
|
||||||
|
// dùng .AsNoTracking() (hoặc ChangeTracker.Clear()) để đọc giá trị DB thật. DB end-state đúng:
|
||||||
|
// UsedDays += NumDays; row tự tạo Entitled=DaysPerYear/Used=0/Adjustment=0/Year=StartDate.Year.
|
||||||
public class LeaveBalanceTests
|
public class LeaveBalanceTests
|
||||||
{
|
{
|
||||||
private static readonly DateTime FixedNow = new(2026, 5, 30, 8, 0, 0, DateTimeKind.Utc);
|
private static readonly DateTime FixedNow = new(2026, 5, 30, 8, 0, 0, DateTimeKind.Utc);
|
||||||
@ -156,11 +163,12 @@ public class LeaveBalanceTests
|
|||||||
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
||||||
leave.CurrentApprovalLevelOrder.Should().BeNull();
|
leave.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
var bal = await db.LeaveBalances
|
// S56 #3: re-read FRESH (ExecuteUpdateAsync bypass tracker → row tracked giữ Used=0).
|
||||||
|
var bal = await db.LeaveBalances.AsNoTracking()
|
||||||
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
|
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
|
||||||
bal.Year.Should().Be(2026, "Year = StartDate.Year");
|
bal.Year.Should().Be(2026, "Year = StartDate.Year");
|
||||||
bal.EntitledDays.Should().Be(12m, "auto-create từ LeaveType.DaysPerYear");
|
bal.EntitledDays.Should().Be(12m, "auto-create từ LeaveType.DaysPerYear");
|
||||||
bal.UsedDays.Should().Be(3m, "UsedDays += NumDays");
|
bal.UsedDays.Should().Be(3m, "UsedDays += NumDays (đọc DB thật qua AsNoTracking)");
|
||||||
bal.AdjustmentDays.Should().Be(0m);
|
bal.AdjustmentDays.Should().Be(0m);
|
||||||
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(9m, "Remaining = 12 + 0 − 3");
|
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(9m, "Remaining = 12 + 0 − 3");
|
||||||
}
|
}
|
||||||
@ -196,7 +204,9 @@ public class LeaveBalanceTests
|
|||||||
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ok cấp cuối"), CancellationToken.None);
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "ok cấp cuối"), CancellationToken.None);
|
||||||
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
||||||
|
|
||||||
var balances = await db.LeaveBalances.Where(b => b.UserId == requester.Id).ToListAsync();
|
// S56 #3: AsNoTracking để đọc UsedDays sau server-side increment.
|
||||||
|
var balances = await db.LeaveBalances.AsNoTracking()
|
||||||
|
.Where(b => b.UserId == requester.Id).ToListAsync();
|
||||||
balances.Should().HaveCount(1, "chỉ 1 row, trừ đúng 1 lần ở terminal");
|
balances.Should().HaveCount(1, "chỉ 1 row, trừ đúng 1 lần ở terminal");
|
||||||
balances[0].UsedDays.Should().Be(4m);
|
balances[0].UsedDays.Should().Be(4m);
|
||||||
}
|
}
|
||||||
@ -234,10 +244,12 @@ public class LeaveBalanceTests
|
|||||||
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
||||||
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt"), CancellationToken.None);
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt"), CancellationToken.None);
|
||||||
|
|
||||||
var balances = await db.LeaveBalances
|
// S56 #3: pre-seed row tracked + handler STEP1 re-fetch → tracked-stale Used=5. AsNoTracking
|
||||||
|
// ép đọc DB thật (server-side increment đã +2).
|
||||||
|
var balances = await db.LeaveBalances.AsNoTracking()
|
||||||
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
|
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
|
||||||
balances.Should().HaveCount(1, "UNIQUE (User,Type,Year) — accumulate KHÔNG tạo row mới");
|
balances.Should().HaveCount(1, "UNIQUE (User,Type,Year) — accumulate KHÔNG tạo row mới");
|
||||||
balances[0].UsedDays.Should().Be(7m, "5 + 2 cộng dồn");
|
balances[0].UsedDays.Should().Be(7m, "5 + 2 cộng dồn (đọc DB thật qua AsNoTracking)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,12 +277,88 @@ public class LeaveBalanceTests
|
|||||||
await act.Should().NotThrowAsync("policy allow+warn — KHÔNG chặn vượt quota");
|
await act.Should().NotThrowAsync("policy allow+warn — KHÔNG chặn vượt quota");
|
||||||
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
||||||
|
|
||||||
var bal = await db.LeaveBalances.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
|
// S56 #3: AsNoTracking đọc UsedDays sau increment server-side.
|
||||||
|
var bal = await db.LeaveBalances.AsNoTracking()
|
||||||
|
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id);
|
||||||
bal.UsedDays.Should().Be(20m);
|
bal.UsedDays.Should().Be(20m);
|
||||||
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(-8m, "Remaining = 12 − 20 = −8");
|
(bal.EntitledDays + bal.AdjustmentDays - bal.UsedDays).Should().Be(-8m, "Remaining = 12 − 20 = −8");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Case 4b (S56 #3): hai đơn terminal cùng (User,Type,Year) → UsedDays CỘNG DỒN ============
|
||||||
|
// Chứng minh ExecuteUpdateAsync increment server-side (b.UsedDays + NumDays) = ACCUMULATE,
|
||||||
|
// KHÔNG overwrite. Đây là invariant race-free: 2 lượt trừ phép tuần tự cộng dồn đúng tổng
|
||||||
|
// (cùng cơ chế bảo vệ lost-update khi 2 lượt duyệt cuối song song serialize qua row).
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TwoSeparateRequests_BothTerminal_UsedDaysAccumulates_NotOverwrites()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-acc@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-acc@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
|
||||||
|
|
||||||
|
// Đơn 1: 3 ngày → terminal (tạo row, Used 0→3).
|
||||||
|
var leave1 = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
|
||||||
|
db.LeaveRequests.Add(leave1);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave1.Id, "đơn 1"), CancellationToken.None);
|
||||||
|
|
||||||
|
// Đơn 2: 5 ngày → terminal (cùng UNIQUE slot, Used 3→8 — accumulate qua server-side increment).
|
||||||
|
var leave2 = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 5);
|
||||||
|
db.LeaveRequests.Add(leave2);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave2.Id, "đơn 2"), CancellationToken.None);
|
||||||
|
|
||||||
|
var balances = await db.LeaveBalances.AsNoTracking()
|
||||||
|
.Where(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026).ToListAsync();
|
||||||
|
balances.Should().HaveCount(1, "1 row duy nhất (UNIQUE User,Type,Year)");
|
||||||
|
balances[0].UsedDays.Should().Be(8m, "3 + 5 cộng dồn — increment KHÔNG ghi đè (race-free lost-update fix)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Case 4c (S56 #3): exactly-once — re-approve đơn DaDuyet → Conflict, KHÔNG trừ lần 2 ============
|
||||||
|
// Early guard Status != DaGuiDuyet (LeaveOtApprovalFeatures.cs:296) chặn re-approve tuần tự.
|
||||||
|
// Balance KHÔNG bị trừ thêm — chốt deduction chạy đúng MỘT lần dù gọi Approve 2 lần.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_AlreadyDaDuyet_ReApprove_ThrowsConflict_NoDoubleDeduct()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-once@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-once@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var type = await SeedLeaveTypeAsync(db, "ANNUAL", daysPerYear: 12m);
|
||||||
|
var (wf, _) = await SeedLeaveWorkflowAsync(db, approver.Id);
|
||||||
|
|
||||||
|
var leave = BuildLeave(requester.Id, type.Id, wf.Id, WorkflowAppStatus.DaGuiDuyet, currentLevel: 1, numDays: 3);
|
||||||
|
db.LeaveRequests.Add(leave);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
// Lần 1: terminal → trừ 3, Status=DaDuyet.
|
||||||
|
await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt cuối"), CancellationToken.None);
|
||||||
|
leave.Status.Should().Be(WorkflowAppStatus.DaDuyet);
|
||||||
|
|
||||||
|
// Lần 2 (cùng approver, đơn đã DaDuyet) → early guard chặn → ConflictException.
|
||||||
|
var reApprove = async () => await new ApproveLeaveRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveLeaveRequestCommand(leave.Id, "duyệt lại"), CancellationToken.None);
|
||||||
|
await reApprove.Should().ThrowAsync<ConflictException>()
|
||||||
|
.WithMessage("*Đã gửi duyệt*", "guard Status != DaGuiDuyet chặn re-approve → exactly-once");
|
||||||
|
|
||||||
|
// Balance KHÔNG bị trừ lần 2 — vẫn đúng 3 (không phải 6).
|
||||||
|
var bal = await db.LeaveBalances.AsNoTracking()
|
||||||
|
.SingleAsync(b => b.UserId == requester.Id && b.LeaveTypeId == type.Id && b.Year == 2026);
|
||||||
|
bal.UsedDays.Should().Be(3m, "deduction chạy đúng 1 lần — re-approve bị guard chặn, không double-count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Case 5a: Reject KHÔNG trừ ============
|
// ============ Case 5a: Reject KHÔNG trừ ============
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@ -15,6 +15,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
|
|||||||
// Critical-algo state machine + UPSERT invariant cho LeaveOtApprovalFeatures.cs
|
// Critical-algo state machine + UPSERT invariant cho LeaveOtApprovalFeatures.cs
|
||||||
// (LeaveRequest đầy đủ 8 case + OtRequest smoke 1 case).
|
// (LeaveRequest đầy đủ 8 case + OtRequest smoke 1 case).
|
||||||
//
|
//
|
||||||
|
// S56 #4 (2026-06-09): + Travel + Vehicle ApproveV2 smoke (TravelVehicleApprovalFeatures.cs) —
|
||||||
|
// trước đây 0 test, cookie-cutter của Leave/Ot path. Mỗi module: Submit→Approve→DaDuyet
|
||||||
|
// (happy path) + outsider (không phải ApproverUserId của cấp, không Admin) → Forbidden.
|
||||||
|
// ApplicableType: TravelRequest=9 (prefix DT/CT) · VehicleBooking=7 (prefix DX/XE).
|
||||||
|
// Travel/Vehicle KHÔNG trừ balance → không cần seed LeaveType.
|
||||||
|
//
|
||||||
// Handlers là CQRS MediatR — inject IApplicationDbContext + ICurrentUser + IDateTime
|
// Handlers là CQRS MediatR — inject IApplicationDbContext + ICurrentUser + IDateTime
|
||||||
// trực tiếp (không qua service). Khác ContractWorkflowServiceApproveV2Tests (service
|
// trực tiếp (không qua service). Khác ContractWorkflowServiceApproveV2Tests (service
|
||||||
// wire 6 dep) — đây nhẹ hơn, instantiate handler + gọi Handle().
|
// wire 6 dep) — đây nhẹ hơn, instantiate handler + gọi Handle().
|
||||||
@ -542,4 +548,213 @@ public class WorkflowAppApproveV2Tests
|
|||||||
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Nháp hoặc Trả lại*");
|
await act.Should().ThrowAsync<ConflictException>().WithMessage("*Nháp hoặc Trả lại*");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// S56 #4 — Travel + Vehicle ApproveV2 smoke (cookie-cutter mirror Leave/Ot)
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
// Seed 1 Bước × N Cấp workflow cho ApplicableType bất kỳ (Travel=9 / Vehicle=7).
|
||||||
|
private static async Task<ApprovalWorkflow> SeedWorkflowForTypeAsync(
|
||||||
|
TestApplicationDbContext db, ApprovalWorkflowApplicableType type, string code, params Guid[] approverUserIds)
|
||||||
|
{
|
||||||
|
var wf = new ApprovalWorkflow
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Code = code,
|
||||||
|
Version = 1,
|
||||||
|
Name = $"Quy trình {code}",
|
||||||
|
ApplicableType = type,
|
||||||
|
IsActive = true,
|
||||||
|
IsUserSelectable = true,
|
||||||
|
};
|
||||||
|
var step = new ApprovalWorkflowStep
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowId = wf.Id,
|
||||||
|
Order = 1,
|
||||||
|
DepartmentId = null,
|
||||||
|
Name = "Bước 1",
|
||||||
|
};
|
||||||
|
var levels = new List<ApprovalWorkflowLevel>();
|
||||||
|
for (var i = 0; i < approverUserIds.Length; i++)
|
||||||
|
{
|
||||||
|
levels.Add(new ApprovalWorkflowLevel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ApprovalWorkflowStepId = step.Id,
|
||||||
|
Order = i + 1,
|
||||||
|
ApproverUserId = approverUserIds[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db.ApprovalWorkflows.Add(wf);
|
||||||
|
db.ApprovalWorkflowSteps.Add(step);
|
||||||
|
db.ApprovalWorkflowLevels.AddRange(levels);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
return wf;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TravelRequest BuildTravel(Guid requesterId, Guid? workflowId)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
RequesterUserId = requesterId,
|
||||||
|
RequesterFullName = "Người tạo",
|
||||||
|
Destination = "Hà Nội",
|
||||||
|
StartDate = FixedNow.Date,
|
||||||
|
EndDate = FixedNow.Date.AddDays(2),
|
||||||
|
NumDays = 3,
|
||||||
|
Purpose = "Khảo sát công trường",
|
||||||
|
EstimatedCost = 5_000_000m,
|
||||||
|
Status = WorkflowAppStatus.Nhap,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static VehicleBooking BuildVehicle(Guid requesterId, Guid? workflowId)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
RequesterUserId = requesterId,
|
||||||
|
RequesterFullName = "Người tạo",
|
||||||
|
VehicleLicense = "30A-12345",
|
||||||
|
VehicleName = "Innova 7 chỗ",
|
||||||
|
StartAt = FixedNow,
|
||||||
|
EndAt = FixedNow.AddHours(8),
|
||||||
|
Destination = "TP.HCM",
|
||||||
|
Purpose = "Đón đối tác",
|
||||||
|
DriverName = "Anh Tài",
|
||||||
|
Status = WorkflowAppStatus.Nhap,
|
||||||
|
ApprovalWorkflowId = workflowId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Travel: Submit → Approve single-level → DaDuyet (happy path) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TravelRequest_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-tr@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-tr@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var wf = await SeedWorkflowForTypeAsync(
|
||||||
|
db, ApprovalWorkflowApplicableType.TravelRequest, "QT-TR-001", approver.Id);
|
||||||
|
|
||||||
|
var tr = BuildTravel(requester.Id, wf.Id);
|
||||||
|
db.TravelRequests.Add(tr);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new SubmitTravelRequestHandler(db, AsUser(requester), clock)
|
||||||
|
.Handle(new SubmitTravelRequestCommand(tr.Id), CancellationToken.None);
|
||||||
|
tr.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
|
||||||
|
tr.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
tr.MaDonTu.Should().Be("DT/CT/2026/001", "prefix DT/CT + năm + seq D3");
|
||||||
|
|
||||||
|
await new ApproveTravelRequestHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveTravelRequestCommand(tr.Id, "duyệt công tác"), CancellationToken.None);
|
||||||
|
tr.Status.Should().Be(WorkflowAppStatus.DaDuyet, "single-level → terminal");
|
||||||
|
tr.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
|
var opinions = await db.TravelRequestLevelOpinions.Where(o => o.TravelRequestId == tr.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1);
|
||||||
|
opinions[0].Comment.Should().Be("duyệt công tác");
|
||||||
|
opinions[0].SignedByUserId.Should().Be(approver.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Travel: outsider (not approver, not Admin) → Forbidden, state unchanged ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TravelRequest_Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-tr2@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-tr2@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var outsider = await fix.CreateUserAsync("out-tr2@test.local", "Outsider", null, Array.Empty<string>());
|
||||||
|
var wf = await SeedWorkflowForTypeAsync(
|
||||||
|
db, ApprovalWorkflowApplicableType.TravelRequest, "QT-TR-002", approver.Id);
|
||||||
|
|
||||||
|
var tr = BuildTravel(requester.Id, wf.Id);
|
||||||
|
tr.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
tr.CurrentApprovalLevelOrder = 1;
|
||||||
|
db.TravelRequests.Add(tr);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = async () => await new ApproveTravelRequestHandler(db, AsUser(outsider), clock)
|
||||||
|
.Handle(new ApproveTravelRequestCommand(tr.Id, "thử duyệt"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
|
||||||
|
tr.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
|
||||||
|
tr.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
(await db.TravelRequestLevelOpinions.CountAsync(o => o.TravelRequestId == tr.Id))
|
||||||
|
.Should().Be(0, "không tạo opinion khi bị chặn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Vehicle: Submit → Approve single-level → DaDuyet (happy path) ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VehicleBooking_Submit_Then_Approve_SingleLevel_ReachesDaDuyet()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-vb@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-vb@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var wf = await SeedWorkflowForTypeAsync(
|
||||||
|
db, ApprovalWorkflowApplicableType.VehicleBooking, "QT-VB-001", approver.Id);
|
||||||
|
|
||||||
|
var vb = BuildVehicle(requester.Id, wf.Id);
|
||||||
|
db.VehicleBookings.Add(vb);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await new SubmitVehicleBookingHandler(db, AsUser(requester), clock)
|
||||||
|
.Handle(new SubmitVehicleBookingCommand(vb.Id), CancellationToken.None);
|
||||||
|
vb.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet);
|
||||||
|
vb.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
vb.MaDonTu.Should().Be("DX/XE/2026/001", "prefix DX/XE + năm + seq D3");
|
||||||
|
|
||||||
|
await new ApproveVehicleBookingHandler(db, AsUser(approver), clock)
|
||||||
|
.Handle(new ApproveVehicleBookingCommand(vb.Id, null), CancellationToken.None);
|
||||||
|
vb.Status.Should().Be(WorkflowAppStatus.DaDuyet, "single-level → terminal");
|
||||||
|
vb.CurrentApprovalLevelOrder.Should().BeNull();
|
||||||
|
|
||||||
|
var opinions = await db.VehicleBookingLevelOpinions.Where(o => o.VehicleBookingId == vb.Id).ToListAsync();
|
||||||
|
opinions.Should().HaveCount(1);
|
||||||
|
opinions[0].Comment.Should().Be("(duyệt — không ý kiến)", "comment null → placeholder");
|
||||||
|
opinions[0].SignedByUserId.Should().Be(approver.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Vehicle: outsider (not approver, not Admin) → Forbidden, state unchanged ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VehicleBooking_Approve_OutsiderNonAdmin_ThrowsForbidden_StateUnchanged()
|
||||||
|
{
|
||||||
|
var (fix, db, clock) = NewCtx();
|
||||||
|
using (fix)
|
||||||
|
{
|
||||||
|
var requester = await fix.CreateUserAsync("req-vb2@test.local", "Requester", null, Array.Empty<string>());
|
||||||
|
var approver = await fix.CreateUserAsync("ap-vb2@test.local", "Approver", null, Array.Empty<string>());
|
||||||
|
var outsider = await fix.CreateUserAsync("out-vb2@test.local", "Outsider", null, Array.Empty<string>());
|
||||||
|
var wf = await SeedWorkflowForTypeAsync(
|
||||||
|
db, ApprovalWorkflowApplicableType.VehicleBooking, "QT-VB-002", approver.Id);
|
||||||
|
|
||||||
|
var vb = BuildVehicle(requester.Id, wf.Id);
|
||||||
|
vb.Status = WorkflowAppStatus.DaGuiDuyet;
|
||||||
|
vb.CurrentApprovalLevelOrder = 1;
|
||||||
|
db.VehicleBookings.Add(vb);
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var act = async () => await new ApproveVehicleBookingHandler(db, AsUser(outsider), clock)
|
||||||
|
.Handle(new ApproveVehicleBookingCommand(vb.Id, "thử duyệt"), CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<ForbiddenException>().WithMessage("*Không phải người duyệt*");
|
||||||
|
vb.Status.Should().Be(WorkflowAppStatus.DaGuiDuyet, "guard chặn trước mutate");
|
||||||
|
vb.CurrentApprovalLevelOrder.Should().Be(1);
|
||||||
|
(await db.VehicleBookingLevelOpinions.CountAsync(o => o.VehicleBookingId == vb.Id))
|
||||||
|
.Should().Be(0, "không tạo opinion khi bị chặn");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,144 @@
|
|||||||
|
using DocumentFormat.OpenXml;
|
||||||
|
using DocumentFormat.OpenXml.Packaging;
|
||||||
|
using DocumentFormat.OpenXml.Wordprocessing;
|
||||||
|
using SolutionErp.Infrastructure.Forms;
|
||||||
|
|
||||||
|
namespace SolutionErp.Infrastructure.Tests.Forms;
|
||||||
|
|
||||||
|
// S56 #6 (2026-06-09) — DocxRenderer.cs nullable-deref guard (CS8602 fix dòng :30,:40).
|
||||||
|
// Trước đây 0 test cho DocxRenderer (form-engine render path). Cover:
|
||||||
|
// - MainDocumentPart null → InvalidOperationException("Template .docx không có MainDocumentPart")
|
||||||
|
// (file .docx hợp lệ package nhưng KHÔNG có main document part — fail-closed message rõ).
|
||||||
|
// - Happy path: template có {{placeholder}} → render thay đúng + giữ text ngoài placeholder.
|
||||||
|
// - Placeholder không có key trong data → giữ nguyên literal {{...}} (không crash).
|
||||||
|
//
|
||||||
|
// DocxRenderer.RenderAsync đọc từ ĐĨA (File.ReadAllBytes) → test ghi file .docx tạm rồi xóa.
|
||||||
|
// OpenXml 3.5.1: WordprocessingDocument.Create(path, type) tạo package RỖNG (no MainDocumentPart);
|
||||||
|
// muốn happy path phải AddMainDocumentPart() + gán Document/Body.
|
||||||
|
public class DocxRendererTests
|
||||||
|
{
|
||||||
|
// Tạo file .docx tạm có MainDocumentPart + 1 paragraph chứa text cho trước. Trả path (caller xóa).
|
||||||
|
private static string WriteTemplateWithText(string paragraphText)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"se-docx-test-{Guid.NewGuid():N}.docx");
|
||||||
|
using (var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
|
||||||
|
{
|
||||||
|
var main = doc.AddMainDocumentPart();
|
||||||
|
main.Document = new Document(
|
||||||
|
new Body(
|
||||||
|
new Paragraph(
|
||||||
|
new Run(
|
||||||
|
new Text(paragraphText) { Space = SpaceProcessingModeValues.Preserve }))));
|
||||||
|
main.Document.Save();
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tạo file .docx tạm RỖNG (package hợp lệ nhưng KHÔNG AddMainDocumentPart) → MainDocumentPart == null.
|
||||||
|
private static string WriteTemplateWithoutMainPart()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"se-docx-nomain-{Guid.NewGuid():N}.docx");
|
||||||
|
using (WordprocessingDocument.Create(path, WordprocessingDocumentType.Document))
|
||||||
|
{
|
||||||
|
// Cố ý KHÔNG AddMainDocumentPart() — package zip hợp lệ, main part khuyết.
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Đọc lại bytes kết quả → combine toàn bộ <w:t> text để assert nội dung sau render.
|
||||||
|
private static string ExtractBodyText(byte[] docxBytes)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(docxBytes);
|
||||||
|
using var doc = WordprocessingDocument.Open(ms, isEditable: false);
|
||||||
|
var body = doc.MainDocumentPart?.Document?.Body;
|
||||||
|
body.Should().NotBeNull("kết quả render phải có Body hợp lệ để đọc text");
|
||||||
|
var texts = body!.Descendants<Text>().Select(t => t.Text);
|
||||||
|
return string.Concat(texts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenderAsync_MissingMainDocumentPart_ThrowsInvalidOperationWithClearMessage()
|
||||||
|
{
|
||||||
|
var path = WriteTemplateWithoutMainPart();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var renderer = new DocxRenderer();
|
||||||
|
var act = async () => await renderer.RenderAsync(
|
||||||
|
path,
|
||||||
|
new Dictionary<string, string?> { ["name"] = "X" },
|
||||||
|
"out.docx");
|
||||||
|
|
||||||
|
(await act.Should().ThrowAsync<InvalidOperationException>())
|
||||||
|
.WithMessage("*MainDocumentPart*", "guard fail-closed thay vì NullReferenceException mơ hồ");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenderAsync_ReplacesPlaceholder_WithProvidedValue()
|
||||||
|
{
|
||||||
|
var path = WriteTemplateWithText("Xin chào {{name}}, dự án {{project}}.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var renderer = new DocxRenderer();
|
||||||
|
var result = await renderer.RenderAsync(
|
||||||
|
path,
|
||||||
|
new Dictionary<string, string?> { ["name"] = "Anh Huy", ["project"] = "FLOCK" },
|
||||||
|
"ket-qua.docx");
|
||||||
|
|
||||||
|
result.FileName.Should().Be("ket-qua.docx");
|
||||||
|
result.ContentType.Should().Be(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
|
ExtractBodyText(result.Content).Should().Be("Xin chào Anh Huy, dự án FLOCK.",
|
||||||
|
"cả 2 placeholder được thay; text tĩnh giữ nguyên");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenderAsync_UnknownPlaceholder_KeepsLiteralToken_NoCrash()
|
||||||
|
{
|
||||||
|
var path = WriteTemplateWithText("Mã: {{maHopDong}} — {{khongCoKey}}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var renderer = new DocxRenderer();
|
||||||
|
var result = await renderer.RenderAsync(
|
||||||
|
path,
|
||||||
|
new Dictionary<string, string?> { ["maHopDong"] = "FLOCK/01/MB" },
|
||||||
|
"out.docx");
|
||||||
|
|
||||||
|
// Key có → thay; key thiếu → giữ literal {{khongCoKey}} (không ném, không rỗng hoá nhầm).
|
||||||
|
ExtractBodyText(result.Content).Should().Be("Mã: FLOCK/01/MB — {{khongCoKey}}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RenderAsync_NullDataValue_ReplacesWithEmptyString()
|
||||||
|
{
|
||||||
|
var path = WriteTemplateWithText("Ghi chú:{{note}}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var renderer = new DocxRenderer();
|
||||||
|
var result = await renderer.RenderAsync(
|
||||||
|
path,
|
||||||
|
new Dictionary<string, string?> { ["note"] = null },
|
||||||
|
"out.docx");
|
||||||
|
|
||||||
|
ExtractBodyText(result.Content).Should().Be("Ghi chú:", "value null → thay bằng chuỗi rỗng");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user