[CLAUDE] PurchaseEvaluation: CCM 3-fix — multi-winner sum + budget=0 hợp lệ + cấp KẾT THÚC quy trình (Mig 58, anh Kiệt FDC)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m10s

CCM (Lưu Trần / anh Kiệt FDC) — làm 3/4 mục (mục 3 "CCM sửa ngân sách" để riêng):

1. Multi-winner: chọn NHIỀU NTP/NCC/ĐVDV cùng trúng → winnerQuoteTotal = SUM
   ThanhTien MỌI đơn vị isWinner (vd 3 ông 37.8+9+10 = 56.8tr). Cột mới
   PurchaseEvaluationSupplier.IsWinner (Mig 58, backfill từ SelectedSupplierId).
   SelectWinner → multi-toggle SupplierIds[]. FE: bảng so sánh tick nhiều ô +
   dòng "Tổng chi phí đã chọn"; mọi guard/checklist/computeGiaChaoThau/tạo-HĐ
   đổi sang isWinner (selectedSupplierId = null khi liên-danh ≥2).

2. Ngân sách = 0 hợp lệ: nới submit-guard + 3 validator (chỉ chặn khi NULL,
   số 0 cố ý đi qua; fallback ProInitial chỉ khi null). Mirror BE + FE.

3. Cấp KẾT THÚC quy trình: ApprovalWorkflowLevel.AllowApproverFinalize (Mig 58)
   — admin bật per-cấp trong Workflow Designer; duyệt tới cấp đó TỰ DaDuyet,
   bỏ qua CEO (AUTO theo cấu hình quy trình, không ô-tích runtime — anh Kiệt
   chốt). FE: banner cảnh báo + bắt chọn giá chốt.

Mig 58 AddPeMultiWinnerAndLevelFinalize (2 AddColumn bool + backfill IsWinner,
additive/reversible). Test 377→395 (+PeMultiWinnerTests +PeApproverFinalizeTests
+budget=0; auto-model gọn bớt 2 test bypass/opt-out). FE 2 app SHA-mirror.
Build sạch BE + 2 FE local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 15:22:35 +07:00
parent ae4f106fd7
commit f1b10e9c33
26 changed files with 7578 additions and 245 deletions

View File

@ -38,6 +38,7 @@ public record AwLevelDto(
bool AllowApproverEditDetails,
bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal,
bool AllowApproverFinalize, // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
@ -184,7 +185,7 @@ public class GetAwAdminOverviewQueryHandler(
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
l.AllowApproverSkipToFinal,
l.AllowApproverSkipToFinal, l.AllowApproverFinalize,
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
}).ToList()
)).ToList());
@ -225,7 +226,8 @@ public record CreateAwLevelInput(
bool AllowReturnToDrafter = true,
bool AllowApproverEditDetails = false,
bool AllowApproverEditBudget = false,
bool AllowApproverSkipToFinal = false);
bool AllowApproverSkipToFinal = false,
bool AllowApproverFinalize = false);
public record CreateAwStepInput(
int Order,
@ -363,6 +365,7 @@ public class CreateAwDefinitionCommandHandler(IApplicationDbContext db)
AllowApproverEditDetails = l.AllowApproverEditDetails,
AllowApproverEditBudget = l.AllowApproverEditBudget,
AllowApproverSkipToFinal = l.AllowApproverSkipToFinal,
AllowApproverFinalize = l.AllowApproverFinalize,
}).ToList(),
})
.ToList(),

View File

