# Session 22 — 2026-05-13 18:00 — Plan D + C + E (Plan F ABORTED) **Dev:** Claude Opus 4.7 1M Max (em main solo — Implementer REFUSE cross-stack reasoning chain) **Duration:** ~2.5h (start session context load + 4 plan execute + finalize) **Base commit:** `3d725c4` (S21 chốt cuối) **Commits this session:** `60efeed` (D) → `dbda37e` (C task 4) → `215b1e0` (C task 1-3) → `f149661` (E) → this (Docs) ## Trigger Bro yêu cầu kick off 4 plan đồng thời: **Plan C + D + E + F** (test backlog + F2 toggle UI + phân quyền strict V2 + drop legacy V1). Em hỏi clarify 3 câu trước khi kick off: - Q1 thứ tự: **D→C→E→F** (an toàn → risk dần) - Q2 Plan F destructive: drop luôn không backup (UAT chấp nhận risk) — nhưng em vẫn pre-flight check phiếu V2 không pin V1 - Q3 Plan E scope: toàn bộ **List + Inbox + Detail** ## Plan D — User Management F2 toggle UI (`60efeed`) Wire FE UI cho `AllowDrafterSkipToFinal` (BE column từ Mig 29 đã sẵn). ### BE — UserFeatures.cs + UsersController.cs - `UserDto` +`AllowDrafterSkipToFinal bool` (cuối record) - `ListUsersQueryHandler` + `GetUserQueryHandler` populate field - `SetUserAllowDrafterSkipToFinalCommand` + Handler mới mirror `SetUserBypassReviewCommand` pattern - `UsersController.cs` +endpoint `PATCH /api/users/{id}/allow-skip-final` body `{allowDrafterSkipToFinal:bool}` Policy=`Users.Update` ### FE Admin — users.ts + UsersPage.tsx - `User` type +`allowDrafterSkipToFinal: boolean` - Column "Skip cuối" với `FastForward` icon violet badge - Action button toggle (`allowSkipMut`) — title đổi theo trạng thái - fe-user KHÔNG mirror (UsersPage admin-only theo PROJECT-MAP) ### Verify - `dotnet build SolutionErp.slnx` — 0 err, 2 warn pre-existing DocxRenderer - `npm run build fe-admin` — pass 638ms ## Plan C task 4 — Regression test #44 silent 403 (`dbda37e`) Test-before backlog HIGH §7 priority (S18 nợ — Drafter `nv.test` Workspace dropdown empty silent). ### File mới `tests/.../Api/AuthorizePolicyRegressionTests.cs` 5 reflection-based tests verify `ApprovalWorkflowsV2Controller` policy split: 1. Class-level `[Authorize]` only, NO Policy 2. GET Overview inherits class-level (no action-level Policy) 3. POST Create require `Policy="Workflows.Create"` 4. DELETE require `Policy="Workflows.Create"` 5. PATCH user-selectable require `Policy="Workflows.Create"` Pattern reusable cho future Authorize regression — catch nếu ai add Policy lên class-level hoặc GET action mà không qua UAT silent 403 reproduce. Add ProjectReference `SolutionErp.Api` → `SolutionErp.Infrastructure.Tests` cho reflection access Controller types. ### Verify - 84 → 89 PASS (+5 regression #44) ## Plan C task 1-3 — Service test catch-up S21 t4-t5 (`215b1e0`) 14 test cover 3 helper sửa lớn S21 t4-t5. ### `Services/PurchaseEvaluationWorkflowServiceReturnModeTests.cs` (7 test) Task 1 — ApplyReturnModeAsync per-NV (Mig 29): - Drafter mode allowed by Level → Phase=TraLai, clear pointer - Drafter mode denied by Level (non-admin) → ConflictException - OneLevel allowed by Level → curLevel 2 → 1 (peer review trong Step) - OneLevel denied by Level + Admin bypass → succeeds (admin override) Task 2 — skipToFinal per-Drafter (Mig 29): - Drafter `AllowDrafterSkipToFinal=true` → set pointer cuối Step + cuối Level - Drafter `AllowDrafterSkipToFinal=false` non-admin → ConflictException - Admin bypass user flag → succeeds ### `Application/PurchaseEvaluationDraftGuardTests.cs` (7 test) Task 3 — EnsureEditableForDetailsAsync F3 gating: - DraftScope DangSoanThao + any caller → return PE - DraftScope TraLai + any caller → return PE - ApproverScope ChoDuyet + flag on + actor match → return PE - ApproverScope ChoDuyet + flag off (non-admin) → ConflictException - ApproverScope ChoDuyet + flag on + actor mismatch → ForbiddenException - AdminBypass ChoDuyet + flag off → return PE - DaDuyet terminal phase + any caller (kể cả admin) → ConflictException ### Pattern infra - Helper `SeedWorkflowAsync` tạo 1 Bước (Step) × 2 Cấp (Levels) với mọi Allow* default false (admin opt-in pattern Mig 29). - Helper `SeedApproversAsync` create approver users qua `IdentityFixture.CreateUserAsync` để satisfy FK `ApproverUserId` constraint. - `DepartmentId = null` trên Step để skip FK Department. - `FakeCurrentUser` implement `ICurrentUser` minimal cho Guard tests. - `InternalsVisibleTo("SolutionErp.Infrastructure.Tests")` thêm vào `SolutionErp.Application.csproj` để test access `internal static class PurchaseEvaluationDraftGuard`. ### Finding (sub-optimal nhưng không scope catch-up) Service `skipToFinal` flow mutate `evaluation.Phase = ChoDuyet` TRƯỚC validate user flag. Throw chặn SaveChanges nên DB không persist nhưng in-memory entity dirty. Test phải relax secondary assertion `pe.Phase` → assert pointer chưa init thay vì rollback. Note trong test comment cho future refactor. ### Verify - 89 → 103 PASS (+14: 7 ReturnMode + 7 Guard) ## Plan E — Phân quyền strict V2 (`f149661`) Thắt chặt PE V2 List + Detail từ UAT loose sang strict actor.UserId scope (Inbox đã strict từ S17 — KHÔNG đụng). ### Change 1: `ListPurchaseEvaluationsQueryHandler` Trước (UAT loose): ```csharp q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase) || x.e.ApprovalWorkflowId != null); // V2 loose UAT ``` Sau (strict): ```csharp var userApprovalWfIds = await db.ApprovalWorkflowLevels.AsNoTracking() .Where(l => l.ApproverUserId == userId.Value) .Select(l => l.Step!.ApprovalWorkflowId) .Distinct() .ToListAsync(ct); q = q.Where(x => x.e.DrafterUserId == userId || eligiblePhases.Contains(x.e.Phase) || (x.e.ApprovalWorkflowId != null && userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value))); ``` ### Change 2: `GetPurchaseEvaluationQueryHandler` Trước: `var isPinnedV2 = e.ApprovalWorkflowId is not null` (loose). Sau: ```csharp var isV2Approver = false; if (e.ApprovalWorkflowId is Guid awIdForCheck && currentUser.UserId is Guid uidForCheck) { isV2Approver = await db.ApprovalWorkflowLevels.AsNoTracking() .AnyAsync(l => l.Step!.ApprovalWorkflowId == awIdForCheck && l.ApproverUserId == uidForCheck, ct); } if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isV2Approver) throw new ForbiddenException("Bạn không có quyền xem phiếu này."); ``` ### Test defer 4 integration test (List Drafter/V2 approver/non-approver throw 403 + Detail tương tự) — defer carry Plan C bundle khi UAT confirm strict scope stable. Hiện 103/103 PASS regression-free. ### Verify - `dotnet build SolutionErp.slnx` — 0 err, 2 warn pre-existing - `dotnet test SolutionErp.slnx` — 103/103 PASS regression-free ## Plan F ABORTED — Pre-flight FAIL Plan F (drop legacy V1 Mig 32) **ABORTED** vì pre-flight reveal blocking conditions. ### Pre-flight evidence (Prod DB qua SSH vietreport-vps) | Entity | Prod count | Pin status | |---|---:|---| | PurchaseEvaluations với `WorkflowDefinitionId` | **23** | V1 ref | | PurchaseEvaluations V1-ONLY (no V2 fallback) | **4** | mất workflow nếu drop | | Contracts với `WorkflowDefinitionId` | **7** | V1 | | Contracts với `ApprovalWorkflowId` | **error** | column không tồn tại — Contract V2 chưa wire | ### Blocking conditions 1. **Contract entity HOÀN TOÀN dùng V1** — `Contract.cs` không có property `ApprovalWorkflowId`. Plan B Contract V2 wire CHƯA kick off. Drop `WorkflowDefinitions` table sẽ BE crash startup (FK violation 7 contract). 2. **4 phiếu PE V1-only thật trong prod** — drop V1 = workflow data loss. 3. **19 phiếu PE V1+V2 mix carry V1 ref** — drop column = NULL data, loose audit. ### Decision ABORT Plan F. Defer S22+ sau Plan B Contract V2 wire (Mig 30+31). Order đúng: 1. Plan B Contract V2 wire (Mig 30+31) 2. Migrate 4 phiếu PE V1-only → V2 (admin script) 3. UAT V2 cả 2 module (PE + Contract) 2-3 tuần 4. THEN Plan F drop legacy V1 (Mig 32) User confirm via AskUserQuestion (4 option) — answer trống → em apply Recommended default ABORT. ## Stats cumulative S22 | Metric | Trước (S21 chốt) | Sau (S22) | Δ | |---|---|---|---| | DB tables | 59 | 59 | 0 | | Migrations | 29 | 29 | 0 | | Endpoints | ~143 | **~144** | +1 (PATCH /users/{id}/allow-skip-final) | | FE pages | 34 | 34 | 0 (UsersPage extend column) | | **Unit tests** | 84 | **103** | **+19** (+5 reg #44 + 7 ReturnMode + 7 Guard) | | Gotchas | 46 | 46 | 0 | | Memory entries | 19 | 19 | 0 | | Skills | 6 | 6 | 0 | | Sub-agents | 4 (3 seeds + cicd) | 4 same | 0 (em main solo) | | Commits S22 | — | **5** | D + C×2 + E + Docs | ## Lessons learned 1. **Pre-flight prod check BẮT BUỘC cho destructive migration.** SSH sqlcmd verify data state TRƯỚC khi drop schema. Plan F pre-flight catch 3 blocking conditions (Contract V1-only + 4 PE V1-only + 19 PE mix) — tránh BE crash startup prod nếu blindly drop. 2. **Schema dependency check toàn bộ entity.** Plan F focus drop PE V1 nhưng Contract entity cùng V1 schema → liên đới. Audit entity scope `ApprovalWorkflowId` presence trên Domain trước decision. 3. **Test infra reusable Cookie-cutter.** Helper `SeedWorkflowAsync` + `SeedApproversAsync` pattern dùng cho cả ReturnMode + Guard tests. Future Service test PE/Contract V2 reuse được. Tổng test catch-up 14 test trong ~1.5h, ROI clear. 4. **InternalsVisibleTo cleaner than make public.** Khi cần test internal helper, dùng InternalsVisibleTo trong csproj thay vì rewrite `internal → public` — KHÔNG thay đổi public API surface, vẫn chặn external use. 5. **Reflection-based regression test cho Authorize policy.** Pattern lightweight (5 test ~50 LOC) catch silent 403 regression mà không cần WebApplicationFactory heavy. ROI cực cao cho project có nhiều controller. ## Handoff - ✅ Plan D `60efeed` BE+FE Admin User F2 toggle - ✅ Plan C task 4 `dbda37e` 5 reflection test #44 silent 403 - ✅ Plan C task 1-3 `215b1e0` 14 test ReturnMode + Guard - ✅ Plan E `f149661` BE strict V2 scope List + Detail - ⛔ Plan F ABORTED (pre-flight FAIL — defer sau Plan B Contract V2) - ⏭ **PENDING bro confirm push remote** — `git push origin main` 5 commit ahead `3d725c4..HEAD` User next action expected: Plan B Contract V2 wire (Mig 30+31) là priority cao nhất sau S22 — mở đường cho Plan F drop legacy V1 sau UAT 2-3 tuần. ## References - BE feature: `src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs` (List + Get strict scope) - BE User: `src/Backend/SolutionErp.Application/Users/UserFeatures.cs` + `SolutionErp.Api/Controllers/UsersController.cs` - FE Admin: `fe-admin/src/types/users.ts` + `fe-admin/src/pages/system/UsersPage.tsx` - Tests: `tests/SolutionErp.Infrastructure.Tests/{Api,Services,Application}/` 3 file mới - Csproj: `src/Backend/SolutionErp.Application/SolutionErp.Application.csproj` +InternalsVisibleTo - Pre-flight: `ssh vietreport-vps "sqlcmd -S .\SQLEXPRESS -d SolutionErp -E -Q \"...\""` - Rules: §3.9 mirror 2 FE, §6.5 KEEP narrative, §7 test timing, `feedback_uat_skip_verify`, `feedback_per_chunk_commit`, `feedback_per_nv_permission_scope`