diff --git a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs index f0b0457..bcfaed5 100644 --- a/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs +++ b/src/Backend/SolutionErp.Infrastructure/Services/PurchaseEvaluationWorkflowService.cs @@ -54,6 +54,22 @@ public class PurchaseEvaluationWorkflowService( var isAdmin = actorRoles.Contains(AppRoles.Admin); 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 ===== // 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 @@ -547,27 +563,23 @@ public class PurchaseEvaluationWorkflowService( return summary; } - // ===== UAT S60 — 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." - // Luật GENERIC theo cấp (anh chốt S60): chỉ xét BƯỚC ĐẦU (= phòng 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 - // Cấp 1..k (k = MAX Order có slot drafter — drafter có thể ở nhiều cấp, - // Designer chỉ chặn duplicate cùng cấp). Phiếu bắt đầu chờ Cấp k+1 / Bước 2 - // Cấp 1 / terminal DaDuyet (quy trình 1 bước mà drafter là cấp cuối). - // Các bước SAU + BOD duyệt đầy đủ bình thường. + // ===== 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 cấp dưới duyệt lại." + // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): chỉ xét BƯỚC ĐẦU (= phòng + // 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) KÝ TAY ô 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 soạn có slot). Pointer DỪNG tại + // minOwn → người soạn tự duyệt Cấp minOwn qua ApproveV2Async (KHÔNG auto-terminal, + // KHÔNG auto nhảy Bước — người soạn phải ký thật để "bám đúng quy trình"). + // 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: - // - LevelOpinion: ghi CHỈ slot CHÍNH CHỦ (Level.ApproverUserId == drafter) - // — KHÔNG ghi hộ cấp NV bị skip (không gán chữ ký người không duyệt + - // không trigger FE badge "duyệt thay" sai nghĩa). Cấp skip để trống - // Section 5 (đúng sự thật); vết nằm ở Approval row + Changelog. - // - Approval row per cấp (Decision=AutoApprove) → ApprovalsTab có vế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). + // Audit trail: mỗi cấp NV bị bypass → 1 Approval row (Decision=AutoApprove, "bỏ + // qua") + 1 dòng Changelog tóm tắt pointer nhảy. KHÔNG tự ký hộ opinion Section 5 + // cho bất kỳ ai (kể cả người soạn — họ ký tay). Idempotent khi TraLai-resubmit + // (approval row add thêm = lịch sử 2 lần gửi — đú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( PurchaseEvaluation evaluation, Guid awId, CancellationToken ct) { @@ -589,17 +601,18 @@ public class PurchaseEvaluationWorkflowService( 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 - var k = drafterSlots.Max(l => l.Order); - var maxLevelOrder = levelGroups.Max(g => g.Key); + // A1 (close-review wf_7df56b89-7bb — owner anh Kiệt): người soạn KÝ TAY ô 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 soạn có slot trong bước đầu. + var minOwn = drafterSlots.Min(l => l.Order); 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) { - 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 { PurchaseEvaluationId = evaluation.Id, @@ -607,75 +620,27 @@ public class PurchaseEvaluationWorkflowService( ToPhase = PurchaseEvaluationPhase.ChoDuyet, ApproverUserId = drafterId, Decision = ApprovalDecision.AutoApprove, - Comment = ownSlot is not null - ? $"[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)", + 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)", 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 - // không liên tục; engine gốc +1 giả định liên tục, đây superset). - if (k < maxLevelOrder) + // 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Ý + // Cấp minOwn qua ApproveV2Async (gate match-approver pass vì họ ∈ ApproverUserId). + // 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 = nextOrder; + evaluation.CurrentApprovalLevelOrder = minOwn; await LogTransitionAsync(evaluation, PurchaseEvaluationPhase.ChoDuyet, PurchaseEvaluationPhase.ChoDuyet, 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}", - 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)", + $"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); } } @@ -783,6 +748,28 @@ public class PurchaseEvaluationWorkflowService( } 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.SignedAt = dateTime.UtcNow; existingOpinion.SignedByUserId = actorUserId ?? Guid.Empty; diff --git a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs index 722eb10..76a885e 100644 --- a/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs +++ b/tests/SolutionErp.Infrastructure.Tests/Services/PeSubmitGuardAndBypassTests.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using SolutionErp.Application.Common.Exceptions; +using SolutionErp.Application.PurchaseEvaluations.Services; // WorkflowReturnMode using SolutionErp.Domain.ApprovalWorkflowsV2; using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE) 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). // // 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 -// ĐẦU → auto qua Cấp 1..k. Approval rows per cấp (Decision=AutoApprove); opinion -// CHỈ ghi slot chính chủ (ownSlot.ApproverUserId==drafter); pointer k0 + manual budget + comparison file). @@ -187,6 +204,42 @@ public class PeSubmitGuardAndBypassTests comment: null, 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(), + 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 // ===================================================================== @@ -462,12 +515,13 @@ public class PeSubmitGuardAndBypassTests } [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 + - // 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 - // → pointer StepIdx=1 Level=1. Opinion CHỈ 1 row slot TP (Cấp 2). 2 approval - // AutoApprove (Cấp 1 + Cấp 2). KHÔNG opinion Cấp 1. + // (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). minOwn=2 → CHỈ bypass Cấp 1 (NV dưới + // thuần). Pointer DỪNG tại (StepIdx=0, Level=2) — KHÔNG advance sang Bước 2; người + // 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(); using (fix) { @@ -486,32 +540,28 @@ public class PeSubmitGuardAndBypassTests await SubmitAsync(svc, pe, drafterTp); 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.CurrentApprovalLevelOrder.Should().Be(1); + 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(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 .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); - opinions.Should().HaveCount(1, "chỉ ghi opinion slot drafter, không ghi hộ NV skip"); - 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"); + opinions.Should().BeEmpty("[A1] KHÔNG tự ký hộ opinion — người soạn ký tay Cấp 2 sau"); - // 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 .Where(a => a.PurchaseEvaluationId == pe.Id && 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] - 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 - // → pointer Level=2 cùng bước (StepIdx giữ 0). Opinion slot NV Cấp 1. 1 approval - // AutoApprove. Cấp 2 KHÔNG bypass (approver khác). + // (10) [A1] Drafter = NV cấp 1/2 bước 1 (workflow 1 bước có 2 cấp). minOwn=1 → + // KHÔNG có cấp nào Order < 1 → bypass RỖNG. Pointer giữ init (StepIdx=0, Level=1) — + // người soạn tự ký Cấp 1 qua ApproveV2Async. Opinion=0, AutoApprove=0 sau submit. var (svc, fix, db, _) = CreateService(); using (fix) { @@ -525,19 +575,16 @@ public class PeSubmitGuardAndBypassTests pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet); 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 .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); - opinions.Should().HaveCount(1); - 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); + opinions.Should().BeEmpty("[A1] người soạn Cấp 1 → không bypass, không auto-sign"); var autoApprovals = await db.PurchaseEvaluationApprovals .Where(a => a.PurchaseEvaluationId == pe.Id && 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] - 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 + - // chỉ 1 bước → terminal DaDuyet, pointers null, SlaDeadline null. + // (12) [A1] Workflow 1 bước (2 cấp) + drafter = cấp cuối (Cấp 2). minOwn=2 → bypass + // 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(); 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 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); - 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.CurrentApprovalLevelOrder.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 .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; opinions[0].ApprovalWorkflowLevelId.Should().Be(drafterLevelId); - - var autoApprovals = await db.PurchaseEvaluationApprovals - .Where(a => a.PurchaseEvaluationId == pe.Id - && a.Decision == ApprovalDecision.AutoApprove).ToListAsync(); - autoApprovals.Should().HaveCount(2, "Cấp 1 + Cấp 2 bypass"); + opinions[0].SignedByUserId.Should().Be(drafterLast); + opinions[0].Comment.Should().Be("Người soạn ký chốt Cấp 2"); } } @@ -640,11 +703,13 @@ public class PeSubmitGuardAndBypassTests } [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 - // row). Approval rows cộng dồn vết (2 lần gửi = 2× AutoApprove cùng cấp). - // Dùng workflow 1 bước 1 cấp = drafter → mỗi submit terminal DaDuyet. + // (14) [A1] Workflow 1 bước 1 cấp = drafter (người soạn LÀ cấp duy nhất). minOwn=1 → + // bypass RỖNG. ĐỔI hành vi cũ (auto-terminal DaDuyet + auto-sign khi gửi): NAY submit + // 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(); using (fix) { @@ -652,16 +717,18 @@ public class PeSubmitGuardAndBypassTests var wf = await SeedWorkflowSingleStepAsync(db, drafterSolo); // 1 bước, 1 cấp = drafter 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); - 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 - .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync(); - opinionsAfter1.Should().HaveCount(1); - var autoAfter1 = await db.PurchaseEvaluationApprovals - .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); - autoAfter1.Should().Be(1); + (await db.PurchaseEvaluationLevelOpinions + .Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync()) + .Should().BeEmpty("[A1] chưa ký → 0 opinion"); + (await db.PurchaseEvaluationApprovals + .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove)) + .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). pe.Phase = PurchaseEvaluationPhase.TraLai; @@ -670,17 +737,328 @@ public class PeSubmitGuardAndBypassTests pe.SlaDeadline = null; 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); - 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())).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())).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(); - 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 - .CountAsync(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.AutoApprove); - autoAfter2.Should().Be(2, "approval rows cộng dồn — 2 lần gửi = 2 vết AutoApprove"); + [Fact] + public async Task Submit_DrafterNonContiguousLevel1And3_NoBypass_FullManualChainInOrder() + { + // [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())).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()); + 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 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(); + 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(); + 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(), + decision: ApprovalDecision.AutoApprove, comment: "cron", ct: CancellationToken.None); + + var ex = await act.Should().ThrowAsync(); + 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())).Id; + var approverB = (await fix.CreateUserAsync("appB@s60.test", "Approver B", null, Array.Empty())).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()); + + // 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()); + + 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())).Id; + var approverB = (await fix.CreateUserAsync("appBe@s60.test", "Approver B", null, Array.Empty())).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()); + + (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"); } } }