[CLAUDE] PurchaseEvaluation: rang buoc du 4 thong tin muc 3 moi gui duyet + bypass nguoi soan trong chuoi duyet (UAT anh Kiet S60)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m38s

- Rename muc 3: "Chon NCC / TP thang thau" -> "Don vi NCC/TP duoc chon" (anh Kiet chot chu) x2 app + wording phu nhat quan
- Guard gui duyet du CA 4 (anh chot): don vi duoc chon + gia chao thau >0 + ngan sach (Budget link HOAC nhap tay) + bang so sanh dinh kem
  + BE ConflictException gop moi muc thieu 1 lan, ap ca Admin (TransitionAsync submit branch)
  + FE pre-check missingForApproval cung predicate -> disable nut + tooltip liet ke du (computeGiaChaoThau extract single-source)
- Bypass drafter-in-chain (luat GENERIC theo cap, anh chot): V2-only, BUOC DAU only - nguoi soan la approver cap k -> auto qua Cap 1..k khi gui
  + Audit 3 tang: Approval row AutoApprove per cap + LevelOpinion CHI slot chinh chu (khong gan chu ky NV bi skip) + Changelog
  + Pointer: k<max -> Cap k+1; het buoc -> Buoc 2 Cap 1; workflow 1 buoc -> terminal DaDuyet
  + TraLai resubmit ap lai idempotent (opinion UPSERT)
