[CLAUDE] PurchaseEvaluation: drafter tự gỡ cờ GẤP + ngân sách kỳ này tự nhảy từ ban-hành (anh Kiệt FDC UAT R2)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m46s

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 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-23 14:00:33 +07:00
parent 24f0083d78
commit a5e936fc0a
5 changed files with 219 additions and 42 deletions

View File

@ -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 // 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. // 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 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 const canToggleProUrgent = evaluation.isUrgentByPro
? (isAdmin || (isPro && isDeptManager)) ? (isAdmin || (isPro && isDeptManager) || isDrafter)
: (isAdmin || isPro) : (isAdmin || isPro)
const canToggleCcmUrgent = evaluation.isUrgentByCcm const canToggleCcmUrgent = evaluation.isUrgentByCcm
? (isAdmin || (isCcm && isDeptManager)) ? (isAdmin || (isCcm && isDeptManager) || isDrafter)
: (isAdmin || isCcm) : (isAdmin || isCcm)
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin 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). // 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. // 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") 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). // 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á // [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õ // 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. // (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
const [draftRow3, setDraftRow3] = useState<number | null>(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<number | null>(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null)
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount) const [draftRow8, setDraftRow8] = useState<number | null>(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]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // 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={ value={
drafterEditable ? ( drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}

View File

@ -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 // 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. // 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 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 const canToggleProUrgent = evaluation.isUrgentByPro
? (isAdmin || (isPro && isDeptManager)) ? (isAdmin || (isPro && isDeptManager) || isDrafter)
: (isAdmin || isPro) : (isAdmin || isPro)
const canToggleCcmUrgent = evaluation.isUrgentByCcm const canToggleCcmUrgent = evaluation.isUrgentByCcm
? (isAdmin || (isCcm && isDeptManager)) ? (isAdmin || (isCcm && isDeptManager) || isDrafter)
: (isAdmin || isCcm) : (isAdmin || isCcm)
const v2Approvers = evaluation.currentApproval?.approvers ?? [] const v2Approvers = evaluation.currentApproval?.approvers ?? []
const actorMatchesLevel = isAdmin 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). // 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. // 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") 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). // 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á // [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õ // 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. // (chưa cần bấm Lưu). Sync lại từ server (ev.*) sau mỗi save/refetch.
const [draftRow3, setDraftRow3] = useState<number | null>(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<number | null>(ev.budgetPeriodAmount ?? bs?.proInitialAmount ?? null)
const [draftRow8, setDraftRow8] = useState<number | null>(ev.expectedRemainingAmount) const [draftRow8, setDraftRow8] = useState<number | null>(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]) useEffect(() => { setDraftRow8(ev.expectedRemainingAmount) }, [ev.expectedRemainingAmount])
// Phiếu cũ chưa gắn Hạng mục công việc → budgetSummary null. // 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={ value={
drafterEditable ? ( drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending}
label="Ngân sách kỳ này" label="Ngân sách kỳ này"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}

View File

