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'."); + } + } +}