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() {