[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

@ -247,10 +247,11 @@ public record PurchaseEvaluationDetailBundleDto(
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro,
bool IsUrgentByCcm,
// [S69] Tổng giá chào của đơn vị NCC/TP ĐƯỢC CHỌN (winner quote total) — SUM
// ThanhTien các báo giá thuộc supplier-rows của SelectedSupplierId. 0 khi chưa
// chọn. Mirror predicate submit-guard (PurchaseEvaluationWorkflowService ~:188).
// FE so với CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
// [S69 · Mig 58 multi-winner] Tổng giá chào của các đơn vị NCC/TP ĐƯỢC CHỌN
// (winner quote total) — SUM ThanhTien báo giá của MỌI đơn vị IsWinner (multi-winner
// Mig 58), KHÔNG còn chỉ SelectedSupplierId. 0 khi chưa chọn. Mirror predicate
// submit-guard (PurchaseEvaluationWorkflowService ~:188). FE so với
// CeoApprovalThreshold hiển thị "CCM duyệt-final" hoặc "cần CEO".
decimal WinnerQuoteTotal,
// [S69] Ngưỡng gói CEO của workflow đã pin (PE.ApprovalWorkflowId). Null khi
// phiếu chưa pin workflow V2 hoặc admin chưa set ngưỡng.

View File

@ -141,59 +141,3 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
public record SetPeCcmBudgetPeriodCommand(
Guid Id,
decimal? CcmBudgetPeriodAmount) : IRequest;
public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator<SetPeCcmBudgetPeriodCommand>
{
public SetPeCcmBudgetPeriodCommandValidator()
{
RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
.When(x => x.CcmBudgetPeriodAmount.HasValue);
}
}
public class SetPeCcmBudgetPeriodCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<SetPeCcmBudgetPeriodCommand>
{
public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
// Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
{
throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập NS kỳ này (CCM).");
}
var oldVal = pe.CcmBudgetPeriodAmount;
pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // absolute-set (null = clear)
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -198,3 +198,60 @@ public class UpdatePeBudgetCcmCommandHandler(
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
// [S88] Chuyển từ PeSuggestedPriceFeatures.cs về đây (đúng nhóm budget setter).
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
public record SetPeCcmBudgetPeriodCommand(
Guid Id,
decimal? CcmBudgetPeriodAmount) : IRequest;
public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator<SetPeCcmBudgetPeriodCommand>
{
public SetPeCcmBudgetPeriodCommandValidator()
{
RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
.When(x => x.CcmBudgetPeriodAmount.HasValue);
}
}
public class SetPeCcmBudgetPeriodCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<SetPeCcmBudgetPeriodCommand>
{
public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
// Fail-closed TRƯỚC mọi side-effect.
if (!currentUser.Roles.Contains(AppRoles.Admin)
&& !currentUser.Roles.Contains(AppRoles.CostControl))
{
throw new ForbiddenException(
"Chỉ Phòng Kiểm soát Chi phí (CCM) hoặc Admin được nhập NS kỳ này (CCM).");
}
var oldVal = pe.CcmBudgetPeriodAmount;
pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // absolute-set (null = clear)
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = pe.Id,
EntityType = PurchaseEvaluationEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = pe.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -277,8 +277,13 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.TenGoiThau = request.TenGoiThau;
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
entity.HoSoLink = request.HoSoLink; // [HoSoLink] absolute-set như MoTa/DiaDiem (Section 1 text field, null = clear)
entity.PaymentTerms = request.PaymentTerms;
// [S88 fix bug-class S42/#73] HoSoLink null-safe + clear-via-empty: link sửa ở
// HoSoLinkRow RIÊNG (form "Sửa header" + InfoTab pencil OMIT field) → absolute-set
// cũ null-hóa link MỖI lần Sửa header (mất dữ liệu). null = form không gửi → GIỮ;
// "" = HoSoLinkRow chủ động xoá → clear; có giá trị → set. (FE HoSoLinkRow gửi "" khi xoá.)
if (request.HoSoLink is not null)
entity.HoSoLink = string.IsNullOrWhiteSpace(request.HoSoLink) ? null : request.HoSoLink;
// [S85 anh Kiet ROOT FIX bug-class S42] null-safe: form Sua KHONG gui approvalWorkflowId
// (tao co WF -> bam Sua = MAT WF -> ket submit "chua chon quy trinh" = root-cause whack-a-mole).
// Client khong gui field -> GIU WF cu (giong BudgetPeriodAmount duoi); doi WF thi FE phai gui gia tri.
@ -829,29 +834,30 @@ public class GetPurchaseEvaluationQueryHandler(
var prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
// [Mig 58 multi-winner] "đã chọn thầu" = phiếu DaDuyet có ÍT NHẤT 1 đơn vị
// IsWinner (thay `SelectedSupplierId != null` cũ — null khi ≥2 đồng-trúng nên
// bỏ sót phiếu liên-danh). Tổng = mọi báo giá của các đơn vị IsWinner.
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
&& p.SelectedSupplierId != null);
&& p.Suppliers.Any(s => s.IsWinner));
var prevSelectedCount = await selectedPeers.CountAsync(ct);
var prevSelectedTotal = await (
from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId
where s.SupplierId == p.SelectedSupplierId
where s.IsWinner
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
// Row 4 "Giá trị kỳ này" = tổng giá chào của đơn vị ĐƯỢC CHỌN phiếu này
// (mirror predicate submit-guard WorkflowService — winner quote total).
var currentProposalTotal = 0m;
if (e.SelectedSupplierId is Guid winId)
{
var winnerRowIds = e.Suppliers.Where(s => s.SupplierId == winId)
.Select(s => s.Id).ToList();
currentProposalTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
// Row 4 "Giá trị kỳ này" = tổng giá chào của MỌI đơn vị IsWinner phiếu này
// [Mig 58 multi-winner] (khớp winnerQuoteTotal :1087 — dùng IsWinner thay
// SelectedSupplierId vì null khi ≥2 đồng-trúng → trước đây báo 0 cho liên-danh).
var curWinnerRowIds = e.Suppliers.Where(s => s.IsWinner)
.Select(s => s.Id).ToList();
var currentProposalTotal = curWinnerRowIds.Count == 0 ? 0m
: await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => curWinnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
}
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
// Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE

View File

@ -93,6 +93,11 @@ public class SetPurchaseEvaluationUrgentCommandHandler(
$"/purchase-evaluations/{entity.Id}",
entity.Id,
ct);
// [S88 fix] NotifyManyAsync CHỈ Add (caller tự flush) → toggle đã flush
// ở :75 TRƯỚC block này nên chuông Director chưa từng được lưu (rớt âm
// thầm + KHÔNG trigger SignalR push). SaveChanges RIÊNG ở đây để chuông
// CEO/Director thực sự lưu. Trong try → vẫn best-effort (lỗi nuốt, cờ đã ổn).
await db.SaveChangesAsync(ct);
}
}
catch