[CLAUDE] PurchaseEvaluation: D1 stability fixes — Block B multi-winner financials + CEO-notify persist + HoSoLink preserve + #70 guard + setter relocate
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m4s

PE stability audit (frozen CEO-approved spec) — D1 behavior-preserving fixes plus
one approved joint-winner financial correction:
- Block B "Gia tri ky nay" + cross-PE peer rollup re-keyed SelectedSupplierId -> IsWinner
  (reported 0 / excluded peers for >=2 joint winners; now matches winnerQuoteTotal).
- CEO-notify-on-urgent: +SaveChanges after NotifyManyAsync (Director notify was added
  after the only flush -> never persisted; now persists + SignalR push fires).
- HoSoLink: UpdatePeDraft null-safe + clear-via-empty (header edit no longer wipes a
  set link; HoSoLinkRow sends "" to clear; header forms omit = preserve).
- #70 stale-echo guard added to PRO budget row3/row8 (|| peFetching).
- Relocate SetPeCcmBudgetPeriodCommand -> PeWorkItemBudgetFeatures (was misplaced in
  PeSuggestedPriceFeatures). DTO/comment cleanups.

Tests 402 -> 413 (+11: HoSoLink preserve/clear/set, Block B >=2-winner, CEO-notify
persist x6). Full suite GREEN. FE 2-app SHA-identical. test-specialist + reviewer
returns truncated (#53, over-budget MEMORY) -> em-main self-gate from disk + suite run.

D2 deferred (create-contract 1-HD-per-winner + list-card winner names) — needs focused
care + anh Kiet UAT on per-winner contract value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-25 09:54:04 +07:00
parent e939fab50a
commit 73cce1f027
12 changed files with 526 additions and 108 deletions

View File

@ -12,19 +12,24 @@ namespace SolutionErp.Infrastructure.Tests.Application;
// `PurchaseEvaluation.HoSoLink string?` — 1 hyperlink tới thư mục hồ sơ NAS, max 1000,
// nullable, KHÔNG entity con / index.
//
// CHỐT theo CODE (single source of truth, S34 rule) — đọc PurchaseEvaluationFeatures.cs:
// - Create handler line 140: HoSoLink = request.HoSoLink (absolute-set, default null)
// - Update handler line 274: entity.HoSoLink = request.HoSoLink (ABSOLUTE-SET như
// MoTa/DiaDiem — Section 1 text field, comment ghi rõ "null = clear link")
// [S88 SPEC CHANGE — bug-fix anh Kiệt FDC, bug-class S42/#73] UpdateDraft HoSoLink chuyển
// từ ABSOLUTE-SET → NULL-SAFE + CLEAR-VIA-EMPTY. CHỐT theo CODE (single source of truth,
// S34 rule) — đọc PurchaseEvaluationFeatures.cs:
// - Create handler line ~140: HoSoLink = request.HoSoLink (absolute-set, default null)
// - Update handler line 285-286 (MỚI):
// if (request.HoSoLink is not null)
// entity.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink;
// ⇒ 3 nhánh:
// • request.HoSoLink == null (form Sửa header / InfoTab pencil OMIT field) → GIỮ link cũ
// (PRESERVE) — đây là FIX: trước absolute-set null-hóa link mỗi lần Sửa header (mất dữ liệu).
// • request.HoSoLink == ""/whitespace (HoSoLinkRow bấm xoá → FE gửi "") → CLEAR (set null).
// • request.HoSoLink == "giá trị" → set giá trị đó.
// - Validator: RuleFor(HoSoLink).MaximumLength(1000) — chỉ field-level, no cross-table.
//
// ⚠️ SPEC-DRIFT vs task brief S66 (test theo CODE, report drift):
// Task brief mục 2 nói "update null-safe (không null-hoá khi UpdateDraft thiếu field
// theo convention S42)". CODE THỰC TẾ thì HoSoLink KHÔNG null-safe — nó absolute-set
// giống MoTa/DiaDiem. CHỈ BudgetPeriodAmount/ExpectedRemainingAmount/WorkItemId mới
// null-safe (3 field đó dùng `if (request.X is not null)`). HoSoLink gán thẳng.
// → Test LOCK behavior absolute-set thật của code (UpdateDraft với HoSoLink=null →
// CLEAR link cũ). Đây là chủ đích của code (Section 1 FE luôn gửi đủ field text).
// LƯU Ý: HoSoLink nay CÙNG họ null-safe với ApprovalWorkflowId (S85) + BudgetPeriodAmount /
// ExpectedRemainingAmount / WorkItemId (S61/S57bis) — tất cả guard `if (request.X is not
// null)`. RIÊNG HoSoLink thêm "" = clear (vì FE HoSoLinkRow cần một cách chủ động xoá link
// mà không có nút riêng — gửi chuỗi rỗng). Các field kia null-safe THUẦN (không clear-via-empty).
//
// UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser cu)
// — 2 dep nhẹ. Phase guard: chỉ DangSoanThao / TraLai mới update được.
@ -112,7 +117,7 @@ public class PeHoSoLinkTests
}
// ============================================================
// UpdateDraft — set / clear HoSoLink (absolute-set theo CODE)
// UpdateDraft — null-safe PRESERVE + clear-via-empty + set (S88 spec change)
// ============================================================
[Fact]
@ -151,26 +156,72 @@ public class PeHoSoLinkTests
reload.HoSoLink.Should().Be(newLink);
}
// CASE (a) — bug-fix cốt lõi: form Sửa header OMIT hoSoLink (null) → GIỮ link cũ.
// TRƯỚC FIX: absolute-set null → link bị null-hóa (mất dữ liệu mỗi lần Sửa header).
// SAU FIX (S88): null-safe guard `if (request.HoSoLink is not null)` → PRESERVE.
// Test này từng RED trên code cũ (đã chứng minh: pre-fix null=clear → fail "expected link").
[Fact]
public async Task UpdateDraft_HoSoLinkNull_ClearsExistingLink_AbsoluteSet()
public async Task UpdateDraft_HoSoLinkNull_PreservesExistingLink()
{
// ⚠️ SPEC-DRIFT cover: code ABSOLUTE-SET (entity.HoSoLink = request.HoSoLink),
// KHÔNG null-safe. UpdateDraft với HoSoLink=null → CLEAR link cũ (về null).
// Khác convention S42 (BudgetPeriodAmount/WorkItemId GIỮ giá trị cũ khi null).
// Test LOCK đúng behavior code (Section 1 FE luôn gửi đủ field → intent đúng).
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\WILL-BE-CLEARED", code: "PE-HSL-U3");
const string existing = @"\\nas\HoSo\MUST-BE-PRESERVED";
var pe = BuildPe(hoSoLink: existing, code: "PE-HSL-U3");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
// HoSoLink default null trong UpdateCmd → clear.
// HoSoLink default null trong UpdateCmd (form Sửa header KHÔNG gửi field) → preserve.
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: null), CancellationToken.None);
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reload.HoSoLink.Should().BeNull("code absolute-set HoSoLink — null request = clear link (KHÔNG null-safe như Budget*/WorkItemId)");
reload.HoSoLink.Should().Be(existing,
"S88 null-safe: request.HoSoLink=null (form Sửa header OMIT) → GIỮ link cũ (KHÔNG null-hóa — root fix bug-class S42/#73)");
// Sanity: handler vẫn chạy (field text khác vẫn được set), không phải no-op.
reload.TenGoiThau.Should().Be("Gói thầu test");
}
// CASE (b) — HoSoLinkRow bấm xoá → FE gửi "" (chuỗi rỗng) → CLEAR (set null).
// "" / whitespace là cách chủ động xoá link (khác null = không gửi). Theory phủ cả " ".
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task UpdateDraft_HoSoLinkEmptyOrWhitespace_ClearsLink(string blank)
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\WILL-BE-CLEARED", code: "PE-HSL-U3B");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
// FE HoSoLinkRow gửi "" khi user xoá link → clear.
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: blank), CancellationToken.None);
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reload.HoSoLink.Should().BeNull(
"S88 clear-via-empty: request.HoSoLink=\"\"/whitespace (HoSoLinkRow xoá) → CLEAR link (set null)");
}
// CASE (c) — gửi giá trị thật → set giá trị đó (overwrite link cũ).
[Fact]
public async Task UpdateDraft_HoSoLinkValue_SetsIt()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\OLD", code: "PE-HSL-U3C");
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
const string newLink = @"O:\new";
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: newLink), CancellationToken.None);
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reload.HoSoLink.Should().Be(newLink, "request.HoSoLink có giá trị → set giá trị đó");
}
[Fact]

