[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

@ -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,

View File

@ -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);
}
}

View File

@ -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

View File

@ -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);