[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

Đổ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:
pqhuy1987
2026-07-12 22:22:09 +07:00
parent 8a1b0d4901
commit d436af2971
18 changed files with 7272 additions and 425 deletions

View File

@ -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]

View File

@ -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)

View File

@ -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");
}
}

View File

@ -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 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}");
}
}
}

View File

@ -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.