[CLAUDE] PurchaseEvaluation: Section B (THỰC HIỆN) tách 3 cột per-cột kỳ-này (Mig 59, anh Kiệt FDC / CCM)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 5m8s

Section B "Tổng hợp ngân sách trình ký" tách 3 cột Dự án|PRO|CCM giống Section A —
mỗi cột nhập "NS kỳ này" RIÊNG + tính thực-hiện độc-lập (lũy-kế / còn-lại / % /
so-sánh) theo ngân-sách-full của cột đó:
- PRO: budgetPeriodAmount (có sẵn) đối chiếu proFull
- CCM: ccmBudgetPeriodAmount (MỚI, Mig 59) đối chiếu ccmFull — CCM nhập (canEditCcm)
- Dự án: — (chưa wire, như A)
Số thực-tế dùng chung mọi cột (NCC đề xuất, lũy kế lịch sử). Submit-guard giữ trên
budgetPeriodAmount (không đụng).

Mig 59 AddCcmBudgetPeriodToPurchaseEvaluation (1 cột nullable, no backfill). NEW
endpoint PATCH /budget/ccm-period role-gate Admin|CostControl (fail-closed). FE
Section B rewrite <BudgetRow> list -> <table> 3 cột (colCalc per-cột), 2 app SHA-mirror.
Test 395->402 (+7 authz setter). Build sạch BE + 2 FE. Workflow wf_aafb18fe-d9d
(BE->FE->test + 3-lane review: math PASS, authz PASS, mirror self-gated identical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-24 16:06:59 +07:00
parent f1b10e9c33
commit 9b1ee0c3a5
14 changed files with 7121 additions and 397 deletions

View File

@ -115,6 +115,18 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
}
public record SuggestedPriceCcmBody(decimal? CcmPrice, string? Note);
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] "NS kỳ này" cột CCM (Section B THỰC HIỆN
// tách 3 cột Dự án|PRO|CCM). PRO dùng /budget-adjust sẵn có; CCM nhập cột riêng.
// Class [Authorize] any-auth; handler fine-grained Forbidden (CCM=CostControl | Admin).
// Absolute-set (null=clear). Budget=0 hợp lệ.
[HttpPatch("{id:guid}/budget/ccm-period")]
public async Task<IActionResult> SetCcmBudgetPeriod(Guid id, [FromBody] CcmBudgetPeriodBody body, CancellationToken ct)
{
await mediator.Send(new SetPeCcmBudgetPeriodCommand(id, body.CcmBudgetPeriodAmount), ct);
return NoContent();
}
public record CcmBudgetPeriodBody(decimal? CcmBudgetPeriodAmount);
[HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionPeBody body, CancellationToken ct)
{

View File

@ -239,6 +239,10 @@ public record PurchaseEvaluationDetailBundleDto(
// (module Budget XÓA HẲN, data BudgetManualAmount migrate → BudgetPeriodAmount).
decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount,
// [Mig 59 2026-06-24] "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột) —
// PRO=BudgetPeriodAmount sẵn có, CCM=cột này độc-lập. Nhập qua PATCH
// /budget/ccm-period role-gate Admin|CostControl.
decimal? CcmBudgetPeriodAmount,
PeBudgetSummaryDto? BudgetSummary,
// [S69 2026-06-17] Cờ gấp per-vai (PRO ĐỎ / CCM XANH) — FE render badge + toggle.
bool IsUrgentByPro,

View File

@ -141,3 +141,59 @@ public class UpdatePeSuggestedPriceCcmCommandHandler(
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — "NS kỳ này" cột CCM (Section B THỰC HIỆN tách 3 cột Dự án|PRO|CCM) =====
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] PRO dùng BudgetPeriodAmount sẵn có (luồng
// /budget-adjust). CCM nhập "NS kỳ này" RIÊNG ở cột mình → tính thực-hiện độc-lập.
// Authz mirror UpdatePeSuggestedPriceCcm: controller [Authorize] any-auth, handler
// ForbiddenException fail-closed TRƯỚC mọi side-effect (S56 #5). Budget=0 hợp lệ
// (GreaterThanOrEqualTo(0).When(HasValue)). Absolute-set (null=clear). KHÔNG
// submit-guard (guard chỉ trên BudgetPeriodAmount của PRO, KHÔNG đụng — gotcha #73).
public record SetPeCcmBudgetPeriodCommand(
Guid Id,
decimal? CcmBudgetPeriodAmount) : IRequest;
public class SetPeCcmBudgetPeriodCommandValidator : AbstractValidator<SetPeCcmBudgetPeriodCommand>
{
public SetPeCcmBudgetPeriodCommandValidator()
{
RuleFor(x => x.CcmBudgetPeriodAmount).GreaterThanOrEqualTo(0) // 0 hợp lệ
.When(x => x.CcmBudgetPeriodAmount.HasValue);
}
}
public class SetPeCcmBudgetPeriodCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<SetPeCcmBudgetPeriodCommand>
{
public async Task Handle(SetPeCcmBudgetPeriodCommand request, CancellationToken ct)
{
var pe = await db.PurchaseEvaluations.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("PurchaseEvaluation", request.Id);
// Fail-closed TRƯỚC mọi side-effect.
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 NS kỳ này (CCM).");
}
var oldVal = pe.CcmBudgetPeriodAmount;
pe.CcmBudgetPeriodAmount = request.CcmBudgetPeriodAmount; // 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 = $"NS kỳ này (CCM): {oldVal?.ToString("N0") ?? "(trống)"}đ → {request.CcmBudgetPeriodAmount?.ToString("N0") ?? "(trống)"}đ",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -1107,7 +1107,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, e.CcmBudgetPeriodAmount, 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.ProSuggestedPriceNote, e.CcmSuggestedPriceNote, // [Mig 57] ghi chú giải thích giá

View File

@ -40,6 +40,14 @@ public class PurchaseEvaluation : AuditableEntity
public decimal? BudgetPeriodAmount { get; set; }
public decimal? ExpectedRemainingAmount { get; set; }
// [Mig 59 2026-06-24 — anh Kiệt FDC / CCM] "NS kỳ này" của CỘT CCM (Section B
// THỰC HIỆN tách 3 cột Dự án|PRO|CCM). PRO dùng BudgetPeriodAmount sẵn có; CCM
// nhập cột riêng độc-lập, đối chiếu full-CCM = (InitialAmount+AdjustmentAmount)
// ở PeWorkItemBudget. Nhập qua endpoint riêng role-gate Admin|CostControl,
// absolute-set (null=clear). Budget=0 hợp lệ. KHÔNG submit-guard (guard chỉ trên
// BudgetPeriodAmount của PRO).
public decimal? CcmBudgetPeriodAmount { get; set; }
// Smart reject (Phase 9 — Migration 16): Phase nguồn khi reject. Drafter
// sửa lại + trình lại → quay về RejectedFromPhase thay vì đi tuần tự.
public PurchaseEvaluationPhase? RejectedFromPhase { get; set; }

View File

@ -24,6 +24,9 @@ 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 59 2026-06-24] "NS kỳ này" cột CCM (Section B 3 cột) — decimal(18,2)
// nullable, mirror BudgetPeriodAmount. Nhập role-gate Admin|CostControl.
b.Property(x => x.CcmBudgetPeriodAmount).HasColumnType("decimal(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);

View File

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCcmBudgetPeriodToPurchaseEvaluation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CcmBudgetPeriodAmount",
table: "PurchaseEvaluations",
type: "decimal(18,2)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CcmBudgetPeriodAmount",
table: "PurchaseEvaluations");
}
}
}

View File

@ -4611,6 +4611,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmBudgetPeriodAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("CcmSuggestedPrice")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");