View File

@ -0,0 +1,237 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.PurchaseEvaluations;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
using SolutionErp.Domain.PurchaseEvaluations;
using SolutionErp.Infrastructure.Services; // real NotificationService (Add-only, caller flushes)
using SolutionErp.Infrastructure.Tests.Common;
namespace SolutionErp.Infrastructure.Tests.Application;
// [S88 FIX 3 — bug-fix anh Kiệt FDC] CEO-notify-on-urgent ĐÃ persist (trước rớt âm thầm).
//
// BUG (đã fix em-main, PurchaseEvaluationUrgentFeatures.cs): khối notify Director chạy SAU
// `db.SaveChangesAsync` DUY NHẤT ở line ~75. NotifyManyAsync CHỈ Add (caller tự flush) →
// chuông Director chưa từng được lưu (rớt + KHÔNG trigger SignalR push).
// FIX: thêm `await db.SaveChangesAsync(ct)` SAU NotifyManyAsync (trong if, vẫn best-effort
// try/catch). ⇒ row Notification cho Director thực sự persist.
//
// Test theo CODE đã land (S34 — KHÔNG touch production). Khác PeUrgentToggleAuthzTests
// (NoOpNotificationService nuốt → không observe được persistence): ở ĐÂY dùng REAL
// NotificationService (Infrastructure) — nó Add Notification vào db (caller flush), khớp
// đúng giả định production. Persistence quan sát qua db.Notifications sau handler.
//
// Gate notify (line 80): `request.IsUrgent && nowUrgent && !wasUrgent` — CHỈ khi MỚI bật
// gấp (false→true). wasUrgent = snapshot 2 cờ TRƯỚC mutate. Suy ra:
// • false→true → notify (1 row / Director).
// • true→true → !wasUrgent = false → KHÔNG notify (no duplicate — guard chống spam).
// • true→false → request.IsUrgent = false → KHÔNG notify (gỡ cờ không báo).
//
// Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService).
public class PeUrgentNotifyDirectorTests
{
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
{
public Guid? UserId { get; init; } = Guid.NewGuid();
public string? Email { get; } = "actor@test.local";
public string? FullName { get; } = "Actor Test";
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
public bool IsAuthenticated => UserId is not null;
}
// REAL NotificationService — Add-only (KHÔNG SaveChanges), đúng hành vi production mà
// handler dựa vào (handler tự flush sau NotifyManyAsync). FixedDateTime cho CreatedAt.
private static SetPurchaseEvaluationUrgentCommandHandler BuildHandler(
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
{
var clock = new FixedDateTime(new DateTime(2026, 6, 24, 9, 0, 0, DateTimeKind.Utc));
var notify = new NotificationService(db, clock);
return new SetPurchaseEvaluationUrgentCommandHandler(db, currentUser, um, notify);
}
private static async Task<PurchaseEvaluation> SeedPeAsync(
TestApplicationDbContext db,
bool urgentByPro = false,
bool urgentByCcm = false,
string code = "PE-URGN-001",
string maPhieu = "PE-URGN-001")
{
var pe = new PurchaseEvaluation
{
Id = Guid.NewGuid(),
Type = PurchaseEvaluationType.DuyetNcc,
Phase = PurchaseEvaluationPhase.ChoDuyet,
MaPhieu = maPhieu,
TenGoiThau = "Gói thầu test urgent-notify " + code,
ProjectId = Guid.NewGuid(),
DrafterUserId = Guid.NewGuid(),
IsUrgentByPro = urgentByPro,
IsUrgentByCcm = urgentByCcm,
};
db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None);
return pe;
}
// =====================================================================
// 1. false→true bởi PRO → Director Notification row PERSIST (title "GẤP" + RefId = PE id).
// Đây là bug đã sửa: trước row này rớt vì save trước khối notify.
// =====================================================================
[Fact]
public async Task SetUrgent_FalseToTrue_ByProcurement_PersistsDirectorNotification()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var director = await fix.CreateUserAsync(
"ceo@test.local", "Giám đốc", departmentId: null, roles: new[] { AppRoles.Director });
var pe = await SeedPeAsync(db, maPhieu: "PE-GAP-01");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
// Chuông Director PHẢI được lưu (FIX 3) — đọc lại bằng context riêng để chắc đã flush.
var rows = await db.Notifications.AsNoTracking()
.Where(n => n.UserId == director.Id && n.RefId == pe.Id).ToListAsync();
rows.Should().HaveCount(1, "false→true → đúng 1 chuông gửi Giám đốc, và ĐÃ persist (bug rớt-chuông đã sửa)");
rows[0].Title.Should().Contain("GẤP", "tiêu đề chuông báo phiếu được đánh dấu GẤP");
rows[0].Title.Should().Contain("PE-GAP-01", "tiêu đề kèm mã phiếu");
rows[0].Href.Should().Be($"/purchase-evaluations/{pe.Id}", "link điều hướng tới phiếu");
rows[0].Type.Should().Be(NotificationType.Generic);
// Cờ ĐỎ cũng đã được bật (toggle thành công + chuông cùng lưu).
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloadPe.IsUrgentByPro.Should().BeTrue();
}
// =====================================================================
// 2. false→true bởi CCM → cũng persist chuông Director (cùng nhánh notify).
// =====================================================================
[Fact]
public async Task SetUrgent_FalseToTrue_ByCostControl_PersistsDirectorNotification()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var director = await fix.CreateUserAsync(
"ceo2@test.local", "Giám đốc 2", departmentId: null, roles: new[] { AppRoles.Director });
var pe = await SeedPeAsync(db, code: "PE-URGN-002", maPhieu: "PE-GAP-02");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var rows = await db.Notifications.AsNoTracking()
.Where(n => n.UserId == director.Id && n.RefId == pe.Id).ToListAsync();
rows.Should().HaveCount(1, "CCM bật gấp cũng notify Director (cờ XANH false→true)");
rows[0].Title.Should().Contain("GẤP");
}
// =====================================================================
// 3. Admin false→true → notify Director. Đồng thời chỉ Director nhận (non-Director KHÔNG).
// =====================================================================
[Fact]
public async Task SetUrgent_FalseToTrue_ByAdmin_NotifiesOnlyDirectors()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
var director = await fix.CreateUserAsync(
"ceo3@test.local", "Giám đốc 3", departmentId: null, roles: new[] { AppRoles.Director });
// Người KHÔNG phải Director — KHÔNG được nhận chuông (target chỉ role Director).
var other = await fix.CreateUserAsync(
"staff@test.local", "Nhân viên", departmentId: null, roles: new[] { AppRoles.Finance });
var pe = await SeedPeAsync(db, code: "PE-URGN-003", maPhieu: "PE-GAP-03");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var all = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
all.Should().ContainSingle("chỉ 1 Director được seed → đúng 1 chuông");
all[0].UserId.Should().Be(director.Id);
all.Should().NotContain(n => n.UserId == other.Id, "người KHÔNG phải Director không nhận chuông gấp");
}
// =====================================================================
// 4. true→true (đã gấp sẵn, bật lại) → KHÔNG thêm chuông (guard !wasUrgent chống spam).
// Pre-seed cờ XANH true, PRO bật cờ ĐỎ (nowUrgent vẫn true nhưng wasUrgent đã true).
// =====================================================================
[Fact]
public async Task SetUrgent_TrueToTrue_DoesNotAddDuplicateNotification()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
await fix.CreateUserAsync(
"ceo4@test.local", "Giám đốc 4", departmentId: null, roles: new[] { AppRoles.Director });
// Phiếu ĐÃ gấp (cờ XANH bật) → wasUrgent = true. PRO bật cờ ĐỎ (vẫn IsUrgent=true).
var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URGN-004", maPhieu: "PE-GAP-04");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
rows.Should().BeEmpty("đã gấp từ trước (wasUrgent=true) → guard !wasUrgent chặn → KHÔNG chuông trùng");
// Nhưng cờ ĐỎ vẫn được set (toggle vẫn chạy, chỉ bỏ qua notify).
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloadPe.IsUrgentByPro.Should().BeTrue("toggle vẫn set cờ ĐỎ; chỉ notify bị skip");
reloadPe.IsUrgentByCcm.Should().BeTrue("cờ XANH giữ nguyên");
}
// =====================================================================
// 5. true→false (GỠ cờ) → KHÔNG notify (request.IsUrgent=false → gate false ngay).
// Dùng Admin để qua authz UNSET (Admin gỡ cả 2). Pre-seed cả 2 cờ true.
// =====================================================================
[Fact]
public async Task SetUrgent_TrueToFalse_DoesNotNotify()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
await fix.CreateUserAsync(
"ceo5@test.local", "Giám đốc 5", departmentId: null, roles: new[] { AppRoles.Director });
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true,
code: "PE-URGN-005", maPhieu: "PE-GAP-05");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
rows.Should().BeEmpty("GỠ cờ (IsUrgent=false) → KHÔNG báo Giám đốc");
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloadPe.IsUrgentByPro.Should().BeFalse("Admin gỡ cả ĐỎ");
reloadPe.IsUrgentByCcm.Should().BeFalse("Admin gỡ cả XANH");
}
// =====================================================================
// 6. false→true nhưng KHÔNG có Director nào → handler không lỗi, không chuông
// (directorIds.Count == 0 → block notify bỏ qua). Cờ vẫn được bật (toggle thành công).
// =====================================================================
[Fact]
public async Task SetUrgent_FalseToTrue_NoDirectorSeeded_TogglesWithoutNotificationOrError()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var um = fix.Services.GetRequiredService<UserManager<User>>();
// KHÔNG seed Director.
var pe = await SeedPeAsync(db, code: "PE-URGN-006", maPhieu: "PE-GAP-06");
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
var act = async () => await handler.Handle(
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
await act.Should().NotThrowAsync("không có Director → bỏ qua notify, KHÔNG fail toggle");
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
rows.Should().BeEmpty("0 Director → 0 chuông");
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloadPe.IsUrgentByPro.Should().BeTrue("cờ ĐỎ vẫn được bật (toggle độc lập với notify)");
}
}

