[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 ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending || peFetching}
label="Ngân sách kỳ này (PRO)" label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount ?? pro.r7} initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending || peFetching}
label="Giá trị thực hiện dự kiến còn lại" label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8} onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
@ -2017,7 +2017,10 @@ function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
paymentTerms: ev.paymentTerms, paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount, budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount, 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: () => { onSuccess: () => {
@ -2504,7 +2507,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</p> </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 {/* 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 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> </div>
{ev.details.length === 0 ? ( {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: // Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet) previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number 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 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 // Mirror BE PeDepartmentKind enum

View File

@ -1545,7 +1545,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
{drafterEditable ? ( {drafterEditable ? (
<VndInlineEdit <VndInlineEdit
initial={ev.budgetPeriodAmount ?? bs.proInitialAmount} initial={ev.budgetPeriodAmount ?? bs.proInitialAmount}
saving={adjustMut.isPending} saving={adjustMut.isPending || peFetching}
label="Ngân sách kỳ này (PRO)" label="Ngân sách kỳ này (PRO)"
onLiveChange={setDraftRow3} onLiveChange={setDraftRow3}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: v, expectedRemainingAmount: ev.expectedRemainingAmount })}
@ -1646,7 +1646,7 @@ function PeBudgetSummaryTable({ ev, readOnly }: { ev: PeDetailBundle; readOnly:
<VndInlineEdit <VndInlineEdit
initial={ev.expectedRemainingAmount ?? pro.r7} initial={ev.expectedRemainingAmount ?? pro.r7}
allowNegative allowNegative
saving={adjustMut.isPending} saving={adjustMut.isPending || peFetching}
label="Giá trị thực hiện dự kiến còn lại" label="Giá trị thực hiện dự kiến còn lại"
onLiveChange={setDraftRow8} onLiveChange={setDraftRow8}
onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })} onSave={v => adjustMut.mutate({ budgetPeriodAmount: ev.budgetPeriodAmount, expectedRemainingAmount: v })}
@ -2017,7 +2017,10 @@ function HoSoLinkRow({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?:
paymentTerms: ev.paymentTerms, paymentTerms: ev.paymentTerms,
budgetPeriodAmount: ev.budgetPeriodAmount, budgetPeriodAmount: ev.budgetPeriodAmount,
expectedRemainingAmount: ev.expectedRemainingAmount, 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: () => { onSuccess: () => {
@ -2504,7 +2507,7 @@ function ItemsTab({ ev, readOnly = false }: { ev: PeDetailBundle; readOnly?: boo
</p> </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 {/* 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 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> </div>
{ev.details.length === 0 ? ( {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: // Lũy kế các phiếu cùng (ProjectId, WorkItemId), Id != this, CreatedAt < this:
previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet) previousSubmittedTotal: number // SUM BudgetPeriodAmount WHERE Phase IN (ChoDuyet, DaDuyet)
previousSubmittedCount: number 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 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 // 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. // [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro, bool IsUrgentByPro,
bool IsUrgentByCcm, bool IsUrgentByCcm,
// [S69] Tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner quote total) — SUM // [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN
// ThanhTien các báo giá thuộc supplier-rows của SelectedSupplierId. 0 khi chưa // (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner
// chọn. Mirror predicate submit-guard (PurchaseEvaluationWorkflowService ~:188). // Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate
// FE so với CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO". // submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với
// CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
decimal WinnerQuoteTotal, decimal WinnerQuoteTotal,
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi // [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. // 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); 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); 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.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem; entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa; entity.MoTa = request.MoTa;
entity.HoSoLink = request.HoSoLink; // [HoSoLink] absolute-set như MoTa/DiaDiem (Section 1 text field, null = clear)
entity.PaymentTerms = request.PaymentTerms; 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 // [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). // (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. // 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 prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m); 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 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 prevSelectedCount = await selectedPeers.CountAsync(ct);
var prevSelectedTotal = await ( var prevSelectedTotal = await (
from p in selectedPeers from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking() join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId on p.Id equals s.PurchaseEvaluationId
where s.SupplierId == p.SelectedSupplierId where s.IsWinner
join q in db.PurchaseEvaluationQuotes.AsNoTracking() join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId on s.Id equals q.PurchaseEvaluationSupplierId
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m; 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 // Row 4 "Giá trị kỳ này" = tổng giá chào của MỌI đơn vị IsWinner phiếu này
// (mirror predicate submit-guard WorkflowService — winner quote total). // [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay
var currentProposalTotal = 0m; // SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh).
if (e.SelectedSupplierId is Guid winId) var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner)
{
var winnerRowIds = e.Suppliers.Where(s => s.SupplierId == winId)
.Select(s => s.Id).ToList(); .Select(s => s.Id).ToList();
currentProposalTotal = await db.PurchaseEvaluationQuotes.AsNoTracking() var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId)) : await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => curWinnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m; .SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
}
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho // [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 // 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}", $"/purchase-evaluations/{entity.Id}",
entity.Id, entity.Id,
ct); 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 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, // `PurchaseEvaluation.HoSoLink string?` — 1 hyperlink tới thư mục hồ sơ NAS, max 1000,
// nullable, KHÔNG entity con / index. // nullable, KHÔNG entity con / index.
// //
// CHỐT theo CODE (single source of truth, S34 rule) — đọc PurchaseEvaluationFeatures.cs: // [S88 SPEC CHANGE — bug-fix anh Kiệt FDC, bug-class S42/#73] UpdateDraft HoSoLink chuyển
// - Create handler line 140: HoSoLink = request.HoSoLink (absolute-set, default null) // từ ABSOLUTE-SET → NULL-SAFE + CLEAR-VIA-EMPTY. CHỐT theo CODE (single source of truth,
// - Update handler line 274: entity.HoSoLink = request.HoSoLink (ABSOLUTE-SET như // S34 rule) — đọc PurchaseEvaluationFeatures.cs:
// MoTa/DiaDiem — Section 1 text field, comment ghi rõ "null = clear link") // - 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. // - Validator: RuleFor(HoSoLink).MaximumLength(1000) — chỉ field-level, no cross-table.
// //
// ⚠️ SPEC-DRIFT vs task brief S66 (test theo CODE, report drift): // LƯU Ý: HoSoLink nay CÙNG họ null-safe với ApprovalWorkflowId (S85) + BudgetPeriodAmount /
// Task brief mục 2 nói "update null-safe (không null-hoá khi UpdateDraft thiếu field // ExpectedRemainingAmount / WorkItemId (S61/S57bis) — tất cả guard `if (request.X is not
// theo convention S42)". CODE THỰC TẾ thì HoSoLink KHÔNG null-safe — nó absolute-set // null)`. RIÊNG HoSoLink thêm "" = clear (vì FE HoSoLinkRow cần một cách chủ động xoá link
// giống MoTa/DiaDiem. CHỈ BudgetPeriodAmount/ExpectedRemainingAmount/WorkItemId mới // 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).
// 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).
// //
// UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser cu) // UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser cu)
// — 2 dep nhẹ. Phase guard: chỉ DangSoanThao / TraLai mới update được. // — 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] [Fact]
@ -151,26 +156,72 @@ public class PeHoSoLinkTests
reload.HoSoLink.Should().Be(newLink); 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] [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(); using var fix = new IdentityFixture();
var db = fix.Services.GetRequiredService<TestApplicationDbContext>(); 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); db.PurchaseEvaluations.Add(pe);
await db.SaveChangesAsync(CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None);
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser()); 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); await handler.Handle(UpdateCmd(pe.Id, hoSoLink: null), CancellationToken.None);
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id); 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] [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ể). // 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 // [Mig 58 multi-winner] Tiêu chí "đã chọn thầu" nay = IsWinner trên supplier-row
// (join row 2 prevSelectedTotal: PES.SupplierId == p.SelectedSupplierId → quotes). // (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( private static async Task SeedWinnerWithQuoteAsync(
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien) TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
{ {
@ -604,6 +606,7 @@ public class PeWorkItemBudgetTests
PurchaseEvaluationId = pe.Id, PurchaseEvaluationId = pe.Id,
SupplierId = supplierId, SupplierId = supplierId,
Order = 0, Order = 0,
IsWinner = true, // [Mig 58] nguồn-sự-thật mới cho "đơn vị trúng"
}; };
var detail = new PurchaseEvaluationDetail var detail = new PurchaseEvaluationDetail
{ {
@ -626,6 +629,48 @@ public class PeWorkItemBudgetTests
await db.SaveChangesAsync(CancellationToken.None); 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( private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser) IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser); => new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
@ -675,10 +720,76 @@ public class PeWorkItemBudgetTests
var s = bundle.BudgetSummary!; var s = bundle.BudgetSummary!;
s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại"); s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
s.PreviousSubmittedCount.Should().Be(2); 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); 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] [Fact]
public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty() public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
{ {