From a5e936fc0a35680b65677fadc00822f30426554e Mon Sep 17 00:00:00 2001 From: pqhuy1987 Date: Tue, 23 Jun 2026 14:00:33 +0700 Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20drafter=20t?= =?UTF-8?q?=E1=BB=B1=20g=E1=BB=A1=20c=E1=BB=9D=20G=E1=BA=A4P=20+=20ng?= =?UTF-8?q?=C3=A2n=20s=C3=A1ch=20k=E1=BB=B3=20n=C3=A0y=20t=E1=BB=B1=20nh?= =?UTF-8?q?=E1=BA=A3y=20t=E1=BB=AB=20ban-h=C3=A0nh=20(anh=20Ki=E1=BB=87t?= =?UTF-8?q?=20FDC=20UAT=20R2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D2 — Người KHAI phiếu tự GỠ cờ GẤP của mình (nới luật S77 "GỠ=chỉ TP"). BE handler restructure SET/UNSET: GẮN giữ role-only (PRO/CCM/Admin); GỠ +drafter (DrafterUserId==actor) → drafter/Admin clear CẢ 2 cờ, TP-vai clear cờ vai mình. FE canToggle +isDrafter. D3 — "Ngân sách kỳ này" (row3) tự nhảy từ "Ngân sách Ban hành lần đầu" (PeWorkItemBudget.ProInitialAmount) khi chưa nhập tay. Submit-guard BE + FE missingForApproval dùng effective = budgetPeriodAmount ?? proInitialAmount (mirror 2 tầng, chống bypass gotcha #44). BE 2 file (Urgent handler + Workflow submit-guard). FE PeDetailTabs ×2 app SHA-identical. Test +5 PeUrgentToggleAuthzTests (drafter-self-unset authz matrix) → 371 PASS. BE slnx + 2 FE build PASS. Co-Authored-By: Claude Opus 4.8 --- fe-admin/src/components/pe/PeDetailTabs.tsx | 19 +- fe-user/src/components/pe/PeDetailTabs.tsx | 19 +- .../PurchaseEvaluationUrgentFeatures.cs | 42 ++--- .../PurchaseEvaluationWorkflowService.cs | 14 +- .../Application/PeUrgentToggleAuthzTests.cs | 167 +++++++++++++++++- 5 files changed, 219 insertions(+), 42 deletions(-) diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx index 64a8b47..1437454 100644 --- a/fe-admin/src/components/pe/PeDetailTabs.tsx +++ b/fe-admin/src/components/pe/PeDetailTabs.tsx @@ -130,11 +130,13 @@ export function PeDetailTabs({ // GỠ = chỉ Trưởng phòng (DeptManager)/Admin (tránh NV khác lỡ tay gỡ). Nút phụ thuộc // trạng thái hiện tại: đã gấp → cần quyền GỠ; chưa gấp → cần quyền GẮN. const isDeptManager = currentUser?.roles?.includes('DeptManager') ?? false + // [S85 anh Kiệt] Người KHAI phiếu tự GỠ cờ gấp của mình (nới GỠ +drafter; GẮN giữ role-only). + const isDrafter = currentUser?.id != null && evaluation.drafterUserId === currentUser.id const canToggleProUrgent = evaluation.isUrgentByPro - ? (isAdmin || (isPro && isDeptManager)) + ? (isAdmin || (isPro && isDeptManager) || isDrafter) : (isAdmin || isPro) const canToggleCcmUrgent = evaluation.isUrgentByCcm - ? (isAdmin || (isCcm && isDeptManager)) + ? (isAdmin || (isCcm && isDeptManager) || isDrafter) : (isAdmin || isCcm) const v2Approvers = evaluation.currentApproval?.approvers ?? [] const actorMatchesLevel = isAdmin @@ -203,7 +205,10 @@ export function PeDetailTabs({ } // 3. Chưa nhập Ngân sách kỳ này (S61 — row 3 bảng tổng hợp, drafter nhập). // Predicate MIRROR BE guard: BudgetPeriodAmount is null || <= 0. - if (evaluation.budgetPeriodAmount == null || evaluation.budgetPeriodAmount <= 0) { + // [S85 D3 anh Kiệt] Effective = budgetPeriodAmount ?? proInitialAmount (Ban hành lần đầu) — + // mirror BE submit-guard fallback (row3 "tự nhảy" từ ban-hành → vẫn gửi-duyệt được). + const effBudget = evaluation.budgetPeriodAmount ?? evaluation.budgetSummary?.proInitialAmount ?? null + if (effBudget == null || effBudget <= 0) { missing.push("Chưa nhập Ngân sách kỳ này") } // 4. Chưa đính kèm Bảng so sánh (attachment supplier-row null — chuẩn Section 3). @@ -1245,9 +1250,11 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: // [C4a anh Kiệt FDC] Live-recompute: giữ giá trị ĐANG GÕ của ô 3 (NS kỳ này) + ô 8 (giá // trị TH dự kiến còn lại) ở state cục bộ → dòng 5/6/7/9 + So sánh + % nhảy NGAY khi gõ // (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch. - const [draftRow3, setDraftRow3] = useState(ev.budgetPeriodAmount) + // [S85 D3 anh Kiệt] draftRow3 default ← "Ngân sách Ban hành lần đầu" (bs.proInitialAmount) + // khi chưa nhập tay budgetPeriodAmount → row3 "tự nhảy". bs có thể null (guard !bs dưới) → optional. + const [draftRow3, setDraftRow3] = useState(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) const [draftRow8, setDraftRow8] = useState(ev.expectedRemainingAmount) - useEffect(() => { setDraftRow3(ev.budgetPeriodAmount) }, [ev.budgetPeriodAmount]) + useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount]) // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. @@ -1434,7 +1441,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: value={ drafterEditable ? ( (ev.budgetPeriodAmount) + // [S85 D3 anh Kiệt] draftRow3 default ← "Ngân sách Ban hành lần đầu" (bs.proInitialAmount) + // khi chưa nhập tay budgetPeriodAmount → row3 "tự nhảy". bs có thể null (guard !bs dưới) → optional. + const [draftRow3, setDraftRow3] = useState(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) const [draftRow8, setDraftRow8] = useState(ev.expectedRemainingAmount) - useEffect(() => { setDraftRow3(ev.budgetPeriodAmount) }, [ev.budgetPeriodAmount]) + useEffect(() => { setDraftRow3(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null) }, [ev.budgetPeriodAmount, bs?.proInitialAmount]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount]) // Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. @@ -1434,7 +1441,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly: value={ drafterEditable ? ( b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId) + .Select(b => b.ProInitialAmount) + .FirstOrDefaultAsync(ct); + } + if (effectiveBudget is null || effectiveBudget <= 0) { missing.Add("chưa nhập Ngân sách kỳ này"); } diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs index 8994f8a..91baab7 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentToggleAuthzTests.cs @@ -32,10 +32,18 @@ namespace SolutionErp.Infrastructure.Tests.Application; // • plain CostControl (no DeptManager) → ForbiddenException + NO mutation // • role khác → ForbiddenException ("*GỠ cờ gấp*") // -// Production gate (PurchaseEvaluationUrgentFeatures.cs:49-55): -// isPro = IsUrgent ? hasPro : (hasPro && isDeptManager); -// isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager); -// if (!isAdmin && !isPro && !isCcm) throw Forbidden(); +// [S85 anh Kiệt — NGƯỜI KHAI tự gỡ cờ của mình] UNSET branch mở thêm 1 lối: +// chính người khai phiếu (actor.UserId == entity.DrafterUserId) được GỠ cờ — và gỡ +// CẢ 2 cờ (un-urgent phiếu), GIỐNG Admin. SET branch KHÔNG đổi (drafter vẫn không tự +// GẮN — chỉ role chức năng/Admin gắn). Production (PurchaseEvaluationUrgentFeatures.cs): +// UNSET allow = isAdmin || (hasPro && isDeptManager) || (hasCcm && isDeptManager) || isDrafter +// isDrafter ⇒ clear CẢ 2 (cùng nhánh với isAdmin, line 70). +// +// Production gate (SET — PurchaseEvaluationUrgentFeatures.cs:57): +// if (!isAdmin && !hasPro && !hasCcm) throw Forbidden("*đánh dấu phiếu gấp*"); +// Production gate (UNSET — line 66-69): +// isProTp = hasPro && isDeptManager; isCcmTp = hasCcm && isDeptManager; +// if (!isAdmin && !isProTp && !isCcmTp && !isDrafter) throw Forbidden("*GỠ cờ gấp*"); // // Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert // notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt @@ -46,10 +54,12 @@ namespace SolutionErp.Infrastructure.Tests.Application; public class PeUrgentToggleAuthzTests { // ICurrentUser stub — Roles set per test (giống FakeCurrentUser PeWorkItemGuardTests - // nhưng configurable roles thay vì hardcode Drafter). + // nhưng configurable roles thay vì hardcode Drafter). UserId settable (init) để test + // [S85] nhánh drafter (actor.UserId == entity.DrafterUserId) — default random khi + // không quan tâm danh tính (mọi test cũ không phụ thuộc UserId cụ thể). private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser { - public Guid? UserId { get; } = Guid.NewGuid(); + public Guid? UserId { get; init; } = Guid.NewGuid(); public string? Email { get; } = "actor@test.local"; public string? FullName { get; } = "Actor Test"; public IReadOnlyList Roles { get; } = roles ?? Array.Empty(); @@ -61,11 +71,15 @@ public class PeUrgentToggleAuthzTests => new(db, currentUser, um, new NoOpNotificationService()); // PE chưa gấp (cả 2 cờ false) — state mặc định để verify role nào set cờ nào. + // [S85] drafterUserId tùy chọn: gán = actor.UserId để test nhánh "người khai tự gỡ". + // Default random (khác mọi FakeCurrentUser.UserId) → các test role-only KHÔNG vô tình + // dính nhánh drafter. private static async Task SeedPeAsync( TestApplicationDbContext db, bool urgentByPro = false, bool urgentByCcm = false, - string code = "PE-URG-001") + string code = "PE-URG-001", + Guid? drafterUserId = null) { var pe = new PurchaseEvaluation { @@ -75,7 +89,7 @@ public class PeUrgentToggleAuthzTests MaPhieu = code, TenGoiThau = "Gói thầu test urgent", ProjectId = Guid.NewGuid(), - DrafterUserId = Guid.NewGuid(), + DrafterUserId = drafterUserId ?? Guid.NewGuid(), IsUrgentByPro = urgentByPro, IsUrgentByCcm = urgentByCcm, }; @@ -315,4 +329,141 @@ public class PeUrgentToggleAuthzTests await act.Should().ThrowAsync(); } + + // ===================================================================== + // [S85 anh Kiệt — NGƯỜI KHAI tự gỡ cờ của mình] Nhánh drafter mới ở UNSET. + // ===================================================================== + + // --------------------------------------------------------------------- + // 10. UNSET — Drafter (người KHAI phiếu) role THƯỜNG (KHÔNG PRO/CCM/TP/Admin) → + // OK gỡ, clear CẢ 2 cờ. Trục chính S85: actor.UserId == DrafterUserId mở lối gỡ, + // và gỡ giống Admin (un-urgent toàn phiếu) dù không có role chức năng nào. + // Pre-seed cờ ĐỎ (ByPro) đang bật để chứng minh CLEAR thật, không phải vốn-dĩ false. + // --------------------------------------------------------------------- + [Fact] + public async Task Drafter_Unset_ClearsBothFlags_NoForbidden() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var drafterId = Guid.NewGuid(); + // Phiếu do drafterId khai, cờ ĐỎ đang bật (vd PRO bật trước đó). + var pe = await SeedPeAsync(db, urgentByPro: true, code: "PE-URG-010", drafterUserId: drafterId); + // Actor = chính drafter, role THƯỜNG (Drafter — KHÔNG Procurement/CostControl/DeptManager/Admin), + // UserId trùng DrafterUserId của phiếu. + var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId }; + var handler = BuildHandler(db, um, actor); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeFalse("người khai gỡ cờ → clear ĐỎ"); + reloaded.IsUrgentByCcm.Should().BeFalse("người khai gỡ → clear CẢ 2 (giống Admin)"); + } + + // --------------------------------------------------------------------- + // 11. UNSET — non-drafter + non-TP + non-admin (Finance, role trung tính) → + // ForbiddenException, cờ KHÔNG đổi. Chốt: chỉ MỞ lối gỡ cho đúng người khai — + // người KHÁC role thường vẫn bị chặn (UserId ngẫu nhiên ≠ DrafterUserId). + // --------------------------------------------------------------------- + [Fact] + public async Task NonDrafterPlainRole_Unset_ThrowsForbidden_NoMutation() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + // Phiếu của 1 drafter KHÁC; cả 2 cờ đang bật. + var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, + code: "PE-URG-011", drafterUserId: Guid.NewGuid()); + // Actor = Finance (role thường), UserId random → KHÔNG phải drafter, KHÔNG TP. + var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Finance)); + + var act = async () => await handler.Handle( + new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*GỠ cờ gấp*"); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeTrue("guard throw TRƯỚC mutate → cờ ĐỎ giữ nguyên"); + reloaded.IsUrgentByCcm.Should().BeTrue("cờ XANH giữ nguyên — người ngoài không gỡ được"); + } + + // --------------------------------------------------------------------- + // 12. SET — Drafter (người khai) role THƯỜNG cố GẮN cờ → ForbiddenException. + // Drafter CHỈ được GỠ, KHÔNG được GẮN (SET branch vẫn role-only, không xét drafter). + // Message nhánh SET ("*đánh dấu phiếu gấp*"). Cờ giữ false (không set). + // --------------------------------------------------------------------- + [Fact] + public async Task Drafter_Set_ThrowsForbidden_DrafterCannotMark() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var drafterId = Guid.NewGuid(); + var pe = await SeedPeAsync(db, code: "PE-URG-012", drafterUserId: drafterId); + // Actor = chính người khai nhưng role thường + IsUrgent=true (GẮN). + var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId }; + var handler = BuildHandler(db, um, actor); + + var act = async () => await handler.Handle( + new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*đánh dấu phiếu gấp*"); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeFalse("drafter chỉ GỠ, không GẮN → không set cờ"); + reloaded.IsUrgentByCcm.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // 13. UNSET — Drafter gỡ khi CHỈ cờ XANH (ByCcm) đang bật → vẫn clear CẢ 2. + // Chứng minh drafter-clear KHÔNG phụ thuộc cờ nào đang bật (un-urgent toàn phiếu). + // --------------------------------------------------------------------- + [Fact] + public async Task Drafter_Unset_OnlyCcmFlagSet_ClearsBoth() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var drafterId = Guid.NewGuid(); + var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URG-013", drafterUserId: drafterId); + var actor = new FakeCurrentUser(AppRoles.Drafter) { UserId = drafterId }; + var handler = BuildHandler(db, um, actor); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByCcm.Should().BeFalse("người khai gỡ cờ XANH"); + reloaded.IsUrgentByPro.Should().BeFalse("vốn-dĩ false, vẫn false sau clear"); + } + + // --------------------------------------------------------------------- + // 14. UNSET — Drafter ĐỒNG THỜI có role Procurement nhưng KHÔNG DeptManager → gỡ + // vẫn clear CẢ 2 qua nhánh drafter (isDrafter ưu tiên/độc lập với isProTp). + // Đối chứng test 6 (TP-PRO chỉ clear cờ vai): cùng có role PRO nhưng vì là người + // khai → gỡ TOÀN phiếu, KHÔNG bị giới hạn role-keyed. + // --------------------------------------------------------------------- + [Fact] + public async Task DrafterWithProcurementButNoDeptManager_Unset_ClearsBoth() + { + using var fix = new IdentityFixture(); + var db = fix.Services.GetRequiredService(); + var um = fix.Services.GetRequiredService>(); + var drafterId = Guid.NewGuid(); + // Cả 2 cờ bật; nếu đi nhánh role-keyed (isProTp) sẽ CHỈ clear ByPro, ByCcm còn true. + // Vì là drafter → phải clear CẢ 2. + var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true, + code: "PE-URG-014", drafterUserId: drafterId); + // role Procurement nhưng KHÔNG DeptManager (nên isProTp=false) + là người khai. + var actor = new FakeCurrentUser(AppRoles.Procurement) { UserId = drafterId }; + var handler = BuildHandler(db, um, actor); + + await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None); + + var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); + reloaded.IsUrgentByPro.Should().BeFalse("drafter gỡ → clear ĐỎ"); + reloaded.IsUrgentByCcm.Should().BeFalse("drafter gỡ CẢ 2 — KHÔNG bị giới hạn role-keyed như TP"); + } }