[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:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user