[CLAUDE] PurchaseEvaluation: Mig 56 ngan sach MA TRAN 3 cot (Du an|PRO|CCM) + badge quyen NS theo role
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m57s
S76 (anh Kiet FDC + chi Tra Sol) — form ngan sach + hien thi quyen nhap NS trong flow: - Part 1: form ngan sach -> MA TRAN 3 cot, moi phong nhap+dieu chinh cot minh (PRO canEditPro / CCM canEditCcm / Du an FE hien-thi-only). Mig 56 +ProInitialAmount/ProAdjustmentAmount (additive-nullable + data-migrate ProEstimate->ProInitial). full moi cot = ban hanh + hieu chinh. - Part 2: Workflow Designer (fe-admin) +badge "NS PRO/CCM" canh approver (suy tu role Admin|Procurement / Admin|CostControl, hien-thi-only no-authz). - Part 3: flow quy trinh fe-user/fe-admin (Duyet NCC) +badge tuong tu. - Fix race mat-du-lieu Part 1 (useIsFetching khoa Luu khi refetch — dong cua-so stale-echo, reviewer Part2/3 bat). - Test 339->344 (+5). 2 workflow review (Part 1 PASS + Part 2/3 PASS). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -72,10 +72,10 @@ public class PurchaseEvaluationsController(IMediator mediator) : ControllerBase
|
||||
[HttpPut("{id:guid}/budget/pro")]
|
||||
public async Task<IActionResult> UpdateBudgetPro(Guid id, [FromBody] BudgetProBody body, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new UpdatePeBudgetProCommand(id, body.ProEstimateAmount, body.ProNote), ct);
|
||||
await mediator.Send(new UpdatePeBudgetProCommand(id, body.ProInitialAmount, body.ProAdjustmentAmount, body.ProNote), ct);
|
||||
return NoContent();
|
||||
}
|
||||
public record BudgetProBody(decimal? ProEstimateAmount, string? ProNote);
|
||||
public record BudgetProBody(decimal? ProInitialAmount, decimal? ProAdjustmentAmount, string? ProNote);
|
||||
|
||||
[HttpPut("{id:guid}/budget/ccm")]
|
||||
public async Task<IActionResult> UpdateBudgetCcm(Guid id, [FromBody] BudgetCcmBody body, CancellationToken ct)
|
||||
|
||||
@ -37,7 +37,12 @@ public record AwLevelDto(
|
||||
bool AllowReturnToDrafter,
|
||||
bool AllowApproverEditDetails,
|
||||
bool AllowApproverEditBudget,
|
||||
bool AllowApproverSkipToFinal);
|
||||
bool AllowApproverSkipToFinal,
|
||||
// [S76] Hiển thị-only: approver này được nhập/điều chỉnh ngân sách cột nào (suy
|
||||
// từ ROLE — KHÔNG đổi quyền). CanEditProBudget = Admin|Procurement (cột PRO);
|
||||
// CanEditCcmBudget = Admin|CostControl (cột CCM). Badge "✎ NS PRO/CCM" trong Designer.
|
||||
bool CanEditProBudget,
|
||||
bool CanEditCcmBudget);
|
||||
|
||||
public record AwStepDto(
|
||||
Guid Id,
|
||||
@ -146,6 +151,13 @@ public class GetAwAdminOverviewQueryHandler(
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
|
||||
|
||||
// [S76] Hiển thị-only: tập user được nhập/điều chỉnh ngân sách (đảo chiều
|
||||
// GetUsersInRoleAsync → set-lookup, no N+1). Khớp gate canEditPro/canEditCcm
|
||||
// (PurchaseEvaluationFeatures.cs:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
|
||||
var adminUserIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
|
||||
var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();
|
||||
var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminUserIds).ToHashSet();
|
||||
|
||||
AwDefinitionDto ToDto(ApprovalWorkflow d) => new(
|
||||
d.Id,
|
||||
d.Code,
|
||||
@ -172,7 +184,8 @@ public class GetAwAdminOverviewQueryHandler(
|
||||
return new AwLevelDto(l.Id, l.Order, l.Name, l.ApproverUserId, info.FullName, info.Email,
|
||||
l.AllowReturnOneLevel, l.AllowReturnOneStep, l.AllowReturnToAssignee,
|
||||
l.AllowReturnToDrafter, l.AllowApproverEditDetails, l.AllowApproverEditBudget,
|
||||
l.AllowApproverSkipToFinal);
|
||||
l.AllowApproverSkipToFinal,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList()
|
||||
)).ToList());
|
||||
|
||||
|
||||
@ -129,7 +129,11 @@ public record PurchaseEvaluationWorkflowSummaryDto(
|
||||
public record PurchaseEvaluationApprovalLevelApproverDto(
|
||||
Guid UserId,
|
||||
string FullName,
|
||||
string? Email);
|
||||
string? Email,
|
||||
// [S76] Hiển thị-only: approver được nhập/điều chỉnh ngân sách cột nào (suy từ role —
|
||||
// Admin|Procurement→PRO, Admin|CostControl→CCM). Badge "✎ NS PRO/CCM" trong flow Duyệt NCC.
|
||||
bool CanEditProBudget,
|
||||
bool CanEditCcmBudget);
|
||||
|
||||
public record PurchaseEvaluationCurrentApprovalDto(
|
||||
int StepIndex, // 0-based
|
||||
@ -306,4 +310,7 @@ public record PeBudgetSummaryDto(
|
||||
int PreviousSubmittedCount,
|
||||
decimal PreviousSelectedTotal,
|
||||
int PreviousSelectedCount,
|
||||
decimal CurrentProposalTotal);
|
||||
decimal CurrentProposalTotal,
|
||||
// [S76] PRO column split — ban hành + hiệu chỉnh riêng cho PRO (mirror CCM Initial/Adjustment).
|
||||
decimal? ProInitialAmount,
|
||||
decimal? ProAdjustmentAmount);
|
||||
|
||||
@ -9,8 +9,10 @@ using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.PurchaseEvaluations;
|
||||
|
||||
// [S61 Mig 50] 2 handler nhập ngân sách gói thầu theo ROLE (anh Kiệt chốt):
|
||||
// - PRO (Procurement | Admin): ProEstimateAmount (dự trù lần đầu) + ProNote.
|
||||
// [S61 Mig 50 · S76 PRO split] 2 handler nhập ngân sách gói thầu MA TRẬN theo ROLE:
|
||||
// - PRO (Procurement | Admin): ProInitialAmount ("Ban hành lần đầu") +
|
||||
// ProAdjustmentAmount ("V0/hiệu chỉnh" — cho phép ÂM) + ProNote. [S76 tách 2 số;
|
||||
// ProEstimateAmount cũ migrate → ProInitialAmount qua Mig 56]
|
||||
// - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") +
|
||||
// AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM) + CcmNote (Mig 55).
|
||||
// Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth,
|
||||
@ -60,15 +62,17 @@ internal static class PeWorkItemBudgetEnsurer
|
||||
|
||||
public record UpdatePeBudgetProCommand(
|
||||
Guid PeId,
|
||||
decimal? ProEstimateAmount,
|
||||
decimal? ProInitialAmount,
|
||||
decimal? ProAdjustmentAmount,
|
||||
string? ProNote) : IRequest;
|
||||
|
||||
public class UpdatePeBudgetProCommandValidator : AbstractValidator<UpdatePeBudgetProCommand>
|
||||
{
|
||||
public UpdatePeBudgetProCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProEstimateAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ProEstimateAmount.HasValue);
|
||||
RuleFor(x => x.ProInitialAmount).GreaterThanOrEqualTo(0)
|
||||
.When(x => x.ProInitialAmount.HasValue);
|
||||
// ProAdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM.
|
||||
RuleFor(x => x.ProNote).MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
@ -95,14 +99,18 @@ public class UpdatePeBudgetProCommandHandler(
|
||||
|
||||
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
|
||||
|
||||
var oldEstimate = rec.ProEstimateAmount;
|
||||
var oldInitial = rec.ProInitialAmount;
|
||||
var oldAdjustment = rec.ProAdjustmentAmount;
|
||||
var oldNote = rec.ProNote;
|
||||
rec.ProEstimateAmount = request.ProEstimateAmount; // absolute-set (null = clear)
|
||||
rec.ProInitialAmount = request.ProInitialAmount; // absolute-set (null = clear)
|
||||
rec.ProAdjustmentAmount = request.ProAdjustmentAmount;
|
||||
rec.ProNote = request.ProNote;
|
||||
|
||||
var parts = new List<string>();
|
||||
if (oldEstimate != request.ProEstimateAmount)
|
||||
parts.Add($"dự trù {oldEstimate?.ToString("N0") ?? "(trống)"}đ → {request.ProEstimateAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldInitial != request.ProInitialAmount)
|
||||
parts.Add($"ban hành PRO {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.ProInitialAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldAdjustment != request.ProAdjustmentAmount)
|
||||
parts.Add($"V0/hiệu chỉnh PRO {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.ProAdjustmentAmount?.ToString("N0") ?? "(trống)"}đ");
|
||||
if (oldNote != request.ProNote)
|
||||
parts.Add("ghi chú PRO cập nhật");
|
||||
|
||||
|
||||
@ -842,21 +842,26 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.SumAsync(q => (decimal?)q.ThanhTien, ct) ?? 0m;
|
||||
}
|
||||
|
||||
// Full = CCM (Initial + Adjustment); CCM chưa nhập gì → fallback dự trù
|
||||
// PRO với cờ FullIsEstimate (FE badge "dự trù PRO").
|
||||
// [S76] Full mỗi cột = Initial + Adjustment (cột đó). Authoritative full cho
|
||||
// Block B công thức = CCM nếu CCM đã nhập, else PRO (FullIsEstimate=true → FE
|
||||
// badge "ngân sách PRO"). PRO full = ProInitial + ProAdjust (migrate từ
|
||||
// ProEstimate cũ qua Mig 56). Cả 2 trống → full 0, không badge.
|
||||
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
|
||||
var hasPro = pairRec?.ProInitialAmount is not null || pairRec?.ProAdjustmentAmount is not null;
|
||||
var proFull = (pairRec?.ProInitialAmount ?? 0m) + (pairRec?.ProAdjustmentAmount ?? 0m);
|
||||
var fullAmount = hasCcm
|
||||
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
|
||||
: (pairRec?.ProEstimateAmount ?? 0m);
|
||||
: proFull;
|
||||
|
||||
peBudgetSummary = new PeBudgetSummaryDto(
|
||||
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
|
||||
pairRec?.InitialAmount, pairRec?.AdjustmentAmount, pairRec?.CcmNote,
|
||||
fullAmount, !hasCcm,
|
||||
fullAmount, !hasCcm && hasPro,
|
||||
canEditPro, canEditCcm,
|
||||
prevSubmittedTotal, prevSubmittedCount,
|
||||
prevSelectedTotal, prevSelectedCount,
|
||||
currentProposalTotal);
|
||||
currentProposalTotal,
|
||||
pairRec?.ProInitialAmount, pairRec?.ProAdjustmentAmount);
|
||||
}
|
||||
|
||||
// Load supplier names for PE suppliers + approver names
|
||||
@ -966,6 +971,13 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToDictionaryAsync(u => u.Id, u => (u.FullName, u.Email), ct);
|
||||
|
||||
// [S76] Hiển thị-only: tập user được nhập/điều chỉnh ngân sách (đảo chiều
|
||||
// GetUsersInRoleAsync → set-lookup, no N+1). Khớp gate canEditPro/canEditCcm
|
||||
// (:800-801): PRO = Admin|Procurement, CCM = Admin|CostControl.
|
||||
var adminBudgetIds = (await userManager.GetUsersInRoleAsync(AppRoles.Admin)).Select(u => u.Id).ToHashSet();
|
||||
var proBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.Procurement)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();
|
||||
var ccmBudgetEditors = (await userManager.GetUsersInRoleAsync(AppRoles.CostControl)).Select(u => u.Id).Concat(adminBudgetIds).ToHashSet();
|
||||
|
||||
// Compute Status mỗi level theo Phase + currentStepIdx + currentLevelOrder
|
||||
var currentIdx = e.CurrentWorkflowStepIndex;
|
||||
var currentLevel = e.CurrentApprovalLevelOrder;
|
||||
@ -1013,7 +1025,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
return new PurchaseEvaluationApprovalLevelApproverDto(
|
||||
l.ApproverUserId,
|
||||
info.FullName ?? l.ApproverUserId.ToString(),
|
||||
info.Email);
|
||||
info.Email,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList();
|
||||
var levelName = g.FirstOrDefault()?.Name;
|
||||
return new PurchaseEvaluationApprovalFlowLevelDto(
|
||||
@ -1044,7 +1057,8 @@ public class GetPurchaseEvaluationQueryHandler(
|
||||
return new PurchaseEvaluationApprovalLevelApproverDto(
|
||||
l.ApproverUserId,
|
||||
info.FullName ?? l.ApproverUserId.ToString(),
|
||||
info.Email);
|
||||
info.Email,
|
||||
proBudgetEditors.Contains(l.ApproverUserId), ccmBudgetEditors.Contains(l.ApproverUserId));
|
||||
}).ToList();
|
||||
var levelName = levelGroup.FirstOrDefault()?.Name;
|
||||
currentApproval = new PurchaseEvaluationCurrentApprovalDto(
|
||||
|
||||
@ -10,21 +10,26 @@ namespace SolutionErp.Domain.PurchaseEvaluations;
|
||||
// Loose-Guid convention PE (giống PE.ProjectId/WorkItemId/SelectedSupplierId):
|
||||
// KHÔNG FK vật lý, KHÔNG navigation property.
|
||||
//
|
||||
// Quyền nhập theo ROLE (anh Kiệt chốt S61):
|
||||
// - PRO (Procurement): ProEstimateAmount (dự trù lần đầu) + ProNote.
|
||||
// - CCM (CostControl): InitialAmount (Ban hành lần đầu) + AdjustmentAmount
|
||||
// (NS V0 hiệu chỉnh tăng/giảm — cho phép ÂM) + CcmNote (ghi chú CCM, Mig 55).
|
||||
// [S76 — anh Kiệt FDC] Form ngân sách MA TRẬN 3 cột (DỰ ÁN | PRO | CCM), mỗi
|
||||
// phòng nhập + điều chỉnh ngân sách của CHÍNH cột phòng đó (quyền theo ROLE):
|
||||
// - PRO (Procurement | Admin): ProInitialAmount (ban hành lần đầu) +
|
||||
// ProAdjustmentAmount (V0/hiệu chỉnh — cho phép ÂM) + ProNote.
|
||||
// - CCM (CostControl | Admin): InitialAmount (ban hành lần đầu) +
|
||||
// AdjustmentAmount (V0/hiệu chỉnh — cho phép ÂM) + CcmNote.
|
||||
// - DỰ ÁN: hiển thị FE-only (chưa wire BE — sau mới có người dự án nhập).
|
||||
//
|
||||
// "Ngân sách full gói thầu" KHÔNG lưu cột — BE compute:
|
||||
// full = (InitialAmount ?? 0) + (AdjustmentAmount ?? 0);
|
||||
// cả Initial + Adjustment đều null → fallback ProEstimateAmount ?? 0
|
||||
// với cờ fullIsEstimate=true (FE badge "dự trù PRO").
|
||||
// "Ngân sách full" mỗi cột = Initial + Adjustment (cột đó). Authoritative full
|
||||
// (Block B công thức) = CCM nếu CCM đã nhập, else PRO (fullIsEstimate=true → FE
|
||||
// badge "ngân sách PRO"). ProEstimateAmount = LEGACY single-estimate (≤S75) —
|
||||
// Mig 56 migrate → ProInitialAmount, FE KHÔNG dùng nữa; giữ cột back-compat.
|
||||
public class PeWorkItemBudget : AuditableEntity
|
||||
{
|
||||
public Guid ProjectId { get; set; } // loose-Guid Projects.Id
|
||||
public Guid WorkItemId { get; set; } // loose-Guid WorkItems.Id
|
||||
|
||||
public decimal? ProEstimateAmount { get; set; } // PRO dự trù lần đầu (đ)
|
||||
public decimal? ProEstimateAmount { get; set; } // [LEGACY ≤S75] PRO dự trù 1 số — Mig 56 migrate → ProInitialAmount
|
||||
public decimal? ProInitialAmount { get; set; } // [S76] PRO "Ban hành lần đầu" (đ)
|
||||
public decimal? ProAdjustmentAmount { get; set; } // [S76] PRO "V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
|
||||
public string? ProNote { get; set; } // "Ghi chú từ PRO"
|
||||
public decimal? InitialAmount { get; set; } // CCM "Ngân sách Ban hành lần đầu" (đ)
|
||||
public decimal? AdjustmentAmount { get; set; } // CCM "NS V0/hiệu chỉnh tăng giảm" (đ, cho phép ÂM)
|
||||
|
||||
@ -17,6 +17,8 @@ public class PeWorkItemBudgetConfiguration : IEntityTypeConfiguration<PeWorkItem
|
||||
|
||||
// Precision match BudgetManualAmount cũ (18,2).
|
||||
b.Property(x => x.ProEstimateAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProInitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProAdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.InitialAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.AdjustmentAmount).HasPrecision(18, 2);
|
||||
b.Property(x => x.ProNote).HasMaxLength(1000);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProBudgetSplitToPeWorkItemBudget : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ProAdjustmentAmount",
|
||||
table: "PeWorkItemBudgets",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ProInitialAmount",
|
||||
table: "PeWorkItemBudgets",
|
||||
type: "decimal(18,2)",
|
||||
precision: 18,
|
||||
scale: 2,
|
||||
nullable: true);
|
||||
|
||||
// [S76] Giu so PRO cu: so du tru 1-cot (ProEstimateAmount, <=S75) coi nhu
|
||||
// "ban hanh lan dau" cot PRO -> migrate sang ProInitialAmount. Chay SAU
|
||||
// AddColumn (cot phai ton tai truoc). Idempotent: chi set khi ProInitial trong.
|
||||
// Design-DB 0 rows; chay THAT lan dau tren prod co data (gotcha #64).
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE PeWorkItemBudgets
|
||||
SET ProInitialAmount = ProEstimateAmount
|
||||
WHERE ProEstimateAmount IS NOT NULL AND ProInitialAmount IS NULL;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProAdjustmentAmount",
|
||||
table: "PeWorkItemBudgets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProInitialAmount",
|
||||
table: "PeWorkItemBudgets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4546,10 +4546,18 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("ProAdjustmentAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ProEstimateAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal?>("ProInitialAmount")
|
||||
.HasPrecision(18, 2)
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProNote")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
Reference in New Issue
Block a user