- Tests: +14 PeSubmitGuardAndBypassTests (240 -> 254 PASS)
- Reviewer die mid-run (gotcha #53 class) -> em main self-gate evidence-checklist PASS 0 blocker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-12 11:53:26 +07:00
parent 6bf28bfdb4
commit 37122f0f64
7 changed files with 949 additions and 28 deletions

View File

@ -0,0 +1,636 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Contracts; // ApprovalDecision enum (shared HĐ/PE)
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services;
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Services;
// ===== UAT S60 (anh Kiệt) — 2 feature mới trong submit branch =====
// File mirror pattern PurchaseEvaluationWorkflowServiceGuardTests.cs cùng folder
// (helper dựng PE + AwV2 + IdentityFixture SQLite). KHÔNG touch production code —
// test theo CODE hiện tại trong PurchaseEvaluationWorkflowService.cs.
//
// Feature 1 — Section 3 completeness guard (submit branch DangSoanThao/TraLai →
// ChoDuyet, line 158-198 prod): chặn ConflictException khi thiếu 1 trong 4:
// (1) SelectedSupplierId null → "chưa chọn Đơn vị NCC/TP"
// (2) winner quote sum (map PES.SupplierId==winner → row Ids → quotes) ≤ 0
// → "chưa có giá chào thầu"
// (3) BudgetId null && (BudgetManualAmount null || ≤0) → "chưa nhập Ngân sách"
// (4) KHÔNG có attachment PES_Id==null → "chưa đính kèm Bảng so sánh"
// 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 k<max →
// Level=nextOrder · k==max+còn step → StepIdx=1/Level=1 · 1-step+k==max →
// terminal DaDuyet pointers null SlaDeadline null.
//
// 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).
public class PeSubmitGuardAndBypassTests
{
private static (PurchaseEvaluationWorkflowService svc, IdentityFixture fix,
TestApplicationDbContext db, FixedDateTime clock) CreateService()
{
var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var clock = new FixedDateTime(new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc));
var notify = new NoOpNotificationService();
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
return (svc, fix, db, clock);
}
// PE ở Nháp (DangSoanThao) — entry point submit branch. Drafter mặc định
// random (test guard không cần drafter trong chuỗi). V2 nếu awId set.
private static PurchaseEvaluation BuildPeNhap(
Guid? selectedSupplierId = null,
Guid? budgetId = null,
decimal? budgetManualAmount = null,
Guid? approvalWorkflowId = null,
Guid? drafterUserId = null,
string code = "PE-S60-001")
{
return new PurchaseEvaluation
{
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.DangSoanThao,
MaPhieu = code,
TenGoiThau = "Test S60 submit guard + bypass",
ProjectId = Guid.NewGuid(),
DrafterUserId = drafterUserId ?? Guid.NewGuid(),
SelectedSupplierId = selectedSupplierId,
BudgetId = budgetId,
BudgetManualAmount = budgetManualAmount,
ApprovalWorkflowId = approvalWorkflowId,
};
}
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
private static async Task<Guid> SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
{
var supplierId = Guid.NewGuid();
var pes = new PurchaseEvaluationSupplier
{
PurchaseEvaluationId = pe.Id,
SupplierId = supplierId,
Order = 0,
};
var detail = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete M100",
Order = 0,
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationDetails.Add(detail);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = quoteThanhTien,
});
await db.SaveChangesAsync(CancellationToken.None);
return supplierId;
}
// Attachment "Bảng so sánh" = PurchaseEvaluationSupplierId NULL (chung phiếu).
private static void SeedComparisonAttachment(TestApplicationDbContext db, PurchaseEvaluation pe)
{
db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment
{
PurchaseEvaluationId = pe.Id,
PurchaseEvaluationSupplierId = null, // chung phiếu = bảng so sánh
FileName = "bang-so-sanh.xlsx",
StoragePath = "/uploads/bang-so-sanh.xlsx",
FileSize = 1024,
ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
Purpose = PurchaseEvaluationAttachmentPurpose.ComparisonTable,
});
}
// Seed workflow V2: 1 bước (1 phòng) với N cấp (Order 1..levelApprovers.Length),
// mỗi cấp 1 NV theo approverUserIds[i]. Return ApprovalWorkflow đã persist.
private static async Task<ApprovalWorkflow> SeedWorkflowSingleStepAsync(
TestApplicationDbContext db, params Guid[] approverUserIds)
{
return await SeedWorkflowAsync(db, new[] { approverUserIds });
}
// Seed workflow V2 multi-step: stepApprovers[s] = mảng NV cho từng cấp (Order
// 1-based) của bước s (Order s+1). 1 NV / cấp (V2 OR-of-N nhưng test dùng 1).
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
TestApplicationDbContext db, Guid[][] stepApprovers)
{
var wf = new ApprovalWorkflow
{
Code = "QT-S60-V2",
Version = 1,
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
Name = "QT test S60 bypass",
IsActive = true,
IsUserSelectable = true,
};
for (int s = 0; s < stepApprovers.Length; s++)
{
var step = new ApprovalWorkflowStep
{
ApprovalWorkflowId = wf.Id,
Order = s + 1,
Name = $"Bước {s + 1}",
};
for (int lvl = 0; lvl < stepApprovers[s].Length; lvl++)
{
step.Levels.Add(new ApprovalWorkflowLevel
{
ApprovalWorkflowStepId = step.Id,
Order = lvl + 1,
Name = $"Cấp {lvl + 1}",
ApproverUserId = stepApprovers[s][lvl],
});
}
wf.Steps.Add(step);
}
db.ApprovalWorkflows.Add(wf);
await db.SaveChangesAsync(CancellationToken.None);
return wf;
}
private static Task SubmitAsync(
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
params string[] roles) =>
svc.TransitionAsync(
evaluation: pe,
targetPhase: PurchaseEvaluationPhase.ChoDuyet,
actorUserId: actorUserId,
actorRoles: roles.Length > 0 ? roles : new[] { AppRoles.Drafter },
decision: ApprovalDecision.Approve,
comment: null,
ct: CancellationToken.None);
// =====================================================================
// FEATURE 1 — Section 3 completeness guard
// =====================================================================
[Fact]
public async Task Submit_MissingAllFour_ThrowsConflict_MessageContainsAllFourClauses()
{
// (1) Thiếu CẢ 4 → message gộp đủ 4 cụm. SelectedSupplier null → branch (1)
// chạy + KHÔNG seed winner quote, budget, comparison.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(); // SelectedSupplierId null, budget null, no attach
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
// Khi winner null → branch winner-quote KHÔNG chạy (chỉ add "chưa chọn
// Đơn vị NCC/TP"). Còn lại 3 mục: ngân sách + bảng so sánh. Tổng 3 cụm.
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP");
ex.Which.Message.Should().Contain("chưa nhập Ngân sách");
ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
// Guard chặn trước mutate
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
}
}
[Fact]
public async Task Submit_MissingWinnerOnly_ThrowsConflict()
{
// (2) Đủ budget + comparison, CHỈ thiếu winner (SelectedSupplierId null).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa chọn Đơn vị NCC/TP");
ex.Which.Message.Should().NotContain("chưa nhập Ngân sách");
ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh");
}
}
[Fact]
public async Task Submit_WinnerWithZeroQuote_ThrowsConflict_NoBidPrice()
{
// (3) Winner CÓ (PES row tồn tại) nhưng sum quote = 0 → "chưa có giá chào thầu".
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 0m); // quote = 0
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa có giá chào thầu");
}
}
[Fact]
public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict()
{
// (4) Đủ winner + quote + comparison. Thiếu ngân sách: BudgetId null VÀ
// BudgetManualAmount = 0 → "chưa nhập Ngân sách" (cover cả nhánh manual=0,
// không chỉ null).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetId: null, budgetManualAmount: 0m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa nhập Ngân sách");
ex.Which.Message.Should().NotContain("chưa chọn Đơn vị NCC/TP");
ex.Which.Message.Should().NotContain("chưa có giá chào thầu");
ex.Which.Message.Should().NotContain("chưa đính kèm Bảng so sánh");
}
}
[Fact]
public async Task Submit_MissingComparisonTable_ThrowsConflict()
{
// (5) Đủ winner + quote + budget. KHÔNG seed attachment chung → thiếu bảng so sánh.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
}
}
[Fact]
public async Task Submit_AttachmentBoundToSupplier_DoesNotCountAsComparison_StillConflict()
{
// (6) Có attachment NHƯNG gắn NCC cụ thể (PurchaseEvaluationSupplierId != null)
// → KHÔNG đếm là bảng so sánh (predicate PES_Id==null) → vẫn Conflict thiếu
// bảng so sánh. Boundary: chỉ attachment chung phiếu mới qua.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetManualAmount: 500_000m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
// Lấy PES row Id của winner để gắn attachment vào NCC cụ thể.
var winnerRowId = await db.PurchaseEvaluationSuppliers
.Where(s => s.PurchaseEvaluationId == pe.Id && s.SupplierId == supplierId)
.Select(s => s.Id).FirstAsync();
db.PurchaseEvaluationAttachments.Add(new PurchaseEvaluationAttachment
{
PurchaseEvaluationId = pe.Id,
PurchaseEvaluationSupplierId = winnerRowId, // gắn NCC → KHÔNG phải bảng so sánh
FileName = "bao-gia-ncc.pdf",
StoragePath = "/uploads/bao-gia-ncc.pdf",
FileSize = 2048,
ContentType = "application/pdf",
Purpose = PurchaseEvaluationAttachmentPurpose.QuoteDocument,
});
await db.SaveChangesAsync(CancellationToken.None);
var act = () => SubmitAsync(svc, pe, Guid.NewGuid());
var ex = await act.Should().ThrowAsync<ConflictException>();
ex.Which.Message.Should().Contain("chưa đính kèm Bảng so sánh");
}
}
[Fact]
public async Task Submit_AllFourMet_ManualBudget_NoWorkflow_SetsChoDuyet()
{
// (7) Đủ 4 (manual budget > 0, KHÔNG BudgetId, KHÔNG ApprovalWorkflowId) →
// submit OK Phase=ChoDuyet. V1/no-workflow: pointer StepIdx=0, Level null
// (line 208 — chỉ init level=1 nếu V2).
var (svc, fix, db, clock) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetManualAmount: 750_000m);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
await SubmitAsync(svc, pe, Guid.NewGuid());
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentWorkflowStepIndex.Should().Be(0);
pe.CurrentApprovalLevelOrder.Should().BeNull("phiếu không pin V2 → level pointer null");
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
}
}
[Fact]
public async Task Submit_AllFourMet_ViaBudgetId_ManualNull_SetsChoDuyet()
{
// (8) Đủ 4 qua BudgetId (manual null) → OK. Cover nhánh budget thoả qua FK
// Budget thay vì manual amount.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var pe = BuildPeNhap(budgetId: Guid.NewGuid(), budgetManualAmount: null);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 2_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
await SubmitAsync(svc, pe, Guid.NewGuid());
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
}
}
// =====================================================================
// FEATURE 2 — Drafter-in-chain bypass khi submit (V2-only)
// Mọi test dưới đây PHẢI dựng PE đủ 4 điều kiện Section 3 (guard chạy trước).
// =====================================================================
// Helper: dựng PE V2 đủ 4 điều kiện Section 3 + pin workflow. Drafter =
// drafterUserId. Return PE đã persist (Nháp).
private static async Task<PurchaseEvaluation> BuildV2PeReadyToSubmitAsync(
TestApplicationDbContext db, Guid workflowId, Guid drafterUserId, string code)
{
var pe = BuildPeNhap(
budgetManualAmount: 1_000_000m,
approvalWorkflowId: workflowId,
drafterUserId: drafterUserId,
code: code);
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_500_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
[Fact]
public async Task Submit_DrafterIsTopLevelOfFirstStep_BypassesAllLevels_MovesToStep2()
{
// (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.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var nvLevel1 = (await fix.CreateUserAsync("nv1@s60.test", "NV Cap 1", null, Array.Empty<string>())).Id;
var drafterTp = (await fix.CreateUserAsync("tp@s60.test", "TP Drafter", null, new[] { AppRoles.Drafter })).Id;
var step2Boss = (await fix.CreateUserAsync("boss@s60.test", "Boss Step2", null, Array.Empty<string>())).Id;
// bước 1: Cấp 1 = NV, Cấp 2 = TP(drafter) · bước 2: Cấp 1 = boss
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { nvLevel1, drafterTp },
new[] { step2Boss },
});
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterTp, "PE-S60-009");
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);
// Opinion CHỈ slot chính chủ (TP Cấp 2). KHÔNG opinion Cấp 1 (NV bị skip).
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");
// 2 approval AutoApprove (Cấp 1 + Cấp 2 bước 1).
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");
}
}
[Fact]
public async Task Submit_DrafterIsLevel1OfFirstStep_BypassesLevel1Only_MovesToLevel2SameStep()
{
// (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).
var (svc, fix, db, _) = CreateService();
using (fix)
{
var drafterNv = (await fix.CreateUserAsync("nv@s60.test", "NV Drafter", null, new[] { AppRoles.Drafter })).Id;
var level2Boss = (await fix.CreateUserAsync("boss2@s60.test", "Boss Cap 2", null, Array.Empty<string>())).Id;
var wf = await SeedWorkflowSingleStepAsync(db, drafterNv, level2Boss); // Cấp1=drafter, Cấp2=boss
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterNv, "PE-S60-010");
await SubmitAsync(svc, pe, drafterNv);
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");
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);
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");
}
}
[Fact]
public async Task Submit_DrafterNotInFirstStep_NoBypass_StartsAtStep1Level1()
{
// (11) Drafter KHÔNG ở bước đầu (chỉ ở bước 2). drafterSlots bước 1 empty →
// return sớm KHÔNG bypass. Pointer giữ init Step 0 Level 1. 0 approval auto.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var step1Nv = (await fix.CreateUserAsync("s1nv@s60.test", "NV Buoc 1", null, Array.Empty<string>())).Id;
var drafterStep2 = (await fix.CreateUserAsync("d2@s60.test", "Drafter Buoc 2", null, new[] { AppRoles.Drafter })).Id;
var wf = await SeedWorkflowAsync(db, new[]
{
new[] { step1Nv }, // bước 1: NV khác (KHÔNG phải drafter)
new[] { drafterStep2 }, // bước 2: drafter — nhưng bypass chỉ xét bước đầu
});
var pe = await BuildV2PeReadyToSubmitAsync(db, wf.Id, drafterStep2, "PE-S60-011");
await SubmitAsync(svc, pe, drafterStep2);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentWorkflowStepIndex.Should().Be(0, "không bypass → start Bước 1");
pe.CurrentApprovalLevelOrder.Should().Be(1);
var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().BeEmpty("drafter ngoài bước đầu → KHÔNG ghi opinion bypass");
var autoApprovals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
autoApprovals.Should().BeEmpty("0 cấp bypass");
}
}
[Fact]
public async Task Submit_OneStepWorkflow_DrafterIsLastLevel_TerminalDaDuyet_PointersNull()
{
// (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.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var level1Nv = (await fix.CreateUserAsync("l1@s60.test", "NV Cap 1", null, Array.Empty<string>())).Id;
var drafterLast = (await fix.CreateUserAsync("last@s60.test", "Drafter Cap cuoi", null, new[] { AppRoles.Drafter })).Id;
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");
await SubmitAsync(svc, pe, drafterLast);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "1 bước + drafter 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.
var opinions = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinions.Should().HaveCount(1);
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");
}
}
[Fact]
public async Task Submit_V1Phieu_NoApprovalWorkflowId_SubmitsOk_NoBypass_NoCrash()
{
// (13) Phiếu V1 (ApprovalWorkflowId null) → submit OK, KHÔNG bypass (V2-only),
// KHÔNG crash. Đủ 4 điều kiện Section 3 vẫn áp.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var drafter = (await fix.CreateUserAsync("v1d@s60.test", "V1 Drafter", null, new[] { AppRoles.Drafter })).Id;
var pe = BuildPeNhap(budgetManualAmount: 1_000_000m, approvalWorkflowId: null, drafterUserId: drafter, code: "PE-S60-013");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
pe.SelectedSupplierId = supplierId;
SeedComparisonAttachment(db, pe);
await db.SaveChangesAsync(CancellationToken.None);
await SubmitAsync(svc, pe, drafter);
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet);
pe.CurrentApprovalLevelOrder.Should().BeNull("V1 → level pointer null, không bypass");
var autoApprovals = await db.PurchaseEvaluationApprovals
.Where(a => a.PurchaseEvaluationId == pe.Id
&& a.Decision == ApprovalDecision.AutoApprove).ToListAsync();
autoApprovals.Should().BeEmpty("V1 không bypass → 0 AutoApprove row");
}
}
[Fact]
public async Task Resubmit_FromTraLai_ReAppliesBypass_OpinionNotDuplicated_ApprovalAccumulates()
{
// (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.
var (svc, fix, db, _) = CreateService();
using (fix)
{
var drafterSolo = (await fix.CreateUserAsync("solo@s60.test", "Drafter Solo", null, new[] { AppRoles.Drafter })).Id;
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.
await SubmitAsync(svc, pe, drafterSolo);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet);
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);
// Mô phỏng Trả lại: reset về TraLai (như Reject branch Drafter mode làm).
pe.Phase = PurchaseEvaluationPhase.TraLai;
pe.CurrentWorkflowStepIndex = null;
pe.CurrentApprovalLevelOrder = null;
pe.SlaDeadline = null;
await db.SaveChangesAsync(CancellationToken.None);
// Lần gửi 2 (resubmit từ TraLai) → bypass áp lại.
await SubmitAsync(svc, pe, drafterSolo);
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "resubmit áp lại bypass → terminal");
var opinionsAfter2 = await db.PurchaseEvaluationLevelOpinions
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
opinionsAfter2.Should().HaveCount(1, "UPSERT — opinion KHÔNG duplicate sau resubmit");
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");
}
}
}