[CLAUDE] PurchaseEvaluation: A1 người-tạo ký ô cấp mình + EDGE-5 khóa terminal + opinion-history

A1: ApplyDrafterBypassOnSubmitAsync bypass CHỈ NV cấp dưới creator (order<minOwn), creator ký tay ô mình qua ApproveV2Async (bỏ auto-ký-hộ), giữ skip-NV S60. EDGE-5: guard đầu TransitionAsync chặn non-admin transition từ DaDuyet/TuChoi (admin+system exempt). History: log ý-kiến cũ→PurchaseEvaluationChangelog trước ghi đè (no migration). Tests: 4 bypass→A1 + 8 mới (happy/non-contiguous/EDGE-5/history), 458 PASS. UAT: phiếu 1-bước creator-là-cấp-cuối nay cần bấm Duyệt (bỏ auto-terminal-on-submit, đúng bám-quy-trình).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-07-12 16:13:46 +07:00
parent 0352b44811
commit a829d0df99
2 changed files with 513 additions and 148 deletions

View File

@ -54,6 +54,22 @@ public class PurchaseEvaluationWorkflowService(
var isAdmin = actorRoles.Contains(AppRoles.Admin); var isAdmin = actorRoles.Contains(AppRoles.Admin);
var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove; var isSystem = actorUserId is null && decision == ApprovalDecision.AutoApprove;
// ===== EDGE-5 hard-lock (close-review wf_7df56b89-7bb — owner anh Kiệt oQ2) =====
// Phiếu ĐÃ ở trạng thái KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG không được trả
// lại, từ chối hay chuyển trạng thái. Chặn exploit EDGE-5: drafter non-admin forge
// Reject trên chính phiếu DaDuyet của mình → lật phiếu đã duyệt về "đang duyệt"
// (un-terminal). ADMIN + SYSTEM MIỄN (owner oQ2 "Admin đầy đủ nhất" — giữ override
// :290 + khả năng un-terminal + cron/SLA auto-job decision=AutoApprove). Đặt TRƯỚC
// MỌI branch → chặn trên CẢ 5 đường (reject / drafter-submit / approve-step /
// admin-override / fall-through) với non-admin. KHÔNG đụng EnsureCanRejectV2Async
// (early-return của nó phục vụ system/cron hợp lệ — guard top-method này là fix duy nhất).
if ((fromPhase is PurchaseEvaluationPhase.DaDuyet or PurchaseEvaluationPhase.TuChoi)
&& !isAdmin && !isSystem)
{
throw new ConflictException(
$"Phiếu đã ở trạng thái kết thúc ({fromPhase}) — không thể trả lại, từ chối hay chuyển trạng thái.");
}
// ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject ===== // ===== GUARD: targetPhase TraLai/TuChoi BẮT BUỘC decision=Reject =====
// Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3): // Defense-in-depth chặn FE inconsistency (gotcha #45 — Session 21 turn 3):
// Bug: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve // Bug: button "← Trả lại" trong PeWorkflowPanel.tsx gửi decision=Approve
@ -547,27 +563,23 @@ public class PurchaseEvaluationWorkflowService(
return summary; return summary;
} }
// ===== UAT S60 — Drafter-in-chain bypass khi gửi duyệt (V2-only) ===== // ===== Drafter-in-chain bypass khi gửi duyệt (V2-only) =====
// Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên duyệt lại." // Anh Kiệt: "Trưởng phòng tạo thì bypass — không cần nhân viên cấp dưới duyệt lại."
// Luật GENERIC theo cấp (anh chốt S60): chỉ xét BƯỚC ĐẦU (= phòng soạn). // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): chỉ xét BƯỚC ĐẦU (= phòng
// Người soạn (DrafterUserId — KHÔNG phải actor submit, Admin gửi hộ vẫn // soạn). Người soạn (DrafterUserId — KHÔNG phải actor submit; Admin gửi hộ vẫn
// tính theo người soạn) là approver ở cấp nào trong bước đầu → auto qua // tính theo người soạn) KÝ TAY ô cấp mình; chỉ bypass các cấp NV DƯỚI THUẦN
// Cấp 1..k (k = MAX Order có slot drafter — drafter có thể ở nhiều cấp, // (Order < minOwn, minOwn = cấp THẤP NHẤT người soạn có slot). Pointer DỪNG tại
// Designer chỉ chặn duplicate cùng cấp). Phiếu bắt đầu chờ Cấp k+1 / Bước 2 // minOwn → người soạn tự duyệt Cấp minOwn qua ApproveV2Async (KHÔNG auto-terminal,
// Cấp 1 / terminal DaDuyet (quy trình 1 bước mà drafter là cấp cuối). // KHÔNG auto nhảy Bước — người soạn phải ký thật để "bám đúng quy trình").
// Các bước SAU + BOD duyệt đầy đủ bình thường. // Non-contiguous (người soạn Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 → bypass
// rỗng, toàn bộ chuỗi ký tay đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
// //
// Audit trail 3 tầng: // Audit trail: mỗi cấp NV bị bypass → 1 Approval row (Decision=AutoApprove, "bỏ
// - LevelOpinion: ghi CHỈ slot CHÍNH CHỦ (Level.ApproverUserId == drafter) // qua") + 1 dòng Changelog tóm tắt pointer nhảy. KHÔNG tự ký hộ opinion Section 5
// — KHÔNG ghi hộ cấp NV bị skip (không gán chữ ký người không duyệt + // cho bất kỳ ai (kể cả người soạn — họ ký tay). Idempotent khi TraLai-resubmit
// không trigger FE badge "duyệt thay" sai nghĩa). Cấp skip để trống // (approval row add thêm = lịch sử 2 lần gửi — đúng semantics history).
// Section 5 (đúng sự thật); vết nằm ở Approval row + Changelog. // Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn hợp lệ
// - Approval row per cấp (Decision=AutoApprove) → ApprovalsTab có vết. // pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt).
// - Changelog 1 dòng tóm tắt pointer nhảy.
// Idempotent khi TraLai-resubmit: opinion UPSERT, approval row add thêm
// (lịch sử 2 lần gửi = 2 vết — đúng semantics history).
// Fail-soft: workflow/step/level lỗi cấu trúc → return im lặng (submit vẫn
// hợp lệ pointer Cấp 1, ApproveV2Async sẽ báo lỗi cấu trúc khi duyệt).
private async Task ApplyDrafterBypassOnSubmitAsync( private async Task ApplyDrafterBypassOnSubmitAsync(
PurchaseEvaluation evaluation, Guid awId, CancellationToken ct) PurchaseEvaluation evaluation, Guid awId, CancellationToken ct)
{ {
@ -589,17 +601,18 @@ public class PurchaseEvaluationWorkflowService(
var drafterSlots = firstStep.Levels.Where(l => l.ApproverUserId == drafterId).ToList(); var drafterSlots = firstStep.Levels.Where(l => l.ApproverUserId == drafterId).ToList();
if (drafterSlots.Count == 0) return; // drafter ngoài chuỗi bước đầu — flow thường if (drafterSlots.Count == 0) return; // drafter ngoài chuỗi bước đầu — flow thường
var k = drafterSlots.Max(l => l.Order); // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): người soạn KÝ TAY ô cấp
var maxLevelOrder = levelGroups.Max(g => g.Key); // mình, chỉ bypass các cấp NV DƯỚI THUẦN (Order < minOwn). minOwn = cấp THẤP
// NHẤT người soạn có slot trong bước đầu.
var minOwn = drafterSlots.Min(l => l.Order);
var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct); var drafterFullName = await ResolveActorFullNameAsync(drafterId, isSystem: false, ct);
var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o <= k).OrderBy(o => o).ToList(); var bypassedOrders = levelGroups.Select(g => g.Key).Where(o => o < minOwn).OrderBy(o => o).ToList();
// Mọi order < minOwn là cấp NV cấp dưới THUẦN (người soạn KHÔNG có slot ở đây)
// → chỉ ghi vết audit "bỏ qua", KHÔNG tự ký hộ opinion (A1 — không tự ký hộ ai;
// người soạn tự ký tay ô cấp mình qua ApproveV2Async).
foreach (var order in bypassedOrders) foreach (var order in bypassedOrders)
{ {
var ownSlot = drafterSlots.FirstOrDefault(l => l.Order == order);
// Approval row vết audit per cấp — Decision=AutoApprove phân biệt
// rõ với duyệt tay (mirror style ApproveV2Async :501-510).
db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval db.PurchaseEvaluationApprovals.Add(new PurchaseEvaluationApproval
{ {
PurchaseEvaluationId = evaluation.Id, PurchaseEvaluationId = evaluation.Id,
@ -607,75 +620,27 @@ public class PurchaseEvaluationWorkflowService(
ToPhase = PurchaseEvaluationPhase.ChoDuyet, ToPhase = PurchaseEvaluationPhase.ChoDuyet,
ApproverUserId = drafterId, ApproverUserId = drafterId,
Decision = ApprovalDecision.AutoApprove, Decision = ApprovalDecision.AutoApprove,
Comment = ownSlot is not null Comment = $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)",
? $"[Bước 1 — Cấp {order}] (duyệt tự động khi gửi — người soạn phiếu là người duyệt cấp này)"
: $"[Bước 1 — Cấp {order}] (bỏ qua — phiếu do người duyệt cấp cao hơn cùng phòng soạn)",
ApprovedAt = dateTime.UtcNow, ApprovedAt = dateTime.UtcNow,
}); });
// Opinion CHỈ slot chính chủ (UPSERT — mirror ApproveV2Async Mig 26).
if (ownSlot is not null)
{
const string autoComment = "(duyệt tự động — người soạn phiếu là người duyệt cấp này)";
var existingOpinion = await db.PurchaseEvaluationLevelOpinions
.FirstOrDefaultAsync(o => o.PurchaseEvaluationId == evaluation.Id
&& o.ApprovalWorkflowLevelId == ownSlot.Id, ct);
if (existingOpinion is null)
{
db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = evaluation.Id,
ApprovalWorkflowLevelId = ownSlot.Id,
Comment = autoComment,
SignedAt = dateTime.UtcNow,
SignedByUserId = drafterId,
SignedByFullName = drafterFullName,
});
}
else
{
existingOpinion.Comment = autoComment;
existingOpinion.SignedAt = dateTime.UtcNow;
existingOpinion.SignedByUserId = drafterId;
existingOpinion.SignedByFullName = drafterFullName;
}
}
} }
// Advance pointer qua Cấp k (next = min Order > k — robust khi Order // A1 — pointer DỪNG tại minOwn (cấp thấp nhất của người soạn) → người soạn TỰ KÝ
// không liên tục; engine gốc +1 giả định liên tục, đây superset). // Cấp minOwn qua ApproveV2Async (gate match-approver pass vì họ ∈ ApproverUserId).
if (k < maxLevelOrder) // KHÔNG advance sang Bước sau, KHÔNG terminal DaDuyet khi gửi — người soạn phải ký
// thật để "bám đúng quy trình".
// - minOwn == 1 (người soạn là Cấp 1): bypassedOrders rỗng + block này skip →
// pointer giữ (0,1) đã set ở nhánh submit → người soạn ký Cấp 1 bình thường.
// - Non-contiguous (người soạn ở Cấp 1 + Cấp 3, NV khác ở Cấp 2): minOwn=1 →
// bypass rỗng; người soạn ký Cấp 1 tay → NV Cấp 2 ký bình thường → người soạn
// ký Cấp 3 tay. BÁM ĐÚNG quy trình, KHÔNG auto nhảy qua Cấp 2 của người khác.
if (minOwn > 1)
{ {
var nextOrder = levelGroups.Select(g => g.Key).Where(o => o > k).Min(); evaluation.CurrentApprovalLevelOrder = minOwn;
evaluation.CurrentApprovalLevelOrder = nextOrder;
await LogTransitionAsync(evaluation, await LogTransitionAsync(evaluation,
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
drafterId, ApprovalDecision.AutoApprove, drafterId, ApprovalDecision.AutoApprove,
$"Bỏ qua Cấp 1..{k} Bước 1 (người soạn {drafterFullName} là người duyệt Cấp {k}) — phiếu chờ từ Cấp {nextOrder}", $"Bỏ qua Cấp 1..{minOwn - 1} Bước 1 (cấp NV dưới) — chờ người soạn {drafterFullName} ký Cấp {minOwn}",
ct);
}
else if (steps.Count > 1)
{
evaluation.CurrentWorkflowStepIndex = 1;
evaluation.CurrentApprovalLevelOrder = 1;
await LogTransitionAsync(evaluation,
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet,
drafterId, ApprovalDecision.AutoApprove,
$"Bỏ qua toàn bộ Bước 1 (người soạn {drafterFullName} là người duyệt cấp cuối của bước) — phiếu chờ Bước 2 (Cấp 1)",
ct);
}
else
{
// Quy trình chỉ 1 bước + drafter là cấp cuối → terminal DaDuyet
// (mirror ApproveV2Async terminal :617-624).
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(evaluation,
PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.DaDuyet,
drafterId, ApprovalDecision.AutoApprove,
"(duyệt tự động toàn bộ — quy trình 1 bước, người soạn là người duyệt cấp cuối)",
ct); ct);
} }
} }
@ -783,6 +748,28 @@ public class PurchaseEvaluationWorkflowService(
} }
else else
{ {
// CHANGE 4 (close-review wf_7df56b89-7bb) — Opinion history: phiếu bị Trả lại
// rồi NV cùng Cấp KÝ LẠI → ghi ý kiến CŨ vào Changelog TRƯỚC khi ghi đè (bảo
// toàn lịch sử; KHÔNG migration / bảng / endpoint mới — edit diễn ra in-flow
// qua re-sign). Chỉ log khi ý kiến cũ non-empty (lần ký ĐẦU đi nhánh Add ở
// trên vì existingOpinion null). Interpolation đọc snapshot CŨ ngay tại đây,
// TRƯỚC 4 dòng ghi đè bên dưới → giá trị đã "đóng băng" vào ContextNote.
if (!string.IsNullOrWhiteSpace(existingOpinion.Comment))
{
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = evaluation.Id,
EntityType = PurchaseEvaluationEntityType.Workflow,
EntityId = matchingLevel.Id,
Action = ChangelogAction.Update,
PhaseAtChange = evaluation.Phase,
UserId = actorUserId,
UserName = actorFullName,
Summary = $"Ghi đè ý kiến duyệt (Bước {currentIdx + 1} — Cấp {currentLevelOrder}) — lưu bản cũ vào lịch sử",
ContextNote = $"[Ý kiến cũ — {existingOpinion.SignedByFullName} lúc {existingOpinion.SignedAt:dd/MM/yyyy HH:mm}] {existingOpinion.Comment}",
});
}
existingOpinion.Comment = normalizedComment; existingOpinion.Comment = normalizedComment;
existingOpinion.SignedAt = dateTime.UtcNow; existingOpinion.SignedAt = dateTime.UtcNow;
existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty; existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty;

