Compare commits
5 Commits
3d725c42f7
...
a74e671431
| Author | SHA1 | Date | |
|---|---|---|---|
| a74e671431 | |||
| f149661d36 | |||
| 215b1e036a | |||
| dbda37eb30 | |||
| 60efeeda63 |
@ -121,7 +121,7 @@ const isValidEmail = (s: string) => !s || EMAIL_RE.test(s)
|
||||
|
||||
- **BE .NET 10:** PascalCase tiếng Anh entities + DTO records + command names. CQRS + MediatR + FluentValidation + AutoMapper. Repository qua `IApplicationDbContext`. `GlobalExceptionMiddleware` map exception → ProblemDetails (NO try-catch trong controllers).
|
||||
- **FE React 19 + Vite 8 + TS 6:** Named export only (trừ App). TanStack Query. shadcn/ui copy-paste. TS6 `erasableSyntaxOnly` cấm `enum` → const-object pattern. UI 100% tiếng Việt. Mirror 2 app rule §3.9.
|
||||
- **Test:** baseline 81/81 PASS (58 Domain + 23 Infra). Phase 9 UAT skip per chunk theo memory `feedback_uat_skip_verify`. Stack xUnit + FluentAssertions 7.2 + EF SQLite 10 `TestApplicationDbContext` override `nvarchar(max) → TEXT`.
|
||||
- **Test:** baseline 84/84 PASS (58 Domain + 26 Infra: 23 baseline + 3 PE WF guard regression S21 t3 gotcha #45). Phase 9 UAT skip per chunk theo memory `feedback_uat_skip_verify`. Stack xUnit + FluentAssertions 7.2 + EF SQLite 10 `TestApplicationDbContext` override `nvarchar(max) → TEXT`.
|
||||
- **Build:** `dotnet build SolutionErp.slnx` clean 0 err + `npm run build` × 2 app pass.
|
||||
- **Commit:** `[CLAUDE] <scope>: <message>` + Co-Authored-By Claude Opus 4.7 (1M context).
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ Common queries: `sys.columns`, `sys.triggers`, `__EFMigrationsHistory`, `COUNT(*
|
||||
- Grep `// Mock` / `alert(` / `setEditing(null) // close UI` — wire claim bugs
|
||||
|
||||
### Pattern: Memory cross-reference
|
||||
16 memory entries tại `C:\Users\pqhuy\.claude\projects\D--Dropbox-CONG-VIEC-SOLUTION\memory\` (S20 +2 turn 11/12):
|
||||
19 memory entries tại `C:\Users\pqhuy\.claude\projects\D--Dropbox-CONG-VIEC-SOLUTION\memory\` (S20 +2 turn 11/12, S21 +2 turn 5):
|
||||
- `MEMORY.md` — index
|
||||
- `project_solution_erp.md` — cumulative narrative S1-S17
|
||||
- `feedback_per_chunk_commit.md` — 5-chunk A-E discipline
|
||||
@ -59,7 +59,10 @@ Common queries: `sys.columns`, `sys.triggers`, `__EFMigrationsHistory`, `COUNT(*
|
||||
- `feedback_user_manual_style.md` — non-tech docs style
|
||||
- `feedback_node_cicd.md` — Node 20.x pin
|
||||
- `feedback_responsive_laptop_breakpoint.md` — 4-tầng responsive pattern (S20 t11)
|
||||
- `feedback_multi_agent_setup.md` — 3 sub-agents setup discipline (S20 t12)
|
||||
- `feedback_multi_agent_setup.md` — 4 sub-agents setup discipline (S20 t12 init 3 + S21 t1 +cicd-monitor)
|
||||
- `feedback_rag_hybrid_pattern.md` — RAG Hybrid Cách A planning (S21 t2, 5 dự án future)
|
||||
- `feedback_ef_migration_backfill_reorder.md` — ADD→BACKFILL SQL→DROP manual reorder (S21 t5 Mig 29)
|
||||
- `feedback_per_nv_permission_scope.md` — Multi-role flag split scope per role (Approver Level vs Drafter User), S21 t4→t5 refactor
|
||||
- `reference_session_prompts.md` — canonical session start template
|
||||
|
||||
### Pattern: External research priority sources
|
||||
@ -83,15 +86,16 @@ Common queries: `sys.columns`, `sys.triggers`, `__EFMigrationsHistory`, `COUNT(*
|
||||
|
||||
## 🧠 SOLUTION_ERP context essentials (auto-load)
|
||||
|
||||
- **DB Dev:** `SolutionErp_Dev` LocalDB (59 tables / 27 migrations / Mig 27 latest `AddVisibilityAndDisplayLabelToMenuItems`)
|
||||
- **DB Dev:** `SolutionErp_Dev` LocalDB (59 tables / 29 migrations / Mig 29 latest `RefactorAdvancedOptionsToPerLevelAndDrafterUser`)
|
||||
- **DB Design:** `SolutionErp_Design` (ef tooling distinct)
|
||||
- **DB Prod:** `.\SQLEXPRESS` / `SolutionErp` / `vrapp` user via SSH `vietreport-vps`
|
||||
- **DB Prod:** `.\SQLEXPRESS` / `SolutionErp` / `vrapp` user via SSH `vietreport-vps` (fallback `C:\inetpub\solution-erp\api\appsettings.Production.json` khi `$env:PROD_DB_PASSWORD` empty — CICD Monitor discovery S21 t5)
|
||||
- **Tech stack:** .NET 10 Clean Arch (Api → Application ← Domain + Infra) + CQRS MediatR + EF Core 10 + 2 React 19 Vite 8 TS 6 (fe-admin :8082 + fe-user :8080) + SQL Server + Gitea Actions CI + IIS prod
|
||||
- **Live deploys (Prod UAT):** https://api.solutions.com.vn · https://admin.solutions.com.vn · https://eoffice.solutions.com.vn
|
||||
- **Gitea remote:** https://git.baocaogiaoduc.vn/vietreport-admin/solution-erp
|
||||
- **Gitea Actions API:** path `/api/v1/repos/.../actions/tasks` (NOT `/actions/runs` — 404). Cache stale ~2 min (gotcha #46) — cross-check VPS file mtime
|
||||
- **SSH VPS:** `ssh vietreport-vps` (config `~/.ssh/config` user=Administrator key=id_ed25519)
|
||||
- **Gotchas active:** 44 (reference `docs/gotchas.md`)
|
||||
- **Tests baseline:** 81 PASS (58 Domain + 23 Infra) — Phase 9 UAT skip per chunk (memory `feedback_uat_skip_verify`)
|
||||
- **Gotchas active:** 46 (reference `docs/gotchas.md`)
|
||||
- **Tests baseline:** 84 PASS (58 Domain + 26 Infra) — Phase 9 UAT skip per chunk (memory `feedback_uat_skip_verify`)
|
||||
- **Master HEAD reference:** check via `git log -1 --format='%H'`
|
||||
- **6 skills:** `contract-workflow` · `permission-matrix` · `form-engine` · `ef-core-migration` · `dependency-audit-erp` · `iis-deploy-runbook`
|
||||
|
||||
@ -100,10 +104,22 @@ Common queries: `sys.columns`, `sys.triggers`, `__EFMigrationsHistory`, `COUNT(*
|
||||
## 🔄 Active workflow schemas (V1 + V2 coexist post-Session 17)
|
||||
|
||||
- **V1 Mig 21 flat workflow** — `WorkflowDefinition` pin với PE/Contract cũ. Match Dept+PositionLevel.
|
||||
- **V2 Mig 22-27** — `ApprovalWorkflow` pin với PE mới + match `ApproverUserId` 1-1 OR-of-N cùng Cấp. Steps (Phòng) > Levels (Cấp). PE đã wire V2. Contract V2 PENDING Session 21+.
|
||||
- **V2 Mig 22-29** — `ApprovalWorkflow` pin với PE mới + match `ApproverUserId` 1-1 OR-of-N cùng Cấp. Steps (Phòng) > Levels (Cấp). PE đã wire V2. Contract V2 PENDING (Plan B).
|
||||
- **Mig 25** IsUserSelectable (admin pin/unpin per workflow cho user pick)
|
||||
- **Mig 26** PE Level Opinions UPSERT (service hook khi Duyệt)
|
||||
- **Mig 28** (S21 t4) 6 Allow* workflow-level — **REPLACED by Mig 29**
|
||||
- **Mig 29** (S21 t5) Allow* refactor per-NV: 5 flag on `ApprovalWorkflowLevels` (F1+F3 per Approver slot) + 1 flag on `Users.AllowDrafterSkipToFinal` (F2 per Drafter)
|
||||
|
||||
State machine 5 trạng thái phiếu PE: Nháp / Đã gửi duyệt / **Trả lại (TraLai=98)** / Từ chối / Đã duyệt.
|
||||
|
||||
**Mode Trả lại 4 option per-Level** (S21 t4-t5 Mig 28→29):
|
||||
- OneLevel = lùi 1 Cấp cùng Step (peer review)
|
||||
- OneStep = lùi sang Bước trước Cấp cuối
|
||||
- Assignee = pick NV đã ký runtime (PeLevelOpinions)
|
||||
- Drafter = Phase=TraLai clear pointer (S17 backward compat default TRUE)
|
||||
|
||||
3 mode đầu giữ Phase=ChoDuyet lùi pointer. Mode Drafter giữ Phase=TraLai. Admin bypass `level.Allow*` flag.
|
||||
|
||||
---
|
||||
|
||||
## 📅 Recent activity (last 10 FIFO)
|
||||
|
||||
@ -107,8 +107,10 @@ Per Cognition documented research:
|
||||
|
||||
## 🧠 SOLUTION_ERP review essentials
|
||||
|
||||
- **Tests baseline:** 81/81 PASS (must increase nếu feature added per §7; UAT iteration exception per memory)
|
||||
- **Gotchas:** 44 active (`docs/gotchas.md` reference)
|
||||
- **Tests baseline:** 84/84 PASS (58 Domain + 26 Infra — +3 regression PE WF guard S21 t3 gotcha #45). Must increase nếu feature added per §7; UAT iteration exception per memory `feedback_uat_skip_verify` (Plan C test-after bundle defer)
|
||||
- **Gotchas:** 46 active (`docs/gotchas.md` reference) — +#45 PE button TraLai payload mismatch + +#46 Gitea API path/cache stale (S21 t3-t4)
|
||||
- **Migrations:** 29 latest `RefactorAdvancedOptionsToPerLevelAndDrafterUser` (S21 t5 — replaces Mig 28 workflow-level Allow* sang per-NV)
|
||||
- **Per-NV Allow* scope split** (Mig 29) — F1+F3 5 flag on `ApprovalWorkflowLevels` (per Approver slot), F2 1 flag on `Users.AllowDrafterSkipToFinal` (per Drafter). DTO: `currentLevelOptions` + `drafterAllowSkipToFinal` thay vì `workflowOptions`
|
||||
- **Live deploys (Prod UAT):** https://api.solutions.com.vn · https://admin.solutions.com.vn · https://eoffice.solutions.com.vn
|
||||
- **Bearer token test:**
|
||||
- Admin: `admin@solutions.com.vn / Admin@123456` (full quyền)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# HANDOFF — Brief 5 phút cho session tiếp theo
|
||||
|
||||
**Last updated:** 2026-05-13 1530 (Session 21 CHỐT CUỐI — 5 turn cumulative `3a34831..c0af9e0` 12 commits pushed remote, CICD Monitor verify 2/2 run PASS. Gotcha #46 mới (Gitea API path/cache stale). 2 memory user-level mới: `feedback_ef_migration_backfill_reorder` + `feedback_per_nv_permission_scope`. 3 agent Inv/Imp/Rev cập nhật MEMORY ghi recent activity S21 t3-t5 em main solo. State final: **29 mig · 84 test · 46 gotcha · 19 memory · 6 skills · 4 sub-agents (3 seeds + 1 cicd 2-run)**. KHÔNG còn pending push. Plan G Trial Week 1 evidence: CICD spawn 2/2 PASS (green), cost ~110-120K under 150K budget, CI time 3-3.5min stable. Pending Plan C test-after bundle defer sau UAT 2-3 lần ổn.)
|
||||
**Last updated:** 2026-05-13 1800 (Session 22 — Plan C + D + E done, Plan F ABORTED pre-flight fail. 5 commits local `60efeed..HEAD` chưa push. Plan D F2 toggle UI (BE+FE Admin). Plan C task 4 + 1-3 — 19 unit test mới (+5 reg #44 + 7 ReturnMode + 7 Guard). Plan E strict V2 scope List + Detail (remove UAT loose `|| ApprovalWorkflowId != null`). Plan F ABORTED — pre-flight prod sqlcmd reveal Contract entity HOÀN TOÀN V1 chưa wire V2 + 4 PE V1-only + 23 PE V1+V2 mix. Defer F sau Plan B Contract V2 wire. State: **29 mig · 103 test (+19) · 46 gotcha · 19 memory · 6 skills · 4 sub-agents (em main solo S22)**. 5 commits pending push `3d725c4..HEAD`.)
|
||||
**S21 CHỐT CUỐI:** 2026-05-13 1530 (Session 21 CHỐT CUỐI — 5 turn cumulative `3a34831..c0af9e0` 12 commits pushed remote, CICD Monitor verify 2/2 run PASS. Gotcha #46 mới (Gitea API path/cache stale). 2 memory user-level mới: `feedback_ef_migration_backfill_reorder` + `feedback_per_nv_permission_scope`. 3 agent Inv/Imp/Rev cập nhật MEMORY ghi recent activity S21 t3-t5 em main solo. State final: **29 mig · 84 test · 46 gotcha · 19 memory · 6 skills · 4 sub-agents (3 seeds + 1 cicd 2-run)**. KHÔNG còn pending push. Plan G Trial Week 1 evidence: CICD spawn 2/2 PASS (green), cost ~110-120K under 150K budget, CI time 3-3.5min stable. Pending Plan C test-after bundle defer sau UAT 2-3 lần ổn.)
|
||||
**S21 turn 5:** 2026-05-13 1400 (Session 21 turn 5 — **🎯 Refactor Allow* sang PER-NV (Mig 29). 4 chunk per-commit `0366946` (A BE+Mig 29) → `63234b2` (B FE Admin Designer per-Level 5 checkbox) → `5ccb2a7` (C FE eOffice mirror 2 app rename) → this Chunk D Docs. **F1+F3** 5 flag MOVED xuống `ApprovalWorkflowLevels` (per slot Approver). **F2** MOVED xuống `Users` (per-Drafter). Mig 29 4-stage: ADD 5 Levels + 1 Users + BACKFILL bulk SQL preserve admin config S21 t4 + DROP 6 workflow column. Service refactor đọc `currentLevel.Allow*` + `drafterUser.AllowDrafterSkipToFinal`. DTO `AwLevelDto +5`, `PeDetailBundle.workflowOptions → currentLevelOptions + drafterAllowSkipToFinal`. FE Admin Designer 5 checkbox per Level slot inline (drop section workflow-level). 84 test PASS. CHƯA push remote — chờ bro confirm.**)
|
||||
**S21 turn 4:** 2026-05-13 1200 (Session 21 turn 4 — **🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693` (A schema) → `c56024b` (B BE) → `a508564` (C FE Admin) → `d27caaf` (D FE eOffice) → this (E Docs). **F1** 4 mode Trả lại admin tick stick (1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo) — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai clear pointer (S17 backward compat). **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel.ApproverUserId + audit ghi PurchaseEvaluationChangelog. Mig 28 thêm 6 bit column lên `ApprovalWorkflows` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service extend signature 3 optional param (returnMode/returnTargetUserId/skipToFinal). Helper `EnsureEditableForDetailsAsync` mới gating Detail/Quote/Supplier CRUD theo Drafter scope OR F3 Approver scope + audit changelog Update/Delete (trước đây silent). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app. UAT mode skip dotnet test mỗi chunk, npm build × 2 app pass mỗi chunk. CHƯA push remote — chờ bro confirm.**)
|
||||
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🔴 BUG FIX CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45 mới). 3 chunk per-commit: `de00887` (BE Chunk A guard + 3 test) + `4b29d00` (FE Chunk B fix 2 app mirror) + this Chunk C Docs. Root: `PeWorkflowPanel.tsx` `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE skip Reject branch → enter APPROVE STEP → `ApproveV2Async` UPSERT opinion "đã duyệt" + advance Cấp tiếp theo. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai `'✓ Duyệt → Trả lại'` + KHÔNG amber warning. Severity CRITICAL — data integrity issue khó rollback (BE đã `SaveChangesAsync`). Test-before §7 BẮT BUỘC: viết test reproduce → confirm FAIL (BE đi sâu vào ApproveV2Async throw "Phiếu chưa pin workflow") → thêm BE guard early throw ConflictException khi `target ∈ {TraLai, TuChoi} && decision != Reject` → confirm PASS. 3 regression test (Throws TraLai+Approve, Throws TuChoi+Approve consistency, happy path Reject+TraLai). Tổng `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3 from 81 baseline). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test (+3)** · **45 gotcha (+1 #45)** · 17 memory · 6 skills · 4 sub-agents seeds-only. Em main solo S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule (decision tree: tightly coupled BE+FE+test). CHƯA push remote — chờ bro confirm sau Chunk C wrap.**)
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
|
||||
> **Update rule:** trước khi bắt đầu 1 task → ghi row vào `🔥 In Progress`. Xong → chuyển sang `✅ Recently Done`.
|
||||
|
||||
**Last updated:** 2026-05-13 1530 (Session 21 chốt cuối — 5 turn cumulative pushed remote, CICD verified PASS 2/2 run, 1 gotcha mới #46 Gitea API path, 2 memory entry mới — `feedback_ef_migration_backfill_reorder` + `feedback_per_nv_permission_scope`. Session 21 5 turn timeline: t1 cicd-monitor add → t2 RAG planning Cách A → t3 fix gotcha #45 → t4 F1+F2+F3 Mig 28 → t5 refactor Allow* per-NV Mig 29. Stats final: 29 mig · 59 tables · ~143 endpoints · 34 FE pages · **84 test pass** · **46 gotcha (+2 từ S20: #45 PE button mismatch + #46 Gitea API)** · **19 memory entries (+3 từ S20: RAG + EF backfill + per-NV scope)** · 6 skills · 4 sub-agents (3 seeds-only + 1 cicd-monitor 2 runs PASS). 12 commits pushed `3a34831..c0af9e0`. Plan G Trial Week 1 evidence: CICD Monitor catch 0 fail vì green 2/2 run, cost 110-120K/run under 150K budget, 3-3.5min CI time stable. KHÔNG còn pending push. UAT mode skip dotnet test mỗi chunk, test-after Plan C bundle cho tất cả turn 3-5 sau UAT 2-3 lần ổn.)
|
||||
**Last updated:** 2026-05-13 1800 (Session 22 — Plan C + D + E done, Plan F ABORTED pre-flight fail. 5 commits local `60efeed`→`dbda37e`→`215b1e0`→`f149661`→Docs. Plan D BE+FE Admin User F2 toggle UI (PATCH /users/{id}/allow-skip-final + UsersPage column "Skip cuối" violet badge). Plan C task 4 5 reflection regression test gotcha #44 silent 403 ApprovalWorkflowsV2Controller policy split (catch class-level Policy regression). Plan C task 1-3 14 service test catch-up — ApplyReturnMode 4 mode per-NV Mig 29 + skipToFinal per-Drafter + EnsureEditableForDetails F3 gating. Plan E strict V2 scope List + Detail (remove UAT loose `|| ApprovalWorkflowId != null`, replace with V2 approver actor.UserId scope via ApprovalWorkflowLevels join). Plan F ABORTED — pre-flight prod sqlcmd reveal 23 PE + 7 Contract pin V1 + Contract entity HOÀN TOÀN V1 chưa wire V2 → drop V1 BE crash startup. Defer F sau Plan B Contract V2 + migrate 4 V1-only PE + UAT 2-3 tuần. Stats S22: 29 mig (0) · 59 tables · ~144 endpoints (+1) · 34 FE pages · **103 test pass (+19: 5 reg #44 + 7 ReturnMode + 7 Guard)** · 46 gotcha · 19 memory · 6 skills · 4 sub-agents (em main solo). Pattern reusable: SeedWorkflowAsync + SeedApproversAsync helper test infra + reflection Authorize regression test + pre-flight prod check pattern. CHƯA push remote — chờ bro confirm.)
|
||||
**S21 chốt cuối:** 2026-05-13 1530 (Session 21 chốt cuối — 5 turn cumulative pushed remote, CICD verified PASS 2/2 run, 1 gotcha mới #46 Gitea API path, 2 memory entry mới — `feedback_ef_migration_backfill_reorder` + `feedback_per_nv_permission_scope`. Session 21 5 turn timeline: t1 cicd-monitor add → t2 RAG planning Cách A → t3 fix gotcha #45 → t4 F1+F2+F3 Mig 28 → t5 refactor Allow* per-NV Mig 29. Stats final: 29 mig · 59 tables · ~143 endpoints · 34 FE pages · **84 test pass** · **46 gotcha (+2 từ S20: #45 PE button mismatch + #46 Gitea API)** · **19 memory entries (+3 từ S20: RAG + EF backfill + per-NV scope)** · 6 skills · 4 sub-agents (3 seeds-only + 1 cicd-monitor 2 runs PASS). 12 commits pushed `3a34831..c0af9e0`. Plan G Trial Week 1 evidence: CICD Monitor catch 0 fail vì green 2/2 run, cost 110-120K/run under 150K budget, 3-3.5min CI time stable. KHÔNG còn pending push. UAT mode skip dotnet test mỗi chunk, test-after Plan C bundle cho tất cả turn 3-5 sau UAT 2-3 lần ổn.)
|
||||
**S21 turn 5:** 2026-05-13 1400 (Session 21 turn 5 — **🎯 Refactor F1+F2+F3 sang PER-NV (Mig 29 drop Mig 28 column workflow-level). 4 chunk per-commit `0366946` (A BE schema+Service refactor) → `63234b2` (B FE Admin Designer 5 checkbox per-Level row) → `5ccb2a7` (C FE eOffice rename currentLevelOptions + drafterAllowSkipToFinal) → this Chunk D Docs. **F1+F3** 5 flag MOVED xuống `ApprovalWorkflowLevels` (per slot Approver, mỗi NV có riêng quyền duyệt). **F2** AllowDrafterSkipToFinal MOVED xuống `Users` (per-Drafter user, admin config ở User Management). Mig 29 4-stage: ADD 5 column Levels + 1 column Users + BACKFILL bulk SQL (copy workflow → all Levels, set TRUE cho Drafter user nào từng dùng workflow Allow) + DROP 6 column workflow `ApprovalWorkflows`. Service `ApplyReturnModeAsync` refactor đọc `currentLevel.AllowXxx` thay vì `workflow.AllowXxx`. Helper `EnsureEditableForDetailsAsync` read `level.AllowApproverEditDetails`. DRAFTER trình branch read `userManager.FindByIdAsync(actorUserId).AllowDrafterSkipToFinal`. DTO refactor: `AwLevelDto +5 Allow*`, `AwDefinitionDto -6 Allow*`, `CreateAwLevelInput +5 Allow*`, `PeDetailBundle.workflowOptions → currentLevelOptions + drafterAllowSkipToFinal`. FE Admin Designer drop section "Cấu hình nâng cao" workflow-level, replace 5 checkbox grid-cols-2 inline mỗi Level entry row (5 flag per slot). FE eOffice rename `wfOptions → levelOptions` đọc `currentLevelOptions`. Backward compat: backfill preserve admin config S21 t4. Test 84/84 PASS unchanged. Stats: **29 mig (+1) · 59 tables · ~143 endpoints · 34 FE pages · 84 test pass · 45 gotcha · 17 memory · 6 skills · 4 sub-agents seeds-only.**)
|
||||
**S21 turn 4:** 2026-05-13 1200 (Session 21 turn 4 — **🎯 F1+F2+F3 PE Workflow advanced options (Mig 28) — 5 chunk per-commit `0294693`→`c56024b`→`a508564`→`d27caaf`→this Chunk E Docs. **F1** 4 mode Trả lại admin tick: "1 Cấp / 1 Bước / Người chỉ định / Người soạn thảo" — 3 mode đầu giữ Phase=ChoDuyet lùi pointer (peer review chain), mode Drafter giữ Phase=TraLai S17 fallback. **F2** Drafter skip thẳng Cấp cuối — workflow tick + Workspace checkbox dynamic confirm. **F3** Approver edit Section 2 (Hạng mục/NCC/Báo giá) khi workflow tick + actor match CurrentLevel + audit ghi PurchaseEvaluationChangelog. Mig 28 `ApprovalWorkflows +6 bool Allow*` (DEFAULT 1 cho AllowReturnToDrafter backward compat, 5 còn lại 0). BE Service `TransitionAsync` extend 3 optional param (returnMode/returnTargetUserId/skipToFinal) + helper `ApplyReturnModeAsync` switch 4 mode. Detail/Quote/Supplier helper `EnsureEditableForDetailsAsync` mới (kế thừa `EnsureDraftAsync` + add ChoDuyet+F3 branch + Admin bypass). FE Admin Designer "Cấu hình nâng cao" section 6 checkbox 3 group. FE eOffice 3 changes mirror 2 app: Trả lại radio picker 1-4 mode + Workspace skip checkbox violet + Section 2 itemsReadOnly approver banner. UAT mode skip dotnet test mỗi chunk (per `feedback_uat_skip_verify`), `npm run build` × 2 app pass mỗi chunk. Stats: **28 mig (+1)** · 59 tables · **~143 endpoints (+1 user-selectable patch existed)** · **34 FE pages (+1 Designer section)** · **84 test pass unchanged** (UAT defer test-after) · **45 gotcha unchanged** · 17 memory · 6 skills · 4 sub-agents seeds-only.**)
|
||||
**S21 turn 3:** 2026-05-12 2100 (Session 21 turn 3 — **🎯 Bug fix CRITICAL "Trả về nhưng hệ thống vẫn duyệt" PE workflow (gotcha #45). 2 chunk per-commit `de00887` (BE Chunk A) + `4b29d00` (FE Chunk B) + Chunk C Docs this. Root: PeWorkflowPanel.tsx `isReject` payload (L64-66) thiếu nhánh TraLai → button "← Trả lại" gửi `decision: 1` (Approve) thay vì `2` (Reject) khi target=TraLai(98) → BE ApproveV2Async UPSERT opinion "đã duyệt" + advance Cấp. Inconsistency phụ: dialog `isSendBack` (L247-248) cùng pattern thiếu TraLai → dialog title sai. Fix BE defense-in-depth + FE 3 chỗ × 2 app mirror rule §3.9. Test-before §7 BẮT BUỘC: 3 regression test mới (2 reproduce bug + 1 happy path control) — `dotnet test SolutionErp.slnx` 84 PASS (58 Domain + 26 Infra = +3). `npm run build` × 2 app pass. Stats: 27 mig (no change) · 59 tables · ~142 endpoints · 34 FE pages · **84 test pass (+3)** · **45 gotcha (+1 #45)** · 17 memory entries (no new) · 6 skills. Em main solo (no sub-agent spawn S21 t3 — bug fix reasoning chain cross BE/FE Implementer REFUSE per multi-agent rule).**)
|
||||
|
||||
@ -0,0 +1,231 @@
|
||||
# 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`
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck } from 'lucide-react'
|
||||
import { Building2, KeyRound, Pencil, Plus, Shield, Unlock, Users, CheckCircle2, XCircle, ShieldCheck, FastForward } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { PageHeader } from '@/components/PageHeader'
|
||||
import { DataTable, Pagination, type Column } from '@/components/DataTable'
|
||||
@ -175,6 +175,19 @@ export function UsersPage() {
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
// F2 per-Drafter (Mig 29): toggle AllowDrafterSkipToFinal. Khi true, Drafter
|
||||
// được tick "Gửi thẳng Cấp cuối" trong PE Workspace để skip Bước/Cấp trung
|
||||
// gian và bay thẳng tới Cấp cuối workflow.
|
||||
const allowSkipMut = useMutation({
|
||||
mutationFn: (u: User) =>
|
||||
api.patch(`/users/${u.id}/allow-skip-final`, { allowDrafterSkipToFinal: !u.allowDrafterSkipToFinal }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['users'] })
|
||||
toast.success('Đã cập nhật quyền gửi thẳng Cấp cuối')
|
||||
},
|
||||
onError: err => toast.error(getErrorMessage(err)),
|
||||
})
|
||||
|
||||
function nextPositionLevel(current: number | null): number | null {
|
||||
// Cycle null → 1 (NV) → 2 (PP) → 3 (TP) → null
|
||||
if (current == null) return 1
|
||||
@ -289,6 +302,21 @@ export function UsersPage() {
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'allowDrafterSkipToFinal',
|
||||
header: 'Skip cuối',
|
||||
width: 'w-20',
|
||||
align: 'center',
|
||||
render: u =>
|
||||
u.allowDrafterSkipToFinal ? (
|
||||
<span title="Drafter được gửi PE thẳng Cấp cuối workflow (skip Bước/Cấp trung gian)" className="inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] text-violet-700">
|
||||
<FastForward className="h-3 w-3" />
|
||||
skip
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-400">—</span>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', header: 'Ngày tạo', width: 'w-24', render: u => fmtDate(u.createdAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
@ -334,6 +362,14 @@ export function UsersPage() {
|
||||
{u.positionLevel != null ? PositionLevelShort[u.positionLevel] : '—'}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => allowSkipMut.mutate(u)}
|
||||
title={u.allowDrafterSkipToFinal ? 'Tắt quyền skip — Drafter phải tuần tự qua mọi Bước/Cấp' : 'Bật quyền skip — Drafter được gửi PE thẳng Cấp cuối workflow'}
|
||||
>
|
||||
<FastForward className={`h-3.5 w-3.5 ${u.allowDrafterSkipToFinal ? 'text-violet-600' : 'text-slate-400'}`} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggleActiveMut.mutate(u)} title={u.isActive ? 'Vô hiệu hóa' : 'Kích hoạt'}>
|
||||
{u.isActive ? <XCircle className="h-3.5 w-3.5 text-red-500" /> : <CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />}
|
||||
</Button>
|
||||
|
||||
@ -11,6 +11,7 @@ export type User = {
|
||||
position: string | null
|
||||
canBypassReview: boolean
|
||||
positionLevel: number | null // Mig 18 — 1=NV, 2=PP, 3=TP, null=admin/external
|
||||
allowDrafterSkipToFinal: boolean // Mig 29 — F2: Drafter được gửi thẳng Cấp cuối workflow PE
|
||||
}
|
||||
|
||||
// Cấp chức danh trong phòng (Mig 18) — phục vụ N-stage workflow inner step.
|
||||
|
||||
@ -84,9 +84,22 @@ public class UsersController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new SetUserPositionLevelCommand(id, body.PositionLevel), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// Mig 29 F2 per-Drafter: admin toggle AllowDrafterSkipToFinal cho user. Khi
|
||||
// true, Drafter có thể tick "Gửi thẳng Cấp cuối" trong PE Workspace để bay
|
||||
// thẳng tới Cấp cuối workflow. Mặc định false.
|
||||
[HttpPatch("{id:guid}/allow-skip-final")]
|
||||
[Authorize(Policy = "Users.Update")]
|
||||
public async Task<IActionResult> SetAllowDrafterSkipToFinal(
|
||||
Guid id, [FromBody] SetAllowDrafterSkipToFinalBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new SetUserAllowDrafterSkipToFinalCommand(id, body.AllowDrafterSkipToFinal), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record AssignRolesBody(List<string> Roles);
|
||||
public record ResetPasswordBody(string NewPassword);
|
||||
public record SetBypassReviewBody(bool CanBypassReview);
|
||||
public record SetPositionLevelBody(int? PositionLevel);
|
||||
public record SetAllowDrafterSkipToFinalBody(bool AllowDrafterSkipToFinal);
|
||||
|
||||
@ -7,6 +7,7 @@ using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.Common.Models;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Dtos;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Services;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2; // Plan E V2 strict scope query
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
@ -315,18 +316,28 @@ public class ListPurchaseEvaluationsQueryHandler(
|
||||
from s in sj.DefaultIfEmpty()
|
||||
select new { e, p, s };
|
||||
|
||||
// IDOR: non-admin chỉ thấy phiếu mình là Drafter hoặc role eligible phase.
|
||||
// [Session 17 UAT loose] Phiếu pin V2 (ApprovalWorkflowId != null) →
|
||||
// tạm cho mọi authenticated user thấy để UAT (sẽ thắt chặt sau khi
|
||||
// hoàn thiện logic permission V2-aware).
|
||||
// IDOR strict (Plan E S22 — Session 21 +1): non-admin chỉ thấy phiếu khi:
|
||||
// 1. là Drafter (mình tạo)
|
||||
// 2. role eligible cho phase legacy V1 (eligiblePhases)
|
||||
// 3. V2 approver: pin workflow có actor.UserId trong any Step.Level.ApproverUserId
|
||||
// Trước đây UAT loose `|| x.e.ApprovalWorkflowId != null` cho mọi
|
||||
// authenticated thấy phiếu V2 — đã thắt chặt sau UAT confirm flow ổn.
|
||||
if (!currentUser.Roles.Contains(AppRoles.Admin))
|
||||
{
|
||||
var userId = currentUser.UserId;
|
||||
var eligiblePhases = GetEligiblePhases(currentUser.Roles);
|
||||
// Pre-compute V2 workflow IDs nơi user là approver trong any Step.Level
|
||||
var userApprovalWfIds = userId is null
|
||||
? new List<Guid>()
|
||||
: 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); // V2 loose UAT
|
||||
|| (x.e.ApprovalWorkflowId != null && userApprovalWfIds.Contains(x.e.ApprovalWorkflowId.Value)));
|
||||
}
|
||||
|
||||
if (request.Type is not null) q = q.Where(x => x.e.Type == request.Type);
|
||||
@ -501,10 +512,18 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
{
|
||||
var isDrafter = e.DrafterUserId == currentUser.UserId;
|
||||
var eligiblePhases = ListPurchaseEvaluationsQueryHandler.GetEligiblePhases(currentUser.Roles);
|
||||
// [Session 17 UAT loose] Phiếu pin V2 → cho mọi authenticated user
|
||||
// xem được (sẽ thắt chặt sau khi user UAT confirm flow).
|
||||
var isPinnedV2 = e.ApprovalWorkflowId is not null;
|
||||
if (!isDrafter && !eligiblePhases.Contains(e.Phase) && !isPinnedV2)
|
||||
// V2 strict scope (Plan E S22 — Session 21 +1): actor là approver
|
||||
// trong any Step.Level của workflow đã pin. Trước đây loose
|
||||
// `isPinnedV2 = e.ApprovalWorkflowId is not null` đã thắt chặt sau
|
||||
// UAT confirm flow V2.
|
||||
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.");
|
||||
}
|
||||
|
||||
|
||||
@ -18,4 +18,9 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Expose internal helpers (e.g. PurchaseEvaluationDraftGuard) cho test project -->
|
||||
<InternalsVisibleTo Include="SolutionErp.Infrastructure.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -22,7 +22,8 @@ public record UserDto(
|
||||
string? DepartmentName,
|
||||
string? Position,
|
||||
bool CanBypassReview,
|
||||
int? PositionLevel); // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user.
|
||||
int? PositionLevel, // Mig 18 — 1=NV, 2=PP, 3=TP. Null cho admin/system/external user.
|
||||
bool AllowDrafterSkipToFinal); // Mig 29 — F2 per-Drafter: cho phép Drafter gửi thẳng Cấp cuối khi tạo PE.
|
||||
|
||||
// ========== LIST ==========
|
||||
public record ListUsersQuery : PagedRequest, IRequest<PagedResult<UserDto>>;
|
||||
@ -61,7 +62,7 @@ public class ListUsersQueryHandler(UserManager<User> userManager, IApplicationDb
|
||||
var roles = await userManager.GetRolesAsync(u);
|
||||
var isLocked = u.LockoutEnd.HasValue && u.LockoutEnd.Value.UtcDateTime > now;
|
||||
string? deptName = u.DepartmentId is { } did && deptNames.TryGetValue(did, out var dn) ? dn : null;
|
||||
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel));
|
||||
items.Add(new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel, u.AllowDrafterSkipToFinal));
|
||||
}
|
||||
|
||||
return new PagedResult<UserDto>(items, total, request.Page, request.PageSize);
|
||||
@ -83,7 +84,7 @@ public class GetUserQueryHandler(UserManager<User> userManager, IApplicationDbCo
|
||||
string? deptName = null;
|
||||
if (u.DepartmentId is { } did)
|
||||
deptName = await db.Departments.AsNoTracking().Where(d => d.Id == did).Select(d => d.Name).FirstOrDefaultAsync(ct);
|
||||
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel);
|
||||
return new UserDto(u.Id, u.Email!, u.FullName, u.IsActive, isLocked, u.CreatedAt, roles.ToList(), u.DepartmentId, deptName, u.Position, u.CanBypassReview, (int?)u.PositionLevel, u.AllowDrafterSkipToFinal);
|
||||
}
|
||||
}
|
||||
|
||||
@ -322,3 +323,24 @@ public class SetUserPositionLevelCommandHandler(UserManager<User> userManager)
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== SET ALLOW DRAFTER SKIP TO FINAL (Mig 29 — F2 per-Drafter) ==========
|
||||
// Admin toggle AllowDrafterSkipToFinal cho 1 user. Khi true, user (Drafter) được
|
||||
// dùng checkbox "Gửi thẳng Cấp cuối" trong PE Workspace để skip toàn bộ Bước/Cấp
|
||||
// trung gian và bay thẳng tới Cấp cuối. Mặc định false (an toàn — Drafter phải
|
||||
// tuần tự qua mọi Bước/Cấp theo workflow).
|
||||
public record SetUserAllowDrafterSkipToFinalCommand(Guid Id, bool AllowDrafterSkipToFinal) : IRequest;
|
||||
|
||||
public class SetUserAllowDrafterSkipToFinalCommandHandler(UserManager<User> userManager)
|
||||
: IRequestHandler<SetUserAllowDrafterSkipToFinalCommand>
|
||||
{
|
||||
public async Task Handle(SetUserAllowDrafterSkipToFinalCommand request, CancellationToken ct)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(request.Id.ToString())
|
||||
?? throw new NotFoundException("User", request.Id);
|
||||
user.AllowDrafterSkipToFinal = request.AllowDrafterSkipToFinal;
|
||||
var result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using SolutionErp.Api.Controllers;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Api;
|
||||
|
||||
// Regression tests for gotcha #44 — Silent 403 từ class-level [Authorize(Policy = ...)]
|
||||
// quá strict (Session 18, fix `f77ea38`).
|
||||
//
|
||||
// Bug ngày 2026-05-08: ApprovalWorkflowsV2Controller class-level
|
||||
// `[Authorize(Policy = "Workflows.Read")]` → Drafter `nv.test` (chỉ có
|
||||
// `PurchaseEvaluations.Read`) bị 403 silent khi GET /api/approval-workflows-v2.
|
||||
// FE TanStack Query catch silent → Workspace dropdown empty không có error toast.
|
||||
//
|
||||
// Fix: split policy per action — class-level [Authorize] (any authenticated)
|
||||
// cho list-pick GET, action-level [Authorize(Policy = "Workflows.Create")]
|
||||
// cho POST/DELETE/PATCH admin-only.
|
||||
//
|
||||
// Regression coverage: nếu future ai đó add Policy="Workflows.Read" lên
|
||||
// class-level OR add Policy lên GET action → test FAIL ngay, không cần UAT
|
||||
// reproduce lại bug silent 403 mà FE catch không hiển thị.
|
||||
public class AuthorizePolicyRegressionTests
|
||||
{
|
||||
private static AuthorizeAttribute? GetClassLevelAuthorize(Type controllerType)
|
||||
=> controllerType.GetCustomAttributes<AuthorizeAttribute>(inherit: false).FirstOrDefault();
|
||||
|
||||
private static AuthorizeAttribute? GetActionAuthorize(Type controllerType, string methodName)
|
||||
=> controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||
.FirstOrDefault(m => m.Name == methodName)
|
||||
?.GetCustomAttributes<AuthorizeAttribute>(inherit: false)
|
||||
.FirstOrDefault();
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_ClassLevel_AuthorizeOnly_NoPolicy()
|
||||
{
|
||||
var attr = GetClassLevelAuthorize(typeof(ApprovalWorkflowsV2Controller));
|
||||
|
||||
attr.Should().NotBeNull("controller phải có [Authorize] class-level để chặn anonymous");
|
||||
attr!.Policy.Should().BeNull(
|
||||
"class-level KHÔNG được hardcode Policy — sẽ block Drafter 403 silent khi GET. " +
|
||||
"Gotcha #44: ràng buộc per-action thay vì class-level uniform.");
|
||||
attr.Roles.Should().BeNull("class-level KHÔNG được hardcode Roles");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_Overview_GET_InheritsClassLevel_NoActionPolicy()
|
||||
{
|
||||
// GET Overview KHÔNG được override với [Authorize(Policy=...)] — Drafter
|
||||
// cần list workflow để pick lúc create PE/HĐ (read-only, không expose
|
||||
// business data nhạy cảm).
|
||||
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Overview));
|
||||
|
||||
attr.Should().BeNull(
|
||||
"GET Overview phải inherit class-level [Authorize] (any authenticated). " +
|
||||
"Nếu add [Authorize(Policy=...)] action-level → Drafter 403 silent (gotcha #44).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_Create_POST_RequiresWorkflowsCreatePolicy()
|
||||
{
|
||||
// POST Create chỉ admin Designer được tạo workflow — phải có policy guard.
|
||||
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Create));
|
||||
|
||||
attr.Should().NotBeNull("POST Create phải có [Authorize(Policy = ...)] admin-only");
|
||||
attr!.Policy.Should().Be("Workflows.Create",
|
||||
"POST Create chỉ admin được tạo workflow mới (Mig 22 V2 Designer).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_Delete_RequiresWorkflowsCreatePolicy()
|
||||
{
|
||||
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.Delete));
|
||||
|
||||
attr.Should().NotBeNull("DELETE phải có [Authorize(Policy = ...)] admin-only");
|
||||
attr!.Policy.Should().Be("Workflows.Create",
|
||||
"DELETE workflow chỉ admin (Designer).");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApprovalWorkflowsV2Controller_SetUserSelectable_PATCH_RequiresWorkflowsCreatePolicy()
|
||||
{
|
||||
// Mig 25 — admin toggle stick/unstick "cho user pick lúc create phiếu".
|
||||
var attr = GetActionAuthorize(typeof(ApprovalWorkflowsV2Controller), nameof(ApprovalWorkflowsV2Controller.SetUserSelectable));
|
||||
|
||||
attr.Should().NotBeNull("PATCH user-selectable phải có [Authorize(Policy = ...)] admin-only");
|
||||
attr!.Policy.Should().Be("Workflows.Create",
|
||||
"PATCH user-selectable chỉ admin (Mig 25 Designer pin/unpin).");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,231 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// Plan C task 3 catch-up cho S21 t4-t5 helper `EnsureEditableForDetailsAsync`:
|
||||
// F3 (Mig 28 → Mig 29) — gating Section 2 (Detail + NCC + Báo giá) edit access.
|
||||
// 3 scenario decision branch:
|
||||
// 1. Drafter scope: Phase ∈ {DangSoanThao, TraLai} → accept any caller
|
||||
// 2. F3 Approver scope: Phase=ChoDuyet + level.AllowApproverEditDetails=true
|
||||
// + actor.Id == level.ApproverUserId → accept
|
||||
// 3. Admin bypass: Phase=ChoDuyet + role contains Admin → accept (skip flag check)
|
||||
// Otherwise throw ConflictException / ForbiddenException.
|
||||
public class PurchaseEvaluationDraftGuardTests
|
||||
{
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public string? FullName { get; init; }
|
||||
public IReadOnlyList<string> Roles { get; init; } = Array.Empty<string>();
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
}
|
||||
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel level)>
|
||||
SeedWorkflowAsync(TestApplicationDbContext db, Guid approverUserId, bool allowApproverEdit = false)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-GUARD-001",
|
||||
Version = 1,
|
||||
Name = "Guard test workflow",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null,
|
||||
Name = "Bước 1",
|
||||
};
|
||||
var level = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approverUserId,
|
||||
AllowApproverEditDetails = allowApproverEdit,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(level);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, step, level);
|
||||
}
|
||||
|
||||
private static PurchaseEvaluation BuildPe(
|
||||
PurchaseEvaluationPhase phase,
|
||||
Guid? workflowId = null,
|
||||
int? stepIdx = null,
|
||||
int? levelOrder = null,
|
||||
string code = "PE-G-001")
|
||||
{
|
||||
return new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = phase,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Test guard",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = stepIdx,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Scenario 1: Drafter scope =====
|
||||
|
||||
[Fact]
|
||||
public async Task DraftScope_DangSoanThao_AnyCaller_ReturnsPe()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.DangSoanThao);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } };
|
||||
|
||||
var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, anyCaller, CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(pe.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DraftScope_TraLai_AnyCaller_ReturnsPe()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.TraLai, code: "PE-G-002");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var anyCaller = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Drafter } };
|
||||
|
||||
var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, anyCaller, CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(pe.Id);
|
||||
}
|
||||
|
||||
// ===== Scenario 2: F3 Approver scope =====
|
||||
|
||||
[Fact]
|
||||
public async Task ApproverScope_ChoDuyet_FlagOn_ActorMatches_ReturnsPe()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approverUser = await fix.CreateUserAsync("approver-g1@test.local", "Approver G1", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true);
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-003");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
|
||||
var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, approver, CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(pe.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproverScope_ChoDuyet_FlagOff_NonAdmin_Throws()
|
||||
{
|
||||
// F3 disabled trên Level slot → throw ConflictException.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approverUser = await fix.CreateUserAsync("approver-g2@test.local", "Approver G2", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false);
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-004");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var approver = new FakeCurrentUser { UserId = approverUser.Id, Roles = new[] { AppRoles.CostControl } };
|
||||
|
||||
var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, approver, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*không được cấp quyền chỉnh sửa Section 2*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproverScope_ChoDuyet_FlagOn_ActorMismatch_ThrowsForbidden()
|
||||
{
|
||||
// Khác user gọi (không phải slot ApproverUserId) → Forbidden.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approverUser = await fix.CreateUserAsync("approver-g3@test.local", "Approver G3", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: true);
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-005");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var someoneElse = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.CostControl } };
|
||||
|
||||
var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, someoneElse, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>()
|
||||
.WithMessage("*Chỉ NV phụ trách*");
|
||||
}
|
||||
|
||||
// ===== Scenario 3: Admin bypass =====
|
||||
|
||||
[Fact]
|
||||
public async Task AdminBypass_ChoDuyet_FlagOff_ReturnsPe()
|
||||
{
|
||||
// Admin role bypass Allow* flag check + actor match — vẫn được edit.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var approverUser = await fix.CreateUserAsync("approver-g4@test.local", "Approver G4", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var (wf, _, _) = await SeedWorkflowAsync(db, approverUser.Id, allowApproverEdit: false);
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.ChoDuyet, wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-G-006");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } };
|
||||
|
||||
var result = await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, admin, CancellationToken.None);
|
||||
|
||||
result.Id.Should().Be(pe.Id);
|
||||
}
|
||||
|
||||
// ===== Other phase locked =====
|
||||
|
||||
[Fact]
|
||||
public async Task DaDuyet_AnyCaller_Throws()
|
||||
{
|
||||
// Phase terminal (DaDuyet/TuChoi/DaPhatHanh) — không ai edit được, kể cả admin trừ khi
|
||||
// tạo helper bypass riêng. Hiện EnsureEditable không có admin bypass cho non-ChoDuyet.
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = BuildPe(PurchaseEvaluationPhase.DaDuyet, code: "PE-G-007");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var admin = new FakeCurrentUser { UserId = Guid.NewGuid(), Roles = new[] { AppRoles.Admin } };
|
||||
|
||||
var act = async () => await PurchaseEvaluationDraftGuard.EnsureEditableForDetailsAsync(
|
||||
db, pe.Id, admin, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Phase=DaDuyet*");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,378 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Notifications;
|
||||
using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode enum
|
||||
using SolutionErp.Domain.ApprovalWorkflowsV2;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts; // ApprovalDecision shared
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Services;
|
||||
|
||||
// Plan C task 1-2 catch-up cho S21 t4-t5 feature:
|
||||
// - ApplyReturnModeAsync 4 mode đọc level.Allow* per-NV (Mig 29 refactor từ workflow-level Mig 28)
|
||||
// - skipToFinal đọc user.AllowDrafterSkipToFinal per-Drafter (Mig 29 split scope theo role)
|
||||
//
|
||||
// Focus: defensive boundary check + admin bypass invariant. KHÔNG cover toàn bộ
|
||||
// edge case (Bước 1 Cấp 1 fallback, Assignee runtime pick, V1 legacy fallback) —
|
||||
// defer khi UAT lộ regression cụ thể.
|
||||
public class PurchaseEvaluationWorkflowServiceReturnModeTests
|
||||
{
|
||||
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db, FixedDateTime dt)
|
||||
CreateService()
|
||||
{
|
||||
var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
var dt = new FixedDateTime(new DateTime(2026, 5, 13, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um);
|
||||
return (svc, fix, db, dt);
|
||||
}
|
||||
|
||||
// Workflow setup: 1 Bước (1 Step) — 2 Cấp (2 Levels) — mỗi Cấp 1 Approver.
|
||||
// Mặc định mọi Allow* = false trên Level slot (admin opt-in pattern Mig 29).
|
||||
// ApproverUserId mặc định = approverId truyền vào (caller có thể override).
|
||||
private static async Task<(ApprovalWorkflow wf, ApprovalWorkflowStep step, ApprovalWorkflowLevel l1, ApprovalWorkflowLevel l2)>
|
||||
SeedWorkflowAsync(
|
||||
TestApplicationDbContext db,
|
||||
Guid approver1UserId,
|
||||
Guid approver2UserId,
|
||||
bool allowReturnOneLevelL2 = false,
|
||||
bool allowReturnToDrafterL2 = false,
|
||||
bool allowApproverEditL2 = false)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Code = "QT-TEST-001",
|
||||
Version = 1,
|
||||
Name = "Test Workflow per-NV",
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
IsActive = true,
|
||||
IsUserSelectable = true,
|
||||
};
|
||||
var step = new ApprovalWorkflowStep
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
Order = 1,
|
||||
DepartmentId = null, // Step.DepartmentId là hint nullable — skip FK seeding Department trong test
|
||||
Name = "Bước 1 CCM",
|
||||
};
|
||||
var l1 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 1,
|
||||
ApproverUserId = approver1UserId,
|
||||
// L1 defaults: Allow* = false (test sad path easier)
|
||||
};
|
||||
var l2 = new ApprovalWorkflowLevel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = 2,
|
||||
ApproverUserId = approver2UserId,
|
||||
AllowReturnOneLevel = allowReturnOneLevelL2,
|
||||
AllowReturnToDrafter = allowReturnToDrafterL2,
|
||||
AllowApproverEditDetails = allowApproverEditL2,
|
||||
};
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
db.ApprovalWorkflowSteps.Add(step);
|
||||
db.ApprovalWorkflowLevels.Add(l1);
|
||||
db.ApprovalWorkflowLevels.Add(l2);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return (wf, step, l1, l2);
|
||||
}
|
||||
|
||||
private static PurchaseEvaluation BuildPeAtLevel2(Guid workflowId, Guid drafterId, string code = "PE-RM-001")
|
||||
{
|
||||
return new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Test return mode",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafterId,
|
||||
ApprovalWorkflowId = workflowId,
|
||||
CurrentWorkflowStepIndex = 0, // Bước 1 (Step Order=1)
|
||||
CurrentApprovalLevelOrder = 2, // Cấp 2 đang chờ duyệt
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Task 1: ApplyReturnModeAsync ============
|
||||
|
||||
private static async Task<(User a1, User a2)> SeedApproversAsync(IdentityFixture fix, string suffix)
|
||||
{
|
||||
var a1 = await fix.CreateUserAsync($"approver1-{suffix}@test.local", $"Approver1 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
var a2 = await fix.CreateUserAsync($"approver2-{suffix}@test.local", $"Approver2 {suffix}", departmentId: null, roles: new[] { AppRoles.CostControl });
|
||||
return (a1, a2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnMode_Drafter_AllowedByLevel_SetsPhaseTraLaiAndClearsPointer()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "rm1");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: true);
|
||||
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid());
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: a2.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "trả về drafter sửa",
|
||||
returnMode: WorkflowReturnMode.Drafter,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai);
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull("Drafter mode clear step pointer");
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("Drafter mode clear level pointer");
|
||||
pe.SlaDeadline.Should().BeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnMode_Drafter_DeniedByLevel_NonAdmin_Throws()
|
||||
{
|
||||
// Level Allow*=false (default) + non-admin → throw ConflictException.
|
||||
// Pattern: admin Designer KHÔNG tick flag cho Level slot này → Approver
|
||||
// không được trả về Drafter mode (phải dùng mode khác hoặc Trả lại admin).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "rm2");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnToDrafterL2: false);
|
||||
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-002");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: a2.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "test denied",
|
||||
returnMode: WorkflowReturnMode.Drafter,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*Cấp Approver hiện tại không bật mode*Drafter*");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "Guard chặn trước mutate");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnMode_OneLevel_AllowedByLevel_LowersLevelPointer()
|
||||
{
|
||||
// Level 2 → Level 1 trong cùng Step (peer review chain).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "rm3");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: true);
|
||||
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-003");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: a2.Id,
|
||||
actorRoles: new[] { AppRoles.CostControl },
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "trả về 1 Cấp",
|
||||
returnMode: WorkflowReturnMode.OneLevel,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"OneLevel giữ ChoDuyet (peer review chain), KHÔNG về TraLai");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1, "Lùi 1 Cấp (từ 2 → 1)");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0, "Giữ Step hiện tại");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReturnMode_OneLevel_DeniedByLevel_AdminBypass_Succeeds()
|
||||
{
|
||||
// Admin override workflow.Allow* check — vẫn được trả lại dù slot disabled.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "rm4");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id, allowReturnOneLevelL2: false);
|
||||
var pe = BuildPeAtLevel2(wf.Id, drafterId: Guid.NewGuid(), code: "PE-RM-004");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.TraLai,
|
||||
actorUserId: a2.Id,
|
||||
actorRoles: new[] { AppRoles.Admin },
|
||||
decision: ApprovalDecision.Reject,
|
||||
comment: "admin override",
|
||||
returnMode: WorkflowReturnMode.OneLevel,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1,
|
||||
"Admin bypass Allow* flag — vẫn lùi pointer");
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Task 2: skipToFinal (Drafter trình branch) ============
|
||||
|
||||
[Fact]
|
||||
public async Task SkipToFinal_DrafterAllowed_SetsPointerToFinalLevel()
|
||||
{
|
||||
// Drafter user có AllowDrafterSkipToFinal=true → init pointer cuối step + cuối level.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "skip1");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
|
||||
var drafter = await fix.CreateUserAsync(
|
||||
"drafter.skip@test.local", "Drafter Skip", departmentId: null,
|
||||
roles: new[] { AppRoles.Drafter });
|
||||
drafter.AllowDrafterSkipToFinal = true;
|
||||
await fix.Services.GetRequiredService<UserManager<User>>().UpdateAsync(drafter);
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = "PE-SKIP-001",
|
||||
TenGoiThau = "Skip to final",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.Id,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: drafter.Id,
|
||||
actorRoles: new[] { AppRoles.Drafter },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "gửi thẳng cấp cuối",
|
||||
skipToFinal: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0, "Step duy nhất (chỉ 1 Step) = index 0");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2,
|
||||
"Final Level Order = 2 (Cấp cuối Bước cuối)");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipToFinal_DrafterDenied_NonAdmin_Throws()
|
||||
{
|
||||
// Drafter user có AllowDrafterSkipToFinal=false (default) + non-admin → throw.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "skip2");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
|
||||
var drafter = await fix.CreateUserAsync(
|
||||
"drafter.noskip@test.local", "Drafter NoSkip", departmentId: null,
|
||||
roles: new[] { AppRoles.Drafter });
|
||||
// drafter.AllowDrafterSkipToFinal = false (default)
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = "PE-SKIP-002",
|
||||
TenGoiThau = "Skip denied",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = drafter.Id,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var act = async () => await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: drafter.Id,
|
||||
actorRoles: new[] { AppRoles.Drafter },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "test denied",
|
||||
skipToFinal: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*không được phép gửi thẳng Cấp cuối*");
|
||||
|
||||
// Service mutate Phase=ChoDuyet TRƯỚC khi validate skipToFinal flag,
|
||||
// throw chặn SaveChangesAsync → DB không persist. Test focus contract
|
||||
// throw, không assert in-memory rollback (note: nếu future refactor
|
||||
// move validate trước mutate, test này vẫn pass).
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull("Skip flow throw trước khi init pointer");
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("Pointer chưa init khi throw");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SkipToFinal_AdminBypass_Succeeds()
|
||||
{
|
||||
// Admin role bypass user.AllowDrafterSkipToFinal flag check.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var (a1, a2) = await SeedApproversAsync(fix, "skip3");
|
||||
var (wf, _, _, _) = await SeedWorkflowAsync(db, a1.Id, a2.Id);
|
||||
var adminUser = await fix.CreateUserAsync(
|
||||
"admin.skip@test.local", "Admin Skip", departmentId: null,
|
||||
roles: new[] { AppRoles.Admin });
|
||||
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.DangSoanThao,
|
||||
MaPhieu = "PE-SKIP-003",
|
||||
TenGoiThau = "Admin skip",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = adminUser.Id,
|
||||
ApprovalWorkflowId = wf.Id,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
|
||||
actorUserId: adminUser.Id,
|
||||
actorRoles: new[] { AppRoles.Admin },
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: "admin gửi thẳng",
|
||||
skipToFinal: true,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(2, "Admin bypass + skip → pointer cuối");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,6 +21,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Backend\SolutionErp.Infrastructure\SolutionErp.Infrastructure.csproj" />
|
||||
<!-- Api reference cho reflection-based Authorize policy regression tests (gotcha #44) -->
|
||||
<ProjectReference Include="..\..\src\Backend\SolutionErp.Api\SolutionErp.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
Reference in New Issue
Block a user