[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
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:
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 ứng–Kiể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);
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user