View File

@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions; using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode
using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity; using SolutionErp.Domain.Identity;
@ -26,11 +27,27 @@ namespace SolutionErp.Infrastructure.Tests.Services;
// Message gộp MỌI mục thiếu 1 lần. Áp CẢ Admin (data-quality ≠ authz). // Message gộp MỌI mục thiếu 1 lần. Áp CẢ Admin (data-quality ≠ authz).
// //
// Feature 2 — Drafter-in-chain bypass khi submit (ApplyDrafterBypassOnSubmitAsync, // Feature 2 — Drafter-in-chain bypass khi submit (ApplyDrafterBypassOnSubmitAsync,
// V2-only, line 528-638 prod): drafter là approver cấp k (MAX Order match) BƯỚC // V2-only). [A1 — close-review wf_7df56b89-7bb, owner anh Kiệt] ĐỔI hành vi cũ (auto qua
// ĐẦU → auto qua Cấp 1..k. Approval rows per cấp (Decision=AutoApprove); opinion // Cấp 1..k + auto-sign slot chính chủ + auto nhảy Bước / terminal). NAY: người soạn
// CHỈ ghi slot chính chủ (ownSlot.ApproverUserId==drafter); pointer k<max → // (DrafterUserId — KHÔNG phải actor gửi; Admin gửi hộ vẫn tính theo người soạn) KÝ TAY ô
// Level=nextOrder · k==max+còn step → StepIdx=1/Level=1 · 1-step+k==max → // cấp mình. CHỈ bypass các cấp NV DƯỚI THUẦN (Order < minOwn, minOwn = cấp THẤP NHẤT người
// terminal DaDuyet pointers null SlaDeadline null. // soạn có slot ở BƯỚC ĐẦU). Pointer DỪNG tại minOwn → người soạn tự ký Cấp minOwn qua
// ApproveV2Async (KHÔNG auto-sign opinion chính chủ, KHÔNG auto nhảy Bước sau, KHÔNG
// auto-terminal DaDuyet khi gửi). Mỗi cấp bypass → 1 Approval row (Decision=AutoApprove);
// opinion Section 5 = 0 NGAY SAU submit (chỉ sinh khi người soạn ký THẬT). minOwn==1 (người
// soạn là Cấp 1, hoặc non-contiguous Cấp 1 + Cấp 3): bypass RỖNG → toàn bộ chuỗi ký tay
// đúng thứ tự (KHÔNG auto qua Cấp 2 của người khác).
//
// [EDGE-5 — close-review wf_7df56b89-7bb, owner anh Kiệt oQ2] TransitionAsync top-guard
// (:57-71 prod): phiếu ĐÃ KẾT THÚC (DaDuyet / TuChoi) → user THƯỜNG (non-admin) KHÔNG được
// trả lại / từ chối / chuyển trạng thái (ConflictException "...trạng thái kết thúc..."). Chặn
// exploit drafter forge Reject lật phiếu DaDuyet của mình về "đang duyệt". ADMIN + SYSTEM
// (actorUserId null + decision=AutoApprove) MIỄN (un-terminal hợp lệ + cron/SLA auto-job).
//
// [Opinion history — close-review CHANGE 4] ApproveV2Async: re-sign ĐÈ 1 opinion CŨ non-empty
// → ghi 1 PurchaseEvaluationChangelog (EntityType=Workflow, Action=Update, EntityId=level.Id,
// ContextNote chứa comment cũ) TRƯỚC khi overwrite (bảo toàn lịch sử, no migration). Lần ký
// ĐẦU (existingOpinion null) HOẶC đè opinion rỗng/whitespace → KHÔNG ghi history row.
// //
// LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng // LƯU Ý GUARD-FIRST: submit guard chạy TRƯỚC bypass → mọi test bypass phải dựng
// PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file). // PE ĐỦ 4 điều kiện Section 3 (winner + quote>0 + manual budget + comparison file).
@ -187,6 +204,42 @@ public class PeSubmitGuardAndBypassTests
comment: null, comment: null,
ct: CancellationToken.None); ct: CancellationToken.None);
// Approver ký Cấp hiện tại (advance pointer trong ChoDuyet qua ApproveV2Async).
// approvedPrice* CHỈ cần khi cú ký NÀY chốt phiếu (Cấp cuối Bước cuối → DaDuyet):
// người duyệt cuối BẮT BUỘC chọn 1 giá (ApplyApprovedPriceOnFinalize). Ký cấp
// trung gian → để null. Match approver V2 = theo ApproverUserId (role không xét
// trừ Admin) → default roles [Drafter] vô hại.
private static Task ApproveAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
string? comment = null,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null,
string[]? roles = null) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
actorUserId: actorUserId,
actorRoles: roles ?? new[] { AppRoles.Drafter },
decision: ApprovalDecision.Approve,
comment: comment,
approvedPriceAmount: approvedPriceAmount,
approvedPriceSource: approvedPriceSource,
ct: CancellationToken.None);
// Approver Cấp hiện tại Trả lại về Người soạn (mode Drafter — Phase=TraLai, clear
// pointer). AllowReturnToDrafter mặc định TRUE trên Level nên không cần set flag.
private static Task ReturnToDrafterAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.TraLai,
actorUserId: actorUserId,
actorRoles: Array.Empty<string>(),
decision: ApprovalDecision.Reject,
comment: "Trả lại để người soạn sửa",
returnMode: WorkflowReturnMode.Drafter,
ct: CancellationToken.None);
// ===================================================================== // =====================================================================
// FEATURE 1 — Section 3 completeness guard // FEATURE 1 — Section 3 completeness guard
// ===================================================================== // =====================================================================
@ -462,12 +515,13 @@ public class PeSubmitGuardAndBypassTests
} }
[Fact] [Fact]
public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesAllLevels_MovesToStep2() public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesSubordinatesOnly_StopsAtOwnLevel()
{ {
// (9) Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV + // (9) [A1] Drafter = TP (cấp 2/2 bước 1), workflow 2 bước (bước 1 có Cấp 1=NV +
// Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). k=2=maxLevel bước 1 + còn bước 2 // Cấp 2=TP-drafter; bước 2 có 1 cấp = sếp). minOwn=2 → CHỈ bypass Cấp 1 (NV dưới
// → pointer StepIdx=1 Level=1. Opinion CHỈ 1 row slot TP (Cấp 2). 2 approval // thuần). Pointer DỪNG tại (StepIdx=0, Level=2) — KHÔNG advance sang Bước 2; người
// AutoApprove (Cấp 1 + Cấp 2). KHÔNG opinion Cấp 1. // soạn TP phải tự ký Cấp 2 qua ApproveV2Async sau đó. Opinion count=0 NGAY SAU submit
// (KHÔNG auto-sign slot chính chủ). 1 approval AutoApprove (chỉ Cấp 1 bypass).
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
@ -486,32 +540,28 @@ public class PeSubmitGuardAndBypassTests
await SubmitAsync(svc, pe, drafterTp); await SubmitAsync(svc, pe, drafterTp);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentWorkflowStepIndex.Should().Be(1, "k=max bước 1 + còn bước 2 → sang Bước 2"); pe.CurrentWorkflowStepIndex.Should().Be(0, "[A1] DỪNG tại bước đầu — người soạn ký Cấp mình, KHÔNG nhảy Bước 2");
pe.CurrentApprovalLevelOrder.Should().Be(1); pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] pointer dừng tại minOwn=2 (cấp thấp nhất của người soạn)");
// Opinion CHỈ slot chính chủ (TP Cấp 2). KHÔNG opinion Cấp 1 (NV bị skip). // [A1] KHÔNG auto-sign opinion chính chủ → 0 opinion sau submit.
var opinions = await db.PurchaseEvaluationLevelOpinions var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().HaveCount(1, "chỉ ghi opinion slot drafter, không ghi hộ NV skip"); opinions.Should().BeEmpty("[A1] KHÔNG tự ký hộ opinion — người soạn ký tay Cấp 2 sau");
var tpLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id;
opinions[0].ApprovalWorkflowLevelId.Should().Be(tpLevelId);
opinions[0].SignedByUserId.Should().Be(drafterTp);
opinions[0].Comment.Should().Contain("duyệt tự động");
// 2 approval AutoApprove (Cấp 1 + Cấp 2 bước 1). // Chỉ Cấp 1 (NV dưới thuần) bypass → 1 approval AutoApprove.
var autoApprovals = await db.PurchaseEvaluationApprovals var autoApprovals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id .Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); && a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
autoApprovals.Should().HaveCount(2, "2 cấp bị bypass = 2 vết Approval AutoApprove"); autoApprovals.Should().HaveCount(1, "[A1] chỉ bypass Cấp 1 NV dưới = 1 vết AutoApprove");
} }
} }
[Fact] [Fact]
public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesLevel1Only_MovesToLevel2SameStep() public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesNothing_StaysAtLevel1()
{ {
// (10) Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). k=1 < maxLevel=2 // (10) [A1] Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). minOwn=1 →
// → pointer Level=2 cùng bước (StepIdx giữ 0). Opinion slot NV Cấp 1. 1 approval // KHÔNG có cấp nào Order < 1 → bypass RỖNG. Pointer giữ init (StepIdx=0, Level=1) —
// AutoApprove. Cấp 2 KHÔNG bypass (approver khác). // người soạn tự ký Cấp 1 qua ApproveV2Async. Opinion=0, AutoApprove=0 sau submit.
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
@ -525,19 +575,16 @@ public class PeSubmitGuardAndBypassTests
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentWorkflowStepIndex.Should().Be(0, "cùng bước"); pe.CurrentWorkflowStepIndex.Should().Be(0, "cùng bước");
pe.CurrentApprovalLevelOrder.Should().Be(2, "k=1 < max=2 → chờ Cấp 2"); pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] minOwn=1 → bypass rỗng → giữ Cấp 1 (người soạn tự ký)");
var opinions = await db.PurchaseEvaluationLevelOpinions var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().HaveCount(1); opinions.Should().BeEmpty("[A1] người soạn Cấp 1 → không bypass, không auto-sign");
var nvLevelId = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
opinions[0].ApprovalWorkflowLevelId.Should().Be(nvLevelId, "slot NV Cấp 1 chính chủ");
opinions[0].SignedByUserId.Should().Be(drafterNv);
var autoApprovals = await db.PurchaseEvaluationApprovals var autoApprovals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id .Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); && a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
autoApprovals.Should().HaveCount(1, "chỉ Cấp 1 bypass"); autoApprovals.Should().BeEmpty("[A1] 0 cấp dưới → 0 bypass");
} }
} }
@ -577,10 +624,12 @@ public class PeSubmitGuardAndBypassTests
} }
[Fact] [Fact]
public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet_PointersNull() public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_StopsAtOwnLevel_ThenSignsToDaDuyet()
{ {
// (12) Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). k=2=maxLevel + // (12) [A1] Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). minOwn=2 → bypass
// chỉ 1 bước → terminal DaDuyet, pointers null, SlaDeadline null. // Cấp 1 (NV dưới) NHƯNG KHÔNG auto-terminal khi gửi: pointer DỪNG tại (0, 2) với
// Phase=ChoDuyet. Người soạn phải TỰ KÝ Cấp 2 qua ApproveV2Async để "bám đúng quy
// trình" → chỉ khi đó mới DaDuyet. Chốt: submit KHÔNG còn tự-duyệt-hộ phiếu của mình.
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
@ -590,24 +639,38 @@ public class PeSubmitGuardAndBypassTests
var wf = await SeedWorkflowSingleStepAsync(db, level1Nv, drafterLast); // Cấp1=NV, Cấp2=drafter(cuối) var wf = await SeedWorkflowSingleStepAsync(db, level1Nv, drafterLast); // Cấp1=NV, Cấp2=drafter(cuối)
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterLast, "PE-S60-012"); var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterLast, "PE-S60-012");
// --- Bước 1: submit → dừng tại Cấp 2, CHƯA terminal ---
await SubmitAsync(svc, pe, drafterLast); await SubmitAsync(svc, pe, drafterLast);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước + drafter cấp cuối → terminal"); pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] submit KHÔNG auto-terminal — dừng chờ người soạn ký");
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().Be(2, "[A1] dừng tại minOwn=2");
(await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
.Should().BeEmpty("[A1] chưa ký → chưa có opinion");
(await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove).ToListAsync())
.Should().HaveCount(1, "[A1] chỉ Cấp 1 NV dưới bypass");
// --- Bước 2: người soạn TỰ KÝ Cấp 2 (cấp cuối bước cuối) → chốt giá → DaDuyet ---
await ApproveAsync(svc, pe, drafterLast, comment: "Người soạn ký chốt Cấp 2",
approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "người soạn ký tay Cấp cuối → terminal");
pe.CurrentWorkflowStepIndex.Should().BeNull(); pe.CurrentWorkflowStepIndex.Should().BeNull();
pe.CurrentApprovalLevelOrder.Should().BeNull(); pe.CurrentApprovalLevelOrder.Should().BeNull();
pe.SlaDeadline.Should().BeNull(); pe.SlaDeadline.Should().BeNull();
// Opinion CHỈ slot drafter (Cấp 2). KHÔNG opinion Cấp 1. // Opinion CHỈ slot drafter (Cấp 2) — sinh khi ký THẬT, comment thật (không canned).
var opinions = await db.PurchaseEvaluationLevelOpinions var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().HaveCount(1); opinions.Should().HaveCount(1, "[A1] chỉ 1 opinion do người soạn ký tay Cấp 2");
var drafterLevelId = wf.Steps.First().Levels.First(l => l.Order == 2).Id; var drafterLevelId = wf.Steps.First().Levels.First(l => l.Order == 2).Id;
opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId); opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
opinions[0].SignedByUserId.Should().Be(drafterLast);
var autoApprovals = await db.PurchaseEvaluationApprovals opinions[0].Comment.Should().Be("Người soạn ký chốt Cấp 2");
.Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
autoApprovals.Should().HaveCount(2, "Cấp 1 + Cấp 2 bypass");
} }
} }
@ -640,11 +703,13 @@ public class PeSubmitGuardAndBypassTests
} }
[Fact] [Fact]
public async Task Resubmit_FromTraLai_ReAppliesBypass_OpinionNotDuplicated_ApprovalAccumulates() public async Task Resubmit_FromTraLai_ReRunsBypass_DrafterSoleLevel_NoBypassNoAutoTerminal()
{ {
// (14) TraLai → resubmit → bypass áp lại. Opinion KHÔNG duplicate (UPSERT 1 // (14) [A1] Workflow 1 bước 1 cấp = drafter (người soạn LÀ cấp duy nhất). minOwn=1 →
// row). Approval rows cộng dồn vết (2 lần gửi = 2× AutoApprove cùng cấp). // bypass RỖNG. ĐỔI hành vi cũ (auto-terminal DaDuyet + auto-sign khi gửi): NAY submit
// Dùng workflow 1 bước 1 cấp = drafter → mỗi submit terminal DaDuyet. // chỉ đưa phiếu tới ChoDuyet tại (0, 1), opinion=0, AutoApprove=0 — người soạn phải tự
// ký. Resubmit từ TraLai chạy LẠI bypass idempotent: vẫn 0 bypass row, KHÔNG cộng dồn,
// KHÔNG duplicate (vì không có gì để cộng/nhân bản khi bypass rỗng).
var (svc, fix, db, _) = CreateService(); var (svc, fix, db, _) = CreateService();
using (fix) using (fix)
{ {
@ -652,16 +717,18 @@ public class PeSubmitGuardAndBypassTests
var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterSolo, "PE-S60-014"); var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterSolo, "PE-S60-014");
// Lần gửi 1 → terminal DaDuyet, 1 opinion + 1 AutoApprove. // Lần gửi 1 → ChoDuyet (0,1), KHÔNG terminal, 0 opinion + 0 AutoApprove.
await SubmitAsync(svc, pe, drafterSolo); await SubmitAsync(svc, pe, drafterSolo);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet); pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] auto-terminal-on-submit đã bỏ");
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().Be(1, "[A1] người soạn Cấp 1 → giữ chờ ký");
var opinionsAfter1 = await db.PurchaseEvaluationLevelOpinions (await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
opinionsAfter1.Should().HaveCount(1); .Should().BeEmpty("[A1] chưa ký → 0 opinion");
var autoAfter1 = await db.PurchaseEvaluationApprovals (await db.PurchaseEvaluationApprovals
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
autoAfter1.Should().Be(1); .Should().Be(0, "[A1] minOwn=1 → bypass rỗng");
// Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm). // Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm).
pe.Phase = PurchaseEvaluationPhase.TraLai; pe.Phase = PurchaseEvaluationPhase.TraLai;
@ -670,17 +737,328 @@ public class PeSubmitGuardAndBypassTests
pe.SlaDeadline = null; pe.SlaDeadline = null;
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
// Lần gửi 2 (resubmit từ TraLai) → bypass áp lại. // Lần gửi 2 (resubmit từ TraLai) → bypass chạy lại nhưng vẫn RỖNG (idempotent).
await SubmitAsync(svc, pe, drafterSolo); await SubmitAsync(svc, pe, drafterSolo);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "resubmit áp lại bypass → terminal"); pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "[A1] resubmit → ChoDuyet (0,1), vẫn KHÔNG terminal");
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().Be(1);
var opinionsAfter2 = await db.PurchaseEvaluationLevelOpinions (await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync())
.Should().BeEmpty("[A1] vẫn 0 opinion sau resubmit — không có auto-sign");
(await db.PurchaseEvaluationApprovals
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
.Should().Be(0, "[A1] bypass rỗng cả 2 lần gửi → KHÔNG cộng dồn AutoApprove");
}
}
// =====================================================================
// A1 NEW COVERAGE — happy-path người soạn ký tay + non-contiguous manual chain
// =====================================================================
[Fact]
public async Task Submit_DrafterAtLevel2_ThenSignsOwnLevelWithRealComment_OpinionIsRealNotCanned()
{
// [A1 happy-path] Workflow 2 bước (Bước 1: Cấp 1=NV dưới, Cấp 2=drafter · Bước 2:
// Cấp 1=boss). Submit → bypass Cấp 1, pointer DỪNG (0, 2). Người soạn TỰ KÝ Cấp 2
// qua ApproveV2Async với ý kiến THẬT → advance sang Bước 2. Opinion phải là comment
// THẬT của người soạn + SignedByUserId=drafter, TUYỆT ĐỐI KHÔNG phải canned
// "(duyệt tự động…)" (chuỗi này đã bị gỡ khỏi prod trong A1 — không còn auto-sign).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var nvSub = (await fix.CreateUserAsync("nvsub@s60.test", "NV Duoi", null, Array.Empty<string>())).Id;
var drafter = (await fix.CreateUserAsync("drafter2@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id;
var boss = (await fix.CreateUserAsync("bossb@s60.test", "Boss Buoc 2", null, Array.Empty<string>())).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { nvSub, drafter }, // Bước 1: Cấp 1 = NV dưới, Cấp 2 = drafter
new[] { boss }, // Bước 2: Cấp 1 = boss
});
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-HAPPY");
await SubmitAsync(svc, pe, drafter);
pe.CurrentApprovalLevelOrder.Should().Be(2, "bypass Cấp 1 → dừng tại Cấp 2 người soạn");
(await db.PurchaseEvaluationLevelOpinions.CountAsync(o => o.PurchaseEvaluationId == pe.Id))
.Should().Be(0, "chưa ký → 0 opinion");
const string realComment = "Tôi đồng ý chọn nhà cung cấp này — giá hợp lý";
await ApproveAsync(svc, pe, drafter, comment: realComment);
// Advance sang Bước 2 (KHÔNG terminal — còn boss).
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentWorkflowStepIndex.Should().Be(1, "ký xong Cấp 2 Bước 1 → sang Bước 2");
pe.CurrentApprovalLevelOrder.Should().Be(1);
var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinionsAfter2.Should().HaveCount(1, "UPSERT — opinion KHÔNG duplicate sau resubmit"); opinions.Should().HaveCount(1, "chỉ 1 opinion — do người soạn ký tay (Cấp 1 bypass KHÔNG có opinion)");
var drafterLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 2).Id;
opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId);
opinions[0].SignedByUserId.Should().Be(drafter, "người soạn ký THẬT — không phải ký hộ");
opinions[0].Comment.Should().Be(realComment, "opinion = ý kiến thật của người soạn");
opinions[0].Comment.Should().NotContain("duyệt tự động", "A1 gỡ canned auto-sign");
opinions[0].Comment.Should().NotContain("bỏ qua");
}
}
var autoAfter2 = await db.PurchaseEvaluationApprovals [Fact]
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); public async Task Submit_DrafterNonContiguousLevel1And3_NoBypass_FullManualChainInOrder()
autoAfter2.Should().Be(2, "approval rows cộng dồn — 2 lần gửi = 2 vết AutoApprove"); {
// [A1 non-contiguous] Bước 1 có 3 cấp: Cấp 1=drafter, Cấp 2=NV khác, Cấp 3=drafter.
// minOwn=1 → bypass RỖNG (KHÔNG auto qua Cấp 2 của người khác). Toàn bộ chuỗi ký TAY
// đúng thứ tự: drafter ký Cấp 1 → NV khác ký Cấp 2 → drafter ký Cấp 3 (chốt giá →
// DaDuyet). 3 opinion đúng người ký, 0 AutoApprove, 3 Approve.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var drafter = (await fix.CreateUserAsync("dnc@s60.test", "Drafter NC", null, new[] { AppRoles.Drafter })).Id;
var otherNv = (await fix.CreateUserAsync("otherc2@s60.test", "NV Cap 2 khac", null, Array.Empty<string>())).Id;
// 1 bước, 3 cấp: Cấp1=drafter, Cấp2=otherNv, Cấp3=drafter (non-contiguous).
var wf = await SeedWorkflowSingleStepAsync(db, drafter, otherNv, drafter);
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafter, "PE-A1-NONCONTIG");
await SubmitAsync(svc, pe, drafter);
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().Be(1, "minOwn=1 → bypass rỗng → bắt đầu Cấp 1");
(await db.PurchaseEvaluationApprovals
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
.Should().Be(0, "non-contiguous minOwn=1 → KHÔNG auto qua Cấp 2 người khác");
// Chuỗi ký tay đúng thứ tự.
await ApproveAsync(svc, pe, drafter, comment: "Cấp 1 — người soạn");
pe.CurrentApprovalLevelOrder.Should().Be(2, "sang Cấp 2");
await ApproveAsync(svc, pe, otherNv, comment: "Cấp 2 — NV khác", roles: Array.Empty<string>());
pe.CurrentApprovalLevelOrder.Should().Be(3, "sang Cấp 3");
await ApproveAsync(svc, pe, drafter, comment: "Cấp 3 — người soạn chốt",
approvedPriceAmount: 1_500_000m, approvedPriceSource: "Ncc");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "ký hết 3 cấp → terminal");
// Map level Id → Order từ wf in-memory (đã có Id sau SaveChanges) — tránh Join SQLite.
var levelByOrder = wf.Steps.First().Levels.ToDictionary(l => l.Order, l => l.Id);
var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().HaveCount(3, "cả 3 cấp ký tay → 3 opinion");
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[1]).SignedByUserId
.Should().Be(drafter, "Cấp 1 do người soạn ký");
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[2]).SignedByUserId
.Should().Be(otherNv, "Cấp 2 do NV khác ký");
opinions.Single(o => o.ApprovalWorkflowLevelId == levelByOrder[3]).SignedByUserId
.Should().Be(drafter, "Cấp 3 do người soạn ký");
opinions.Should().OnlyContain(o => !o.Comment!.Contains("bỏ qua"), "không có auto-sign hộ");
(await db.PurchaseEvaluationApprovals
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove))
.Should().Be(0, "toàn bộ chuỗi ký tay → 0 AutoApprove");
(await db.PurchaseEvaluationApprovals
.CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve))
.Should().Be(3, "3 lượt ký tay = 3 Approve");
}
}
// =====================================================================
// EDGE-5 (security) — top-guard chặn non-admin đổi trạng thái phiếu ĐÃ KẾT THÚC
// =====================================================================
// Dựng phiếu ở phase kết thúc cho test EDGE-5 (không cần workflow — guard đứng TRƯỚC).
private static async Task<PurchaseEvaluation> BuildTerminalPeAsync(
TestApplicationDbContext db, PurchaseEvaluationPhase terminalPhase, string code)
{
var pe = BuildPeNhap(code: code);
pe.Phase = terminalPhase;
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
[Fact]
public async Task Transition_NonAdminReject_FromDaDuyet_ThrowsTerminalLock_Edge5()
{
// [EDGE-5] non-admin forge Reject (target=TraLai) trên phiếu DaDuyet → ConflictException
// "trạng thái kết thúc". Chặn lật phiếu đã duyệt về "đang duyệt". Phiếu giữ DaDuyet.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-DADUYET");
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("trạng thái kết thúc");
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "guard chặn TRƯỚC mutate — phiếu giữ DaDuyet");
}
}
[Fact]
public async Task Transition_NonAdminReject_FromTuChoi_ThrowsTerminalLock_Edge5()
{
// [EDGE-5] Tương tự từ TuChoi (phase kết thúc thứ 2) → cùng ConflictException.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.TuChoi, "PE-EDGE5-TUCHOI");
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Drafter },
decision: ApprovalDecision.Reject, comment: "forge trả lại", ct: CancellationToken.None);
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("trạng thái kết thúc");
pe.Phase.Should().Be(PurchaseEvaluationPhase.TuChoi);
}
}
[Fact]
public async Task Transition_AdminReject_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
{
// [EDGE-5] ADMIN MIỄN guard → qua top-guard, un-terminal phiếu DaDuyet về TraLai
// (owner oQ2 "Admin đầy đủ nhất"). KHÔNG ném "trạng thái kết thúc".
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-ADMIN");
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.TraLai,
actorUserId: Guid.NewGuid(), actorRoles: new[] { AppRoles.Admin },
decision: ApprovalDecision.Reject, comment: "admin un-terminal",
returnMode: WorkflowReturnMode.Drafter, ct: CancellationToken.None);
await act.Should().NotThrowAsync("Admin được MIỄN EDGE-5 guard");
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai, "Admin qua guard + un-terminal thành công");
}
}
[Fact]
public async Task Transition_SystemAutoApprove_FromDaDuyet_NotBlockedByTerminalLock_Edge5()
{
// [EDGE-5] SYSTEM (actorUserId=null + decision=AutoApprove, vd cron/SLA) MIỄN guard.
// Từ DaDuyet không có transition system hợp lệ → rơi xuống fall-through "không hỗ trợ"
// (KHÁC "trạng thái kết thúc") → chứng minh đã QUA EDGE-5, không bị guard này chặn.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = await BuildTerminalPeAsync(db, PurchaseEvaluationPhase.DaDuyet, "PE-EDGE5-SYSTEM");
var act = () => svc.TransitionAsync(pe, PurchaseEvaluationPhase.ChoDuyet,
actorUserId: null, actorRoles: Array.Empty<string>(),
decision: ApprovalDecision.AutoApprove, comment: "cron", ct: CancellationToken.None);
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().NotContain("trạng thái kết thúc", "system MIỄN EDGE-5 guard");
ex.Which.Message.Should().Contain("không hỗ trợ", "qua EDGE-5 → fall-through vì DaDuyet không có transition system");
}
}
// =====================================================================
// Opinion history (CHANGE 4) — re-sign đè opinion cũ → 1 Changelog Workflow/Update
// =====================================================================
[Fact]
public async Task ReSign_OverExistingNonEmptyOpinion_LogsExactlyOneHistoryRow_FirstSignLogsNone()
{
// [CHANGE 4] Workflow 1 bước 2 cấp (Cấp 1=approverA, Cấp 2=approverB). Drafter ngoài
// chuỗi → không bypass. approverA ký Cấp 1 (lần ĐẦU) → KHÔNG history row. approverB
// Trả lại → drafter resubmit → approverA ký Cấp 1 LẦN 2 → đúng 1 Changelog
// (EntityType=Workflow, Action=Update, EntityId=Cấp1.Id, ContextNote chứa comment cũ)
// + opinion.Comment cập nhật bản mới.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var approverA = (await fix.CreateUserAsync("appA@s60.test", "Approver A", null, Array.Empty<string>())).Id;
var approverB = (await fix.CreateUserAsync("appB@s60.test", "Approver B", null, Array.Empty<string>())).Id;
var drafterX = (await fix.CreateUserAsync("dx@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;
var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-1");
await SubmitAsync(svc, pe, drafterX); // drafter ngoài chuỗi → pointer (0,1), no bypass
const string round1 = "Round 1: đồng ý sơ bộ";
await ApproveAsync(svc, pe, approverA, comment: round1, roles: Array.Empty<string>());
// Lần ký ĐẦU → KHÔNG có history row cho Cấp 1.
(await db.PurchaseEvaluationChangelogs.CountAsync(c =>
c.PurchaseEvaluationId == pe.Id
&& c.EntityId == cap1Id
&& c.Action == ChangelogAction.Update))
.Should().Be(0, "[CHANGE 4] lần ký đầu (existingOpinion null) → KHÔNG log history");
// approverB Trả lại về người soạn → resubmit → về lại Cấp 1.
await ReturnToDrafterAsync(svc, pe, approverB);
pe.Phase.Should().Be(PurchaseEvaluationPhase.TraLai);
await SubmitAsync(svc, pe, drafterX);
pe.CurrentApprovalLevelOrder.Should().Be(1, "resubmit → chạy lại từ Cấp 1");
// approverA ký Cấp 1 LẦN 2 (đè opinion cũ non-empty).
const string round2 = "Round 2: đã xem lại, vẫn đồng ý";
await ApproveAsync(svc, pe, approverA, comment: round2, roles: Array.Empty<string>());
var histRows = await db.PurchaseEvaluationChangelogs
.Where(c => c.PurchaseEvaluationId == pe.Id
&& c.EntityId == cap1Id
&& c.Action == ChangelogAction.Update)
.ToListAsync();
histRows.Should().HaveCount(1, "[CHANGE 4] re-sign đè opinion non-empty → đúng 1 history row");
histRows[0].EntityType.Should().Be(PurchaseEvaluationEntityType.Workflow);
histRows[0].ContextNote.Should().Contain(round1, "history lưu snapshot ý kiến CŨ");
var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
cap1Opinion.Comment.Should().Be(round2, "opinion overwrite bằng bản mới");
}
}
[Fact]
public async Task ReSign_OverEmptyOpinion_LogsNoHistoryRow()
{
// [CHANGE 4 boundary] Opinion cũ Comment rỗng (chỉ tồn tại qua data legacy / seed trực
// tiếp — service không bao giờ tạo rỗng vì có placeholder "(duyệt — không ý kiến)").
// Guard `!IsNullOrWhiteSpace(existingOpinion.Comment)` → re-sign đè opinion RỖNG KHÔNG
// ghi history row. Pin defensive-guard.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var approverA = (await fix.CreateUserAsync("appAe@s60.test", "Approver A", null, Array.Empty<string>())).Id;
var approverB = (await fix.CreateUserAsync("appBe@s60.test", "Approver B", null, Array.Empty<string>())).Id;
var drafterX = (await fix.CreateUserAsync("dxe@s60.test", "Drafter Ngoai", null, new[] { AppRoles.Drafter })).Id;
var wf = await SeedWorkflowSingleStepAsync(db, approverA, approverB); // Cấp1=A, Cấp2=B
var cap1Id = wf.Steps.First().Levels.First(l => l.Order == 1).Id;
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterX, "PE-HIST-2");
await SubmitAsync(svc, pe, drafterX); // pointer (0,1)
// Seed opinion Cấp 1 với Comment RỖNG (mô phỏng data legacy).
db.PurchaseEvaluationLevelOpinions.Add(new PurchaseEvaluationLevelOpinion
{
PurchaseEvaluationId = pe.Id,
ApprovalWorkflowLevelId = cap1Id,
Comment = "", // rỗng — chỉ đạt được qua seed trực tiếp
SignedAt = DateTime.UtcNow,
SignedByUserId = approverA,
SignedByFullName = "Approver A",
});
await db.SaveChangesAsync(CancellationToken.None);
// approverA ký Cấp 1 → đè opinion rỗng.
await ApproveAsync(svc, pe, approverA, comment: "Ý kiến mới sau bản rỗng", roles: Array.Empty<string>());
(await db.PurchaseEvaluationChangelogs.CountAsync(c =>
c.PurchaseEvaluationId == pe.Id
&& c.EntityId == cap1Id
&& c.Action == ChangelogAction.Update))
.Should().Be(0, "[CHANGE 4] đè opinion rỗng → KHÔNG log history");
var cap1Opinion = await db.PurchaseEvaluationLevelOpinions
.SingleAsync(o => o.PurchaseEvaluationId == pe.Id && o.ApprovalWorkflowLevelId == cap1Id);
cap1Opinion.Comment.Should().Be("Ý kiến mới sau bản rỗng", "opinion overwrite bình thường");
} }
} }
} }