[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
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:
@ -96,6 +96,25 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
}
|
||||
public record SetUrgentBody(bool IsUrgent);
|
||||
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] Nhập GIÁ ĐỀ XUẤT tại "c. Giá chào thầu" theo
|
||||
// role. Class [Authorize] any-auth; handler fine-grained Forbidden (PRO=Procurement
|
||||
// Min/Max, CCM=CostControl 1 giá, Admin cả 2). Set per-phiếu. Absolute-set (null=clear).
|
||||
[HttpPut("{id:guid}/suggested-price/pro")]
|
||||
public async Task<IActionResult> SetSuggestedPricePro(Guid id, [FromBody] SuggestedPriceProBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdatePeSuggestedPriceProCommand(id, body.MinPrice, body.MaxPrice), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record SuggestedPriceProBody(decimal? MinPrice, decimal? MaxPrice);
|
||||
|
||||
[HttpPut("{id:guid}/suggested-price/ccm")]
|
||||
public async Task<IActionResult> SetSuggestedPriceCcm(Guid id, [FromBody] SuggestedPriceCcmBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdatePeSuggestedPriceCcmCommand(id, body.CcmPrice), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record SuggestedPriceCcmBody(decimal? CcmPrice);
|
||||
|
||||
[HttpPost("{id:guid}/transitions")]
|
||||
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
|
||||
{
|
||||
@ -106,7 +125,8 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
// Root cause bro UAT 2026-05-15 "Trả lại Người chỉ định fail".
|
||||
await mediator.Send(new TransitionPurchaseEvaluationCommand(
|
||||
id, body.TargetPhase, body.Decision, body.Comment,
|
||||
body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal), ct);
|
||||
body.ReturnMode, body.ReturnTargetUserId, body.SkipToFinal,
|
||||
body.FinalizeByCcmDelegation, body.ApprovedPriceAmount, body.ApprovedPriceSource), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@ -314,7 +334,11 @@ public record TransitionPeBody(
|
||||
string? Comment,
|
||||
WorkflowReturnMode? ReturnMode = null, // F1 mode Trả lại
|
||||
Guid? ReturnTargetUserId = null, // F1 Assignee target
|
||||
bool SkipToFinal = false); // F2 duyệt thẳng Cấp cuối
|
||||
bool SkipToFinal = false, // F2 duyệt thẳng Cấp cuối
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM duyệt done miễn CEO + ① giá chốt.
|
||||
bool FinalizeByCcmDelegation = false,
|
||||
decimal? ApprovedPriceAmount = null,
|
||||
string? ApprovedPriceSource = null);
|
||||
|
||||
public record AddSupplierBody(
|
||||
Guid SupplierId,
|
||||
|
||||
@ -245,6 +245,18 @@ public record PurchaseEvaluationDetailBundleDto(
|
||||
// [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.
|
||||
decimal? CeoApprovalThreshold,
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại "c. Giá chào thầu" — NGOÀI giá
|
||||
// NCC (WinnerQuoteTotal). PRO nhập dải Min/Max; CCM nhập 1 giá. ApprovedPrice* = giá
|
||||
// CHỐT người duyệt cuối chọn (source ∈ Ncc/ProMin/ProMax/Ccm). CanEdit* = capability
|
||||
// theo role (mirror PeBudgetSummary). FE tự suy "đủ điều kiện CCM duyệt-done" + "là
|
||||
// người duyệt cuối" từ WinnerQuoteTotal / CeoApprovalThreshold / roles / ApprovalFlow.
|
||||
decimal? ProSuggestedMinPrice,
|
||||
decimal? ProSuggestedMaxPrice,
|
||||
decimal? CcmSuggestedPrice,
|
||||
decimal? ApprovedPriceAmount,
|
||||
string? ApprovedPriceSource,
|
||||
bool CanEditProSuggestedPrice,
|
||||
bool CanEditCcmSuggestedPrice,
|
||||
// Mig 23 — schema mới ApprovalWorkflowsV2 pin lúc create. Hiển thị Code +
|
||||
// Name + Version để FE show "QT-DN-V2-001 - Quy trình Duyệt NCC (v01)".
|
||||
Guid? ApprovalWorkflowId,
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] 2 handler nhập GIÁ ĐỀ XUẤT tại mục "c. Giá chào
|
||||
// thầu" theo ROLE — NGOÀI giá NCC báo lên (WinnerQuoteTotal computed):
|
||||
// - PRO (Procurement | Admin): ProSuggestedMinPrice + ProSuggestedMaxPrice (dải giá;
|
||||
// chỉ 1 trong 2 = hiểu là giá chốt đó).
|
||||
// - CCM (CostControl | Admin): CcmSuggestedPrice (1 giá để CEO nhìn + duyệt theo).
|
||||
// Authz mirror UpdatePeBudgetPro/Ccm (S61): controller [Authorize] any-auth, handler
|
||||
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). KHÔNG ràng Phase
|
||||
// (mirror ngân sách — chỉnh được như tài liệu sống; trade-off ghi nhận). Set per-PHIẾU
|
||||
// trực tiếp trên PurchaseEvaluation (KHÔNG per-cặp như ngân sách — giá chào thầu là của
|
||||
// phiếu, không dùng chung mọi phiếu cùng Hạng mục).
|
||||
|
||||
// ===== PRO — dải giá đề xuất Min/Max =====
|
||||
|
||||
public record UpdatePeSuggestedPriceProCommand(
|
||||
Guid PeId,
|
||||
decimal? MinPrice,
|
||||
decimal? MaxPrice) : IRequest;
|
||||
|
||||
public class UpdatePeSuggestedPriceProCommandValidator : AbstractValidator<UpdatePeSuggestedPriceProCommand>
|
||||
{
|
||||
public UpdatePeSuggestedPriceProCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.MinPrice).GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue);
|
||||
RuleFor(x => x.MaxPrice).GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue);
|
||||
// Cả 2 có → Min ≤ Max. Chỉ 1 trong 2 = giá chốt đó (không ràng).
|
||||
RuleFor(x => x).Must(x => x.MinPrice!.Value <= x.MaxPrice!.Value)
|
||||
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue)
|
||||
.WithMessage("Giá Min phải ≤ Giá Max.");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdatePeSuggestedPriceProCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<UpdatePeSuggestedPriceProCommand>
|
||||
{
|
||||
public async Task Handle(UpdatePeSuggestedPriceProCommand request, CancellationToken ct)
|
||||
{
|
||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
|
||||
|
||||
// Fail-closed TRƯỚC mọi side-effect.
|
||||
if (!currentUser.Roles.Contains(AppRoles.Admin)
|
||||
&& !currentUser.Roles.Contains(AppRoles.Procurement))
|
||||
{
|
||||
throw new ForbiddenException(
|
||||
"Chỉ Phòng Cung ứng (PRO) hoặc Admin được nhập giá đề xuất PRO (Min/Max).");
|
||||
}
|
||||
|
||||
var oldMin = pe.ProSuggestedMinPrice;
|
||||
var oldMax = pe.ProSuggestedMaxPrice;
|
||||
pe.ProSuggestedMinPrice = request.MinPrice; // absolute-set (null = clear)
|
||||
pe.ProSuggestedMaxPrice = request.MaxPrice;
|
||||
|
||||
var parts = new List<string>();
|
||||
if (oldMin != request.MinPrice)
|
||||
parts.Add($"giá Min {oldMin?.ToString("N0") ?? "(trống)"}đ → {request.MinPrice?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldMax != request.MaxPrice)
|
||||
parts.Add($"giá Max {oldMax?.ToString("N0") ?? "(trống)"}đ → {request.MaxPrice?.ToString("N0") ?? "(trống)"}đ");
|
||||
|
||||
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 = $"Giá đề xuất (PRO): {(parts.Count == 0 ? "không đổi" : string.Join(", ", parts))}",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CCM — 1 giá đề xuất (để CEO nhìn + duyệt theo) =====
|
||||
|
||||
public record UpdatePeSuggestedPriceCcmCommand(
|
||||
Guid PeId,
|
||||
decimal? CcmPrice) : IRequest;
|
||||
|
||||
public class UpdatePeSuggestedPriceCcmCommandValidator : AbstractValidator<UpdatePeSuggestedPriceCcmCommand>
|
||||
{
|
||||
public UpdatePeSuggestedPriceCcmCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.CcmPrice).GreaterThanOrEqualTo(0).When(x => x.CcmPrice.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdatePeSuggestedPriceCcmCommandHandler(
|
||||
IApplicationDbContext db,
|
||||
ICurrentUser currentUser) : IRequestHandler<UpdatePeSuggestedPriceCcmCommand>
|
||||
{
|
||||
public async Task Handle(UpdatePeSuggestedPriceCcmCommand request, CancellationToken ct)
|
||||
{
|
||||
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.PeId, ct)
|
||||
?? throw new NotFoundException("PurchaseEvaluation", request.PeId);
|
||||
|
||||
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 giá đề xuất CCM.");
|
||||
}
|
||||
|
||||
var oldCcm = pe.CcmSuggestedPrice;
|
||||
pe.CcmSuggestedPrice = request.CcmPrice; // 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 = $"Giá đề xuất (CCM): {oldCcm?.ToString("N0") ?? "(trống)"}đ → {request.CcmPrice?.ToString("N0") ?? "(trống)"}đ",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -457,7 +457,12 @@ public record TransitionPurchaseEvaluationCommand(
|
||||
Guid? ReturnTargetUserId = null,
|
||||
// F2 — Approver skip thẳng Cấp cuối lúc duyệt ChoDuyet (Mig 31 admin opt-in
|
||||
// per slot, AllowApproverSkipToFinal). Default false.
|
||||
bool SkipToFinal = false) : IRequest;
|
||||
bool SkipToFinal = false,
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" + ① giá CHỐT
|
||||
// người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm).
|
||||
bool FinalizeByCcmDelegation = false,
|
||||
decimal? ApprovedPriceAmount = null,
|
||||
string? ApprovedPriceSource = null) : IRequest;
|
||||
|
||||
public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<TransitionPurchaseEvaluationCommand>
|
||||
{
|
||||
@ -472,6 +477,15 @@ public class TransitionPurchaseEvaluationCommandValidator : AbstractValidator<Tr
|
||||
RuleFor(x => x.ReturnTargetUserId).NotEmpty()
|
||||
.When(x => x.ReturnMode == WorkflowReturnMode.Assignee)
|
||||
.WithMessage("ReturnTargetUserId yêu cầu khi mode=Assignee.");
|
||||
// [Mig 54] Giá chốt ≥ 0; nguồn ∈ {Ncc,ProMin,ProMax,Ccm}. Quy tắc "bắt-buộc-
|
||||
// chọn-khi-duyệt-cuối" enforce ở service (ApplyApprovedPriceOnFinalize — chỉ nó
|
||||
// biết nhánh DaDuyet); validator chỉ chặn giá trị rác.
|
||||
RuleFor(x => x.ApprovedPriceAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ApprovedPriceAmount.HasValue);
|
||||
RuleFor(x => x.ApprovedPriceSource)
|
||||
.Must(s => s is "Ncc" or "ProMin" or "ProMax" or "Ccm")
|
||||
.When(x => x.ApprovedPriceSource is not null)
|
||||
.WithMessage("Nguồn giá chốt phải là Ncc/ProMin/ProMax/Ccm.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -498,6 +512,9 @@ public class TransitionPurchaseEvaluationCommandHandler(
|
||||
request.ReturnMode,
|
||||
request.ReturnTargetUserId,
|
||||
request.SkipToFinal,
|
||||
request.FinalizeByCcmDelegation,
|
||||
request.ApprovedPriceAmount,
|
||||
request.ApprovedPriceSource,
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@ -1050,6 +1067,10 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.Where(q => winnerSupplierRowIds.Contains(q.PurchaseEvaluationSupplierId))
|
||||
.Sum(q => q.ThanhTien);
|
||||
|
||||
// [Mig 54] Capability nhập giá đề xuất theo role (mirror PeBudgetSummary canEdit).
|
||||
var canEditProSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
|
||||
var canEditCcmSuggested = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
|
||||
|
||||
return new PurchaseEvaluationDetailBundleDto(
|
||||
e.Id, e.MaPhieu, e.Type, e.Phase, e.TenGoiThau, e.DiaDiem, e.MoTa,
|
||||
e.HoSoLink, // [HoSoLink] hyperlink thư mục hồ sơ NAS
|
||||
@ -1062,6 +1083,9 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
|
||||
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
|
||||
e.IsUrgentByPro, e.IsUrgentByCcm, winnerQuoteTotal, awCeoThreshold, // [S69] cờ gấp + giá trị gói + ngưỡng CEO
|
||||
e.ProSuggestedMinPrice, e.ProSuggestedMaxPrice, e.CcmSuggestedPrice, // [Mig 54] giá đề xuất PRO/CCM
|
||||
e.ApprovedPriceAmount, e.ApprovedPriceSource, // [Mig 54] giá chốt người duyệt chọn
|
||||
canEditProSuggested, canEditCcmSuggested, // [Mig 54] capability role-gate
|
||||
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
|
||||
currentApproval, approvalFlow,
|
||||
e.Suppliers
|
||||
|
||||
@ -27,6 +27,11 @@ public interface IPurchaseEvaluationWorkflowService
|
||||
WorkflowReturnMode? returnMode = null,
|
||||
Guid? returnTargetUserId = null,
|
||||
bool skipToFinal = false,
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] ③ CCM tích "Duyệt done miễn CEO" +
|
||||
// ① giá CHỐT người duyệt cuối chọn (amount + source ∈ Ncc/ProMin/ProMax/Ccm).
|
||||
bool finalizeByCcmDelegation = false,
|
||||
decimal? approvedPriceAmount = null,
|
||||
string? approvedPriceSource = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
TimeSpan? GetPhaseSla(PurchaseEvaluationPhase phase);
|
||||
|
||||
@ -65,6 +65,22 @@ public class PurchaseEvaluation : AuditableEntity
|
||||
public bool IsUrgentByPro { get; set; }
|
||||
public bool IsUrgentByCcm { get; set; }
|
||||
|
||||
// [Mig 54 2026-06-18 — anh Kiệt FDC] Giá đề xuất tại mục "c. Giá chào thầu" — NGOÀI
|
||||
// giá NCC báo lên (WinnerQuoteTotal = SUM báo giá của NCC được chọn, computed). PRO
|
||||
// (role Procurement) nhập dải Min/Max — nếu chỉ 1 trong 2 → hiểu là giá chốt đó. CCM
|
||||
// (role CostControl) nhập 1 giá đề xuất để CEO nhìn + duyệt theo. Role-gate qua
|
||||
// UpdatePeSuggestedPrice{Pro,Ccm}Command (mirror ngân sách PRO/CCM Mig 50).
|
||||
public decimal? ProSuggestedMinPrice { get; set; }
|
||||
public decimal? ProSuggestedMaxPrice { get; set; }
|
||||
public decimal? CcmSuggestedPrice { get; set; }
|
||||
|
||||
// [Mig 54] Giá CHỐT người duyệt cấp cuối chọn khi duyệt (① "duyệt theo giá đề xuất").
|
||||
// Set tại MỌI nhánh DaDuyet của ApproveV2Async (terminal + CCM-delegation-finalize).
|
||||
// ApprovedPriceSource ∈ {Ncc, ProMin, ProMax, Ccm} = nguồn giá đã chọn; Amount =
|
||||
// snapshot giá trị lúc duyệt. Null cho phiếu duyệt trước Mig 54 hoặc luồng V1 legacy.
|
||||
public decimal? ApprovedPriceAmount { get; set; }
|
||||
public string? ApprovedPriceSource { get; set; }
|
||||
|
||||
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
|
||||
public List<PurchaseEvaluationDetail> Details { get; set; } = new();
|
||||
public List<PurchaseEvaluationQuote> Quotes { get; set; } = new();
|
||||
|
||||
@ -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 });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user