View File

@ -593,8 +593,10 @@ public class PeWorkItemBudgetTests
// =====================================================================
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
// Set pe.SelectedSupplierId = supplierId trùng PurchaseEvaluationSupplier.SupplierId
// (join row 2 prevSelectedTotal: PES.SupplierId == p.SelectedSupplierId → quotes).
// [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).
private static async Task SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{
@ -604,6 +606,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"
};
var detail = new PurchaseEvaluationDetail
{
@ -626,6 +629,48 @@ public class PeWorkItemBudgetTests
await db.SaveChangesAsync(CancellationToken.None);
}
// [Mig 58 multi-winner — FIX 2] Seed N đơn vị IsWinner=true cho 1 phiếu, mỗi đơn vị 1
// quote ThanhTien (1 detail dùng chung). SelectedSupplierId = NULL (liên-danh ≥2 → không
// có 1 winner duy nhất). Đây là biến thể đa-thắng của SeedWinnerWithQuoteAsync — chứng
// minh code đọc IsWinner (multi) thay SelectedSupplierId (single, null khi ≥2).
private static async Task SeedMultiWinnersWithQuotesAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] winnerQuotes)
{
var detail = new PurchaseEvaluationDetail
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
GroupCode = "A.I",
GroupName = "Bê tông",
NoiDung = "Concrete",
Order = 0,
};
db.PurchaseEvaluationDetails.Add(detail);
var order = 0;
foreach (var thanhTien in winnerQuotes)
{
var pes = new PurchaseEvaluationSupplier
{
Id = Guid.NewGuid(),
PurchaseEvaluationId = pe.Id,
SupplierId = Guid.NewGuid(),
Order = order++,
IsWinner = true, // ≥2 đơn vị cùng trúng (multi-winner / liên-danh)
};
db.PurchaseEvaluationSuppliers.Add(pes);
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
{
Id = Guid.NewGuid(),
PurchaseEvaluationDetailId = detail.Id,
PurchaseEvaluationSupplierId = pes.Id,
ThanhTien = thanhTien,
});
}
// pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner.
await db.SaveChangesAsync(CancellationToken.None);
}
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
@ -675,10 +720,76 @@ public class PeWorkItemBudgetTests
var s = bundle.BudgetSummary!;
s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
s.PreviousSubmittedCount.Should().Be(2);
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote");
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote (IsWinner=true)");
s.PreviousSelectedCount.Should().Be(1);
}
// =====================================================================
// [FIX 2 — Mig 58 multi-winner re-key IsWinner] Block B financials khi ≥2 đơn vị
// cùng trúng (SelectedSupplierId = null). TRƯỚC FIX 2: CurrentProposalTotal +
// PreviousSelected* gate trên SelectedSupplierId (single) → null khi ≥2 winner →
// báo 0 / bỏ sót phiếu liên-danh. SAU FIX: dùng Suppliers.Where(IsWinner) (multi).
// =====================================================================
// (a) Phiếu HIỆN TẠI có ≥2 đơn vị IsWinner, mỗi đơn vị 1 báo giá → CurrentProposalTotal
// = TỔNG báo giá của TẤT CẢ winner (KHÔNG phải 0). Pre-fix = 0 vì SelectedSupplierId
// null khi ≥2 winner → curWinnerRowIds rỗng → 0. Đây là bug đã sửa.
[Fact]
public async Task BudgetSummary_CurrentProposalTotal_SumsAllWinnerQuotes_WhenMultipleWinners()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-MW1");
// Phiếu này đang xét — SelectedSupplierId null (mặc định), 2 đơn vị IsWinner: 120 + 80.
var pe = await SeedPeAsync(db, project.Id, wi.Id,
PurchaseEvaluationPhase.ChoDuyet, code: "PE-MW-CURR");
await SeedMultiWinnersWithQuotesAsync(db, pe, 120m, 80m);
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.CurrentProposalTotal.Should().Be(200m,
"≥2 winner → tổng MỌI báo giá winner (120+80); pre-fix báo 0 vì SelectedSupplierId null khi liên-danh");
// Sanity: phiếu thật sự ở trạng thái multi-winner (SelectedSupplierId null).
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
reloadPe.SelectedSupplierId.Should().BeNull("đặc trưng ≥2 đồng-trúng — không có 1 winner duy nhất");
}
// (b) Phiếu DaDuyet TRƯỚC (peer) trao cho ≥2 đơn vị liên-danh (SelectedSupplierId null,
// IsWinner=true trên ≥2 row) → VẪN tính vào PreviousSelectedCount + báo giá winner
// cộng vào PreviousSelectedTotal. Pre-fix: peer bị loại vì SelectedSupplierId null.
[Fact]
public async Task BudgetSummary_PreviousSelected_CountsMultiWinnerPeer_AndSumsWinnerQuotes()
{
using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
var project = await SeedProjectAsync(db);
var wi = await SeedWorkItemAsync(db, "WI-MW2");
var baseT = new DateTime(2026, 6, 24, 8, 0, 0, DateTimeKind.Utc);
// Peer DaDuyet TRƯỚC (CreatedAt -2d), liên-danh ≥2 winner: 60 + 40 = 100. SelectedSupplierId null.
var peer = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
createdAt: baseT.AddDays(-2), code: "PE-MW-PEER");
await SeedMultiWinnersWithQuotesAsync(db, peer, 60m, 40m);
// Phiếu đang xét (mới nhất) — chỉ để trigger build summary.
var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
createdAt: baseT, code: "PE-MW-THIS");
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);
var s = bundle.BudgetSummary!;
s.PreviousSelectedCount.Should().Be(1,
"peer DaDuyet liên-danh (≥2 IsWinner, SelectedSupplierId null) VẪN được đếm; pre-fix bỏ sót");
s.PreviousSelectedTotal.Should().Be(100m,
"tổng báo giá MỌI winner của peer (60+40); pre-fix = 0 vì gate SelectedSupplierId null");
}
[Fact]
public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
{