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