[CLAUDE] PurchaseEvaluation: Mig 54 giá đề xuất PRO/CCM + CEO chọn giá chốt + CCM duyệt-done ô-tích
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m22s

Theo note anh Kiệt FDC (go-live so-sánh-giá thứ Hai):
- (1) Giá chào thầu thêm giá đề xuất NGOÀI giá NCC: PRO nhập dải Min/Max +
  CCM nhập 1 giá (2 lệnh role-gate Procurement/CostControl, fail-closed).
  Khi duyệt cấp cuối, người duyệt CHỌN 1 giá chốt (Ncc/ProMin/ProMax/Ccm)
  -> luu ApprovedPriceAmount/Source (bind tai moi nhanh DaDuyet, bat buoc
  chon; auto-approve he thong mien).
- (3) CCM duyet-done mien CEO: DOI tu AUTO-threshold (S69) sang O-TICH-TAY
  (finalizeByCcmDelegation) -- CCM chu dong tich, fail-closed theo nguong
  + role + gia goi. An toan hon (khong vo tinh bo CEO).
- Mig 54 additive-nullable (5 cot PE) - FE 2 app SHA-mirror - test 306->334
  (+28: opt-in 6->11, +10 gia chot, +13 setter authz).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-18 15:51:39 +07:00
parent 77ad219361
commit 1d86abcdc5
20 changed files with 7931 additions and 101 deletions

View File