@ -42,34 +42,34 @@ public class SetPurchaseEvaluationUrgentCommandHandler(
var hasPro = roles.Contains(AppRoles.Procurement); var hasPro = roles.Contains(AppRoles.Procurement);
var hasCcm = roles.Contains(AppRoles.CostControl); var hasCcm = roles.Contains(AppRoles.CostControl);
// [S77 Tra Sol/anh Kiệt — chốt 14:25] BẤT ĐỐI XỨNG theo IsUrgent: // [S77 Tra Sol/anh Kiệt] GẮN bất đối xứng + [S85 anh Kiệt] NGƯỜI KHAI tự gỡ cờ của mình:
// • GẮN (true): "ai làm người đó gắn" → NV chức năng PRO/CCM tự đánh dấu (chỉ cần role chức năng). // • GẮN (true): NV chức năng PRO/CCM (hoặc Admin) tự đánh dấu cờ của vai mình.
// • GỠ (false): "gỡ thì chỉ cho TP gỡ" → CHỈ Trưởng phòng (DeptManager) của phòng đó hoặc Admin // • GỠ (false): Admin / Trưởng phòng (role chức năng + DeptManager) / chính người KHAI phiếu
// (tránh NV khác lỡ tay gỡ cờ của người đã gắn). // (DrafterUserId). Drafter/Admin gỡ CẢ 2 cờ (un-urgent phiếu); TP-vai chỉ gỡ cờ của vai mình.
var isPro = request.IsUrgent ? hasPro : (hasPro && isDeptManager); var isDrafter = currentUser.UserId is Guid actorUid && actorUid == entity.DrafterUserId;
var isCcm = request.IsUrgent ? hasCcm : (hasCcm && isDeptManager);
if (!isAdmin && !isPro && !isCcm)
throw new ForbiddenException(request.IsUrgent
? "Chỉ PRO (Cung ứng) / CCM (Kiểm soát chi phí) / Admin được đánh dấu phiếu gấp."
: "Chỉ Trưởng phòng Cung ứng / Kiểm soát chi phí hoặc Admin được GỠ cờ gấp.");
// Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần. // Snapshot để phát hiện chuyển false→true (MỚI bật gấp) → notify CEO 1 lần.
var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm; var wasUrgent = entity.IsUrgentByPro || entity.IsUrgentByCcm;
// Role quyết định cờ nào: PRO → ĐỎ (ByPro), CCM → XANH (ByCcm), Admin → CẢ 2. if (request.IsUrgent)
if (isAdmin)
{ {
entity.IsUrgentByPro = request.IsUrgent; // GẮN — chỉ role chức năng / Admin. Role quyết cờ: PRO → ĐỎ, CCM → XANH, Admin → CẢ 2.
entity.IsUrgentByCcm = request.IsUrgent; if (!isAdmin && !hasPro && !hasCcm)
throw new ForbiddenException("Chỉ PRO (Cung ứng) / CCM (Kiểm soát chi phí) / Admin được đánh dấu phiếu gấp.");
if (isAdmin) { entity.IsUrgentByPro = true; entity.IsUrgentByCcm = true; }
else if (hasPro) entity.IsUrgentByPro = true;
else entity.IsUrgentByCcm = true; // hasCcm
} }
else if (isPro) else
{ {
entity.IsUrgentByPro = request.IsUrgent; // GỠ — Admin / TP-vai / người khai phiếu. Drafter+Admin gỡ cả 2; TP-vai gỡ cờ vai mình.
} var isProTp = hasPro && isDeptManager;
else // isCcm var isCcmTp = hasCcm && isDeptManager;
{ if (!isAdmin && !isProTp && !isCcmTp && !isDrafter)
entity.IsUrgentByCcm = request.IsUrgent; throw new ForbiddenException("Chỉ Admin / Trưởng phòng Cung ứngKiểm soát chi phí / người khai phiếu được GỠ cờ gấp.");
if (isAdmin || isDrafter) { entity.IsUrgentByPro = false; entity.IsUrgentByCcm = false; }
else if (isProTp) entity.IsUrgentByPro = false;
else entity.IsUrgentByCcm = false; // isCcmTp
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@ -198,7 +198,19 @@ public class PurchaseEvaluationWorkflowService(
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này" // [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval // (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
// mirror CÙNG predicate (đổi đồng bộ 2 tầng). // mirror CÙNG predicate (đổi đồng bộ 2 tầng).
if (evaluation.BudgetPeriodAmount is null || evaluation.BudgetPeriodAmount <= 0) // [S85 anh Kiệt — D3] "Ngân sách kỳ này" tự nhảy từ "Ngân sách Ban hành lần đầu"
// (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
// ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
// (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
if ((effectiveBudget is null || effectiveBudget <= 0) && evaluation.WorkItemId is Guid wiId)
{
effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
.Where(b => 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"); missing.Add("chưa nhập Ngân sách kỳ này");
} }

View File

@ -32,10 +32,18 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// • plain CostControl (no DeptManager) → ForbiddenException + NO mutation // • plain CostControl (no DeptManager) → ForbiddenException + NO mutation
// • role khác → ForbiddenException ("*GỠ cờ gấp*") // • role khác → ForbiddenException ("*GỠ cờ gấp*")
// //
// Production gate (PurchaseEvaluationUrgentFeatures.cs:49-55): // [S85 anh Kiệt — NGƯỜI KHAI tự gỡ cờ của mình] UNSET branch mở thêm 1 lối:
// isPro = IsUrgent ? hasPro : (hasPro && isDeptManager); // chính người khai phiếu (actor.UserId == entity.DrafterUserId) được GỠ cờ — và gỡ
// isCcm = IsUrgent ? hasCcm : (hasCcm && isDeptManager); // CẢ 2 cờ (un-urgent phiếu), GIỐNG Admin. SET branch KHÔNG đổi (drafter vẫn không tự
// if (!isAdmin && !isPro && !isCcm) throw Forbidden(<msg theo IsUrgent>); // 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 // Notify CEO (Director) là best-effort try/catch khi false→true → KHÔNG assert
// notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt // notification ở đây (focus = flag-setting + authz). NoOpNotificationService nuốt
@ -46,10 +54,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
public class PeUrgentToggleAuthzTests public class PeUrgentToggleAuthzTests
{ {
// ICurrentUser stub — Roles set per test (giống FakeCurrentUser PeWorkItemGuardTests // 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 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? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test"; public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>(); public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
@ -61,11 +71,15 @@ public class PeUrgentToggleAuthzTests
=> new(db, currentUser, um, new NoOpNotificationService()); => 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. // 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<PurchaseEvaluation> SeedPeAsync( private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db, TestApplicationDbContext db,
bool urgentByPro = false, bool urgentByPro = false,
bool urgentByCcm = false, bool urgentByCcm = false,
string code = "PE-URG-001") string code = "PE-URG-001",
Guid? drafterUserId = null)
{ {
var pe = new PurchaseEvaluation var pe = new PurchaseEvaluation
{ {
@ -75,7 +89,7 @@ public class PeUrgentToggleAuthzTests
MaPhieu = code, MaPhieu = code,
TenGoiThau = "Gói thầu test urgent", TenGoiThau = "Gói thầu test urgent",
ProjectId = Guid.NewGuid(), ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(), DrafterUserId = drafterUserId ?? Guid.NewGuid(),
IsUrgentByPro = urgentByPro, IsUrgentByPro = urgentByPro,
IsUrgentByCcm = urgentByCcm, IsUrgentByCcm = urgentByCcm,
}; };
@ -315,4 +329,141 @@ public class PeUrgentToggleAuthzTests
await act.Should().ThrowAsync<NotFoundException>(); await act.Should().ThrowAsync<NotFoundException>();
} }
// =====================================================================
// [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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// 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<ForbiddenException>()
.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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<ForbiddenException>()
.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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
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");
}
} }