[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

@ -93,5 +93,10 @@ public class ApprovalWorkflowLevelConfiguration : IEntityTypeConfiguration<Appro
// slot này skip thẳng Cấp cuối lúc đang duyệt ChoDuyet. Default false
// (admin opt-in). Semantic + storage cũ ở Users table per-Drafter đã drop.
e.Property(x => x.AllowApproverSkipToFinal).HasDefaultValue(false);
// Mig 58 (2026-06-24 — CCM/anh Kiệt FDC) — F5 per-NV: cho phép NV slot này
// duyệt = KẾT THÚC quy trình (Phase=DaDuyet) miễn trình CEO. Default false
// (admin opt-in). Khác F2 (skip = advance tới Cấp cuối): F5 = terminal ngay.
e.Property(x => x.AllowApproverFinalize).HasDefaultValue(false);
}
}

View File

@ -82,6 +82,9 @@ public class PurchaseEvaluationSupplierConfiguration : IEntityTypeConfiguration<
b.Property(x => x.PaymentTermText).HasMaxLength(200);
b.Property(x => x.Note).HasMaxLength(500);
// [Mig 58] Multi-winner flag — default false (mirror per-slot bool pattern).
b.Property(x => x.IsWinner).HasDefaultValue(false);
b.HasIndex(x => new { x.PurchaseEvaluationId, x.SupplierId }).IsUnique();
b.HasIndex(x => x.SupplierId);
}

View File

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeMultiWinnerAndLevelFinalize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsWinner",
table: "PurchaseEvaluationSuppliers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "AllowApproverFinalize",
table: "ApprovalWorkflowLevels",
type: "bit",
nullable: false,
defaultValue: false);
// [Mig 58] Backfill IsWinner từ winner đơn lẻ cũ (SelectedSupplierId) —
// phiếu UAT đang chạy giữ winner đã chọn dưới mô hình multi-winner mới.
// Chạy SAU AddColumn (cột đã tồn tại). Idempotent: chỉ set 1 (default 0
// không đụng) → re-run no-op. KHÔNG un-backfill ở Down (DropColumn xoá hẳn).
migrationBuilder.Sql(@"
UPDATE pes SET IsWinner = 1
FROM PurchaseEvaluationSuppliers pes
INNER JOIN PurchaseEvaluations pe ON pe.Id = pes.PurchaseEvaluationId
WHERE pe.SelectedSupplierId IS NOT NULL AND pes.SupplierId = pe.SelectedSupplierId;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsWinner",
table: "PurchaseEvaluationSuppliers");
migrationBuilder.DropColumn(
name: "AllowApproverFinalize",
table: "ApprovalWorkflowLevels");
}
}
}

View File

@ -202,6 +202,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverFinalize")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("AllowApproverSkipToFinal")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
@ -5275,6 +5280,11 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<bool>("IsWinner")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<string>("Note")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");

View File

