[CLAUDE] PurchaseEvaluation: CCM 3-fix — multi-winner sum + budget=0 hợp lệ + cấp KẾT THÚC quy trình (Mig 58, anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s
CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng): 1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId). SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô + dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2). 2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL, số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE. 3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58) — admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet, bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt chốt). FE: banner cảnh báo + bắt chọn giá chốt. Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner, additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests +budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror. Build sạch BE + 2 FE local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,319 @@
|
||||
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;
|
||||
|
||||
// ===== item 4 (Mig 58 2026-06-24 — CCM/anh Kiệt FDC) — PER-LEVEL finalize "Duyệt là
|
||||
// KẾT THÚC, không trình CEO" — AUTO theo cấu hình quy trình (anh Kiệt chốt 2026-06-24
|
||||
// "cho bật trong quy trình hay hơn") — test theo CODE (S34 rule, KHÔNG touch production).
|
||||
//
|
||||
// SPEC item 4 (AUTO, KHÔNG ô-tích runtime): ApprovalWorkflowLevel +AllowApproverFinalize
|
||||
// (bool, Mig 58). Trong ApproveV2Async, SAU opinion UPSERT + skipToFinal, TRƯỚC
|
||||
// ccm-delegation + advance:
|
||||
// if (matchingLevel.AllowApproverFinalize) {
|
||||
// ApplyApprovedPriceOnFinalize(...); // human PHẢI chọn giá chốt (Conflict nếu null)
|
||||
// Phase = DaDuyet; clear pointers (StepIdx/LevelOrder/SlaDeadline = null); return;
|
||||
// }
|
||||
// ⚠️ Quy-trình-driven: cấp được cấu hình KẾT THÚC → duyệt tới đó là TỰ done, KHÔNG cần
|
||||
// cờ runtime. KHÔNG threshold, KHÔNG role-gate (admin chỉ-định slot = đủ thẩm quyền). Bỏ
|
||||
// qua MỌI Bước/Cấp còn lại (kể cả CEO ở step sau). Wrong-actor vẫn Forbidden TRƯỚC finalize
|
||||
// (actor-match guard chạy trước).
|
||||
//
|
||||
// Mirror harness PeCcmThresholdFinalizeTests.cs cùng folder (IdentityFixture + SQLite +
|
||||
// SeedWorkflowAsync + BuildPeAtApprovalSlot + NoOpNotificationService). Dựng PE TRỰC TIẾP
|
||||
// ở ChoDuyet tại slot NON-terminal (còn Step/CEO sau) → drive 1 Approve.
|
||||
public class PeApproverFinalizeTests
|
||||
{
|
||||
private const decimal ValidApprovedPrice = 400_000_000m;
|
||||
private const string ValidApprovedSource = "Ncc";
|
||||
|
||||
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, 24, 0, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NoOpNotificationService();
|
||||
var svc = new PurchaseEvaluationWorkflowService(db, clock, notify, um);
|
||||
return (svc, fix, db, clock);
|
||||
}
|
||||
|
||||
private static PurchaseEvaluation BuildPeAtApprovalSlot(
|
||||
Guid approvalWorkflowId, int stepIdx, int levelOrder, string code = "PE-AF-001")
|
||||
=> new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = code,
|
||||
TenGoiThau = "Test item 4 — per-level finalize (auto)",
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
ApprovalWorkflowId = approvalWorkflowId,
|
||||
CurrentWorkflowStepIndex = stepIdx,
|
||||
CurrentApprovalLevelOrder = levelOrder,
|
||||
SlaDeadline = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// Seed workflow V2. stepApprovers[s][lvl] = (approverUserId, allowFinalize) cho Cấp
|
||||
// (Order lvl+1) của Bước (Order s+1). 1 NV / cấp. Return ApprovalWorkflow đã persist.
|
||||
private static async Task<ApprovalWorkflow> SeedWorkflowAsync(
|
||||
TestApplicationDbContext db, (Guid user, bool allowFinalize)[][] stepApprovers)
|
||||
{
|
||||
var wf = new ApprovalWorkflow
|
||||
{
|
||||
Code = "QT-AF-V2",
|
||||
Version = 1,
|
||||
ApplicableType = ApprovalWorkflowApplicableType.DuyetNcc,
|
||||
Name = "QT test item 4 per-level finalize (auto)",
|
||||
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++)
|
||||
{
|
||||
var (user, allow) = stepApprovers[s][lvl];
|
||||
step.Levels.Add(new ApprovalWorkflowLevel
|
||||
{
|
||||
ApprovalWorkflowStepId = step.Id,
|
||||
Order = lvl + 1,
|
||||
Name = $"Cấp {lvl + 1}",
|
||||
ApproverUserId = user,
|
||||
AllowApproverFinalize = allow,
|
||||
});
|
||||
}
|
||||
wf.Steps.Add(step);
|
||||
}
|
||||
db.ApprovalWorkflows.Add(wf);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return wf;
|
||||
}
|
||||
|
||||
// AUTO model: KHÔNG còn finalizeByApprover param — finalize tự chạy theo cờ cấp.
|
||||
private static Task ApproveAsync(
|
||||
PurchaseEvaluationWorkflowService svc, PurchaseEvaluation pe, Guid actorUserId,
|
||||
string[] roles,
|
||||
decimal? approvedPriceAmount = null,
|
||||
string? approvedPriceSource = null) =>
|
||||
svc.TransitionAsync(
|
||||
evaluation: pe,
|
||||
targetPhase: PurchaseEvaluationPhase.ChoDuyet, // approve-in-place
|
||||
actorUserId: actorUserId,
|
||||
actorRoles: roles,
|
||||
decision: ApprovalDecision.Approve,
|
||||
comment: null,
|
||||
approvedPriceAmount: approvedPriceAmount,
|
||||
approvedPriceSource: approvedPriceSource,
|
||||
ct: CancellationToken.None);
|
||||
|
||||
// =====================================================================
|
||||
// 1. ⭐ LOAD-BEARING — slot NON-terminal AllowApproverFinalize=true + approver duyệt
|
||||
// (+ giá chốt) → TỰ DaDuyet, bỏ CEO (Step 2), pointers cleared. AUTO, không ô-tích.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_LevelAllowsFinalize_AutoFinalizesDaDuyet_SkipsCeo_PointersCleared()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// Bước 1 Cấp 1 = approver (AllowApproverFinalize=TRUE, đang đứng đây).
|
||||
// Bước 2 Cấp 1 = CEO (sẽ bị BỎ QUA hoàn toàn).
|
||||
var approver = (await fix.CreateUserAsync("appr@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { (approver, true) }, // Bước 1 Cấp 1 = approver, finalize-allowed
|
||||
new[] { (ceo, false) }, // Bước 2 Cấp 1 = CEO (bỏ qua)
|
||||
});
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1);
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"slot AllowApproverFinalize → duyệt tới đây là TỰ kết thúc, bỏ CEO");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull("terminal → step pointer null");
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull("terminal → level pointer null");
|
||||
pe.SlaDeadline.Should().BeNull("terminal → SLA null");
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice, "giá chốt bind khi finalize");
|
||||
pe.ApprovedPriceSource.Should().Be(ValidApprovedSource);
|
||||
|
||||
// CEO KHÔNG được ghi opinion (bị bỏ qua — chỉ approver Bước 1 ký).
|
||||
var opinions = await db.PurchaseEvaluationLevelOpinions
|
||||
.Where(o => o.PurchaseEvaluationId == pe.Id).ToListAsync();
|
||||
var approverLevelId = wf.Steps.First(s => s.Order == 1).Levels.First(l => l.Order == 1).Id;
|
||||
opinions.Should().ContainSingle(o => o.ApprovalWorkflowLevelId == approverLevelId,
|
||||
"chỉ slot approver ký, không ghi hộ CEO");
|
||||
|
||||
// 1 Approval Approve (của approver), finalize-branch return → KHÔNG double.
|
||||
var approvals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
|
||||
.ToListAsync();
|
||||
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double-finalize");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 2. CONFIG GATES IT — slot AllowApproverFinalize=FALSE + approver duyệt → KHÔNG
|
||||
// finalize, advance bình thường lên CEO (Step 2). Chứng minh CHỈ cấp được cấu
|
||||
// hình KẾT THÚC mới dừng; cấp thường vẫn trình tiếp.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_LevelNotAllowed_AdvancesToCeo_PhaseStaysChoDuyet()
|
||||
{
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("appr2@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo2@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { (approver, false) }, // ⭐ slot KHÔNG cấu hình KẾT THÚC
|
||||
new[] { (ceo, false) },
|
||||
});
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-002");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, approver, Array.Empty<string>()); // không giá chốt — chưa terminal
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet,
|
||||
"slot không cấu hình kết thúc → advance bình thường lên CEO");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(1, "advance sang Bước 2 (CEO)");
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7), "SLA reset cho CEO");
|
||||
pe.ApprovedPriceAmount.Should().BeNull("chưa terminal → chưa bind giá chốt");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 3. PRICE REQUIRED — slot allow=true NHƯNG approvedPrice null (human) →
|
||||
// Conflict("giá chốt"). Finalize cũng cần giá chốt như terminal cuối.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_LevelAllowsFinalize_NullPrice_ThrowsConflict()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("appr3@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo3@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { (approver, true) },
|
||||
new[] { (ceo, false) },
|
||||
});
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-003");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// slot allowed NHƯNG KHÔNG truyền giá chốt → finalize đòi giá chốt.
|
||||
var act = async () => await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||
approvedPriceAmount: null, approvedPriceSource: null);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
.WithMessage("*giá chốt*");
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "thiếu giá chốt → không finalize");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 4. WRONG-ACTOR precedence — approver-match guard chạy TRƯỚC finalize-branch:
|
||||
// NV KHÔNG thuộc Cấp hiện tại duyệt → Forbidden (không phải lượt), KHÔNG tới
|
||||
// được finalize logic. Bảo vệ forge request.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_ActorNotInLevel_ThrowsForbidden_BeforeFinalize()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var realApprover = (await fix.CreateUserAsync("real4@af.test", "Real Approver", null, Array.Empty<string>())).Id;
|
||||
var intruder = (await fix.CreateUserAsync("intruder4@af.test", "Intruder", null, Array.Empty<string>())).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceo4@af.test", "CEO", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { (realApprover, true) }, // slot cấu hình kết thúc — nhưng intruder KHÔNG phải người này
|
||||
new[] { (ceo, false) },
|
||||
});
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-004");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// intruder (không trong Cấp) duyệt → Forbidden TRƯỚC finalize logic.
|
||||
var act = async () => await ApproveAsync(svc, pe, intruder, Array.Empty<string>(),
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().ThrowAsync<ForbiddenException>();
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "không phải lượt → không finalize");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 5. AT-LAST-SLOT — slot CUỐI (1 bước 1 cấp) + allow=true → DaDuyet qua finalize-
|
||||
// branch (chạy TRƯỚC normal terminal advance). 1 Approval, không double.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_AtLastSlot_LevelAllowsFinalize_FinalizesDaDuyet_NoDoubleApproval()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
// 1 bước 1 cấp = approver (slot cuối luôn), allow finalize.
|
||||
var approver = (await fix.CreateUserAsync("appr5@af.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { (approver, true) },
|
||||
});
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, stepIdx: 0, levelOrder: 1, code: "PE-AF-005");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
await ApproveAsync(svc, pe, approver, Array.Empty<string>(),
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet,
|
||||
"finalize ở slot cuối → DaDuyet qua finalize-branch (trước normal advance)");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.CurrentApprovalLevelOrder.Should().BeNull();
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
|
||||
var approvals = await db.PurchaseEvaluationApprovals
|
||||
.Where(a => a.PurchaseEvaluationId == pe.Id && a.Decision == ApprovalDecision.Approve)
|
||||
.ToListAsync();
|
||||
approvals.Should().HaveCount(1, "1 lần duyệt = 1 Approval row, không double");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -89,6 +89,7 @@ public class PeCcmThresholdFinalizeTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
|
||||
@ -79,6 +79,9 @@ public class PeSubmitGuardAndBypassTests
|
||||
|
||||
// Seed 1 NCC tham gia (PurchaseEvaluationSupplier) cho winner + 1 detail + 1
|
||||
// quote ThanhTien=amount để winner quote sum > 0. Return supplierId (master ref).
|
||||
// [Mig 58] Winner = supplier-row IsWinner=true (nguồn-sự-thật mới — submit guard +
|
||||
// ccm-delegate + read DTO SUM theo IsWinner, KHÔNG còn theo SelectedSupplierId). Set
|
||||
// IsWinner=true + sync SelectedSupplierId trong caller để mirror handler select-winner.
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
@ -88,6 +91,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -257,15 +261,16 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MissingBudget_BothNullAndManualZero_ThrowsConflict()
|
||||
public async Task Submit_MissingBudget_NullBlocks_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).
|
||||
// (4) [Mig 58 item 2] Đủ winner + quote + comparison. Ngân sách = NULL (chưa nhập,
|
||||
// và phiếu KHÔNG có WorkItemId fallback ProInitial) → "chưa nhập Ngân sách kỳ này".
|
||||
// ĐỔI từ trước (block khi null||<=0): NAY chỉ NULL block, 0 hợp lệ (test 4b). Phiếu
|
||||
// KHÔNG pin WorkItemId → effectiveBudget=null sau khi không fallback được.
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 0m);
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: null); // null = chưa nhập → block
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var supplierId = await SeedWinnerWithQuoteAsync(db, pe, quoteThanhTien: 1_000_000m);
|
||||
@ -283,6 +288,65 @@ public class PeSubmitGuardAndBypassTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_BudgetZero_IsValid_PassesBudgetGuard_ThenWorkflowRequired()
|
||||
{
|
||||
// (4b) [Mig 58 item 2] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn cho gửi duyệt).
|
||||
// ĐỔI hành vi: trước `is null || <= 0` block; NAY chỉ `is null` block → 0 đi qua.
|
||||
// Đủ winner + quote>0 + comparison + budget=0 → guard Section-3 PASS, KHÔNG còn
|
||||
// "chưa nhập Ngân sách". Phiếu KHÔNG pin workflow → dừng ở require-workflow guard
|
||||
// (chứng minh đã qua budget-guard: message NHẮC workflow chứ KHÔNG nhắc ngân sách).
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 0m); // 0 = cố ý → hợp lệ
|
||||
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().NotContain("Ngân sách", "ngân sách = 0 hợp lệ → qua budget-guard");
|
||||
ex.Which.Message.Should().Contain("quy trình duyệt", "dừng ở require-workflow (sau budget-guard)");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DangSoanThao);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_BudgetZero_WithWorkflow_SucceedsToChoDuyet()
|
||||
{
|
||||
// (4c) [Mig 58 item 2] END-TO-END ngân sách 0 hợp lệ: đủ 4 Section-3 (winner +
|
||||
// quote>0 + comparison + budget=0) + pin workflow V2 → submit THÀNH CÔNG, Phase
|
||||
// ChoDuyet (KHÔNG ConflictException "chưa nhập Ngân sách"). Đây là trục item 2 cốt
|
||||
// lõi: ngân sách 0 không cản gửi duyệt. Drafter ngoài chuỗi → KHÔNG bypass, start
|
||||
// pointer Bước 1 Cấp 1.
|
||||
var (svc, fix, db, clock) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var approver = (await fix.CreateUserAsync("appr0@s60.test", "Approver", null, Array.Empty<string>())).Id;
|
||||
var wf = await SeedWorkflowSingleStepAsync(db, approver);
|
||||
|
||||
var pe = BuildPeNhap(budgetPeriodAmount: 0m, approvalWorkflowId: wf.Id, code: "PE-S60-B0");
|
||||
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()); // drafter random (ngoài chuỗi)
|
||||
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.ChoDuyet, "ngân sách 0 + đủ ĐK + workflow → gửi duyệt OK");
|
||||
pe.CurrentWorkflowStepIndex.Should().Be(0);
|
||||
pe.CurrentApprovalLevelOrder.Should().Be(1);
|
||||
pe.SlaDeadline.Should().Be(clock.UtcNow.AddDays(7));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Submit_MissingComparisonTable_ThrowsConflict()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user