[CLAUDE] PurchaseEvaluation: multi-NCC per hạng mục — chọn ≥2 NCC-TP trúng thầu/1 hạng mục (mỗi nhà 1 giá riêng)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m17s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m17s
Đổi winner-truth từ whole-supplier Supplier.IsWinner (Mig 58 liên-danh) sang per-hạng-mục
Quote.IsSelected (Mig 12 dormant hook → nay nguồn-sự-thật); Supplier.IsWinner = DERIVED (== Any IsSelected).
Anh Kiệt LOCKED D1=SUM(IsSelected) · D2=THAY-THẾ · D3=giá-chào-gốc · D4=1HĐ/NCC. 0 bảng/cột mới.
BE:
- SelectWinnerCommand + POST /select-winner: body {supplierIds} → {detailId, supplierIds} (per hạng mục).
- Re-key 5 financial-SUM → WHERE IsSelected: winnerQuoteTotal, submit-guard, CCM-threshold, giaTri, budget-B.
- giaTri bỏ shortcut isSingle→detailsSum (single đổi budget→bid theo D4).
- Derive IsWinner invariant ở MỌI writer: SelectWinner + UpsertQuote + seed + DeleteQuote/DeleteDetail (helper PeWinnerInvariant, fix Bước 4).
- GIỮ IsWinner-derived cho winner-names/existence + CreateContract winners-list.
- Migration BackfillPeQuoteIsSelectedAndWinnerInvariant (data-only 2-chiều IsWinner↔IsSelected, Down no-op).
FE 2-app (PeDetailTabs SHA-identical): HangMucCard per-cell winner grid Detail×NCC + toggle per-detail; NccSelectorRow hạ-cấp read-only summary; giữ derived display.
Test: 7 file update + 8 test mới (per-hạng-mục total, giaTri single-partial, threshold-crossing, derive-invariant, delete-path). 486 PASS.
Flow: fable-clone invest ensemble → anh Kiệt 4-decision → fable-clone review ensemble (6 adjustment) → hmw implement (disk-recover #53) → fable-clone verify (bắt MUST bug delete-path → fix test-before).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -64,6 +64,11 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
// Seed 1 PE (default DaDuyet) + N supplier rows; winnersByIndex marks which supplier-rows
|
||||
// are IsWinner. detailQuotes[i][j] = ThanhTien báo giá của supplier j cho detail i.
|
||||
// detailBudgets[i] = ThanhTienNganSach (ngân sách) của detail i.
|
||||
// [multi-NCC 2026-07-12 cardinality-change] GiaTri mỗi HĐ NAY = SUM ThanhTien quote ĐƯỢC CHỌN
|
||||
// (Quote.IsSelected) của winner đó — CreateContractFromEvaluationFeatures.cs:88-90
|
||||
// `q.PurchaseEvaluationSupplierId == w.Id && q.IsSelected`. Nên quote của winner PHẢI
|
||||
// IsSelected=true mới cộng vào GiaTri. detailQuoteSelected[i][j] override IsSelected per
|
||||
// (detail,supplier); null → mặc định winner's-all-selected (IsSelected = winnerIndexes.Contains(j)).
|
||||
private static async Task<Seeded> SeedAsync(
|
||||
IdentityFixture fix,
|
||||
TestApplicationDbContext db,
|
||||
@ -71,7 +76,8 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
int[] winnerIndexes,
|
||||
decimal[] detailBudgets,
|
||||
decimal[][] detailQuotes,
|
||||
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet)
|
||||
PurchaseEvaluationPhase phase = PurchaseEvaluationPhase.DaDuyet,
|
||||
bool[][]? detailQuoteSelected = null)
|
||||
{
|
||||
var actor = await fix.CreateUserAsync("drafter-d2@test.local", "Drafter D2",
|
||||
departmentId: null, roles: new[] { AppRoles.Drafter });
|
||||
@ -136,12 +142,16 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
// Quote per supplier cho detail này — FK PESupplierId → supplier-row.Id.
|
||||
for (var j = 0; j < supplierCount; j++)
|
||||
{
|
||||
var isSelected = detailQuoteSelected is not null
|
||||
? detailQuoteSelected[i][j]
|
||||
: winnerIndexes.Contains(j); // mặc định: quote của winner = được chọn
|
||||
detail.Quotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = suppliers[j].Item2.Id,
|
||||
ThanhTien = detailQuotes[i][j],
|
||||
IsSelected = isSelected,
|
||||
});
|
||||
}
|
||||
pe.Details.Add(detail);
|
||||
@ -206,16 +216,19 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
peReloaded.ContractId.Should().Be(ids[0]);
|
||||
}
|
||||
|
||||
// ===== (b) single winner → 1 HĐ, GiaTri = details budget sum (LEGACY preserved) =====
|
||||
// ===== single winner → 1 HĐ, GiaTri = SUM báo giá ĐƯỢC CHỌN của winner (D4) =====
|
||||
// [multi-NCC 2026-07-12 — SPEC CHANGE D4] BỎ shortcut detailsSum (budget) cũ cho single.
|
||||
// NAY single = SUM(Quote.IsSelected của winner), giống multi. Trước đây test này assert
|
||||
// GiaTri = 800 (budget sum); giờ = 140 (100+40 = quote ĐƯỢC CHỌN của winner0).
|
||||
|
||||
[Fact]
|
||||
public async Task Create_SingleWinner_GiaTriIsDetailsBudgetSum_NotQuoteTotal()
|
||||
public async Task Create_SingleWinner_GiaTriIsSelectedQuoteTotal_NotBudgetSum()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800. winner0 quote total =
|
||||
// 100 + 40 = 140. Single-winner PHẢI = 800 (budget), KHÔNG = 140 (quote).
|
||||
// 1 winner (idx 0). detailBudgets sum = 500 + 300 = 800 (KHÔNG dùng nữa). winner0 quote
|
||||
// ĐƯỢC CHỌN = 100 + 40 = 140. Single-winner NAY = 140 (quote IsSelected), KHÔNG = 800 (budget).
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: new[] { 0 },
|
||||
@ -235,7 +248,7 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
|
||||
var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]);
|
||||
contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id);
|
||||
contract.GiaTri.Should().Be(800m); // LEGACY: budget sum, NOT 140
|
||||
contract.GiaTri.Should().Be(140m, "D4: single-winner = SUM báo giá ĐƯỢC CHỌN (100+40), KHÔNG phải budget 800");
|
||||
contract.Phase.Should().Be(ContractPhase.DangSoanThao);
|
||||
contract.MaHopDong.Should().NotBeNull();
|
||||
// TenHopDong null trong request → fallback pe.TenGoiThau.
|
||||
@ -245,6 +258,45 @@ public class CreateContractFromEvaluationMultiWinnerTests
|
||||
peReloaded.ContractId.Should().Be(ids[0]);
|
||||
}
|
||||
|
||||
// ===== (b) [multi-NCC 2026-07-12 — NEW] single-winner thắng SUBSET hạng mục =====
|
||||
// Winner thắng CHỈ 1 trong 2 hạng mục (detail0 IsSelected=true, detail1 IsSelected=false)
|
||||
// → GiaTri = SUM(selected quotes) = quote detail0 THÔI, KHÁC SUM-mọi-quote-của-winner
|
||||
// (chống bug detailsSum/all-quote cũ). Chứng minh handler lọc theo Quote.IsSelected per hạng mục.
|
||||
[Fact]
|
||||
public async Task Create_SingleWinner_WinsSubsetOfDetails_GiaTriSumsOnlySelectedQuotes()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
// Winner = supplier0. detail0 quote 300 ĐƯỢC CHỌN, detail1 quote 500 KHÔNG chọn.
|
||||
// GiaTri = 300 (chỉ selected); SUM-mọi-quote-winner = 800; budget-sum = 2000. Cả 3 khác nhau.
|
||||
var seeded = await SeedAsync(fix, db,
|
||||
supplierCount: 2,
|
||||
winnerIndexes: new[] { 0 },
|
||||
detailBudgets: new[] { 1000m, 1000m }, // sum 2000 — KHÔNG dùng
|
||||
detailQuotes: new[]
|
||||
{
|
||||
new[] { 300m, 11m }, // detail0: winner0=300
|
||||
new[] { 500m, 13m }, // detail1: winner0=500 (KHÔNG chọn)
|
||||
},
|
||||
detailQuoteSelected: new[]
|
||||
{
|
||||
new[] { true, false }, // detail0: winner0 ĐƯỢC CHỌN
|
||||
new[] { false, false }, // detail1: winner0 KHÔNG chọn
|
||||
});
|
||||
var handler = BuildHandler(db, fix.Services, seeded.Actor);
|
||||
|
||||
var ids = await handler.Handle(
|
||||
new CreateContractFromEvaluationCommand(seeded.Pe.Id, ContractType.HopDongThauPhu, null),
|
||||
CancellationToken.None);
|
||||
|
||||
ids.Should().HaveCount(1);
|
||||
var contract = await db.Contracts.AsNoTracking().FirstAsync(c => c.Id == ids[0]);
|
||||
contract.SupplierId.Should().Be(seeded.Suppliers[0].supplier.Id);
|
||||
contract.GiaTri.Should().Be(300m,
|
||||
"SUM chỉ quote ĐƯỢC CHỌN (detail0=300); KHÁC SUM-mọi-quote-winner (800) + budget-sum (2000)");
|
||||
}
|
||||
|
||||
// ===== (c) idempotency → re-call throws ConflictException =====
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -20,6 +20,12 @@ namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
// NẾU EF KHÔNG translate (runtime InvalidOperationException) → test FAIL = blocker
|
||||
// REPORT em main (fallback 2-pass). Dùng SqliteDbFixture (KHÔNG cần Identity — query
|
||||
// chỉ đọc, DrafterUserId LEFT-join Users nullable).
|
||||
//
|
||||
// [multi-NCC 2026-07-12 cardinality-change] Winner-truth chuyển whole-supplier IsWinner →
|
||||
// per-(Detail×Supplier) Quote.IsSelected; NHƯNG Supplier.IsWinner GIỮ là cột PERSISTED
|
||||
// (BE tự derive = Any quote IsSelected trong SelectWinner/UpsertQuote handler). WinnerSupplierNames
|
||||
// projection ĐỌC THẲNG cột IsWinner đã persist (`where sw.IsWinner`) → KHÔNG đổi theo cardinality.
|
||||
// Seed IsWinner trực tiếp = hợp lệ cho translation-smoke (không cần seed quote — projection không đọc quote).
|
||||
public class PeListWinnerNamesProjectionTests
|
||||
{
|
||||
private static (Project project, Supplier[] suppliers) SeedMasters(TestApplicationDbContext db, int supplierCount)
|
||||
|
||||
@ -72,7 +72,11 @@ public class PeMultiWinnerTests
|
||||
}
|
||||
|
||||
// 1 NCC tham gia (master + PES row) + 1 quote ThanhTien thuộc detail. isWinner pin
|
||||
// IsWinner trên PES row. Return supplierId master.
|
||||
// IsWinner trên PES row VÀ IsSelected trên quote (invariant IsWinner==Any(IsSelected)).
|
||||
// [multi-NCC 2026-07-12 cardinality-change] DTO WinnerQuoteTotal NAY = SUM ThanhTien
|
||||
// quote ĐƯỢC CHỌN (Quote.IsSelected — PurchaseEvaluationFeatures.cs:1188-1191
|
||||
// `.Where(q => q.IsSelected)`), KHÔNG còn build từ IsWinner supplier-row. Nên winner PHẢI
|
||||
// có quote.IsSelected=true thì mới lọt tổng; loser IsSelected=false → bị loại. Return supplierId.
|
||||
private static async Task<Guid> SeedSupplierWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, Guid detailId,
|
||||
string code, decimal thanhTien, bool isWinner, int order)
|
||||
@ -94,6 +98,7 @@ public class PeMultiWinnerTests
|
||||
PurchaseEvaluationDetailId = detailId,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = thanhTien,
|
||||
IsSelected = isWinner, // [multi-NCC] winner ⟺ báo giá được chọn
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplier.Id;
|
||||
@ -215,11 +220,13 @@ public class PeMultiWinnerTests
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail1,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 20_000_000m,
|
||||
IsSelected = true, // [multi-NCC] cả 2 dòng của winner ĐƯỢC CHỌN → vào tổng
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(), PurchaseEvaluationDetailId = detail2.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id, ThanhTien = 30_000_000m,
|
||||
IsSelected = true,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
@ -228,4 +235,35 @@ public class PeMultiWinnerTests
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(50_000_000m, "SUM mọi dòng báo giá của winner-row qua nhiều detail");
|
||||
}
|
||||
|
||||
// ===== [multi-NCC 2026-07-12 cardinality-change — NEW test (a)] =====
|
||||
// ≥2 NCC/TP cùng trúng TRÊN CÙNG 1 HẠNG MỤC (Detail) — mỗi nhà 1 giá riêng (Quote.ThanhTien).
|
||||
// winnerQuoteTotal = SUM 2 quote IsSelected CỦA hạng mục đó; NCC thứ 3 chào cùng hạng mục
|
||||
// nhưng KHÔNG được chọn (IsSelected=false) → bị loại. Đây là trục cardinality-change cốt lõi:
|
||||
// winner-truth = per-(Detail×Supplier) Quote.IsSelected, KHÔNG phải whole-supplier.
|
||||
[Fact]
|
||||
public async Task WinnerQuoteTotal_TwoNccSameDetail_SumsBothSelected_ExcludesUnselectedThird()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var pe = await SeedPeAsync(db, "PE-MW-2NCC1HM");
|
||||
var detailId = await SeedDetailAsync(db, pe); // 1 hạng mục dùng chung 3 NCC
|
||||
|
||||
// 3 NCC chào CÙNG hạng mục detailId: 2 được chọn (33 + 27) + 1 không chọn (50).
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P1", 33_000_000m, isWinner: true, order: 0);
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P2", 27_000_000m, isWinner: true, order: 1);
|
||||
await SeedSupplierWithQuoteAsync(db, pe, detailId, "NCC-P3", 50_000_000m, isWinner: false, order: 2);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
bundle.WinnerQuoteTotal.Should().Be(60_000_000m,
|
||||
"≥2 NCC trúng CÙNG 1 hạng mục → SUM 2 quote ĐƯỢC CHỌN (33+27); NCC thứ 3 không chọn bị loại");
|
||||
bundle.SelectedSupplierId.Should().BeNull("≥2 đơn vị cùng trúng → không có 1 winner duy nhất");
|
||||
|
||||
// Quote của NCC thứ 3 tồn tại nhưng IsSelected=false → chứng minh loại theo IsSelected.
|
||||
var detailDto = bundle.Details.Single(d => d.Id == detailId);
|
||||
detailDto.Quotes.Should().HaveCount(3, "cả 3 NCC đều có báo giá cho hạng mục");
|
||||
detailDto.Quotes.Count(q => q.IsSelected).Should().Be(2, "đúng 2 báo giá được chọn");
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,26 +10,32 @@ using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC — test-after] SelectPurchaseEvaluationWinnerCommand
|
||||
// MULTI-WINNER. Command record ĐỔI `(Guid PurchaseEvaluationId, Guid? SupplierId)` →
|
||||
// `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)`.
|
||||
// Handler (PurchaseEvaluationDetailFeatures.cs ~404-447):
|
||||
// • Load MỌI supplier-row của phiếu (tracking) → set IsWinner = (row.SupplierId ∈
|
||||
// selectedIds), false phần còn lại. IsWinner trên supplier-row = nguồn-sự-thật mới.
|
||||
// • selectedIds rỗng [] = BỎ CHỌN tất cả (IsWinner=false hết) — thay null cũ (anh Kiệt C5).
|
||||
// • Mỗi id chọn PHẢI thuộc participating-list (PurchaseEvaluationSuppliers) → Conflict.
|
||||
// • Distinct id (FE có thể gửi trùng).
|
||||
// • Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng tạo HĐ +
|
||||
// DTO cũ); ≥2 HOẶC 0 → null.
|
||||
// • KHÔNG còn check master Suppliers existence (logic mới chỉ validate participating-list)
|
||||
// — NotFound master-existence bỏ; chỉ Conflict khi id không thuộc list của phiếu.
|
||||
// Mọi nhánh ghi 1 Changelog (Header/Update) — Summary "Bỏ chọn đơn vị NCC/TP trúng thầu"
|
||||
// (count==0) vs "Chọn {n} đơn vị NCC/TP trúng thầu".
|
||||
// [multi-NCC 2026-07-12 — anh Kiệt LOCKED — cardinality-change test-before] SelectPurchaseEvaluationWinnerCommand
|
||||
// đổi `(Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds)` → thêm DetailId:
|
||||
// `(Guid PurchaseEvaluationId, Guid DetailId, IReadOnlyList<Guid> SupplierIds)`. Winner NAY chọn
|
||||
// theo TỪNG HẠNG MỤC (Detail) — mỗi hạng mục ≥2 NCC/TP có thể cùng trúng, mỗi nhà 1 giá riêng.
|
||||
//
|
||||
// Handler 2-dep (db + ICurrentUser), KHÔNG cần UserManager → SqliteDbFixture đủ nhẹ
|
||||
// (mirror DepartmentTreeTests). ICurrentUser fake cứng để Changelog ghi UserId/Name.
|
||||
// Handler (PurchaseEvaluationDetailFeatures.cs ~410-480):
|
||||
// • Load PE (NotFound) → Load Detail thuộc PE (NotFound) → Load MỌI supplier-row của phiếu.
|
||||
// • Validate mỗi selected SupplierId ∈ participating-list → Conflict "NCC chưa được thêm vào phiếu".
|
||||
// • Load MỌI quote của phiếu → set Quote.IsSelected cho quotes CỦA DetailId NÀY: true nếu
|
||||
// supplier-row thắng, false còn lại. NGUỒN-SỰ-THẬT = Quote.IsSelected (per Detail×Supplier).
|
||||
// • Derive Supplier.IsWinner (INVARIANT): winner ⟺ có ÍT NHẤT 1 quote IsSelected TOÀN PHIẾU.
|
||||
// • Sync SelectedSupplierId: đúng 1 winner toàn phiếu → đơn vị đó; ≥2 HOẶC 0 → null.
|
||||
// • Changelog Header/Update Summary: count==0 → "Bỏ chọn NCC/TP trúng thầu cho hạng mục {GroupCode}";
|
||||
// else "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
|
||||
//
|
||||
// ⚠️ CARDINALITY-CHANGE (khác test cũ): IsWinner KHÔNG còn set-thẳng — nó DERIVE từ Quote.IsSelected.
|
||||
// Muốn supplier thành winner qua SelectWinner thì (Detail × Supplier) PHẢI có 1 quote để handler
|
||||
// flip IsSelected → derive IsWinner. Test seed 1 detail + 1 quote/participating-row (helper
|
||||
// AddParticipatingWithQuoteAsync). Test dùng SQLite EnsureCreated — KHÔNG replay migration
|
||||
// backfill (chỉ SQL-Server) → seed IsSelected/IsWinner trực tiếp cho pre-state.
|
||||
//
|
||||
// Handler 2-dep (db + ICurrentUser) → SqliteDbFixture đủ nhẹ. ICurrentUser fake để Changelog ghi.
|
||||
public class PeSelectWinnerClearTests
|
||||
{
|
||||
private const string DetailGroupCode = "A.I";
|
||||
|
||||
private sealed class FakeCurrentUser : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; init; } = Guid.NewGuid();
|
||||
@ -43,6 +49,10 @@ public class PeSelectWinnerClearTests
|
||||
TestApplicationDbContext db)
|
||||
=> new(db, new FakeCurrentUser());
|
||||
|
||||
private static UpsertPurchaseEvaluationQuoteCommandHandler BuildUpsertQuoteHandler(
|
||||
TestApplicationDbContext db)
|
||||
=> new(db, new FakeCurrentUser());
|
||||
|
||||
private static async Task<Supplier> SeedSupplierAsync(
|
||||
TestApplicationDbContext db, string code)
|
||||
{
|
||||
@ -52,8 +62,8 @@ public class PeSelectWinnerClearTests
|
||||
return s;
|
||||
}
|
||||
|
||||
// Phiếu PE + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
|
||||
// row — caller quyết định để cô lập nhánh clear (skip-list) vs select (need-list).
|
||||
// Phiếu PE Nháp + (tùy chọn) SelectedSupplierId pre-set. KHÔNG tự thêm participating
|
||||
// row — caller quyết định để cô lập nhánh clear vs select.
|
||||
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||
TestApplicationDbContext db, Guid? selectedSupplierId, string code = "PE-SW")
|
||||
{
|
||||
@ -72,9 +82,54 @@ public class PeSelectWinnerClearTests
|
||||
return pe;
|
||||
}
|
||||
|
||||
// Gắn supplier vào participating-list (PurchaseEvaluationSuppliers) của phiếu.
|
||||
// Return PES row Id để assert IsWinner.
|
||||
private static async Task<Guid> AddParticipatingAsync(
|
||||
// 1 hạng mục (Detail) cho phiếu. Winner chọn theo hạng mục → mọi command cần DetailId này.
|
||||
private static async Task<Guid> SeedDetailAsync(
|
||||
TestApplicationDbContext db, Guid peId, string groupCode = DetailGroupCode)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
GroupCode = groupCode,
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = "Concrete M100",
|
||||
Order = 0,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return detail.Id;
|
||||
}
|
||||
|
||||
// Gắn supplier vào participating-list + 1 quote thuộc detailId. isSelected pin pre-state
|
||||
// (quote.IsSelected + pes.IsWinner giữ invariant). SelectWinner sẽ flip IsSelected của
|
||||
// quote NÀY → derive IsWinner. Return PES row Id.
|
||||
private static async Task<Guid> AddParticipatingWithQuoteAsync(
|
||||
TestApplicationDbContext db, Guid peId, Guid supplierId, Guid detailId,
|
||||
bool isSelected = false, decimal thanhTien = 100m, int order = 0)
|
||||
{
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = peId,
|
||||
SupplierId = supplierId,
|
||||
Order = order,
|
||||
IsWinner = isSelected, // derived == Any(quote.IsSelected)
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(row);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detailId,
|
||||
PurchaseEvaluationSupplierId = row.Id,
|
||||
ThanhTien = thanhTien,
|
||||
IsSelected = isSelected,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return row.Id;
|
||||
}
|
||||
|
||||
// Participating-row THUẦN (không quote) — cho test derive-invariant qua UpsertQuote.
|
||||
private static async Task<Guid> AddParticipatingNoQuoteAsync(
|
||||
TestApplicationDbContext db, Guid peId, Guid supplierId, int order = 0)
|
||||
{
|
||||
var row = new PurchaseEvaluationSupplier
|
||||
@ -98,52 +153,48 @@ public class PeSelectWinnerClearTests
|
||||
.FirstAsync();
|
||||
|
||||
// ============================================================
|
||||
// 1. CLEAR branch ([] = bỏ chọn tất cả — trục quan trọng nhất)
|
||||
// 1. CLEAR branch ([] = bỏ chọn cho hạng mục — trục quan trọng nhất)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_PhieuHasSelectedWinner_SetsSelectedSupplierIdNull_AllRowsNotWinner()
|
||||
{
|
||||
// ⭐ Phiếu đang có 1 winner (s1, IsWinner=true) + s1 trong participating-list →
|
||||
// gọi SupplierIds=[] → clear: SelectedSupplierId=null + s1.IsWinner=false.
|
||||
// ⭐ Phiếu đang có 1 winner (s1, quote IsSelected=true → IsWinner=true) + s1 trong
|
||||
// participating-list → gọi SupplierIds=[] cho hạng mục → clear: quote IsSelected=false,
|
||||
// IsWinner derive=false, SelectedSupplierId=null.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
// Pre-set s1 = winner (mô phỏng phiếu đã chọn trước).
|
||||
var row = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||
row.IsWinner = true;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().BeNull("bỏ chọn = clear winner về none");
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → mọi supplier-row IsWinner=false");
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("clear → quote IsSelected=false → IsWinner=false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_EmptyList_AllRowsNotWinner_NoConflict()
|
||||
{
|
||||
// Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không
|
||||
// có id cần kiểm). Phiếu có 2 NCC tham gia, s1 đang là winner → clear [] →
|
||||
// KHÔNG ném + cả 2 row IsWinner=false + SelectedSupplierId=null.
|
||||
// Trục cốt lõi nhánh clear: SKIP validate participating-list (list rỗng = không có id
|
||||
// cần kiểm). Phiếu 2 NCC tham gia, s1 winner (quote sel=true) → clear [] cho hạng mục →
|
||||
// KHÔNG ném + cả 2 IsWinner=false + SelectedSupplierId=null.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||
r1.IsWinner = true;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync("clear [] KHÔNG validate participating-list");
|
||||
@ -156,14 +207,16 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Clear_PhieuAlreadyNull_IsIdempotent_StaysNull_NoThrow()
|
||||
{
|
||||
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không row IsWinner) →
|
||||
// clear [] lần nữa = idempotent, vẫn null, không ném. Empty list cũng không cản.
|
||||
// Phiếu vốn-dĩ chưa chọn winner (SelectedSupplierId=null, không quote IsSelected) →
|
||||
// clear [] cho hạng mục lần nữa = idempotent, vẫn null, không ném. Cần seed detail
|
||||
// (handler load Detail thuộc phiếu). Không có supplier/quote.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync();
|
||||
@ -174,22 +227,23 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Clear_WritesChangelog_WithBoChonSummary()
|
||||
{
|
||||
// Nhánh clear vẫn ghi 1 Changelog Header/Update với Summary "Bỏ chọn đơn vị
|
||||
// NCC/TP trúng thầu" (phân biệt với "Chọn {n} đơn vị..." của nhánh select).
|
||||
// Nhánh clear ghi 1 Changelog Header/Update Summary "Bỏ chọn NCC/TP trúng thầu cho
|
||||
// hạng mục {GroupCode}" (phân biệt với "Chọn {n}..." của nhánh select).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>());
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>());
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
||||
.SingleAsync();
|
||||
log.Summary.Should().Be("Bỏ chọn đơn vị NCC/TP trúng thầu");
|
||||
log.Summary.Should().Be($"Bỏ chọn NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
|
||||
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
||||
log.Action.Should().Be(ChangelogAction.Update);
|
||||
}
|
||||
@ -201,16 +255,17 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_OneSupplierInList_SetsSelectedSupplierId_RowIsWinner()
|
||||
{
|
||||
// Happy-path single-winner: 1 supplier hợp lệ (thuộc participating-list) →
|
||||
// SelectedSupplierId = đúng id (count==1 sync) + row IsWinner=true.
|
||||
// Happy-path single-winner: 1 supplier hợp lệ (trong list, có quote cho hạng mục) →
|
||||
// quote IsSelected=true → IsWinner=true + SelectedSupplierId = đúng id (count==1 sync).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-WIN");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id });
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -221,22 +276,20 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_ChangeWinner_FromS1ToS2_BothInList_UpdatesToS2_OnlyS2IsWinner()
|
||||
{
|
||||
// Đổi winner: phiếu đang chọn s1 → chọn [s2] (cả 2 trong list) → SelectedSupplierId
|
||||
// chuyển s2 + s2.IsWinner=true + s1.IsWinner=false (set false phần còn lại).
|
||||
// Đổi winner: phiếu đang chọn s1 (quote sel=true) → chọn [s2] cho hạng mục → s2 quote
|
||||
// IsSelected=true + s1 quote IsSelected=false → SelectedSupplierId=s2 + s2.IsWinner=true
|
||||
// + s1.IsWinner=false.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
// s1 đang là winner.
|
||||
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||
r1.IsWinner = true;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s2.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s2.Id });
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -248,20 +301,18 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_SupplierNotInParticipatingList_ThrowsConflict_NoMutate()
|
||||
{
|
||||
// Supplier không thuộc participating-list của phiếu → Conflict. IsWinner +
|
||||
// SelectedSupplierId giữ nguyên (throw TRƯỚC khi set + SaveChanges).
|
||||
// Supplier không thuộc participating-list → Conflict. IsWinner + SelectedSupplierId giữ
|
||||
// nguyên (throw TRƯỚC khi flip IsSelected + SaveChanges).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-IN"); // trong list (winner)
|
||||
var outsider = await SeedSupplierAsync(db, "NCC-OUT"); // không trong list
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var r1 = await db.PurchaseEvaluationSuppliers.FirstAsync(s => s.SupplierId == s1.Id);
|
||||
r1.IsWinner = true;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { outsider.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { outsider.Id });
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
@ -275,18 +326,18 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_OneIdInListOneOutside_ThrowsConflict_NoPartialMutate()
|
||||
{
|
||||
// Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict (mỗi id
|
||||
// phải thuộc list). KHÔNG partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ
|
||||
// (validate TRƯỚC khi set IsWinner cho toàn bộ rows).
|
||||
// Multi-select với 1 id ngoài list (s1 trong, outsider ngoài) → Conflict. KHÔNG
|
||||
// partial-write: s1.IsWinner KHÔNG bị set true dù hợp lệ (validate TRƯỚC khi flip IsSelected).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-OK");
|
||||
var outsider = await SeedSupplierAsync(db, "NCC-FORGE");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, outsider.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, outsider.Id });
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>()
|
||||
@ -295,25 +346,26 @@ public class PeSelectWinnerClearTests
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. SELECT branch — MULTI winner (≥2 → joint winner, SelectedSupplierId=null)
|
||||
// [TASK 2.1] regression cho hành vi multi-winner mới (Mig 58).
|
||||
// 3. SELECT branch — MULTI winner CÙNG HẠNG MỤC (≥2 → joint, SelectedSupplierId=null)
|
||||
// [multi-NCC] ≥2 NCC/TP trúng CÙNG 1 hạng mục.
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Select_TwoSuppliers_BothIsWinner_SelectedSupplierIdNull()
|
||||
{
|
||||
// Chọn 2 đơn vị cùng trúng → cả 2 IsWinner=true, SelectedSupplierId=null (≥2 →
|
||||
// không còn "1 winner" duy nhất). Trục multi-winner cốt lõi.
|
||||
// Chọn 2 đơn vị cùng trúng 1 hạng mục → cả 2 quote IsSelected=true → cả 2 IsWinner=true,
|
||||
// SelectedSupplierId=null (≥2 → không "1 winner" duy nhất). Trục multi-NCC cốt lõi.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-J1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-J2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id });
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -325,8 +377,8 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_ThreeOfFour_ThreeIsWinner_NonSelectedIsFalse_SelectedSupplierIdNull()
|
||||
{
|
||||
// 4 NCC tham gia, chọn 3 → đúng 3 row IsWinner=true, NCC thứ 4 IsWinner=false,
|
||||
// SelectedSupplierId=null. Verify "set false phần còn lại" với nhiều row.
|
||||
// 4 NCC chào cùng hạng mục, chọn 3 → đúng 3 quote IsSelected=true (3 IsWinner), NCC thứ 4
|
||||
// quote IsSelected=false (IsWinner=false), SelectedSupplierId=null.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-Q1");
|
||||
@ -334,13 +386,14 @@ public class PeSelectWinnerClearTests
|
||||
var s3 = await SeedSupplierAsync(db, "NCC-Q3");
|
||||
var s4 = await SeedSupplierAsync(db, "NCC-Q4");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
await AddParticipatingAsync(db, pe.Id, s3.Id, order: 2);
|
||||
await AddParticipatingAsync(db, pe.Id, s4.Id, order: 3);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s3.Id, detailId, isSelected: false, order: 2);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s4.Id, detailId, isSelected: false, order: 3);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id, s3.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id, s3.Id });
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -354,16 +407,17 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_DuplicateIds_Deduped_TreatedAsSingleWinner_SelectedSupplierIdSet()
|
||||
{
|
||||
// FE gửi trùng id [s1, s1] → Distinct → count==1 → đối xử single-winner:
|
||||
// s1.IsWinner=true + SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
|
||||
// FE gửi trùng id [s1, s1] → Distinct → count==1 → single-winner: s1.IsWinner=true +
|
||||
// SelectedSupplierId=s1 (KHÔNG bị coi là ≥2 do trùng).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-DUP");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s1.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s1.Id });
|
||||
await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -374,25 +428,25 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_MultiThenClear_RoundTrip_AllWinnersReset()
|
||||
{
|
||||
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false +
|
||||
// SelectedSupplierId=null. Chứng minh clear reset đúng kể cả từ trạng thái multi.
|
||||
// Round-trip: chọn 2 (joint) → rồi clear [] → cả 2 IsWinner về false + SelectedSupplierId=null.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-R1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-R2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
await handler.Handle(
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||
CancellationToken.None);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("sau chọn 2");
|
||||
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue();
|
||||
|
||||
await handler.Handle(
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: Array.Empty<Guid>()),
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
|
||||
CancellationToken.None);
|
||||
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
@ -404,43 +458,42 @@ public class PeSelectWinnerClearTests
|
||||
[Fact]
|
||||
public async Task Select_MultiWinner_WritesChangelog_WithChonNSummary()
|
||||
{
|
||||
// Multi-winner ghi Changelog Summary "Chọn {n} đơn vị NCC/TP trúng thầu" (n = số
|
||||
// đơn vị chọn). Verify count chính xác trong message.
|
||||
// Multi-winner ghi Changelog Summary "Chọn {n} NCC/TP trúng thầu cho hạng mục {GroupCode}".
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-CL1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-CL2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
await AddParticipatingAsync(db, pe.Id, s1.Id, order: 0);
|
||||
await AddParticipatingAsync(db, pe.Id, s2.Id, order: 1);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false, order: 0);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s2.Id, detailId, isSelected: false, order: 1);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
await handler.Handle(
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s1.Id, s2.Id }),
|
||||
CancellationToken.None);
|
||||
|
||||
var log = await db.PurchaseEvaluationChangelogs.AsNoTracking()
|
||||
.Where(c => c.PurchaseEvaluationId == pe.Id)
|
||||
.SingleAsync();
|
||||
log.Summary.Should().Be("Chọn 2 đơn vị NCC/TP trúng thầu");
|
||||
log.Summary.Should().Be($"Chọn 2 NCC/TP trúng thầu cho hạng mục {DetailGroupCode}");
|
||||
log.EntityType.Should().Be(PurchaseEvaluationEntityType.Header);
|
||||
log.Action.Should().Be(ChangelogAction.Update);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. PE existence — chung mọi nhánh (check TRƯỚC khi rẽ nhánh SupplierIds)
|
||||
// 4. Existence guards — PE + Detail (check TRƯỚC khi rẽ nhánh SupplierIds)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownPe_Clear_ThrowsNotFound_BeforeBranch()
|
||||
{
|
||||
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler, TRƯỚC
|
||||
// khi xét SupplierIds (kể cả nhánh clear [] cũng không bỏ qua existence).
|
||||
// PE không tồn tại → NotFound("PurchaseEvaluation") ngay đầu handler (TRƯỚC Detail + SupplierIds).
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: Array.Empty<Guid>());
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), Guid.NewGuid(), SupplierIds: Array.Empty<Guid>());
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
@ -454,9 +507,190 @@ public class PeSelectWinnerClearTests
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), SupplierIds: new[] { s1.Id });
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(Guid.NewGuid(), Guid.NewGuid(), SupplierIds: new[] { s1.Id });
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownDetail_NotBelongingToPhieu_ThrowsNotFound()
|
||||
{
|
||||
// [multi-NCC] Detail phải thuộc phiếu — DetailId lạ (random Guid) → NotFound("PurchaseEvaluationDetail").
|
||||
// Chặn cross-phiếu / stale detail. Kiểm SAU PE-existence, TRƯỚC supplier validate.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-A");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var validDetailId = await SeedDetailAsync(db, pe.Id); // phiếu CÓ 1 detail hợp lệ
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, validDetailId);
|
||||
var handler = BuildHandler(db);
|
||||
|
||||
// cmd truyền DetailId LẠ (không thuộc phiếu) → NotFound TRƯỚC supplier validate.
|
||||
var cmd = new SelectPurchaseEvaluationWinnerCommand(pe.Id, Guid.NewGuid(), SupplierIds: new[] { s1.Id });
|
||||
var act = async () => await handler.Handle(cmd, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage("*PurchaseEvaluationDetail*");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. [multi-NCC NEW test (d)] DERIVE INVARIANT: Supplier.IsWinner == Any(Quote.IsSelected)
|
||||
// duy trì qua UpsertQuote + SelectWinner + seed. Nguồn-sự-thật = Quote.IsSelected.
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task DeriveInvariant_IsWinnerEqualsAnySelectedQuote_AfterUpsertQuoteAndSelectWinner()
|
||||
{
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-INV1");
|
||||
var s2 = await SeedSupplierAsync(db, "NCC-INV2");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
var s1Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s1.Id, order: 0);
|
||||
var s2Row = await AddParticipatingNoQuoteAsync(db, pe.Id, s2.Id, order: 1);
|
||||
|
||||
var upsert = BuildUpsertQuoteHandler(db);
|
||||
var select = BuildHandler(db);
|
||||
|
||||
// Step 1 — UpsertQuote (Detail × s1) IsSelected=true → handler set s1.IsWinner=true.
|
||||
await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
|
||||
pe.Id, detailId, s1Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 100m, IsSelected: true, Note: null),
|
||||
CancellationToken.None);
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UpsertQuote IsSelected=true → derive IsWinner=true");
|
||||
|
||||
// Step 2 — UpsertQuote (Detail × s2) IsSelected=false → s2.IsWinner=false.
|
||||
await upsert.Handle(new UpsertPurchaseEvaluationQuoteCommand(
|
||||
pe.Id, detailId, s2Row, BgVat: 0m, ChuaVat: 0m, ThanhTien: 200m, IsSelected: false, Note: null),
|
||||
CancellationToken.None);
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse("UpsertQuote IsSelected=false → IsWinner=false");
|
||||
|
||||
// Step 3 — SelectWinner [s2] cho hạng mục → s2 quote IsSelected=true, s1 quote IsSelected=false.
|
||||
await select.Handle(
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: new[] { s2.Id }),
|
||||
CancellationToken.None);
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("SelectWinner đổi sang s2 → s1 quote IsSelected=false → IsWinner=false");
|
||||
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeTrue("s2 quote IsSelected=true → IsWinner=true");
|
||||
|
||||
// Step 4 — SelectWinner [] clear → cả 2 quote IsSelected=false → cả 2 IsWinner=false.
|
||||
await select.Handle(
|
||||
new SelectPurchaseEvaluationWinnerCommand(pe.Id, detailId, SupplierIds: Array.Empty<Guid>()),
|
||||
CancellationToken.None);
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse();
|
||||
(await IsWinnerAsync(db, pe.Id, s2.Id)).Should().BeFalse();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// [S114 Bước 4 verify — MUST/SHOULD] Delete-path re-derive IsWinner (writer bị sót ở implement)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteQuote_LastSelectedQuoteOfSupplier_ClearsIsWinner_NoPhantom()
|
||||
{
|
||||
// ⭐ BUG (Bước 4): DeleteQuoteHandler Remove(quote) KHÔNG re-derive IsWinner → xóa quote
|
||||
// IsSelected CUỐI của 1 NCC để IsWinner kẹt=true trong khi Any(IsSelected)=false → phantom
|
||||
// winner → CreateContract sinh HĐ giaTri=0. Test-before: assert IsWinner về false + invariant.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-DQ");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
|
||||
var quoteId = await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.Where(q => q.PurchaseEvaluationSupplierId == rowId).Select(q => q.Id).FirstAsync();
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state: quote IsSelected=true → IsWinner=true");
|
||||
|
||||
var del = new DeletePurchaseEvaluationQuoteCommandHandler(db, new FakeCurrentUser());
|
||||
await del.Handle(new DeletePurchaseEvaluationQuoteCommand(pe.Id, quoteId), CancellationToken.None);
|
||||
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa quote-selected cuối → IsWinner về false");
|
||||
var reloaded = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloaded.SelectedSupplierId.Should().BeNull("0 winner → SelectedSupplierId=null");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDetail_WithSupplierLastSelectedQuote_ClearsIsWinner()
|
||||
{
|
||||
// ⭐ BUG (Bước 4): DeleteDetailHandler Remove(entity) cascade xóa quotes NHƯNG không re-derive
|
||||
// IsWinner → NCC mất quote-selected cuối bị kẹt IsWinner=true. Test-before.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-DD");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: true);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("pre-state IsWinner=true");
|
||||
|
||||
var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
|
||||
await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailId), CancellationToken.None);
|
||||
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("xóa hạng mục chứa quote-selected cuối → IsWinner=false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteDetail_SupplierStillWinsAnotherDetail_KeepsIsWinner()
|
||||
{
|
||||
// Guard: re-derive per-PHIẾU (không over-clear). Xóa 1 hạng mục nhưng NCC còn thắng hạng
|
||||
// mục KHÁC → IsWinner GIỮ true.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-2D");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: s1.Id);
|
||||
var detailA = await SeedDetailAsync(db, pe.Id, "A.I");
|
||||
var detailB = await SeedDetailAsync(db, pe.Id, "B.I");
|
||||
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailA, isSelected: true);
|
||||
var up = BuildUpsertQuoteHandler(db);
|
||||
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailB, rowId, 0, 0, 50m, true, null), CancellationToken.None);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue();
|
||||
|
||||
var del = new DeletePurchaseEvaluationDetailCommandHandler(db, new FakeCurrentUser());
|
||||
await del.Handle(new DeletePurchaseEvaluationDetailCommand(pe.Id, detailA), CancellationToken.None);
|
||||
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("vẫn thắng detailB → IsWinner giữ true");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertQuote_UpdateBranch_TogglesIsWinnerCorrectly()
|
||||
{
|
||||
// [Bước 4 SHOULD] Coverage-gap: UPDATE-path (quote đã tồn tại) re-derive dùng loại-trừ
|
||||
// q.Id != existing.Id (né stale-DB-read). Test-before đã có, giữ để cover UPDATE branch.
|
||||
using var fix = new SqliteDbFixture();
|
||||
var db = fix.Db;
|
||||
var s1 = await SeedSupplierAsync(db, "NCC-UP");
|
||||
var pe = await SeedPeAsync(db, selectedSupplierId: null);
|
||||
var detailId = await SeedDetailAsync(db, pe.Id);
|
||||
var rowId = await AddParticipatingWithQuoteAsync(db, pe.Id, s1.Id, detailId, isSelected: false);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("quote tồn tại nhưng chưa selected");
|
||||
var up = BuildUpsertQuoteHandler(db);
|
||||
|
||||
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, true, null), CancellationToken.None);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeTrue("UPDATE IsSelected=true → IsWinner=true");
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
|
||||
await up.Handle(new UpsertPurchaseEvaluationQuoteCommand(pe.Id, detailId, rowId, 0, 0, 100m, false, null), CancellationToken.None);
|
||||
(await IsWinnerAsync(db, pe.Id, s1.Id)).Should().BeFalse("UPDATE IsSelected=false (loại chính nó) → IsWinner=false");
|
||||
await AssertWinnerInvariantAsync(db, pe.Id);
|
||||
}
|
||||
|
||||
// Invariant checker: MỌI supplier-row → IsWinner == (tồn tại ÍT NHẤT 1 quote IsSelected của row).
|
||||
private static async Task AssertWinnerInvariantAsync(TestApplicationDbContext db, Guid peId)
|
||||
{
|
||||
var rows = await db.PurchaseEvaluationSuppliers.AsNoTracking()
|
||||
.Where(s => s.PurchaseEvaluationId == peId).ToListAsync();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var anySelected = await db.PurchaseEvaluationQuotes.AsNoTracking()
|
||||
.AnyAsync(q => q.PurchaseEvaluationSupplierId == r.Id && q.IsSelected);
|
||||
r.IsWinner.Should().Be(anySelected,
|
||||
$"invariant Supplier.IsWinner == Any(Quote.IsSelected) cho row SupplierId={r.SupplierId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -593,10 +593,11 @@ public class PeWorkItemBudgetTests
|
||||
// =====================================================================
|
||||
|
||||
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
|
||||
// [Mig 58 multi-winner] Tiêu chí "đã chọn thầu" nay = IsWinner trên supplier-row
|
||||
// (KHÔNG còn SelectedSupplierId). Helper này set CẢ HAI để mô phỏng single-winner đúng
|
||||
// nghiệp vụ (đúng 1 winner ⇒ SelectedSupplierId = winner đó được sync). Code GetDetail
|
||||
// chỉ đọc IsWinner cho prevSelected*; SelectedSupplierId giữ để các path khác (banner FE).
|
||||
// [multi-NCC 2026-07-12 cardinality-change] budgetSummary PreviousSelectedTotal +
|
||||
// CurrentProposalTotal NAY = SUM ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected —
|
||||
// PurchaseEvaluationFeatures.cs:911-926 `where q.IsSelected`), KHÔNG còn build từ IsWinner.
|
||||
// PreviousSelectedCount vẫn dùng IsWinner (existence). Helper set CẢ quote.IsSelected=true +
|
||||
// pes.IsWinner=true (invariant) → tổng tiền lọt đúng + count đúng. SelectedSupplierId sync caller.
|
||||
private static async Task SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
|
||||
{
|
||||
@ -606,7 +607,7 @@ public class PeWorkItemBudgetTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] nguồn-sự-thật mới cho "đơn vị trúng"
|
||||
IsWinner = true, // existence (PreviousSelectedCount) — derived == Any(IsSelected)
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -625,6 +626,7 @@ public class PeWorkItemBudgetTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = thanhTien,
|
||||
IsSelected = true, // [multi-NCC] báo giá được chọn → vào PrevSelectedTotal/CurrentProposalTotal
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
@ -665,6 +667,7 @@ public class PeWorkItemBudgetTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = thanhTien,
|
||||
IsSelected = true, // [multi-NCC] mỗi winner có báo giá được chọn → vào SUM IsSelected
|
||||
});
|
||||
}
|
||||
// pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner.
|
||||
|
||||
@ -80,6 +80,11 @@ public class PeCcmThresholdFinalizeTests
|
||||
|
||||
// Seed 1 NCC tham gia (winner) + 1 detail + 1 quote ThanhTien=amount → winner
|
||||
// quote sum = amount. Return supplierId (master ref) để pin SelectedSupplierId.
|
||||
// [multi-NCC 2026-07-12 cardinality-change] ccm-delegate winnerQuoteTotal NAY = SUM
|
||||
// ThanhTien quote ĐƯỢC CHỌN (Quote.IsSelected — service :884-889 `where q.IsSelected`),
|
||||
// KHÔNG còn theo IsWinner supplier-row. Quote PHẢI IsSelected=true thì mới vào tổng
|
||||
// → boundary test (b)/(b2) (gói >= ngưỡng → Conflict) mới đúng; nếu IsSelected=false thì
|
||||
// tổng=0 luôn < ngưỡng → mất case Conflict. Set CẢ IsSelected + IsWinner (giữ invariant).
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
@ -89,7 +94,7 @@ public class PeCcmThresholdFinalizeTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] ccm-delegate winnerQuoteTotal đếm SUM theo IsWinner rows
|
||||
IsWinner = true, // derived == Any(quote.IsSelected)
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -106,6 +111,47 @@ public class PeCcmThresholdFinalizeTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = quoteThanhTien,
|
||||
IsSelected = true, // [multi-NCC] báo giá được chọn → vào winnerQuoteTotal
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
}
|
||||
|
||||
// [multi-NCC 2026-07-12 — new test (c) threshold-crossing] Seed 1 winner-row với 2 quote:
|
||||
// 1 ĐƯỢC CHỌN (selectedThanhTien) + 1 KHÔNG chọn (unselectedThanhTien). Tổng-theo-IsSelected
|
||||
// = selectedThanhTien; tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) =
|
||||
// selected + unselected. Return supplierId.
|
||||
private static async Task<Guid> SeedWinnerWithSelectedAndUnselectedQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe,
|
||||
decimal selectedThanhTien, decimal unselectedThanhTien)
|
||||
{
|
||||
var supplierId = Guid.NewGuid();
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, SupplierId = supplierId, Order = 0, IsWinner = true,
|
||||
};
|
||||
var detailSel = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.I", GroupName = "Bê tông",
|
||||
NoiDung = "Hạng mục ĐƯỢC chọn", Order = 0,
|
||||
};
|
||||
var detailUnsel = new PurchaseEvaluationDetail
|
||||
{
|
||||
PurchaseEvaluationId = pe.Id, GroupCode = "A.II", GroupName = "Thép",
|
||||
NoiDung = "Hạng mục KHÔNG chọn", Order = 1,
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationDetails.Add(detailSel);
|
||||
db.PurchaseEvaluationDetails.Add(detailUnsel);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detailSel.Id, PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = selectedThanhTien, IsSelected = true,
|
||||
});
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
PurchaseEvaluationDetailId = detailUnsel.Id, PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = unselectedThanhTien, IsSelected = false,
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
@ -611,4 +657,50 @@ public class PeCcmThresholdFinalizeTests
|
||||
.WithMessage("*giá chốt*");
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// (c) [multi-NCC 2026-07-12 cardinality-change — NEW] THRESHOLD-CROSSING:
|
||||
// Winner có 1 quote ĐƯỢC CHỌN 500tr + 1 quote KHÔNG chọn 700tr.
|
||||
// • Tổng-theo-CŨ (whole-supplier IsWinner, gồm cả quote không chọn) = 1.2 tỷ ≥ ngưỡng
|
||||
// 1 tỷ → nếu code còn dùng logic cũ = Conflict (SAI).
|
||||
// • Tổng-theo-MỚI (Quote.IsSelected only) = 500tr < 1 tỷ → CCM duyệt done miễn CEO
|
||||
// HỢP LỆ → finalize DaDuyet, KHÔNG throw.
|
||||
// Chống regression: chứng minh winnerQuoteTotal đọc IsSelected-subset, KHÔNG whole-supplier.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task ApproveV2_CcmDelegation_WholeSupplierSumAboveButSelectedSubsetBelow_Finalizes_NoThrow()
|
||||
{
|
||||
var (svc, fix, db, _) = CreateService();
|
||||
using (fix)
|
||||
{
|
||||
var ccm = (await fix.CreateUserAsync("ccmXc@fb.test", "CCM User", null, new[] { AppRoles.CostControl })).Id;
|
||||
var ceo = (await fix.CreateUserAsync("ceoXc@fb.test", "CEO User", null, new[] { AppRoles.Director })).Id;
|
||||
|
||||
var wf = await SeedWorkflowAsync(db, new[]
|
||||
{
|
||||
new[] { ccm }, // Bước 1 = CCM (đứng đây)
|
||||
new[] { ceo }, // Bước 2 = CEO (sẽ bị bỏ qua)
|
||||
}, ceoThreshold: CeoThreshold); // 1 tỷ
|
||||
|
||||
var pe = BuildPeAtApprovalSlot(wf.Id, selectedSupplierId: null, stepIdx: 0, levelOrder: 1, code: "PE-FB-XC");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
// Selected 500tr + Unselected 700tr → whole-supplier=1.2 tỷ ≥ ngưỡng, IsSelected=500tr < ngưỡng.
|
||||
var supplierId = await SeedWinnerWithSelectedAndUnselectedQuoteAsync(
|
||||
db, pe, selectedThanhTien: 500_000_000m, unselectedThanhTien: 700_000_000m);
|
||||
pe.SelectedSupplierId = supplierId;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
// finalize theo IsSelected-subset (500tr < 1 tỷ) → KHÔNG throw, DaDuyet.
|
||||
var act = async () => await ApproveAsync(svc, pe, ccm, new[] { AppRoles.CostControl },
|
||||
finalizeByCcmDelegation: true,
|
||||
approvedPriceAmount: ValidApprovedPrice, approvedPriceSource: ValidApprovedSource);
|
||||
|
||||
await act.Should().NotThrowAsync(
|
||||
"winnerQuoteTotal đọc SUM Quote.IsSelected (500tr < ngưỡng); logic cũ whole-supplier 1.2 tỷ đã bị thay");
|
||||
pe.Phase.Should().Be(PurchaseEvaluationPhase.DaDuyet, "CCM duyệt done miễn CEO — gói ĐƯỢC CHỌN < ngưỡng");
|
||||
pe.CurrentWorkflowStepIndex.Should().BeNull();
|
||||
pe.ApprovedPriceAmount.Should().Be(ValidApprovedPrice);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,9 +96,13 @@ 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.
|
||||
// [multi-NCC 2026-07-12 cardinality-change] Submit guard NAY đọc SUM theo
|
||||
// Quote.IsSelected (nguồn-sự-thật per Detail×Supplier — PurchaseEvaluationWorkflowService
|
||||
// :196-201 `where q.IsSelected`), KHÔNG còn IsWinner/SelectedSupplierId. Winner quote PHẢI
|
||||
// IsSelected=true thì mới lọt selectedThanhTien (nếu không → "chưa chọn Đơn vị NCC/TP" bắn
|
||||
// sai). Set CẢ quote.IsSelected=true + pes.IsWinner=true (invariant IsWinner==Any(IsSelected))
|
||||
// để mirror handler select-winner. Test (3) ThanhTien=0 vẫn đúng: IsSelected=true → lọt
|
||||
// list nhưng Sum≤0 → "chưa có giá chào thầu".
|
||||
private static async Task<Guid> SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, decimal quoteThanhTien)
|
||||
{
|
||||
@ -108,7 +112,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] đánh dấu winner — submit guard đếm IsWinner rows
|
||||
IsWinner = true, // derived == Any(quote.IsSelected) — giữ đúng invariant
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -125,6 +129,7 @@ public class PeSubmitGuardAndBypassTests
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = quoteThanhTien,
|
||||
IsSelected = true, // [multi-NCC] báo giá ĐƯỢC CHỌN → lọt SUM submit-guard/DTO/threshold
|
||||
});
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return supplierId;
|
||||
|
||||
Reference in New Issue
Block a user