@ -172,28 +172,25 @@ public class PurchaseEvaluationWorkflowService(
// missingForApproval (FE KHÔNG check Purpose enum → BE cũng không,
// tránh mismatch 2 tầng).
var missing = new List<string>();
if (evaluation.SelectedSupplierId is not Guid winnerId)
// [Mig 58 CCM item 1] Multi-winner: winner = các supplier-row IsWinner (≥1). Tổng giá
// gói = SUM ThanhTien MỌI winner-row (vd 3 đơn vị 37.8+9+10=56.8tr). 0 winner →
// "chưa chọn"; có winner nhưng tổng ≤ 0 → "chưa có giá chào thầu" (item 2 chỉ nới
// NGÂN SÁCH, KHÔNG nới giá NCC — winner phải có giá).
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner)
.Select(s => s.Id)
.ToListAsync(ct);
if (winnerRowIds.Count == 0)
{
missing.Add("chưa chọn Đơn vị NCC/TP");
}
else
{
var winnerRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.SupplierId == winnerId)
.Select(s => s.Id)
.ToListAsync(ct);
if (winnerRowIds.Count == 0)
{
missing.Add("Đơn vị được chọn không còn trong danh sách NCC tham gia");
}
else
{
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal <= 0)
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
}
var winnerQuoteTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal <= 0)
missing.Add("Đơn vị được chọn chưa có giá chào thầu");
}
// [S61 Mig 50] Schema ngân sách mới — điều kiện (3) = "Ngân sách - kỳ này"
// (BudgetPeriodAmount, drafter nhập). FE PeDetailTabs missingForApproval
@ -202,15 +199,18 @@ public class PurchaseEvaluationWorkflowService(
// (PeWorkItemBudget.ProInitialAmount per Dự-án×Hạng-mục). Effective = BudgetPeriodAmount
// ?? ProInitialAmount → phiếu chưa nhập tay row3 nhưng đã có ban-hành PRO vẫn gửi-duyệt được
// (FE missingForApproval mirror cùng fallback — đổi đồng bộ 2 tầng).
// [Mig 58 item 2 CCM] Ngân sách = 0 HỢP LỆ (SSG ngân sách 0 vẫn gửi duyệt được). Chỉ
// fallback ProInitial khi drafter CHƯA nhập (null) — KHÔNG override số 0 cố ý thành
// ProInitial rồi vô tình null-block. Block cuối chỉ fire khi null; 0 (cố ý) đi qua.
decimal? effectiveBudget = evaluation.BudgetPeriodAmount;
if ((effectiveBudget is null || effectiveBudget <= 0) && evaluation.WorkItemId is Guid wiId)
if (effectiveBudget is null && evaluation.WorkItemId is Guid wiId)
{
effectiveBudget = await db.PeWorkItemBudgets.AsNoTracking()
.Where(b => b.ProjectId == evaluation.ProjectId && b.WorkItemId == wiId)
.Select(b => b.ProInitialAmount)
.FirstOrDefaultAsync(ct);
}
if (effectiveBudget is null || effectiveBudget <= 0)
if (effectiveBudget is null)
{
missing.Add("chưa nhập Ngân sách kỳ này");
}
@ -851,6 +851,31 @@ public class PurchaseEvaluationWorkflowService(
// (winnerQuoteTotal = SUM báo giá NCC được chọn) < ngưỡng. Tích mà KHÔNG đủ điều
// kiện → Conflict/Forbidden. KHÔNG tích → bỏ block, advance bình thường lên CEO.
// Opinion + approval row đã ghi ở trên → finalize giữ đủ vết. ① giá chốt: ApplyApprovedPriceOnFinalize.
// [Mig 58 2026-06-24 — CCM/anh Kiệt FDC item 4] Cấp được cấu hình KẾT THÚC trong Quy
// trình duyệt (admin bật AllowApproverFinalize cho slot, vd Cấp anh Chương). Duyệt tới
// cấp này = TỰ kết thúc (DaDuyet), bỏ qua MỌI Bước/Cấp còn lại (kể cả CEO) — quy-trình-
// driven, KHÔNG cần ô-tích runtime (anh Kiệt chốt 2026-06-24 "cho bật trong quy trình
// hay hơn"). KHÔNG threshold/role-gate (admin chỉ-định slot = đủ thẩm quyền). ① giá chốt:
// người duyệt phải chọn (Conflict nếu thiếu, isSystem miễn). Đặt SAU skipToFinal, TRƯỚC
// ccm-delegation + advance.
if (matchingLevel.AllowApproverFinalize)
{
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(
evaluation,
PurchaseEvaluationPhase.ChoDuyet,
PurchaseEvaluationPhase.DaDuyet,
actorUserId,
ApprovalDecision.Approve,
$"[Duyệt KẾT THÚC tại Bước {currentIdx + 1} Cấp {currentLevelOrder} — cấu hình quy trình, không trình CEO] {comment ?? ""}".Trim(),
ct);
return;
}
if (finalizeByCcmDelegation)
{
if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
@ -861,8 +886,7 @@ public class PurchaseEvaluationWorkflowService(
"Chỉ CCM (Kiểm soát Chi phí) được duyệt done miễn CEO.");
var winnerSupplierRowIds = await db.PurchaseEvaluationSuppliers.AsNoTracking()
.Where(s => s.PurchaseEvaluationId == evaluation.Id
&& s.SupplierId == evaluation.SelectedSupplierId)
.Where(s => s.PurchaseEvaluationId == evaluation.Id && s.IsWinner) // [Mig 58] multi-winner
.Select(s => s.Id)
.ToListAsync(ct);
var winnerQuoteTotal = winnerSupplierRowIds.Count == 0 ? 0m