From 73cce1f0277cfa2c0d76aa1cdf9e3e6592a17a4f Mon Sep 17 00:00:00 2001
From: pqhuy1987
Date: Thu, 25 Jun 2026 09:54:04 +0700
Subject: [PATCH] =?UTF-8?q?[CLAUDE]=20PurchaseEvaluation:=20D1=20stability?=
=?UTF-8?q?=20fixes=20=E2=80=94=20Block=20B=20multi-winner=20financials=20?=
=?UTF-8?q?+=20CEO-notify=20persist=20+=20HoSoLink=20preserve=20+=20#70=20?=
=?UTF-8?q?guard=20+=20setter=20relocate?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
fe-admin/src/components/pe/PeDetailTabs.tsx | 11 +-
fe-admin/src/types/purchaseEvaluation.ts | 4 +-
fe-user/src/components/pe/PeDetailTabs.tsx | 11 +-
fe-user/src/types/purchaseEvaluation.ts | 4 +-
.../Dtos/PurchaseEvaluationDtos.cs | 9 +-
.../PeSuggestedPriceFeatures.cs | 56 -----
.../PeWorkItemBudgetFeatures.cs | 57 +++++
.../PurchaseEvaluationFeatures.cs | 32 ++-
.../PurchaseEvaluationUrgentFeatures.cs | 5 +
.../Application/PeHoSoLinkTests.cs | 91 +++++--
.../PeUrgentNotifyDirectorTests.cs | 237 ++++++++++++++++++
.../Application/PeWorkItemBudgetTests.cs | 117 ++++++++-
12 files changed, 526 insertions(+), 108 deletions(-)
create mode 100644 tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentNotifyDirectorTests.cs
diff --git a/fe-admin/src/components/pe/PeDetailTabs.tsx b/fe-admin/src/components/pe/PeDetailTabs.tsx
index 88b8962..883e010 100644
--- a/fe-admin/src/components/pe/PeDetailTabs.tsx
+++ b/fe-admin/src/components/pe/PeDetailTabs.tsx
@@ -1545,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{drafterEditable ? (
adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
@@ -2017,7 +2017,10 @@ function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
- hoSoLink: hoSoLink || null,
+ // BE UpdatePeDraftHandler null-safe: "" = clear, non-empty = set,
+ // omit/null = preserve. Gửi raw trimmed (="" khi xóa) để CLEAR đúng,
+ // KHÔNG gửi null (null = giữ nguyên → không xóa được link cũ).
+ hoSoLink: hoSoLink.trim(),
})
},
onSuccess: () => {
@@ -2504,7 +2507,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
- hạng mục thứ 2+ sai mô hình. AddItemDialog giữ (dead) để flip lại dễ nếu cần. */}
+ hạng mục thứ 2+ sai mô hình → nút đã bỏ (dialog thêm/sửa hạng mục là DetailDialog). */}
{ev.details.length === 0 ? (
diff --git a/fe-admin/src/types/purchaseEvaluation.ts b/fe-admin/src/types/purchaseEvaluation.ts
index 66dfd1e..e641412 100644
--- a/fe-admin/src/types/purchaseEvaluation.ts
+++ b/fe-admin/src/types/purchaseEvaluation.ts
@@ -312,9 +312,9 @@ export type PeBudgetSummary = {
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number
- previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
+ previousSelectedTotal: number // SUM quote ThanhTien NCC trúng (IsWinner) WHERE Phase=DaDuyet
previousSelectedCount: number
- currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
+ currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
}
// Mirror BE PeDepartmentKind enum
diff --git a/fe-user/src/components/pe/PeDetailTabs.tsx b/fe-user/src/components/pe/PeDetailTabs.tsx
index 88b8962..883e010 100644
--- a/fe-user/src/components/pe/PeDetailTabs.tsx
+++ b/fe-user/src/components/pe/PeDetailTabs.tsx
@@ -1545,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{drafterEditable ? (
adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
@@ -2017,7 +2017,10 @@ function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount,
- hoSoLink: hoSoLink || null,
+ // BE UpdatePeDraftHandler null-safe: "" = clear, non-empty = set,
+ // omit/null = preserve. Gửi raw trimmed (="" khi xóa) để CLEAR đúng,
+ // KHÔNG gửi null (null = giữ nguyên → không xóa được link cũ).
+ hoSoLink: hoSoLink.trim(),
})
},
onSuccess: () => {
@@ -2504,7 +2507,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
{/* S59 vòng 6 (anh chốt "bỏ luôn cái nút thêm hạng mục"): 1 phiếu = 1 hạng mục
chọn từ header (S57bis/S58) — hạng mục đầu auto-seed khi tạo phiếu, nút thêm
- hạng mục thứ 2+ sai mô hình. AddItemDialog giữ (dead) để flip lại dễ nếu cần. */}
+ hạng mục thứ 2+ sai mô hình → nút đã bỏ (dialog thêm/sửa hạng mục là DetailDialog). */}
{ev.details.length === 0 ? (
diff --git a/fe-user/src/types/purchaseEvaluation.ts b/fe-user/src/types/purchaseEvaluation.ts
index f521153..687daf0 100644
--- a/fe-user/src/types/purchaseEvaluation.ts
+++ b/fe-user/src/types/purchaseEvaluation.ts
@@ -314,9 +314,9 @@ export type PeBudgetSummary = {
// Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number
- previousSelectedTotal: number // SUM quote ThanhTien của SelectedSupplier WHERE Phase=DaDuyet
+ previousSelectedTotal: number // SUM quote ThanhTien NCC trúng (IsWinner) WHERE Phase=DaDuyet
previousSelectedCount: number
- currentProposalTotal: number // SUM ThanhTien quotes của SelectedSupplier phiếu NÀY (0 khi chưa chọn)
+ currentProposalTotal: number // SUM ThanhTien quotes NCC trúng (IsWinner) phiếu NÀY (0 khi chưa chọn)
}
// Mirror BE PeDepartmentKind enum
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
index db7e9e2..85c200a 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/Dtos/PurchaseEvaluationDtos.cs
@@ -247,10 +247,11 @@ public record PurchaseEvaluationDetailBundleDto(
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro,
bool IsUrgentByCcm,
- // [S69] Tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner quote total) — SUM
- // ThanhTien các báo giá thuộc supplier-rows của SelectedSupplierId. 0 khi chưa
- // chọn. Mirror predicate submit-guard (PurchaseEvaluationWorkflowService ~:188).
- // FE so với CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
+ // [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN
+ // (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner
+ // Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate
+ // submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với
+ // CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
decimal WinnerQuoteTotal,
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs
index abb921a..ec8aaf4 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeSuggestedPriceFeatures.cs
@@ -141,59 +141,3 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
await db.SaveChangesAsync(ct);
}
}
-
-// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
-// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
-// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
-// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
-// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
-// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
-// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
-
-public record SetPeCcmBudgetPeriodCommand(
- Guid Id,
- decimal? CcmBudgetPeriodAmount) : IRequest;
-
-public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator
-{
- public SetPeCcmBudgetPeriodCommandValidator()
- {
- RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
- .When(x => x.CcmBudgetPeriodAmount.HasValue);
- }
-}
-
-public class SetPeCcmBudgetPeriodCommandHandler(
- IApplicationDbContext db,
- ICurrentUser currentUser) : IRequestHandler
-{
- public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
- {
- var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
- ?? throw new NotFoundException("PurchaseEvaluation", request.Id);
-
- // Fail-closed TRƯỚC mọi side-effect.
- if (!currentUser.Roles.Contains(AppRoles.Admin)
- && !currentUser.Roles.Contains(AppRoles.CostControl))
- {
- throw new ForbiddenException(
- "Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập NS kỳ này (CCM).");
- }
-
- var oldVal = pe.CcmBudgetPeriodAmount;
- pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // absolute-set (null = clear)
-
- db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
- {
- PurchaseEvaluationId = pe.Id,
- EntityType = PurchaseEvaluationEntityType.Header,
- Action = ChangelogAction.Update,
- PhaseAtChange = pe.Phase,
- UserId = currentUser.UserId,
- UserName = currentUser.FullName ?? currentUser.Email,
- Summary = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
- });
-
- await db.SaveChangesAsync(ct);
- }
-}
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
index 084fcb2..85524ae 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PeWorkItemBudgetFeatures.cs
@@ -198,3 +198,60 @@ public class UpdatePeBudgetCcmCommandHandler(
await db.SaveChangesAsync(ct);
}
}
+
+// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
+// [S88] Chuyển từ PeSuggestedPriceFeatures.cs về đây (đúng nhóm budget setter).
+// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
+// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
+// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
+// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
+// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
+// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
+
+public record SetPeCcmBudgetPeriodCommand(
+ Guid Id,
+ decimal? CcmBudgetPeriodAmount) : IRequest;
+
+public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator
+{
+ public SetPeCcmBudgetPeriodCommandValidator()
+ {
+ RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
+ .When(x => x.CcmBudgetPeriodAmount.HasValue);
+ }
+}
+
+public class SetPeCcmBudgetPeriodCommandHandler(
+ IApplicationDbContext db,
+ ICurrentUser currentUser) : IRequestHandler
+{
+ public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
+ {
+ var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
+ ?? throw new NotFoundException("PurchaseEvaluation", request.Id);
+
+ // Fail-closed TRƯỚC mọi side-effect.
+ if (!currentUser.Roles.Contains(AppRoles.Admin)
+ && !currentUser.Roles.Contains(AppRoles.CostControl))
+ {
+ throw new ForbiddenException(
+ "Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập NS kỳ này (CCM).");
+ }
+
+ var oldVal = pe.CcmBudgetPeriodAmount;
+ pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // absolute-set (null = clear)
+
+ db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
+ {
+ PurchaseEvaluationId = pe.Id,
+ EntityType = PurchaseEvaluationEntityType.Header,
+ Action = ChangelogAction.Update,
+ PhaseAtChange = pe.Phase,
+ UserId = currentUser.UserId,
+ UserName = currentUser.FullName ?? currentUser.Email,
+ Summary = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
+ });
+
+ await db.SaveChangesAsync(ct);
+ }
+}
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
index 646778f..5acdfc5 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationFeatures.cs
@@ -277,8 +277,13 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
- entity.HoSoLink = request.HoSoLink; // [HoSoLink] absolute-set như MoTa/DiaDiem (Section 1 text field, null = clear)
entity.PaymentTerms = request.PaymentTerms;
+ // [S88 fix bug-class S42/#73] HoSoLink null-safe + clear-via-empty: link sửa ở
+ // HoSoLinkRow RIÊNG (form "Sửa header" + InfoTab pencil OMIT field) → absolute-set
+ // cũ null-hóa link MỖI lần Sửa header (mất dữ liệu). null = form không gửi → GIỮ;
+ // "" = HoSoLinkRow chủ động xoá → clear; có giá trị → set. (FE HoSoLinkRow gửi "" khi xoá.)
+ if (request.HoSoLink is not null)
+ entity.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink;
// [S85 anh Kiet ROOT FIX bug-class S42] null-safe: form Sua KHONG gui approvalWorkflowId
// (tao co WF -> bam Sua = MAT WF -> ket submit "chua chon quy trinh" = root-cause whack-a-mole).
// Client khong gui field -> GIU WF cu (giong BudgetPeriodAmount duoi); doi WF thi FE phai gui gia tri.
@@ -829,29 +834,30 @@ public class GetPurchaseEvaluationQueryHandler(
var prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
+ // [Mig 58 multi-winner] "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị
+ // IsWinner (thay `SelectedSupplierId != null` cũ — null khi ≥2 đồng-trúng nên
+ // bỏ sót phiếu liên-danh). Tổng = mọi báo giá của các đơn vị IsWinner.
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
- && p.SelectedSupplierId != null);
+ && p.Suppliers.Any(s => s.IsWinner));
var prevSelectedCount = await selectedPeers.CountAsync(ct);
var prevSelectedTotal = await (
from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId
- where s.SupplierId == p.SelectedSupplierId
+ where s.IsWinner
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
- // Row 4 "Giá trị kỳ này" = tổng giá chào của đơn vị ĐƯỢC CHỌN phiếu này
- // (mirror predicate submit-guard WorkflowService — winner quote total).
- var currentProposalTotal = 0m;
- if (e.SelectedSupplierId is Guid winId)
- {
- var winnerRowIds = e.Suppliers.Where(s => s.SupplierId == winId)
- .Select(s => s.Id).ToList();
- currentProposalTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
- .Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
+ // Row 4 "Giá trị kỳ này" = tổng giá chào của MỌI đơn vị IsWinner phiếu này
+ // [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay
+ // SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh).
+ var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner)
+ .Select(s => s.Id).ToList();
+ var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
+ : await db.PurchaseEvaluationQuotes.AsNoTracking()
+ .Where(q => curWinnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
- }
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
// Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE
diff --git a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs
index 3a7fada..f42a908 100644
--- a/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs
+++ b/src/Backend/SolutionErp.Application/PurchaseEvaluations/PurchaseEvaluationUrgentFeatures.cs
@@ -93,6 +93,11 @@ public class SetPurchaseEvaluationUrgentCommandHandler(
$"/purchase-evaluations/{entity.Id}",
entity.Id,
ct);
+ // [S88 fix] NotifyManyAsync CHỈ Add (caller tự flush) → toggle đã flush
+ // ở :75 TRƯỚC block này nên chuông Director chưa từng được lưu (rớt âm
+ // thầm + KHÔNG trigger SignalR push). SaveChanges RIÊNG ở đây để chuông
+ // CEO/Director thực sự lưu. Trong try → vẫn best-effort (lỗi nuốt, cờ đã ổn).
+ await db.SaveChangesAsync(ct);
}
}
catch
diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs
index fc5e2da..6805a75 100644
--- a/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs
+++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeHoSoLinkTests.cs
@@ -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();
- 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();
+
+ 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();
+
+ 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]
diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentNotifyDirectorTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentNotifyDirectorTests.cs
new file mode 100644
index 0000000..5ef459d
--- /dev/null
+++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeUrgentNotifyDirectorTests.cs
@@ -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, 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 Roles { get; } = roles ?? Array.Empty();
+ 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 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 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();
+ var um = fix.Services.GetRequiredService>();
+
+ 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();
+ var um = fix.Services.GetRequiredService>();
+
+ 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();
+ var um = fix.Services.GetRequiredService>();
+
+ 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();
+ var um = fix.Services.GetRequiredService>();
+
+ 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();
+ var um = fix.Services.GetRequiredService>();
+
+ 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();
+ var um = fix.Services.GetRequiredService>();
+
+ // 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)");
+ }
+}
diff --git a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs
index 965d6c7..81cb14e 100644
--- a/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs
+++ b/tests/SolutionErp.Infrastructure.Tests/Application/PeWorkItemBudgetTests.cs
@@ -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>(), 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();
+ 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();
+ 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()
{