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

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

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

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

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

View File

@ -1545,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending}
saving={adjustMut.isPending || peFetching}
label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<VndInlineEdit
initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative
saving={adjustMut.isPending}
saving={adjustMut.isPending || peFetching}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => 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
</p>
{/* 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). */}
</div>
{ev.details.length === 0 ? (

View File

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

View File

@ -1545,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{drafterEditable ? (
<VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending}
saving={adjustMut.isPending || peFetching}
label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<VndInlineEdit
initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative
saving={adjustMut.isPending}
saving={adjustMut.isPending || peFetching}
label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8}
onSave={v => 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
</p>
{/* 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). */}
</div>
{ev.details.length === 0 ? (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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