@ -24,6 +24,13 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
// [S61 Mig 50] 2 cột ngân sách mới thay BudgetManual* — precision giữ (18,2).
b.Property(x => x.BudgetPeriodAmount).HasPrecision(18, 2);
b.Property(x => x.ExpectedRemainingAmount).HasPrecision(18, 2);
// [Mig 54 2026-06-18] Giá đề xuất PRO/CCM + giá chốt khi duyệt — precision (18,2)
// mirror các cột tiền khác. ApprovedPriceSource nvarchar(20): Ncc/ProMin/ProMax/Ccm.
b.Property(x => x.ProSuggestedMinPrice).HasPrecision(18, 2);
b.Property(x => x.ProSuggestedMaxPrice).HasPrecision(18, 2);
b.Property(x => x.CcmSuggestedPrice).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceAmount).HasPrecision(18, 2);
b.Property(x => x.ApprovedPriceSource).HasMaxLength(20);
b.HasIndex(x => x.MaPhieu).IsUnique().HasFilter("[MaPhieu] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });

View File

@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPeSuggestedAndApprovedPrice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ApprovedPriceAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ApprovedPriceSource",
table: "PurchaseEvaluations",
type: "nvarchar(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "CcmSuggestedPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ProSuggestedMaxPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ProSuggestedMinPrice",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
precision: 18,
scale: 2,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ApprovedPriceAmount",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ApprovedPriceSource",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "CcmSuggestedPrice",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ProSuggestedMaxPrice",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "ProSuggestedMinPrice",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4582,10 +4582,22 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<Guid?>("ApprovalWorkflowId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("ApprovedPriceAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("ApprovedPriceSource")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<decimal?>("BudgetPeriodAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmSuggestedPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier");
@ -4648,6 +4660,14 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<decimal?>("ProSuggestedMaxPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("ProSuggestedMinPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");

View File

@ -44,6 +44,9 @@ public class PurchaseEvaluationWorkflowService(
WorkflowReturnMode? returnMode = null,
Guid? returnTargetUserId = null,
bool skipToFinal = false,
bool finalizeByCcmDelegation = false,
decimal? approvedPriceAmount = null,
string? approvedPriceSource = null,
CancellationToken ct = default)
{
var fromPhase = evaluation.Phase;
@ -245,7 +248,7 @@ public class PurchaseEvaluationWorkflowService(
// V2 path nhận flag, V1 legacy throw nếu non-admin gọi skipToFinal=true.
if (evaluation.ApprovalWorkflowId is Guid awId)
{
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, ct);
await ApproveV2Async(evaluation, awId, actorUserId, actorRoles, isAdmin, isSystem, comment, skipToFinal, finalizeByCcmDelegation, approvedPriceAmount, approvedPriceSource, ct);
}
else
{
@ -664,6 +667,9 @@ public class PurchaseEvaluationWorkflowService(
bool isSystem,
string? comment,
bool skipToFinal,
bool finalizeByCcmDelegation,
decimal? approvedPriceAmount,
string? approvedPriceSource,
CancellationToken ct)
{
var aw = await db.ApprovalWorkflows.AsNoTracking()
@ -813,18 +819,23 @@ public class PurchaseEvaluationWorkflowService(
}
}
// [S69 2026-06-17] CCM duyệt-final theo NGƯỠNG GIÁ TRỊ (anh Kiệt FDC). Khi NV
// duyệt có role CostControl (CCM) + quy trình set CeoApprovalThreshold + giá trị
// gói (tổng giá NCC được chọn, winnerQuoteTotal) < ngưỡng → DaDuyet luôn, BỎ các
// Bước/Cấp còn lại (CEO). Q4 chốt: nhận diện theo ROLE người duyệt. Ngưỡng null =
// bỏ qua (luồng tuyến tính cũ — rollout an toàn). Cờ gấp KHÔNG ảnh hưởng routing
// (visibility-only, Q3). Guard "chưa ở slot cuối" → chỉ skip-forward (nếu CCM đã
// ở Cấp cuối Bước cuối thì normal-advance bên dưới cũng ra DaDuyet). Giả định quy
// trình đặt CCM ngay trước CEO — UAT anh Kiệt xác nhận cấu trúc.
if (aw.CeoApprovalThreshold is decimal ceoThreshold
&& actorRoles.Contains(AppRoles.CostControl)
&& !(currentIdx == steps.Count - 1 && currentLevelOrder == maxLevelOrder))
// [Mig 54 2026-06-18 — anh Kiệt FDC] CCM duyệt-DONE miễn CEO — ĐỔI TỪ AUTO (S69)
// SANG Ô-TÍCH-TAY (③). Trước S69: gói < ngưỡng + CCM duyệt = tự DaDuyet (im lặng).
// NAY: CCM phải CHỦ ĐỘNG tích "Duyệt done, miễn CEO" (finalizeByCcmDelegation) thì
// mới done — an toàn hơn, KHÔNG vô tình bỏ CEO. Điều kiện uỷ-quyền (fail-closed):
// workflow đặt CeoApprovalThreshold + actor role CostControl + giá gói
// (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.
if (finalizeByCcmDelegation)
{
if (aw.CeoApprovalThreshold is not decimal ceoThreshold)
throw new ConflictException(
"Quy trình chưa đặt 'Ngưỡng giá trị gói CEO' — không thể duyệt done miễn CEO.");
if (!actorRoles.Contains(AppRoles.CostControl))
throw new ForbiddenException(
"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)
@ -835,22 +846,24 @@ public class PurchaseEvaluationWorkflowService(
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
if (winnerQuoteTotal < ceoThreshold)
{
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
evaluation.SlaDeadline = null;
await LogTransitionAsync(
evaluation,
PurchaseEvaluationPhase.ChoDuyet,
PurchaseEvaluationPhase.DaDuyet,
actorUserId,
ApprovalDecision.Approve,
$"[CCM duyệt cuối — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ, không cần CEO duyệt] {comment ?? ""}".Trim(),
ct);
return;
}
if (winnerQuoteTotal >= ceoThreshold)
throw new ConflictException(
$"Giá gói {winnerQuoteTotal:N0}đ ≥ ngưỡng CEO {ceoThreshold:N0}đ — phải trình CEO duyệt, không được duyệt done miễn CEO.");
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,
$"[CCM duyệt done miễn CEO — gói {winnerQuoteTotal:N0}đ < ngưỡng CEO {ceoThreshold:N0}đ] {comment ?? ""}".Trim(),
ct);
return;
}
// Advance: nếu còn cấp tiếp trong Step → levelOrder++; else → next Step + level 1
@ -867,7 +880,9 @@ public class PurchaseEvaluationWorkflowService(
var nextIdx = currentIdx + 1;
if (nextIdx >= steps.Count)
{
// All Steps done — terminal DaDuyet
// All Steps done — terminal DaDuyet. [Mig 54 ①] người duyệt cấp cuối (CEO/NV
// cuối) chọn giá chốt khi duyệt — bind trước khi sang DaDuyet.
ApplyApprovedPriceOnFinalize(evaluation, isSystem, approvedPriceAmount, approvedPriceSource);
evaluation.Phase = PurchaseEvaluationPhase.DaDuyet;
evaluation.CurrentWorkflowStepIndex = null;
evaluation.CurrentApprovalLevelOrder = null;
@ -885,6 +900,28 @@ public class PurchaseEvaluationWorkflowService(
}
}
// [Mig 54 2026-06-18 — anh Kiệt FDC] Gán giá CHỐT người duyệt chọn (①) tại nhánh
// DaDuyet. Người duyệt THẬT bắt buộc chọn 1 giá (Conflict nếu thiếu) → đúng ý "CEO
// phải chọn 1 giá làm giá chốt"; auto-approve hệ thống (SLA, isSystem) MIỄN — không
// có người để chọn. Source ∈ {Ncc,ProMin,ProMax,Ccm}; amount = snapshot lúc duyệt.
private static readonly string[] ValidApprovedPriceSources = { "Ncc", "ProMin", "ProMax", "Ccm" };
private static void ApplyApprovedPriceOnFinalize(
PurchaseEvaluation evaluation, bool isSystem, decimal? amount, string? source)
{
if (amount is null)
{
if (!isSystem)
throw new ConflictException(
"Chọn 1 giá chốt (NCC / PRO Min / PRO Max / CCM) trước khi duyệt cấp cuối.");
return; // auto-approve hệ thống (SLA) — không bắt chọn giá
}
if (source is null || !ValidApprovedPriceSources.Contains(source))
throw new ConflictException(
"Nguồn giá chốt không hợp lệ (phải là NCC / PRO Min / PRO Max / CCM).");
evaluation.ApprovedPriceAmount = amount;
evaluation.ApprovedPriceSource = source;
}
// ===== V1 legacy (Mig 21) — iterate PurchaseEvaluationWorkflowSteps =====
private async Task ApproveV1LegacyAsync(
PurchaseEvaluation evaluation,