From ae01ca56f2d6b2ab66d0a19fb9228136db11b01b Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Fri, 15 May 2026 13:05:02 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation=20Tests:=20Chunk?= =?UTF-8?q?=20O1-O5=20=E2=80=94=20HOTFIX=204=20lookup=20sites=20c=C3=B9ng?= =?UTF-8?q?=20pattern=20per-NV=20(Plan=20N=20point=209=20cascade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bro UAT 2026-05-15 sau Plan N deploy phát hiện 2 bug mới: 1. Actor NV Test trong OR-of-N slot click "Trả lại Người chỉ định" → toast "Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu" mặc dù NV Test đúng trong slot. 2. F2 Duyệt thẳng Cấp cuối → trỏ đến Phan Văn Chương Bước 2 Cấp 2 thay vì Nguyễn Văn Trường Bước 3 Cấp 1 (BOD) — defer follow-up vì F2 logic line 483-524 đã đúng (lastStepIdx + lastLevelMaxOrder), cần verify workflow v14 DB structure. Audit em main: Plan N chỉ fix 1/5 lookup sites — còn 4 sites cùng bug pattern: 1. Service.cs:201 EnsureCanRejectV2Async — bug bro UAT 1 ROOT CAUSE 2. Service.cs:248 ApplyReturnModeAsync — read Allow flag từ row đầu 3. DetailFeatures.cs:72 F3 EnsureEditableForDetailsAsync — cùng bug 4. Features.cs:311 F4 AdjustBudgetCommand — cùng bug 4 fix surgical (~30 LOC BE total): **Site 1** (`PurchaseEvaluationWorkflowService.cs:201`): ```diff - var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl); - if (currentLevel?.ApproverUserId != actorId) + var currentLevel = step.Levels.FirstOrDefault(l => + l.Order == curLvl && l.ApproverUserId == actorId); + if (currentLevel is null) throw new ForbiddenException("Không phải lượt bạn — ..."); ``` **Site 2** (`PurchaseEvaluationWorkflowService.cs:248`): ApplyReturnModeAsync +`Guid? actorUserId` param 4th + caller TransitionAsync:94 update. Filter `l.ApproverUserId == actorUserId` trong FirstOrDefault. Non-admin actor KHÔNG match slot → currentLevel=null → validation skip (mode logic switch KHÔNG dùng currentLevel object — chỉ dùng curStepIdx + curLevel int values). Admin bypass validation existing line 252. **Site 3** (`PurchaseEvaluationDetailFeatures.cs:72`): ```diff - var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder); - if (level is null) throw ConflictException("schema lỗi"); - if (!level.AllowApproverEditDetails) throw ConflictException(...); - if (level.ApproverUserId != actorUserId) throw ForbiddenException(...); + var level = step?.Levels.FirstOrDefault(lv => + lv.Order == levelOrder && lv.ApproverUserId == actorUserId); + if (level is null) throw ForbiddenException(...); + if (!level.AllowApproverEditDetails) throw ConflictException(...); ``` **Site 4** (`PurchaseEvaluationFeatures.cs:311`): ```diff - var level = step.Levels.FirstOrDefault(l => l.Order == curLvl); - if (level is null) throw ConflictException("schema lỗi"); - if (!level.AllowApproverEditBudget) throw ConflictException(...); - if (level.ApproverUserId != actorId) throw ForbiddenException(...); + var level = step.Levels.FirstOrDefault(l => + l.Order == curLvl && l.ApproverUserId == actorId); + if (level is null) throw ForbiddenException(...); + if (!level.AllowApproverEditBudget) throw ConflictException(...); ``` **Regression test** (`PurchaseEvaluationPerNvLookupRegressionTests.cs` 3 test): 1. `TransitionReject_ActorD_LastInSlot_AllowsRejectViaDrafterMode` — Actor D (non-first-row trong OR-of-N) trả lại Drafter mode → no throw. Pre-fix: throw "Không phải lượt bạn" vì handler check row đầu A. 2. `TransitionReject_Outsider_NotInSlot_ThrowsForbidden` — Outsider không trong slot → throw đúng intent (verify fix KHÔNG over-permissive). 3. `TransitionRejectOneLevel_ActorC_HasFlagWhileOthersDont_AllowsMode` — Actor C only tick AllowReturnOneLevel, 3 NV khác KHÔNG. Actor C click "Trả lại 1 Cấp" → mode allowed. Pre-fix: read flag từ row A (false) → throw ConflictException "không bật mode OneLevel". Pattern reinforced: per-NV admin opt-in flag wire **5 lookup sites** đều phải discriminate ApproverUserId. Plan N chỉ catch 1/5. Plan O catch 4/5 còn lại. Memory user-level cần update danh sách 5 sites cho future audit. Verify: - dotnet build SolutionErp.slnx clean (0 err, 2 warn pre-existing DocxRenderer) - dotnet test SolutionErp.slnx **111/111 PASS** (+3 từ 108 baseline Plan N) Pending Chunk O7: docs + memory update commit + push. Pending Chunk O8: CICD Monitor post-deploy verify. Pending follow-up Bug 2 F2 đến Phan Văn Chương: verify workflow v14 DB. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../PurchaseEvaluationDetailFeatures.cs | 17 +- .../PurchaseEvaluationFeatures.cs | 12 +- .../PurchaseEvaluationWorkflowService.cs | 20 +- ...aseEvaluationPerNvLookupRegressionTests.cs | 227 ++++++++++++++++++ 4 files changed, 259 insertions(+), 17 deletions(-) create mode 100644 tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationPerNvLookupRegressionTests.cs diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs index 0a987b0..3f6e261 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationDetailFeatures.cs @@ -69,23 +69,24 @@ internal static class PurchaseEvaluationDraftGuard ?? throw new ConflictException("Workflow không tồn tại."); var step = workflow.Steps.OrderBy(s => s.Order).Skip(stepIdx).FirstOrDefault(); - var level = step?.Levels.FirstOrDefault(lv => lv.Order == levelOrder); + // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId. + // Schema Mig 29 OR-of-N: N rows cùng Order. Filter actor match + // ngay trong FirstOrDefault tránh chọn nhầm row đầu DB. + var level = step?.Levels.FirstOrDefault(lv => + lv.Order == levelOrder && lv.ApproverUserId == actorUserId); if (level is null) - throw new ConflictException("Workflow Bước/Cấp không tìm thấy — schema lỗi."); + throw new ForbiddenException( + $"Chỉ NV phụ trách Bước {step?.Order} / Cấp {levelOrder} " + + "mới được chỉnh sửa Section 2 lúc đang duyệt."); // Mig 29 (S21 t5) — F3 flag move xuống Level slot (per-NV). Đọc - // từ level.AllowApproverEditDetails thay vì workflow-level cũ. + // từ level.AllowApproverEditDetails của chính actor. if (!level.AllowApproverEditDetails) throw new ConflictException( $"Cấp Approver hiện tại (Bước {step!.Order} / Cấp {levelOrder}) " + "không được cấp quyền chỉnh sửa Section 2. " + "Phải Trả lại Drafter sửa hoặc liên hệ Admin Designer cấp quyền slot."); - if (level.ApproverUserId != actorUserId) - throw new ForbiddenException( - $"Chỉ NV phụ trách Bước {step!.Order} / Cấp {levelOrder} " + - "mới được chỉnh sửa Section 2 lúc đang duyệt."); - return pe; } diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs index 074eec5..f0cdb4a 100644 --- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs +++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs @@ -308,17 +308,19 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler( if (csi < 0 || csi >= stepsOrdered.Count) throw new ConflictException("Pointer step out of range — schema lỗi."); var step = stepsOrdered[csi]; - var level = step.Levels.FirstOrDefault(l => l.Order == curLvl); + // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId. + // Schema Mig 29 OR-of-N: N rows cùng Order. Filter actor match + // ngay trong FirstOrDefault tránh chọn nhầm row đầu DB. + var level = step.Levels.FirstOrDefault(l => + l.Order == curLvl && l.ApproverUserId == actorId); if (level is null) - throw new ConflictException("Cấp duyệt không tìm thấy — schema lỗi."); + throw new ForbiddenException( + $"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt."); if (!level.AllowApproverEditBudget) throw new ConflictException( $"Cấp Approver hiện tại (Bước {step.Order} / Cấp {curLvl}) " + "không được cấp quyền chỉnh sửa Section ngân sách. " + "Liên hệ Admin Designer cấp quyền slot."); - if (level.ApproverUserId != actorId) - throw new ForbiddenException( - $"Chỉ NV phụ trách Bước {step.Order} / Cấp {curLvl} mới được điều chỉnh ngân sách lúc đang duyệt."); actorTag = $"[Approver Bước {step.Order}/Cấp {curLvl}]"; } else diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index b3f7518..597ae0f 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -92,7 +92,7 @@ public class PurchaseEvaluationWorkflowService( // Default fallback (returnMode=null) = Drafter mode = S17 behavior. var effectiveMode = returnMode ?? WorkflowReturnMode.Drafter; var returnSummary = await ApplyReturnModeAsync( - evaluation, effectiveMode, returnTargetUserId, isAdmin, ct); + evaluation, effectiveMode, returnTargetUserId, actorUserId, isAdmin, ct); comment = string.IsNullOrWhiteSpace(comment) ? returnSummary : $"{comment} [{returnSummary}]"; @@ -198,8 +198,11 @@ public class PurchaseEvaluationWorkflowService( var stepsOrdered = workflow.Steps.OrderBy(s => s.Order).ToList(); if (csi < 0 || csi >= stepsOrdered.Count) return; // pointer corrupt var step = stepsOrdered[csi]; - var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl); - if (currentLevel?.ApproverUserId != actorId) + // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId. + // Schema Mig 29 OR-of-N: N rows cùng Order, mỗi row 1 NV. Filter ngay + // trong FirstOrDefault thay vì compare sau (tránh chọn nhầm row đầu DB). + var currentLevel = step.Levels.FirstOrDefault(l => l.Order == curLvl && l.ApproverUserId == actorId); + if (currentLevel is null) throw new ForbiddenException( "Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới được Trả lại / Từ chối phiếu."); } @@ -213,6 +216,7 @@ public class PurchaseEvaluationWorkflowService( PurchaseEvaluation evaluation, WorkflowReturnMode mode, Guid? returnTargetUserId, + Guid? actorUserId, bool isAdmin, CancellationToken ct) { @@ -241,11 +245,19 @@ public class PurchaseEvaluationWorkflowService( // Resolve Level hiện tại (slot Approver đang duyệt) — đọc Allow* từ slot // này. Required cho mọi mode (kể cả Drafter — Approver hiện tại quyết // định mode Trả lại theo flag riêng của slot). + // + // Plan O S23 t5 — Per-NV lookup site MUST discriminate ApproverUserId. + // Schema Mig 29 OR-of-N: N rows cùng Order. Non-admin actor BẮT BUỘC + // match slot để đọc Allow flag riêng. Admin bypass validation line 252 + // bất kể currentLevel null (mode logic switch phía dưới không dùng + // currentLevel object — chỉ dùng curStepIdx + curLevel int values). ApprovalWorkflowLevel? currentLevel = null; if (evaluation.CurrentWorkflowStepIndex is int csi && csi >= 0 && csi < stepsOrdered.Count) { var step = stepsOrdered[csi]; - currentLevel = step.Levels.FirstOrDefault(l => l.Order == evaluation.CurrentApprovalLevelOrder); + currentLevel = step.Levels.FirstOrDefault(l => + l.Order == evaluation.CurrentApprovalLevelOrder + && l.ApproverUserId == actorUserId); } // Validate Allow* flag từ Level slot hiện tại (Admin bypass) diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationPerNvLookupRegressionTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationPerNvLookupRegressionTests.cs new file mode 100644 index 0000000..a2df72e --- /dev/null +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PurchaseEvaluationPerNvLookupRegressionTests.cs @@ -0,0 +1,227 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.PurchaseEvaluations.Services; +using SolutionErp.Domain.ApprovalWorkflowsV2; +using SolutionErp.Domain.Contracts; // ApprovalDecision +using SolutionErp.Domain.Identity; +using SolutionErp.Domain.PurchaseEvaluations; +using SolutionErp.Infrastructure.Services; +using SolutionErp.Infrastructure.Tests.Common; + +namespace SolutionErp.Infrastructure.Tests.Services; + +// Plan O S23 t5 (2026-05-15) — Regression test cho 4 BE lookup sites cùng +// bug pattern Plan N: Schema Mig 29 OR-of-N (N rows cùng Order) — handler +// FirstOrDefault(Order==X) không discriminate ApproverUserId → lấy row đầu DB. +// +// Bro UAT 2026-05-15 phát hiện sau Plan N deploy: Actor non-row-đầu click +// "Trả lại" → toast "Không phải lượt bạn — chỉ NV Cấp duyệt hiện tại mới +// được Trả lại / Từ chối phiếu" mặc dù actor TRONG slot. +// +// 4 sites fixed Plan O: +// 1. PurchaseEvaluationWorkflowService.cs:201 — EnsureCanRejectV2Async (bug bro UAT 1) +// 2. PurchaseEvaluationWorkflowService.cs:248 — ApplyReturnModeAsync (cùng bug) +// 3. PurchaseEvaluationDetailFeatures.cs:72 — F3 EnsureEditableForDetailsAsync +// 4. PurchaseEvaluationFeatures.cs:311 — F4 AdjustBudgetCommand +// +// Test cover 2 site Service.cs qua TransitionAsync entry point. +// 2 site Features.cs đã có test existing (PurchaseEvaluationDraftGuardTests +// + implicit qua command handler) — không add thêm. +public class PurchaseEvaluationPerNvLookupRegressionTests +{ + private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix, TestApplicationDbContext db) + CreateService() + { + var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var dt = new FixedDateTime(new DateTime(2026, 5, 15, 12, 0, 0, DateTimeKind.Utc)); + var notify = new NoOpNotificationService(); + var svc = new PurchaseEvaluationWorkflowService(db, dt, notify, um); + return (svc, fix, db); + } + + private static async Task<(ApprovalWorkflow wf, Guid lAId, Guid lBId, Guid lCId, Guid lDId)> + Seed1StepOrOfNAsync(TestApplicationDbContext db, + Guid uA, Guid uB, Guid uC, Guid uD, + bool aAllowReturn, bool bAllowReturn, bool cAllowReturn, bool dAllowReturn) + { + var wf = new ApprovalWorkflow + { + Id = Guid.NewGuid(), + Code = "QT-O-001", + Version = 1, + Name = "Plan O OR-of-N regression", + ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc, + IsActive = true, + }; + var step = new ApprovalWorkflowStep + { + Id = Guid.NewGuid(), + ApprovalWorkflowId = wf.Id, + Order = 1, + Name = "Bước 1 (4 NV OR-of-N cùng Cấp)", + }; + var lA = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = uA, + AllowReturnOneLevel = aAllowReturn, + AllowReturnToDrafter = true, + }; + var lB = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = uB, + AllowReturnOneLevel = bAllowReturn, + AllowReturnToDrafter = true, + }; + var lC = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = uC, + AllowReturnOneLevel = cAllowReturn, + AllowReturnToDrafter = true, + }; + var lD = new ApprovalWorkflowLevel + { + Id = Guid.NewGuid(), + ApprovalWorkflowStepId = step.Id, + Order = 1, + ApproverUserId = uD, + AllowReturnOneLevel = dAllowReturn, + AllowReturnToDrafter = true, + }; + + db.ApprovalWorkflows.Add(wf); + db.ApprovalWorkflowSteps.Add(step); + db.ApprovalWorkflowLevels.AddRange(lA, lB, lC, lD); + await db.SaveChangesAsync(CancellationToken.None); + return (wf, lA.Id, lB.Id, lC.Id, lD.Id); + } + + private static async Task SeedPeChoDuyetAsync( + TestApplicationDbContext db, Guid drafterId, Guid workflowId) + { + var pe = new PurchaseEvaluation + { + Id = Guid.NewGuid(), + Type = PurchaseEvaluationType.DuyetNcc, + Phase = PurchaseEvaluationPhase.ChoDuyet, + MaPhieu = $"PE-O-{Guid.NewGuid().ToString("N").Substring(0, 6)}", + TenGoiThau = "Plan O regression", + ProjectId = Guid.NewGuid(), + DrafterUserId = drafterId, + ApprovalWorkflowId = workflowId, + CurrentWorkflowStepIndex = 0, + CurrentApprovalLevelOrder = 1, + }; + db.PurchaseEvaluations.Add(pe); + await db.SaveChangesAsync(CancellationToken.None); + return pe; + } + + // ============================================================ + // Site #1: EnsureCanRejectV2Async (Service.cs:201) — bug bro UAT 1 + // Pre-fix: handler check `currentLevel?.ApproverUserId != actorId` where + // currentLevel = first row by Order. Non-first-row actor luôn fail check. + // ============================================================ + + [Fact] + public async Task TransitionReject_ActorD_LastInSlot_AllowsRejectViaDrafterMode() + { + var (svc, fix, db) = CreateService(); + using (fix) + { + var drafter = await fix.CreateUserAsync("drafter-o1@test.local", "Drafter", null, new[] { AppRoles.Drafter }); + var a = await fix.CreateUserAsync("a-o1@test.local", "A", null, new[] { AppRoles.CostControl }); + var b = await fix.CreateUserAsync("b-o1@test.local", "B", null, new[] { AppRoles.CostControl }); + var c = await fix.CreateUserAsync("c-o1@test.local", "C", null, new[] { AppRoles.CostControl }); + var d = await fix.CreateUserAsync("d-o1@test.local", "D", null, new[] { AppRoles.CostControl }); + // All 4 slot allow Drafter mode (default Trả lại) + var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, false, false); + var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id); + + // Actor D = NON-first-row in OR-of-N slot. + // Pre-Plan O bug: handler chỉ check Levels[0]=A → ApproverUserId(A) != actorId(D) → throw "Không phải lượt bạn" + // Post-Plan O fix: handler filter `Order==X && ApproverUserId==actorId` → match D row → allow. + var act = async () => await svc.TransitionAsync( + pe, PurchaseEvaluationPhase.TraLai, d.Id, new[] { AppRoles.CostControl }, + ApprovalDecision.Reject, "Test Plan O Actor D Trả lại"); + + await act.Should().NotThrowAsync( + "Plan O O1+O2 fix: actor D match slot → cho phép Trả lại Drafter mode. " + + "Pre-fix bug: throw 'Không phải lượt bạn' vì handler chỉ check row đầu DB."); + } + } + + [Fact] + public async Task TransitionReject_Outsider_NotInSlot_ThrowsForbidden() + { + var (svc, fix, db) = CreateService(); + using (fix) + { + var drafter = await fix.CreateUserAsync("drafter-o2@test.local", "Drafter", null, new[] { AppRoles.Drafter }); + var a = await fix.CreateUserAsync("a-o2@test.local", "A", null, new[] { AppRoles.CostControl }); + var b = await fix.CreateUserAsync("b-o2@test.local", "B", null, new[] { AppRoles.CostControl }); + var c = await fix.CreateUserAsync("c-o2@test.local", "C", null, new[] { AppRoles.CostControl }); + var d = await fix.CreateUserAsync("d-o2@test.local", "D", null, new[] { AppRoles.CostControl }); + var outsider = await fix.CreateUserAsync("outsider@test.local", "Outsider", null, new[] { AppRoles.CostControl }); + var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, false, false); + var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id); + + // Outsider không trong slot OR-of-N → throw đúng intent + var act = async () => await svc.TransitionAsync( + pe, PurchaseEvaluationPhase.TraLai, outsider.Id, new[] { AppRoles.CostControl }, + ApprovalDecision.Reject, "Outsider test"); + + await act.Should().ThrowAsync() + .WithMessage("*Không phải lượt bạn*", + "Plan O fix: outsider không có trong slot OR-of-N → throw đúng nghiệp vụ. " + + "Verify fix KHÔNG over-permissive (cho phép outsider)."); + } + } + + // ============================================================ + // Site #2: ApplyReturnModeAsync (Service.cs:248) — read Allow flag từ Level slot + // Pre-fix: currentLevel = first row → flag check dùng row đầu thay vì actor row. + // ============================================================ + + [Fact] + public async Task TransitionRejectOneLevel_ActorC_HasFlagWhileOthersDont_AllowsMode() + { + var (svc, fix, db) = CreateService(); + using (fix) + { + var drafter = await fix.CreateUserAsync("drafter-o3@test.local", "Drafter", null, new[] { AppRoles.Drafter }); + var a = await fix.CreateUserAsync("a-o3@test.local", "A", null, new[] { AppRoles.CostControl }); + var b = await fix.CreateUserAsync("b-o3@test.local", "B", null, new[] { AppRoles.CostControl }); + var c = await fix.CreateUserAsync("c-o3@test.local", "C", null, new[] { AppRoles.CostControl }); + var d = await fix.CreateUserAsync("d-o3@test.local", "D", null, new[] { AppRoles.CostControl }); + // Only Actor C tick AllowReturnOneLevel — 3 NV khác KHÔNG tick. + // Bug pre-fix: handler read flag từ row đầu (Actor A KHÔNG tick) + // → throw ConflictException "Cấp Approver hiện tại không bật mode OneLevel" + // mặc dù actor C TICK đúng. + var (wf, _, _, _, _) = await Seed1StepOrOfNAsync(db, a.Id, b.Id, c.Id, d.Id, false, false, true, false); + var pe = await SeedPeChoDuyetAsync(db, drafter.Id, wf.Id); + // Set pointer Step 0 Cấp 1 (slot OR-of-N — actor C có flag) + // Đang ở Cấp 1 Bước 1 → fallback Plan M reset (0,1) giữ ChoDuyet — no throw + var act = async () => await svc.TransitionAsync( + pe, PurchaseEvaluationPhase.ChoDuyet, c.Id, new[] { AppRoles.CostControl }, + ApprovalDecision.Reject, "Actor C Trả lại 1 Cấp", + returnMode: WorkflowReturnMode.OneLevel); + + await act.Should().NotThrowAsync( + "Plan O O2 fix: actor C match slot + có flag OneLevel → mode allowed. " + + "Pre-fix bug: read flag từ row đầu (A=false) → throw 'không bật mode OneLevel'."); + } + } +}