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