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