@ -51,7 +51,8 @@ public record PurchaseEvaluationSupplierDto(
string? ContactPhone,
string? PaymentTermText,
string? Note,
int Order);
int Order,
bool IsWinner); // [Mig 58] đơn vị này có thuộc tập winner không (multi)
public record PurchaseEvaluationQuoteDto(
Guid Id,
@ -115,7 +116,8 @@ public record ApprovalWorkflowOptionsDto(
bool AllowReturnToDrafter,
bool AllowApproverEditDetails,
bool AllowApproverEditBudget,
bool AllowApproverSkipToFinal);
bool AllowApproverSkipToFinal,
bool AllowApproverFinalize); // [Mig 58] slot này Approve = KẾT THÚC (không trình CEO)
public record PurchaseEvaluationWorkflowSummaryDto(
string PolicyName,

View File

@ -385,11 +385,17 @@ public class DeletePurchaseEvaluationQuoteCommandHandler(
}
}
// ========== Select winner (NCC được chọn tổng thể) ==========
// ========== Select winner (NCC/TP được chọn tổng thể — MULTI) ==========
// SupplierId nullable [anh Kiệt FDC C5]: null = BỎ CHỌN (xóa đơn vị đã chọn, điền
// lại) — un-select winner về none; có giá trị = chọn/đổi NCC (logic cũ giữ 100%).
public record SelectPurchaseEvaluationWinnerCommand(Guid PurchaseEvaluationId, Guid? SupplierId) : IRequest;
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC] Multi-winner: chọn NHIỀU đơn vị NCC/TP cùng
// trúng (whole-supplier multi-select). SupplierIds = tập đơn vị thắng:
// - List rỗng [] = BỎ CHỌN tất cả (un-select về none) — thay null cũ (anh Kiệt C5).
// - 1 phần tử = 1 winner (SelectedSupplierId sync = đơn vị đó — backward compat).
// - ≥2 phần tử = joint winner (SelectedSupplierId = null vì không còn "1 winner").
// IsWinner trên supplier-row là nguồn-sự-thật mới; winnerQuoteTotal = SUM ThanhTien
// MỌI winner-row. Mỗi id phải nằm trong danh sách NCC tham gia của phiếu.
public record SelectPurchaseEvaluationWinnerCommand(
Guid PurchaseEvaluationId, IReadOnlyList<Guid> SupplierIds) : IRequest;
public class SelectPurchaseEvaluationWinnerCommandHandler(
IApplicationDbContext db,
@ -400,28 +406,30 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
var entity = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PurchaseEvaluationId, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.PurchaseEvaluationId);
if (request.SupplierId is Guid supplierId)
{
// ── Chọn / đổi NCC trúng thầu (logic cũ giữ nguyên 100%) ──
_ = await db.Suppliers.FirstOrDefaultAsync(s => s.Id == supplierId, ct)
?? throw new NotFoundException("Supplier", supplierId);
// Load mọi supplier-row của phiếu (tracking — để set IsWinner).
var rows = await db.PurchaseEvaluationSuppliers
.Where(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId)
.ToListAsync(ct);
// Verify supplier nằm trong danh sách phiếu
var hasSupplier = await db.PurchaseEvaluationSuppliers
.AnyAsync(s => s.PurchaseEvaluationId == request.PurchaseEvaluationId && s.SupplierId == supplierId, ct);
if (!hasSupplier) throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
// Distinct các id được chọn (FE có thể gửi trùng).
var selectedIds = request.SupplierIds?.Distinct().ToHashSet() ?? new HashSet<Guid>();
entity.SelectedSupplierId = supplierId;
}
else
// Mỗi id chọn phải là 1 đơn vị tham gia phiếu (chặn forge/stale).
var participatingSupplierIds = rows.Select(r => r.SupplierId).ToHashSet();
foreach (var sid in selectedIds)
{
// ── Bỏ chọn (clear về none) — KHÔNG validate participating list ──
// Row 4 "Giá trị kỳ này" + winnerQuoteTotal tính-khi-đọc theo
// SelectedSupplierId (PurchaseEvaluationFeatures GetDetail) → tự về 0.
// ApprovedPriceAmount/Source là cột chốt giai-đoạn DaDuyet, KHÔNG đụng ở đây.
entity.SelectedSupplierId = null;
if (!participatingSupplierIds.Contains(sid))
throw new ConflictException("NCC chưa được thêm vào phiếu đánh giá.");
}
// Set IsWinner per-row: true nếu nằm trong tập chọn, false phần còn lại.
foreach (var r in rows)
r.IsWinner = selectedIds.Contains(r.SupplierId);
// Sync SelectedSupplierId: đúng 1 winner → đơn vị đó (backward compat luồng
// tạo HĐ + DTO cũ); ≥2 hoặc 0 → null.
entity.SelectedSupplierId = selectedIds.Count == 1 ? selectedIds.First() : null;
db.PurchaseEvaluationChangelogs.Add(new PurchaseEvaluationChangelog
{
PurchaseEvaluationId = entity.Id,
@ -430,7 +438,9 @@ public class SelectPurchaseEvaluationWinnerCommandHandler(
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
UserName = currentUser.FullName ?? currentUser.Email,
Summary = request.SupplierId is null ? "Bỏ chọn NCC trúng thầu" : "Chọn NCC trúng thầu",
Summary = selectedIds.Count == 0
? "Bỏ chọn đơn vị NCC/TP trúng thầu"
: $"Chọn {selectedIds.Count} đơn vị NCC/TP trúng thầu",
});
await db.SaveChangesAsync(ct);

View File

@ -52,7 +52,7 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
RuleFor(x => x.HoSoLink).MaximumLength(1000);
// [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
}
@ -230,7 +230,7 @@ public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<U
RuleFor(x => x.MoTa).MaximumLength(2000);
// [HoSoLink] MaxLength MATCH EF config HasMaxLength(1000) (S35 lesson).
RuleFor(x => x.HoSoLink).MaximumLength(1000);
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0) // [Mig 58 item 2] 0 hợp lệ
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
}
@ -336,12 +336,13 @@ public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<
{
public AdjustPurchaseEvaluationBudgetCommandValidator()
{
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue);
RuleFor(x => x.BudgetPeriodAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetPeriodAmount.HasValue); // [Mig 58 item 2] 0 hợp lệ
// [S62] "Giá trị thực hiện dự kiến còn lại" (row 8) CHO PHÉP ÂM. Khi giá trị NCC
// vượt ngân sách → số dư còn lại tự ra âm; nghiệp vụ (anh Kiệt FDC 2026-06-13)
// yêu cầu VẪN lưu được, chỉ cảnh báo (FE banner "Vượt ngân sách"). Gỡ ràng buộc
// GreaterThanOrEqualTo(0) cũ — mirror tiền lệ LeaveBalance (cho phép số dư âm).
// BudgetPeriodAmount vẫn phải > 0; submit guard "đã nhập NS kỳ này" không đổi.
// [Mig 58 item 2 CCM] BudgetPeriodAmount cho phép = 0 (SSG ngân sách 0 hợp lệ); submit
// guard chỉ chặn khi NULL (chưa nhập gì), KHÔNG chặn 0 cố ý.
}
}
@ -958,7 +959,8 @@ public class GetPurchaseEvaluationQueryHandler(
curLevel.AllowReturnToDrafter,
curLevel.AllowApproverEditDetails,
curLevel.AllowApproverEditBudget,
curLevel.AllowApproverSkipToFinal);
curLevel.AllowApproverSkipToFinal,
curLevel.AllowApproverFinalize);
}
}
@ -1083,7 +1085,7 @@ public class GetPurchaseEvaluationQueryHandler(
// Tính từ data đã load (Suppliers + Details.Quotes) — KHÔNG query thêm. 0 khi
// chưa chọn NCC. FE so với CeoApprovalThreshold → "CCM duyệt-final" / "cần CEO".
var winnerSupplierRowIds = e.Suppliers
.Where(s => s.SupplierId == e.SelectedSupplierId)
.Where(s => s.IsWinner) // [Mig 58] multi-winner: tổng MỌI đơn vị IsWinner
.Select(s => s.Id)
.ToHashSet();
var winnerQuoteTotal = e.Details
@ -1119,7 +1121,7 @@ public class GetPurchaseEvaluationQueryHandler(
s.Id, s.SupplierId,
suppliers.TryGetValue(s.SupplierId, out var sn) ? sn : "",
s.DisplayName, s.ContactName, s.ContactEmail, s.ContactPhone,
s.PaymentTermText, s.Note, s.Order))
s.PaymentTermText, s.Note, s.Order, s.IsWinner))
.ToList(),
e.Details
.OrderBy(d => d.Order)