[CLAUDE] PurchaseEvaluation: D1 stability fixes — Block B multi-winner financials + CEO-notify persist + HoSoLink preserve + #70 guard + setter relocate
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m4s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m4s
PE stability audit (frozen CEO-approved spec) — D1 behavior-preserving fixes plus one approved joint-winner financial correction: - Block B "Gia tri ky nay" + cross-PE peer rollup re-keyed SelectedSupplierId -> IsWinner (reported 0 / excluded peers for >=2 joint winners; now matches winnerQuoteTotal). - CEO-notify-on-urgent: +SaveChanges after NotifyManyAsync (Director notify was added after the only flush -> never persisted; now persists + SignalR push fires). - HoSoLink: UpdatePeDraft null-safe + clear-via-empty (header edit no longer wipes a set link; HoSoLinkRow sends "" to clear; header forms omit = preserve). - #70 stale-echo guard added to PRO budget row3/row8 (|| peFetching). - Relocate SetPeCcmBudgetPeriodCommand -> PeWorkItemBudgetFeatures (was misplaced in PeSuggestedPriceFeatures). DTO/comment cleanups. Tests 402 -> 413 (+11: HoSoLink preserve/clear/set, Block B >=2-winner, CEO-notify persist x6). Full suite GREEN. FE 2-app SHA-identical. test-specialist + reviewer returns truncated (#53, over-budget MEMORY) -> em-main self-gate from disk + suite run. D2 deferred (create-contract 1-HD-per-winner + list-card winner names) — needs focused care + anh Kiet UAT on per-winner contract value. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -12,19 +12,24 @@ namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
// `PurchaseEvaluation.HoSoLink string?` — 1 hyperlink tới thư mục hồ sơ NAS, max 1000,
|
||||
// nullable, KHÔNG entity con / index.
|
||||
//
|
||||
// CHỐT theo CODE (single source of truth, S34 rule) — đọc PurchaseEvaluationFeatures.cs:
|
||||
// - Create handler line 140: HoSoLink = request.HoSoLink (absolute-set, default null)
|
||||
// - Update handler line 274: entity.HoSoLink = request.HoSoLink (ABSOLUTE-SET như
|
||||
// MoTa/DiaDiem — Section 1 text field, comment ghi rõ "null = clear link")
|
||||
// [S88 SPEC CHANGE — bug-fix anh Kiệt FDC, bug-class S42/#73] UpdateDraft HoSoLink chuyển
|
||||
// từ ABSOLUTE-SET → NULL-SAFE + CLEAR-VIA-EMPTY. CHỐT theo CODE (single source of truth,
|
||||
// S34 rule) — đọc PurchaseEvaluationFeatures.cs:
|
||||
// - Create handler line ~140: HoSoLink = request.HoSoLink (absolute-set, default null)
|
||||
// - Update handler line 285-286 (MỚI):
|
||||
// if (request.HoSoLink is not null)
|
||||
// entity.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink;
|
||||
// ⇒ 3 nhánh:
|
||||
// • request.HoSoLink == null (form Sửa header / InfoTab pencil OMIT field) → GIỮ link cũ
|
||||
// (PRESERVE) — đây là FIX: trước absolute-set null-hóa link mỗi lần Sửa header (mất dữ liệu).
|
||||
// • request.HoSoLink == ""/whitespace (HoSoLinkRow bấm xoá → FE gửi "") → CLEAR (set null).
|
||||
// • request.HoSoLink == "giá trị" → set giá trị đó.
|
||||
// - Validator: RuleFor(HoSoLink).MaximumLength(1000) — chỉ field-level, no cross-table.
|
||||
//
|
||||
// ⚠️ SPEC-DRIFT vs task brief S66 (test theo CODE, report drift):
|
||||
// Task brief mục 2 nói "update null-safe (không null-hoá khi UpdateDraft thiếu field
|
||||
// theo convention S42)". CODE THỰC TẾ thì HoSoLink KHÔNG null-safe — nó absolute-set
|
||||
// giống MoTa/DiaDiem. CHỈ BudgetPeriodAmount/ExpectedRemainingAmount/WorkItemId mới
|
||||
// null-safe (3 field đó dùng `if (request.X is not null)`). HoSoLink gán thẳng.
|
||||
// → Test LOCK behavior absolute-set thật của code (UpdateDraft với HoSoLink=null →
|
||||
// CLEAR link cũ). Đây là chủ đích của code (Section 1 FE luôn gửi đủ field text).
|
||||
// LƯU Ý: HoSoLink nay CÙNG họ null-safe với ApprovalWorkflowId (S85) + BudgetPeriodAmount /
|
||||
// ExpectedRemainingAmount / WorkItemId (S61/S57bis) — tất cả guard `if (request.X is not
|
||||
// null)`. RIÊNG HoSoLink thêm "" = clear (vì FE HoSoLinkRow cần một cách chủ động xoá link
|
||||
// mà không có nút riêng — gửi chuỗi rỗng). Các field kia null-safe THUẦN (không clear-via-empty).
|
||||
//
|
||||
// UpdatePurchaseEvaluationDraftCommandHandler(IApplicationDbContext db, ICurrentUser cu)
|
||||
// — 2 dep nhẹ. Phase guard: chỉ DangSoanThao / TraLai mới update được.
|
||||
@ -112,7 +117,7 @@ public class PeHoSoLinkTests
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UpdateDraft — set / clear HoSoLink (absolute-set theo CODE)
|
||||
// UpdateDraft — null-safe PRESERVE + clear-via-empty + set (S88 spec change)
|
||||
// ============================================================
|
||||
|
||||
[Fact]
|
||||
@ -151,26 +156,72 @@ public class PeHoSoLinkTests
|
||||
reload.HoSoLink.Should().Be(newLink);
|
||||
}
|
||||
|
||||
// CASE (a) — bug-fix cốt lõi: form Sửa header OMIT hoSoLink (null) → GIỮ link cũ.
|
||||
// TRƯỚC FIX: absolute-set null → link bị null-hóa (mất dữ liệu mỗi lần Sửa header).
|
||||
// SAU FIX (S88): null-safe guard `if (request.HoSoLink is not null)` → PRESERVE.
|
||||
// Test này từng RED trên code cũ (đã chứng minh: pre-fix null=clear → fail "expected link").
|
||||
[Fact]
|
||||
public async Task UpdateDraft_HoSoLinkNull_ClearsExistingLink_AbsoluteSet()
|
||||
public async Task UpdateDraft_HoSoLinkNull_PreservesExistingLink()
|
||||
{
|
||||
// ⚠️ SPEC-DRIFT cover: code ABSOLUTE-SET (entity.HoSoLink = request.HoSoLink),
|
||||
// KHÔNG null-safe. UpdateDraft với HoSoLink=null → CLEAR link cũ (về null).
|
||||
// Khác convention S42 (BudgetPeriodAmount/WorkItemId GIỮ giá trị cũ khi null).
|
||||
// Test LOCK đúng behavior code (Section 1 FE luôn gửi đủ field → intent đúng).
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\WILL-BE-CLEARED", code: "PE-HSL-U3");
|
||||
const string existing = @"\\nas\HoSo\MUST-BE-PRESERVED";
|
||||
var pe = BuildPe(hoSoLink: existing, code: "PE-HSL-U3");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
|
||||
// HoSoLink default null trong UpdateCmd → clear.
|
||||
// HoSoLink default null trong UpdateCmd (form Sửa header KHÔNG gửi field) → preserve.
|
||||
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: null), CancellationToken.None);
|
||||
|
||||
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reload.HoSoLink.Should().BeNull("code absolute-set HoSoLink — null request = clear link (KHÔNG null-safe như Budget*/WorkItemId)");
|
||||
reload.HoSoLink.Should().Be(existing,
|
||||
"S88 null-safe: request.HoSoLink=null (form Sửa header OMIT) → GIỮ link cũ (KHÔNG null-hóa — root fix bug-class S42/#73)");
|
||||
// Sanity: handler vẫn chạy (field text khác vẫn được set), không phải no-op.
|
||||
reload.TenGoiThau.Should().Be("Gói thầu test");
|
||||
}
|
||||
|
||||
// CASE (b) — HoSoLinkRow bấm xoá → FE gửi "" (chuỗi rỗng) → CLEAR (set null).
|
||||
// "" / whitespace là cách chủ động xoá link (khác null = không gửi). Theory phủ cả " ".
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task UpdateDraft_HoSoLinkEmptyOrWhitespace_ClearsLink(string blank)
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\WILL-BE-CLEARED", code: "PE-HSL-U3B");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
|
||||
// FE HoSoLinkRow gửi "" khi user xoá link → clear.
|
||||
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: blank), CancellationToken.None);
|
||||
|
||||
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reload.HoSoLink.Should().BeNull(
|
||||
"S88 clear-via-empty: request.HoSoLink=\"\"/whitespace (HoSoLinkRow xoá) → CLEAR link (set null)");
|
||||
}
|
||||
|
||||
// CASE (c) — gửi giá trị thật → set giá trị đó (overwrite link cũ).
|
||||
[Fact]
|
||||
public async Task UpdateDraft_HoSoLinkValue_SetsIt()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
|
||||
var pe = BuildPe(hoSoLink: @"\\nas\HoSo\OLD", code: "PE-HSL-U3C");
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
const string newLink = @"O:\new";
|
||||
var handler = new UpdatePurchaseEvaluationDraftCommandHandler(db, new FakeCurrentUser());
|
||||
await handler.Handle(UpdateCmd(pe.Id, hoSoLink: newLink), CancellationToken.None);
|
||||
|
||||
var reload = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reload.HoSoLink.Should().Be(newLink, "request.HoSoLink có giá trị → set giá trị đó");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -0,0 +1,237 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Application.PurchaseEvaluations;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
using SolutionErp.Infrastructure.Services; // real NotificationService (Add-only, caller flushes)
|
||||
using SolutionErp.Infrastructure.Tests.Common;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Tests.Application;
|
||||
|
||||
// [S88 FIX 3 — bug-fix anh Kiệt FDC] CEO-notify-on-urgent ĐÃ persist (trước rớt âm thầm).
|
||||
//
|
||||
// BUG (đã fix em-main, PurchaseEvaluationUrgentFeatures.cs): khối notify Director chạy SAU
|
||||
// `db.SaveChangesAsync` DUY NHẤT ở line ~75. NotifyManyAsync CHỈ Add (caller tự flush) →
|
||||
// chuông Director chưa từng được lưu (rớt + KHÔNG trigger SignalR push).
|
||||
// FIX: thêm `await db.SaveChangesAsync(ct)` SAU NotifyManyAsync (trong if, vẫn best-effort
|
||||
// try/catch). ⇒ row Notification cho Director thực sự persist.
|
||||
//
|
||||
// Test theo CODE đã land (S34 — KHÔNG touch production). Khác PeUrgentToggleAuthzTests
|
||||
// (NoOpNotificationService nuốt → không observe được persistence): ở ĐÂY dùng REAL
|
||||
// NotificationService (Infrastructure) — nó Add Notification vào db (caller flush), khớp
|
||||
// đúng giả định production. Persistence quan sát qua db.Notifications sau handler.
|
||||
//
|
||||
// Gate notify (line 80): `request.IsUrgent && nowUrgent && !wasUrgent` — CHỈ khi MỚI bật
|
||||
// gấp (false→true). wasUrgent = snapshot 2 cờ TRƯỚC mutate. Suy ra:
|
||||
// • false→true → notify (1 row / Director).
|
||||
// • true→true → !wasUrgent = false → KHÔNG notify (no duplicate — guard chống spam).
|
||||
// • true→false → request.IsUrgent = false → KHÔNG notify (gỡ cờ không báo).
|
||||
//
|
||||
// Handler 4 dep: (IApplicationDbContext, ICurrentUser, UserManager<User>, INotificationService).
|
||||
public class PeUrgentNotifyDirectorTests
|
||||
{
|
||||
private sealed class FakeCurrentUser(params string[] roles) : ICurrentUser
|
||||
{
|
||||
public Guid? UserId { get; init; } = Guid.NewGuid();
|
||||
public string? Email { get; } = "actor@test.local";
|
||||
public string? FullName { get; } = "Actor Test";
|
||||
public IReadOnlyList<string> Roles { get; } = roles ?? Array.Empty<string>();
|
||||
public bool IsAuthenticated => UserId is not null;
|
||||
}
|
||||
|
||||
// REAL NotificationService — Add-only (KHÔNG SaveChanges), đúng hành vi production mà
|
||||
// handler dựa vào (handler tự flush sau NotifyManyAsync). FixedDateTime cho CreatedAt.
|
||||
private static SetPurchaseEvaluationUrgentCommandHandler BuildHandler(
|
||||
TestApplicationDbContext db, UserManager<User> um, ICurrentUser currentUser)
|
||||
{
|
||||
var clock = new FixedDateTime(new DateTime(2026, 6, 24, 9, 0, 0, DateTimeKind.Utc));
|
||||
var notify = new NotificationService(db, clock);
|
||||
return new SetPurchaseEvaluationUrgentCommandHandler(db, currentUser, um, notify);
|
||||
}
|
||||
|
||||
private static async Task<PurchaseEvaluation> SeedPeAsync(
|
||||
TestApplicationDbContext db,
|
||||
bool urgentByPro = false,
|
||||
bool urgentByCcm = false,
|
||||
string code = "PE-URGN-001",
|
||||
string maPhieu = "PE-URGN-001")
|
||||
{
|
||||
var pe = new PurchaseEvaluation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = PurchaseEvaluationType.DuyetNcc,
|
||||
Phase = PurchaseEvaluationPhase.ChoDuyet,
|
||||
MaPhieu = maPhieu,
|
||||
TenGoiThau = "Gói thầu test urgent-notify " + code,
|
||||
ProjectId = Guid.NewGuid(),
|
||||
DrafterUserId = Guid.NewGuid(),
|
||||
IsUrgentByPro = urgentByPro,
|
||||
IsUrgentByCcm = urgentByCcm,
|
||||
};
|
||||
db.PurchaseEvaluations.Add(pe);
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return pe;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 1. false→true bởi PRO → Director Notification row PERSIST (title "GẤP" + RefId = PE id).
|
||||
// Đây là bug đã sửa: trước row này rớt vì save trước khối notify.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_FalseToTrue_ByProcurement_PersistsDirectorNotification()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
var director = await fix.CreateUserAsync(
|
||||
"ceo@test.local", "Giám đốc", departmentId: null, roles: new[] { AppRoles.Director });
|
||||
var pe = await SeedPeAsync(db, maPhieu: "PE-GAP-01");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||
|
||||
// Chuông Director PHẢI được lưu (FIX 3) — đọc lại bằng context riêng để chắc đã flush.
|
||||
var rows = await db.Notifications.AsNoTracking()
|
||||
.Where(n => n.UserId == director.Id && n.RefId == pe.Id).ToListAsync();
|
||||
rows.Should().HaveCount(1, "false→true → đúng 1 chuông gửi Giám đốc, và ĐÃ persist (bug rớt-chuông đã sửa)");
|
||||
rows[0].Title.Should().Contain("GẤP", "tiêu đề chuông báo phiếu được đánh dấu GẤP");
|
||||
rows[0].Title.Should().Contain("PE-GAP-01", "tiêu đề kèm mã phiếu");
|
||||
rows[0].Href.Should().Be($"/purchase-evaluations/{pe.Id}", "link điều hướng tới phiếu");
|
||||
rows[0].Type.Should().Be(NotificationType.Generic);
|
||||
// Cờ ĐỎ cũng đã được bật (toggle thành công + chuông cùng lưu).
|
||||
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloadPe.IsUrgentByPro.Should().BeTrue();
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 2. false→true bởi CCM → cũng persist chuông Director (cùng nhánh notify).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_FalseToTrue_ByCostControl_PersistsDirectorNotification()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
var director = await fix.CreateUserAsync(
|
||||
"ceo2@test.local", "Giám đốc 2", departmentId: null, roles: new[] { AppRoles.Director });
|
||||
var pe = await SeedPeAsync(db, code: "PE-URGN-002", maPhieu: "PE-GAP-02");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.CostControl));
|
||||
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||
|
||||
var rows = await db.Notifications.AsNoTracking()
|
||||
.Where(n => n.UserId == director.Id && n.RefId == pe.Id).ToListAsync();
|
||||
rows.Should().HaveCount(1, "CCM bật gấp cũng notify Director (cờ XANH false→true)");
|
||||
rows[0].Title.Should().Contain("GẤP");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 3. Admin false→true → notify Director. Đồng thời chỉ Director nhận (non-Director KHÔNG).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_FalseToTrue_ByAdmin_NotifiesOnlyDirectors()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
var director = await fix.CreateUserAsync(
|
||||
"ceo3@test.local", "Giám đốc 3", departmentId: null, roles: new[] { AppRoles.Director });
|
||||
// Người KHÔNG phải Director — KHÔNG được nhận chuông (target chỉ role Director).
|
||||
var other = await fix.CreateUserAsync(
|
||||
"staff@test.local", "Nhân viên", departmentId: null, roles: new[] { AppRoles.Finance });
|
||||
var pe = await SeedPeAsync(db, code: "PE-URGN-003", maPhieu: "PE-GAP-03");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
|
||||
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||
|
||||
var all = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
|
||||
all.Should().ContainSingle("chỉ 1 Director được seed → đúng 1 chuông");
|
||||
all[0].UserId.Should().Be(director.Id);
|
||||
all.Should().NotContain(n => n.UserId == other.Id, "người KHÔNG phải Director không nhận chuông gấp");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 4. true→true (đã gấp sẵn, bật lại) → KHÔNG thêm chuông (guard !wasUrgent chống spam).
|
||||
// Pre-seed cờ XANH true, PRO bật cờ ĐỎ (nowUrgent vẫn true nhưng wasUrgent đã true).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_TrueToTrue_DoesNotAddDuplicateNotification()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
await fix.CreateUserAsync(
|
||||
"ceo4@test.local", "Giám đốc 4", departmentId: null, roles: new[] { AppRoles.Director });
|
||||
// Phiếu ĐÃ gấp (cờ XANH bật) → wasUrgent = true. PRO bật cờ ĐỎ (vẫn IsUrgent=true).
|
||||
var pe = await SeedPeAsync(db, urgentByCcm: true, code: "PE-URGN-004", maPhieu: "PE-GAP-04");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||
|
||||
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
|
||||
rows.Should().BeEmpty("đã gấp từ trước (wasUrgent=true) → guard !wasUrgent chặn → KHÔNG chuông trùng");
|
||||
// Nhưng cờ ĐỎ vẫn được set (toggle vẫn chạy, chỉ bỏ qua notify).
|
||||
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloadPe.IsUrgentByPro.Should().BeTrue("toggle vẫn set cờ ĐỎ; chỉ notify bị skip");
|
||||
reloadPe.IsUrgentByCcm.Should().BeTrue("cờ XANH giữ nguyên");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 5. true→false (GỠ cờ) → KHÔNG notify (request.IsUrgent=false → gate false ngay).
|
||||
// Dùng Admin để qua authz UNSET (Admin gỡ cả 2). Pre-seed cả 2 cờ true.
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_TrueToFalse_DoesNotNotify()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
await fix.CreateUserAsync(
|
||||
"ceo5@test.local", "Giám đốc 5", departmentId: null, roles: new[] { AppRoles.Director });
|
||||
var pe = await SeedPeAsync(db, urgentByPro: true, urgentByCcm: true,
|
||||
code: "PE-URGN-005", maPhieu: "PE-GAP-05");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Admin));
|
||||
await handler.Handle(new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: false), CancellationToken.None);
|
||||
|
||||
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
|
||||
rows.Should().BeEmpty("GỠ cờ (IsUrgent=false) → KHÔNG báo Giám đốc");
|
||||
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloadPe.IsUrgentByPro.Should().BeFalse("Admin gỡ cả ĐỎ");
|
||||
reloadPe.IsUrgentByCcm.Should().BeFalse("Admin gỡ cả XANH");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// 6. false→true nhưng KHÔNG có Director nào → handler không lỗi, không chuông
|
||||
// (directorIds.Count == 0 → block notify bỏ qua). Cờ vẫn được bật (toggle thành công).
|
||||
// =====================================================================
|
||||
[Fact]
|
||||
public async Task SetUrgent_FalseToTrue_NoDirectorSeeded_TogglesWithoutNotificationOrError()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var um = fix.Services.GetRequiredService<UserManager<User>>();
|
||||
|
||||
// KHÔNG seed Director.
|
||||
var pe = await SeedPeAsync(db, code: "PE-URGN-006", maPhieu: "PE-GAP-06");
|
||||
|
||||
var handler = BuildHandler(db, um, new FakeCurrentUser(AppRoles.Procurement));
|
||||
var act = async () => await handler.Handle(
|
||||
new SetPurchaseEvaluationUrgentCommand(pe.Id, IsUrgent: true), CancellationToken.None);
|
||||
|
||||
await act.Should().NotThrowAsync("không có Director → bỏ qua notify, KHÔNG fail toggle");
|
||||
|
||||
var rows = await db.Notifications.AsNoTracking().Where(n => n.RefId == pe.Id).ToListAsync();
|
||||
rows.Should().BeEmpty("0 Director → 0 chuông");
|
||||
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloadPe.IsUrgentByPro.Should().BeTrue("cờ ĐỎ vẫn được bật (toggle độc lập với notify)");
|
||||
}
|
||||
}
|
||||
@ -593,8 +593,10 @@ public class PeWorkItemBudgetTests
|
||||
// =====================================================================
|
||||
|
||||
// Seed 1 NCC thắng + 1 detail + 1 quote ThanhTien cho phiếu (winner tổng thể).
|
||||
// Set pe.SelectedSupplierId = supplierId trùng PurchaseEvaluationSupplier.SupplierId
|
||||
// (join row 2 prevSelectedTotal: PES.SupplierId == p.SelectedSupplierId → quotes).
|
||||
// [Mig 58 multi-winner] Tiêu chí "đã chọn thầu" nay = IsWinner trên supplier-row
|
||||
// (KHÔNG còn SelectedSupplierId). Helper này set CẢ HAI để mô phỏng single-winner đúng
|
||||
// nghiệp vụ (đúng 1 winner ⇒ SelectedSupplierId = winner đó được sync). Code GetDetail
|
||||
// chỉ đọc IsWinner cho prevSelected*; SelectedSupplierId giữ để các path khác (banner FE).
|
||||
private static async Task SeedWinnerWithQuoteAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, Guid supplierId, decimal thanhTien)
|
||||
{
|
||||
@ -604,6 +606,7 @@ public class PeWorkItemBudgetTests
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = supplierId,
|
||||
Order = 0,
|
||||
IsWinner = true, // [Mig 58] nguồn-sự-thật mới cho "đơn vị trúng"
|
||||
};
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
@ -626,6 +629,48 @@ public class PeWorkItemBudgetTests
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// [Mig 58 multi-winner — FIX 2] Seed N đơn vị IsWinner=true cho 1 phiếu, mỗi đơn vị 1
|
||||
// quote ThanhTien (1 detail dùng chung). SelectedSupplierId = NULL (liên-danh ≥2 → không
|
||||
// có 1 winner duy nhất). Đây là biến thể đa-thắng của SeedWinnerWithQuoteAsync — chứng
|
||||
// minh code đọc IsWinner (multi) thay SelectedSupplierId (single, null khi ≥2).
|
||||
private static async Task SeedMultiWinnersWithQuotesAsync(
|
||||
TestApplicationDbContext db, PurchaseEvaluation pe, params decimal[] winnerQuotes)
|
||||
{
|
||||
var detail = new PurchaseEvaluationDetail
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
GroupCode = "A.I",
|
||||
GroupName = "Bê tông",
|
||||
NoiDung = "Concrete",
|
||||
Order = 0,
|
||||
};
|
||||
db.PurchaseEvaluationDetails.Add(detail);
|
||||
|
||||
var order = 0;
|
||||
foreach (var thanhTien in winnerQuotes)
|
||||
{
|
||||
var pes = new PurchaseEvaluationSupplier
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationId = pe.Id,
|
||||
SupplierId = Guid.NewGuid(),
|
||||
Order = order++,
|
||||
IsWinner = true, // ≥2 đơn vị cùng trúng (multi-winner / liên-danh)
|
||||
};
|
||||
db.PurchaseEvaluationSuppliers.Add(pes);
|
||||
db.PurchaseEvaluationQuotes.Add(new PurchaseEvaluationQuote
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PurchaseEvaluationDetailId = detail.Id,
|
||||
PurchaseEvaluationSupplierId = pes.Id,
|
||||
ThanhTien = thanhTien,
|
||||
});
|
||||
}
|
||||
// pe.SelectedSupplierId GIỮ null (đã null từ SeedPeAsync) — đặc trưng ≥2 winner.
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static GetPurchaseEvaluationQueryHandler BuildQueryHandler(
|
||||
IdentityFixture fix, TestApplicationDbContext db, ICurrentUser currentUser)
|
||||
=> new(db, fix.Services.GetRequiredService<UserManager<User>>(), currentUser);
|
||||
@ -675,10 +720,76 @@ public class PeWorkItemBudgetTests
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.PreviousSubmittedTotal.Should().Be(150m, "P1(100,DaDuyet)+P2(50,ChoDuyet); P3 TraLai loại");
|
||||
s.PreviousSubmittedCount.Should().Be(2);
|
||||
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote");
|
||||
s.PreviousSelectedTotal.Should().Be(90m, "chỉ P1 DaDuyet + có winner quote (IsWinner=true)");
|
||||
s.PreviousSelectedCount.Should().Be(1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// [FIX 2 — Mig 58 multi-winner re-key IsWinner] Block B financials khi ≥2 đơn vị
|
||||
// cùng trúng (SelectedSupplierId = null). TRƯỚC FIX 2: CurrentProposalTotal +
|
||||
// PreviousSelected* gate trên SelectedSupplierId (single) → null khi ≥2 winner →
|
||||
// báo 0 / bỏ sót phiếu liên-danh. SAU FIX: dùng Suppliers.Where(IsWinner) (multi).
|
||||
// =====================================================================
|
||||
|
||||
// (a) Phiếu HIỆN TẠI có ≥2 đơn vị IsWinner, mỗi đơn vị 1 báo giá → CurrentProposalTotal
|
||||
// = TỔNG báo giá của TẤT CẢ winner (KHÔNG phải 0). Pre-fix = 0 vì SelectedSupplierId
|
||||
// null khi ≥2 winner → curWinnerRowIds rỗng → 0. Đây là bug đã sửa.
|
||||
[Fact]
|
||||
public async Task BudgetSummary_CurrentProposalTotal_SumsAllWinnerQuotes_WhenMultipleWinners()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-MW1");
|
||||
|
||||
// Phiếu này đang xét — SelectedSupplierId null (mặc định), 2 đơn vị IsWinner: 120 + 80.
|
||||
var pe = await SeedPeAsync(db, project.Id, wi.Id,
|
||||
PurchaseEvaluationPhase.ChoDuyet, code: "PE-MW-CURR");
|
||||
await SeedMultiWinnersWithQuotesAsync(db, pe, 120m, 80m);
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pe.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.CurrentProposalTotal.Should().Be(200m,
|
||||
"≥2 winner → tổng MỌI báo giá winner (120+80); pre-fix báo 0 vì SelectedSupplierId null khi liên-danh");
|
||||
// Sanity: phiếu thật sự ở trạng thái multi-winner (SelectedSupplierId null).
|
||||
var reloadPe = await db.PurchaseEvaluations.AsNoTracking().FirstAsync(x => x.Id == pe.Id);
|
||||
reloadPe.SelectedSupplierId.Should().BeNull("đặc trưng ≥2 đồng-trúng — không có 1 winner duy nhất");
|
||||
}
|
||||
|
||||
// (b) Phiếu DaDuyet TRƯỚC (peer) trao cho ≥2 đơn vị liên-danh (SelectedSupplierId null,
|
||||
// IsWinner=true trên ≥2 row) → VẪN tính vào PreviousSelectedCount + báo giá winner
|
||||
// cộng vào PreviousSelectedTotal. Pre-fix: peer bị loại vì SelectedSupplierId null.
|
||||
[Fact]
|
||||
public async Task BudgetSummary_PreviousSelected_CountsMultiWinnerPeer_AndSumsWinnerQuotes()
|
||||
{
|
||||
using var fix = new IdentityFixture();
|
||||
var db = fix.Services.GetRequiredService<TestApplicationDbContext>();
|
||||
var project = await SeedProjectAsync(db);
|
||||
var wi = await SeedWorkItemAsync(db, "WI-MW2");
|
||||
|
||||
var baseT = new DateTime(2026, 6, 24, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Peer DaDuyet TRƯỚC (CreatedAt -2d), liên-danh ≥2 winner: 60 + 40 = 100. SelectedSupplierId null.
|
||||
var peer = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DaDuyet,
|
||||
createdAt: baseT.AddDays(-2), code: "PE-MW-PEER");
|
||||
await SeedMultiWinnersWithQuotesAsync(db, peer, 60m, 40m);
|
||||
|
||||
// Phiếu đang xét (mới nhất) — chỉ để trigger build summary.
|
||||
var pThis = await SeedPeAsync(db, project.Id, wi.Id, PurchaseEvaluationPhase.DangSoanThao,
|
||||
createdAt: baseT, code: "PE-MW-THIS");
|
||||
|
||||
var handler = BuildQueryHandler(fix, db, AsRoles(AppRoles.Admin));
|
||||
var bundle = await handler.Handle(new GetPurchaseEvaluationQuery(pThis.Id), CancellationToken.None);
|
||||
|
||||
var s = bundle.BudgetSummary!;
|
||||
s.PreviousSelectedCount.Should().Be(1,
|
||||
"peer DaDuyet liên-danh (≥2 IsWinner, SelectedSupplierId null) VẪN được đếm; pre-fix bỏ sót");
|
||||
s.PreviousSelectedTotal.Should().Be(100m,
|
||||
"tổng báo giá MỌI winner của peer (60+40); pre-fix = 0 vì gate SelectedSupplierId null");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BudgetSummary_FullAmount_FallsBackToProFull_WhenCcmEmpty()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user