[CLAUDE] PurchaseEvaluation: ngan sach goi thau theo Excel anh Kiet - bang tong hop 2 block + nhap theo role PRO/CCM + xoa module Budget cu (Mig 50)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m31s

- Mig 50 ReplaceBudgetModuleWithPeWorkItemBudgets: bang moi PeWorkItemBudgets (1 record/cap Du an x Hang muc, UNIQUE filtered [IsDeleted]=0) + drop 5 bang Budget cu + PE/Contracts drop BudgetId + backfill BudgetManualAmount->BudgetPeriodAmount TRUOC DropColumn (phieu UAT giu so) + DELETE menu/permission Bg_* IN-list children-first
- BE: PUT {id}/budget/pro (role Procurement) + {id}/budget/ccm (role CostControl, Adjustment cho phep AM) fail-closed Forbidden-truoc-side-effect + EnsureTrackedAsync race-safe (catch unique -> re-fetch winner, loi khac rethrow) + auto-create record khi tao phieu + budgetSummary DTO (luy ke trinh-truoc/chon-thau-truoc/de-xuat-ky-nay + full fallback du-tru-PRO + canEdit flags) + submit-guard (3) doi predicate BudgetPeriodAmount -> "chua nhap Ngan sach ky nay" + PATCH budget-adjust absolute-set 2 field moi + Contract GIU BudgetManual* (HD nhap tay khong doi) + ke thua HD map BudgetPeriodAmount
- FE x2 app SHA256 identical: bang "TONG HOP NGAN SACH TRINH KY" block A (full dam + ban hanh + V0 hieu chinh + du tru PRO + ghi chu, editable theo canEditPro/canEditCcm) + block B 9 dong cong thuc Excel (5=1+3, 6=2+4, 7=full-5, 8 tu nhap default 7, 9=4+8) + to mau vuot ngan sach #C00000 / am do / red-soft row8>row7 + "Chua chon" khi count=0 + banner phieu chua gan Hang muc + o "Ngan sach ky nay" o create/header + XOA pages/components/types budgets + routes + menuKeys + Layout staticMap 4-place
- Tests: +22 PeWorkItemBudgetTests (auto-create x3, ensure/race x2, authz matrix PRO x5 + CCM x3, budgetSummary aggregates x5, adjust x4) - 14 BudgetPolicyTests xoa theo module - 1 test via-BudgetId -> 263 PASS (45 Domain + 218 Infra, 0 fail)
- database-agent advise adopted: khong FK vat ly PE/Contracts->Budgets (DropColumn khong can DropForeignKey) + DropIndex truoc DropColumn (SQL 5074) + IN-list thay LIKE Bg_% (underscore wildcard + miss root) + khong Serializable wrap (nested-tx conflict codegen)
- Reviewer PASS-with-minor 0 blocker (verdict-first survived); 2 minor da sua truoc commit (comment adjustMut absolute-set + dead key budgetId); note: F4 approver-edit-budget UI entry tam drafter-only, BE van cho approver scope - cho UAT anh Kiet
- Scaffold-bug caught: EF tu sinh RenameColumn BudgetManualAmount->ExpectedRemainingAmount (SAI semantics) -> thay bang Add+UPDATE+Drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-13 01:07:27 +07:00
parent 6db195dd42
commit 79ef8da9f4
70 changed files with 9052 additions and 5956 deletions

View File

@ -1,74 +0,0 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Common;
namespace SolutionErp.Application.Budgets;
// 2-stage department approval list cho Budget (Phase 9 — Migration 16).
// Mirror PE/Contract — query để FE Workflow Panel render timeline progress.
//
// Insertion + Block logic ở TransitionBudgetCommandHandler (BudgetFeatures.cs).
public record ListBudgetDepartmentApprovalsQuery(Guid BudgetId)
: IRequest<List<BudgetDepartmentApprovalDto>>;
public record BudgetDepartmentApprovalDto(
Guid Id,
int PhaseAtApproval,
Guid DepartmentId,
string? DepartmentName,
ApprovalStage Stage,
Guid ApproverUserId,
string? ApproverName,
string? ApproverRoleSnapshot,
string? Comment,
DateTime ApprovedAt,
bool IsBypassed);
public class ListBudgetDepartmentApprovalsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListBudgetDepartmentApprovalsQuery, List<BudgetDepartmentApprovalDto>>
{
public async Task<List<BudgetDepartmentApprovalDto>> Handle(
ListBudgetDepartmentApprovalsQuery request, CancellationToken ct)
{
var rows = await (
from a in db.BudgetDepartmentApprovals.AsNoTracking()
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
from d in deptJoin.DefaultIfEmpty()
where a.BudgetId == request.BudgetId
orderby a.PhaseAtApproval, a.Stage, a.ApprovedAt
select new
{
a.Id,
a.PhaseAtApproval,
a.DepartmentId,
DepartmentName = d != null ? d.Name : null,
a.Stage,
a.ApproverUserId,
a.ApproverRoleSnapshot,
a.Comment,
a.ApprovedAt,
a.IsBypassed,
}).ToListAsync(ct);
var userIds = rows.Select(r => r.ApproverUserId).Distinct().ToList();
var users = await db.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, Name = u.FullName ?? u.Email ?? "" })
.ToDictionaryAsync(u => u.Id, u => u.Name, ct);
return rows.Select(r => new BudgetDepartmentApprovalDto(
r.Id,
r.PhaseAtApproval,
r.DepartmentId,
r.DepartmentName,
r.Stage,
r.ApproverUserId,
users.TryGetValue(r.ApproverUserId, out var n) ? n : null,
r.ApproverRoleSnapshot,
r.Comment,
r.ApprovedAt,
r.IsBypassed)).ToList();
}
}

View File

@ -1,531 +0,0 @@
using FluentValidation;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Common.Models;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Common; // ApprovalStage
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
namespace SolutionErp.Application.Budgets;
// Compact CQRS feature file cho module Ngân sách. Pattern simplified vs PE
// (no versioned WF, hardcoded BudgetPolicy.Default). Workflow 5 phase:
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet/TuChoi.
// ========== CREATE ==========
public record CreateBudgetCommand(
string TenNganSach,
string? Description,
int NamNganSach,
Guid ProjectId,
Guid? DepartmentId) : IRequest<Guid>;
public class CreateBudgetCommandValidator : AbstractValidator<CreateBudgetCommand>
{
public CreateBudgetCommandValidator()
{
RuleFor(x => x.TenNganSach).NotEmpty().MaximumLength(500);
RuleFor(x => x.Description).MaximumLength(2000);
RuleFor(x => x.ProjectId).NotEmpty();
RuleFor(x => x.NamNganSach).InclusiveBetween(2020, 2100);
}
}
public class CreateBudgetCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<CreateBudgetCommand, Guid>
{
public async Task<Guid> Handle(CreateBudgetCommand request, CancellationToken ct)
{
_ = await db.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, ct)
?? throw new NotFoundException("Project", request.ProjectId);
var sla = BudgetPolicies.Default.PhaseSla.GetValueOrDefault(BudgetPhase.DangSoanThao);
var entity = new Budget
{
TenNganSach = request.TenNganSach,
Description = request.Description,
NamNganSach = request.NamNganSach,
ProjectId = request.ProjectId,
DepartmentId = request.DepartmentId,
DrafterUserId = currentUser.UserId,
Phase = BudgetPhase.DangSoanThao,
TongNganSach = 0,
SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value),
// Auto-gen MaNganSach đơn giản — atomic sequence sau (Phase 8)
MaNganSach = $"NS-{DateTime.UtcNow:yyyyMM}-{Random.Shared.Next(1000, 9999)}",
};
db.Budgets.Add(entity);
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Header,
Action = ChangelogAction.Insert,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = $"Tạo ngân sách {entity.MaNganSach} — {entity.TenNganSach}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
// ========== UPDATE draft ==========
public record UpdateBudgetDraftCommand(
Guid Id,
string TenNganSach,
string? Description,
int NamNganSach) : IRequest;
public class UpdateBudgetDraftCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdateBudgetDraftCommand>
{
public async Task Handle(UpdateBudgetDraftCommand request, CancellationToken ct)
{
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
if (entity.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException("Chỉ sửa được ngân sách khi Đang soạn thảo.");
entity.TenNganSach = request.TenNganSach;
entity.Description = request.Description;
entity.NamNganSach = request.NamNganSach;
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Header,
Action = ChangelogAction.Update,
PhaseAtChange = entity.Phase,
UserId = currentUser.UserId,
Summary = "Cập nhật ngân sách",
});
await db.SaveChangesAsync(ct);
}
}
// ========== TRANSITION ==========
public record TransitionBudgetCommand(
Guid Id, BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment) : IRequest;
public class TransitionBudgetCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
UserManager<User> userManager,
INotificationService notifications,
IDateTime dateTime) : IRequestHandler<TransitionBudgetCommand>
{
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
{
if (!currentUser.IsAuthenticated || currentUser.UserId is null)
throw new UnauthorizedException();
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
// ===== Reject → TraLai (Session 17 spec mới) =====
// Bỏ smart-reject jump-back. Trả lại = Phase riêng (TraLai).
// Drafter từ TraLai gửi lại như Nháp — Policy `(TraLai, ChoCCM)` đã wire.
// Field RejectedFromPhase giữ DB column nhưng KHÔNG set value mới (data cũ vẫn đọc).
var fromPhase = entity.Phase;
var targetPhase = request.TargetPhase;
if (request.Decision == ApprovalDecision.Reject && targetPhase != BudgetPhase.TuChoi)
{
// Trả lại — override target → TraLai
targetPhase = BudgetPhase.TraLai;
}
var policy = BudgetPolicies.Default;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
// Policy guard
if (!isAdmin
&& !policy.IsTransitionAllowed(fromPhase, targetPhase, currentUser.Roles))
throw new ForbiddenException(
$"Role không đủ quyền chuyển {fromPhase} → {targetPhase}.");
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
// Mirror PE/Contract. Low-priority cho Budget vì ít dept duyệt budget,
// nhưng giữ consistent UX 3 module.
if (request.Decision == ApprovalDecision.Approve
&& targetPhase != BudgetPhase.DangSoanThao
&& targetPhase != BudgetPhase.TraLai
&& targetPhase != BudgetPhase.TuChoi
&& !isAdmin
&& currentUser.UserId is Guid actorUid)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
if (actor?.DepartmentId is Guid deptId)
{
var isManager = currentUser.Roles.Contains(AppRoles.DeptManager);
var canBypass = actor.CanBypassReview;
var stage = (isManager || canBypass) ? ApprovalStage.Confirm : ApprovalStage.Review;
var isBypassed = !isManager && canBypass;
var roleSnapshot = isManager ? "TPB" : (canBypass ? "NV(bypass)" : "NV");
var existing = await db.BudgetDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.BudgetId == entity.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage, ct);
if (existing is null)
{
db.BudgetDepartmentApprovals.Add(new BudgetDepartmentApproval
{
BudgetId = entity.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = request.Comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = request.Comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.BudgetDepartmentApprovals.AnyAsync(a =>
a.BudgetId == entity.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm, ct);
if (!hasConfirm)
{
// BLOCK transition. Log audit Approval + Changelog.
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = fromPhase,
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {request.Comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
string? reviewerName = (actor.FullName ?? actor.Email);
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = request.Comment,
});
// Notify TPB cùng dept. Best effort.
try
{
var managers = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId == deptId && u.Id != actorUid && u.IsActive)
.Select(u => u.Id)
.ToListAsync(ct);
foreach (var mgrId in managers)
{
var mgr = await userManager.FindByIdAsync(mgrId.ToString());
if (mgr is null) continue;
var roles = await userManager.GetRolesAsync(mgr);
if (!roles.Contains(AppRoles.DeptManager)) continue;
await notifications.NotifyAsync(
mgrId,
NotificationType.ContractPhaseTransition,
title: $"NS {entity.MaNganSach ?? entity.TenNganSach} chờ TPB confirm",
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
href: $"/budgets/{entity.Id}",
refId: entity.Id,
ct: ct);
}
}
catch { /* notification fail non-critical */ }
await db.SaveChangesAsync(ct);
return;
}
}
}
entity.SlaWarningSent = false;
entity.Phase = targetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(targetPhase);
entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value);
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = targetPhase,
ApproverUserId = currentUser.UserId,
Decision = request.Decision,
Comment = request.Comment,
ApprovedAt = DateTime.UtcNow,
});
string? actorName = null;
if (currentUser.UserId is Guid uid)
{
var u = await userManager.FindByIdAsync(uid.ToString());
actorName = u?.FullName ?? u?.Email;
}
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = entity.Id,
EntityType = BudgetEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = targetPhase,
UserId = currentUser.UserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {targetPhase}",
ContextNote = request.Comment,
});
await db.SaveChangesAsync(ct);
}
}
// ========== LIST ==========
public record ListBudgetsQuery(
BudgetPhase? Phase = null,
Guid? ProjectId = null,
int? NamNganSach = null) : PagedRequest, IRequest<PagedResult<BudgetListItemDto>>;
public class ListBudgetsQueryHandler(
IApplicationDbContext db) : IRequestHandler<ListBudgetsQuery, PagedResult<BudgetListItemDto>>
{
public async Task<PagedResult<BudgetListItemDto>> Handle(ListBudgetsQuery request, CancellationToken ct)
{
var q = from e in db.Budgets.AsNoTracking()
join p in db.Projects.AsNoTracking() on e.ProjectId equals p.Id
select new { e, p };
if (request.Phase is not null) q = q.Where(x => x.e.Phase == request.Phase);
if (request.ProjectId is not null) q = q.Where(x => x.e.ProjectId == request.ProjectId);
if (request.NamNganSach is not null) q = q.Where(x => x.e.NamNganSach == request.NamNganSach);
if (!string.IsNullOrWhiteSpace(request.Search))
{
var s = request.Search.Trim();
q = q.Where(x => (x.e.MaNganSach != null && x.e.MaNganSach.Contains(s))
|| x.e.TenNganSach.Contains(s) || x.p.Name.Contains(s));
}
q = request.SortDesc ? q.OrderByDescending(x => x.e.CreatedAt) : q.OrderBy(x => x.e.CreatedAt);
var total = await q.CountAsync(ct);
var items = await q.Skip((request.Page - 1) * request.PageSize).Take(request.PageSize)
.Select(x => new BudgetListItemDto(
x.e.Id, x.e.MaNganSach, x.e.TenNganSach, x.e.NamNganSach, x.e.Phase,
x.e.ProjectId, x.p.Name, x.e.TongNganSach, x.e.SlaDeadline, x.e.CreatedAt))
.ToListAsync(ct);
return new PagedResult<BudgetListItemDto>(items, total, request.Page, request.PageSize);
}
}
// ========== GET detail bundle ==========
public record GetBudgetQuery(Guid Id) : IRequest<BudgetDetailBundleDto>;
public class GetBudgetQueryHandler(
IApplicationDbContext db,
UserManager<User> userManager) : IRequestHandler<GetBudgetQuery, BudgetDetailBundleDto>
{
public async Task<BudgetDetailBundleDto> Handle(GetBudgetQuery request, CancellationToken ct)
{
var e = await db.Budgets.AsNoTracking()
.Include(x => x.Details)
.Include(x => x.Approvals)
.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == e.ProjectId, ct);
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking()
.FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var userIds = new HashSet<Guid>();
if (e.DrafterUserId is Guid did) userIds.Add(did);
foreach (var a in e.Approvals) if (a.ApproverUserId is Guid aid) userIds.Add(aid);
var users = await userManager.Users.AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.FullName, ct);
var policy = BudgetPolicies.Default;
return new BudgetDetailBundleDto(
e.Id, e.MaNganSach, e.TenNganSach, e.Description, e.NamNganSach, e.Phase,
e.ProjectId, project?.Name ?? "",
e.DepartmentId, department?.Name,
e.DrafterUserId, e.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
e.TongNganSach, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.Details.OrderBy(d => d.Order).Select(d => new BudgetDetailDto(
d.Id, d.GroupCode, d.GroupName, d.ItemCode, d.NoiDung, d.DonViTinh,
d.KhoiLuong, d.DonGia, d.ThanhTien, d.Order, d.GhiChu)).ToList(),
e.Approvals.OrderBy(a => a.ApprovedAt).Select(a => new BudgetApprovalDto(
a.Id, a.FromPhase, a.ToPhase, a.ApproverUserId,
a.ApproverUserId is Guid uid && users.TryGetValue(uid, out var an) ? an : null,
a.Decision, a.Comment, a.ApprovedAt)).ToList(),
new BudgetWorkflowSummaryDto(
policy.Name, policy.Description,
policy.ActivePhases.ToList(),
policy.NextPhasesFrom(e.Phase).ToList()));
}
}
// ========== DELETE ==========
public record DeleteBudgetCommand(Guid Id) : IRequest;
public class DeleteBudgetCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeleteBudgetCommand>
{
public async Task Handle(DeleteBudgetCommand request, CancellationToken ct)
{
var entity = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.Id, ct)
?? throw new NotFoundException("Budget", request.Id);
if (entity.Phase != BudgetPhase.DangSoanThao && entity.Phase != BudgetPhase.TuChoi)
throw new ConflictException("Chỉ xóa được ngân sách phase Soạn thảo / Từ chối.");
db.Budgets.Remove(entity);
await db.SaveChangesAsync(ct);
}
}
// ========== Detail CRUD ==========
public record AddBudgetDetailCommand(
Guid BudgetId, string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu) : IRequest<Guid>;
public class AddBudgetDetailCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<AddBudgetDetailCommand, Guid>
{
public async Task<Guid> Handle(AddBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(x => x.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể thêm hạng mục. Phải reject để Drafter sửa lại.");
var maxOrder = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.Select(d => (int?)d.Order).MaxAsync(ct);
var entity = new BudgetDetail
{
BudgetId = bg.Id,
GroupCode = request.GroupCode, GroupName = request.GroupName,
ItemCode = request.ItemCode, NoiDung = request.NoiDung, DonViTinh = request.DonViTinh,
KhoiLuong = request.KhoiLuong, DonGia = request.DonGia, ThanhTien = request.ThanhTien,
GhiChu = request.GhiChu, Order = (maxOrder ?? 0) + 1,
};
db.BudgetDetails.Add(entity);
// Recompute TongNganSach
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct) + entity.ThanhTien;
db.BudgetChangelogs.Add(new BudgetChangelog
{
BudgetId = bg.Id, EntityType = BudgetEntityType.Detail, EntityId = entity.Id,
Action = ChangelogAction.Insert, PhaseAtChange = bg.Phase,
UserId = currentUser.UserId,
Summary = $"Thêm hạng mục {request.GroupCode} — {request.NoiDung}",
});
await db.SaveChangesAsync(ct);
return entity.Id;
}
}
public record UpdateBudgetDetailCommand(
Guid BudgetId, Guid DetailId, string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu) : IRequest;
public class UpdateBudgetDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<UpdateBudgetDetailCommand>
{
public async Task Handle(UpdateBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể sửa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
entity.GroupCode = request.GroupCode; entity.GroupName = request.GroupName;
entity.ItemCode = request.ItemCode; entity.NoiDung = request.NoiDung; entity.DonViTinh = request.DonViTinh;
entity.KhoiLuong = request.KhoiLuong; entity.DonGia = request.DonGia; entity.ThanhTien = request.ThanhTien;
entity.GhiChu = request.GhiChu;
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}
}
public record DeleteBudgetDetailCommand(Guid BudgetId, Guid DetailId) : IRequest;
public class DeleteBudgetDetailCommandHandler(
IApplicationDbContext db) : IRequestHandler<DeleteBudgetDetailCommand>
{
public async Task Handle(DeleteBudgetDetailCommand request, CancellationToken ct)
{
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct)
?? throw new NotFoundException("Budget", request.BudgetId);
// Lock edit guard (Phase 9 — Migration 16)
if (bg.Phase != BudgetPhase.DangSoanThao)
throw new ConflictException($"Ngân sách đã trình duyệt (Phase={bg.Phase}), không thể xóa hạng mục. Phải reject để Drafter sửa lại.");
var entity = await db.BudgetDetails
.FirstOrDefaultAsync(d => d.Id == request.DetailId && d.BudgetId == request.BudgetId, ct)
?? throw new NotFoundException("BudgetDetail", request.DetailId);
db.BudgetDetails.Remove(entity);
bg.TongNganSach = await db.BudgetDetails.Where(d => d.BudgetId == bg.Id && d.Id != entity.Id)
.SumAsync(d => d.ThanhTien, ct);
await db.SaveChangesAsync(ct);
}
}
// ========== CHANGELOG list ==========
public record ListBudgetChangelogsQuery(Guid BudgetId, int Take = 200) : IRequest<List<BudgetChangelogDto>>;
public class ListBudgetChangelogsQueryHandler(IApplicationDbContext db)
: IRequestHandler<ListBudgetChangelogsQuery, List<BudgetChangelogDto>>
{
public async Task<List<BudgetChangelogDto>> Handle(ListBudgetChangelogsQuery request, CancellationToken ct) =>
await db.BudgetChangelogs.AsNoTracking()
.Where(c => c.BudgetId == request.BudgetId)
.OrderByDescending(c => c.CreatedAt)
.Take(request.Take)
.Select(c => new BudgetChangelogDto(
c.Id, c.EntityType, c.EntityId, c.Action, c.PhaseAtChange,
c.UserId, c.UserName, c.Summary, c.FieldChangesJson, c.ContextNote, c.CreatedAt))
.ToListAsync(ct);
}

View File

@ -1,90 +0,0 @@
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Budgets.Dtos;
public record BudgetListItemDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
int NamNganSach,
BudgetPhase Phase,
Guid ProjectId,
string ProjectName,
decimal TongNganSach,
DateTime? SlaDeadline,
DateTime CreatedAt);
public record BudgetDetailDto(
Guid Id,
string GroupCode,
string GroupName,
string? ItemCode,
string NoiDung,
string? DonViTinh,
decimal KhoiLuong,
decimal DonGia,
decimal ThanhTien,
int Order,
string? GhiChu);
public record BudgetApprovalDto(
Guid Id,
BudgetPhase FromPhase,
BudgetPhase ToPhase,
Guid? ApproverUserId,
string? ApproverName,
ApprovalDecision Decision,
string? Comment,
DateTime ApprovedAt);
public record BudgetChangelogDto(
Guid Id,
BudgetEntityType EntityType,
Guid? EntityId,
ChangelogAction Action,
BudgetPhase? PhaseAtChange,
Guid? UserId,
string? UserName,
string? Summary,
string? FieldChangesJson,
string? ContextNote,
DateTime CreatedAt);
public record BudgetWorkflowSummaryDto(
string PolicyName,
string PolicyDescription,
List<BudgetPhase> ActivePhases,
List<BudgetPhase> NextPhases);
// Snapshot ngân sách link cho PE / Contract DetailBundle. Compact — chỉ
// header info, không bao gồm BudgetDetails (gọi /budgets/{id} riêng nếu cần
// đối chiếu chi tiết theo từng GroupCode/ItemCode).
public record BudgetSummaryDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
int NamNganSach,
BudgetPhase Phase,
decimal TongNganSach);
public record BudgetDetailBundleDto(
Guid Id,
string? MaNganSach,
string TenNganSach,
string? Description,
int NamNganSach,
BudgetPhase Phase,
Guid ProjectId,
string ProjectName,
Guid? DepartmentId,
string? DepartmentName,
Guid? DrafterUserId,
string? DrafterName,
decimal TongNganSach,
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt,
List<BudgetDetailDto> Details,
List<BudgetApprovalDto> Approvals,
BudgetWorkflowSummaryDto Workflow);

View File

@ -1,6 +1,5 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.ApprovalWorkflowsV2;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
@ -71,6 +70,9 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationDepartmentApproval> PurchaseEvaluationDepartmentApprovals { get; }
// Mig 26 (Session 19) — Ý kiến cấp duyệt V2 dynamic theo ApprovalWorkflowLevel
DbSet<PurchaseEvaluationLevelOpinion> PurchaseEvaluationLevelOpinions { get; }
// [S61 Mig 50] Ngân sách gói thầu per cặp (ProjectId, WorkItemId) — 1 record/cặp,
// loose-Guid KHÔNG FK vật lý. Thay module Budget cũ (5 bảng đã drop).
DbSet<PeWorkItemBudget> PeWorkItemBudgets { get; }
// Quy trình duyệt MỚI (Mig 22 — Session 17): schema riêng UAT trước khi
// drop legacy WorkflowDefinition. Cấu trúc: Quy trình > Bước (Phòng) > Cấp (NV cụ thể).
@ -78,13 +80,6 @@ public interface IApplicationDbContext
DbSet<ApprovalWorkflowStep> ApprovalWorkflowSteps { get; }
DbSet<ApprovalWorkflowLevel> ApprovalWorkflowLevels { get; }
// Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; }
DbSet<BudgetDetail> BudgetDetails { get; }
DbSet<BudgetApproval> BudgetApprovals { get; }
DbSet<BudgetChangelog> BudgetChangelogs { get; }
DbSet<BudgetDepartmentApproval> BudgetDepartmentApprovals { get; }
// Phase 10.1 G-H1 (Mig 34 — S33) — Hồ sơ Nhân sự port từ NamGroup.
// 1 main + 5 satellite + 1 sequence. 1-1 với User qua UserId UNIQUE.
// 3 HĐLĐ satellite defer Plan H2 sau.

View File

@ -25,7 +25,8 @@ public record CreateContractCommand(
string? NoiDung,
bool BypassProcurementAndCCM,
string? DraftData,
Guid? BudgetId,
// [S61 Mig 50] BudgetId DROP (module Budget cũ xóa hẳn) — GIỮ BudgetManual*
// (ngân sách nhập tay HĐ không đổi).
string? BudgetManualName,
decimal? BudgetManualAmount,
// [Plan B S29 2026-05-22 Chunk E1] Drafter pick V2 workflow lúc create —
@ -85,18 +86,6 @@ public class CreateContractCommandHandler(
$"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với HĐ (cần ApplicableType=Contract).");
}
// Validate Budget link nếu có: cùng Project + Phase=DaDuyet.
if (request.BudgetId is Guid bid)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != request.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
var entity = new Contract
{
Type = request.Type,
@ -111,7 +100,6 @@ public class CreateContractCommandHandler(
NoiDung = request.NoiDung,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = request.DraftData,
BudgetId = request.BudgetId,
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
WorkflowDefinitionId = activeWfId,
@ -152,7 +140,7 @@ public record UpdateContractDraftCommand(
string? NoiDung,
Guid? TemplateId,
string? DraftData,
Guid? BudgetId,
// [S61 Mig 50] BudgetId DROP — GIỮ BudgetManual* (HĐ nhập tay không đổi).
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest;
@ -168,18 +156,6 @@ public class UpdateContractDraftCommandHandler(
if (entity.Phase != ContractPhase.DangSoanThao)
throw new ConflictException("Chỉ được sửa HĐ khi ở phase Đang soạn thảo.");
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với HĐ.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// Capture diff trước update để log
var changes = new List<object>();
if (entity.GiaTri != request.GiaTri)
@ -190,8 +166,6 @@ public class UpdateContractDraftCommandHandler(
changes.Add(new { Field = "NoiDung", Old = entity.NoiDung, New = request.NoiDung });
if (entity.TemplateId != request.TemplateId)
changes.Add(new { Field = "TemplateId", Old = entity.TemplateId, New = request.TemplateId });
if (entity.BudgetId != request.BudgetId)
changes.Add(new { Field = "BudgetId", Old = entity.BudgetId, New = request.BudgetId });
if (entity.BudgetManualName != request.BudgetManualName)
changes.Add(new { Field = "BudgetManualName", Old = entity.BudgetManualName, New = request.BudgetManualName });
if (entity.BudgetManualAmount != request.BudgetManualAmount)
@ -202,7 +176,6 @@ public class UpdateContractDraftCommandHandler(
entity.NoiDung = request.NoiDung;
entity.TemplateId = request.TemplateId;
entity.DraftData = request.DraftData;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
@ -462,16 +435,6 @@ public class GetContractQueryHandler(
var project = await db.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == c.ProjectId, ct);
var department = c.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == c.DepartmentId, ct);
// Load Budget summary nếu có link.
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
if (c.BudgetId is Guid budgetId)
{
budgetSummary = await db.Budgets.AsNoTracking()
.Where(b => b.Id == budgetId)
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach))
.FirstOrDefaultAsync(ct);
}
// Resolve workflow: pinned WorkflowDefinition > overrides > hardcoded
WorkflowPolicy workflowPolicy;
@ -557,7 +520,6 @@ public class GetContractQueryHandler(
c.DrafterUserId, c.DrafterUserId is Guid d && users.TryGetValue(d, out var dn) ? dn : null,
c.TemplateId, c.GiaTri, c.BypassProcurementAndCCM, c.SlaDeadline, c.DraftData,
c.CreatedAt, c.UpdatedAt,
c.BudgetId, budgetSummary,
c.BudgetManualName, c.BudgetManualAmount,
c.Approvals
.OrderBy(a => a.ApprovedAt)

View File

@ -1,4 +1,3 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts.Dtos;
@ -39,8 +38,8 @@ public record ContractDetailDto(
string? DraftData,
DateTime CreatedAt,
DateTime? UpdatedAt,
Guid? BudgetId,
BudgetSummaryDto? Budget,
// [S61 Mig 50] Module Budget cũ XÓA — Contract.BudgetId drop, GIỮ BudgetManual*
// (ngân sách nhập tay của HĐ không đổi, anh Kiệt chỉ redesign ngân sách PE).
string? BudgetManualName,
decimal? BudgetManualAmount,
List<ContractApprovalDto> Approvals,

View File

@ -79,9 +79,10 @@ public class CreateContractFromEvaluationCommandHandler(
NoiDung = pe.MoTa,
BypassProcurementAndCCM = request.BypassProcurementAndCCM,
DraftData = pe.PaymentTerms, // carry forward payment terms
BudgetId = pe.BudgetId, // carry forward Budget link nếu PE đã link
BudgetManualName = pe.BudgetManualName, // carry forward manual budget (Mig 17)
BudgetManualAmount = pe.BudgetManualAmount,
// [S61 Mig 50] Budget link cũ DROP — kế thừa "Ngân sách - kỳ này" của
// phiếu sang ngân sách nhập tay HĐ (tham chiếu, HĐ sửa được sau).
BudgetManualName = pe.MaPhieu is null ? null : $"NS kỳ này phiếu {pe.MaPhieu}",
BudgetManualAmount = pe.BudgetPeriodAmount,
WorkflowDefinitionId = activeWfId,
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(ContractPhase.DangSoanThao) ?? TimeSpan.FromDays(7)),
@ -150,6 +151,7 @@ public class ListApprovedPurchaseEvaluationsQueryHandler(IApplicationDbContext d
e.SelectedSupplierId, s != null ? s.Name : null,
e.ContractId, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.DrafterUserId, u != null ? u.FullName : null,
e.DepartmentId, d != null ? d.Name : null)).ToListAsync(ct);
e.DepartmentId, d != null ? d.Name : null,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount)).ToListAsync(ct);
}
}

View File

@ -1,4 +1,3 @@
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.PurchaseEvaluations;
@ -32,7 +31,11 @@ public record PurchaseEvaluationListItemDto(
Guid? DrafterUserId,
string? DrafterName,
Guid? DepartmentId,
string? DepartmentName);
string? DepartmentName,
// [S61 Mig 50] 2 cột ngân sách mới (mirror detail — FE list chưa render, giữ
// parity type PeListItem).
decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount);
public record PurchaseEvaluationSupplierDto(
Guid Id,
@ -220,10 +223,11 @@ public record PurchaseEvaluationDetailBundleDto(
DateTime? SlaDeadline,
DateTime CreatedAt,
DateTime? UpdatedAt,
Guid? BudgetId,
BudgetSummaryDto? Budget,
string? BudgetManualName,
decimal? BudgetManualAmount,
// [S61 Mig 50] Ngân sách PE mới — thay BudgetId/Budget/BudgetManual* cũ
// (module Budget XÓA HẲN, data BudgetManualAmount migrate → BudgetPeriodAmount).
decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount,
PeBudgetSummaryDto? BudgetSummary,
// 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,
@ -247,3 +251,29 @@ public record PurchaseEvaluationDetailBundleDto(
// legacy hoặc phiếu V2 chưa có cấp nào duyệt → FE fallback message.
List<PurchaseEvaluationLevelOpinionDto> LevelOpinions,
PurchaseEvaluationWorkflowSummaryDto Workflow);
// [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt — BE compute
// kèm PE detail GET. Record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp
// (ProjectId, WorkItemId). BudgetId null = phiếu cũ chưa gắn Hạng mục.
// FullAmount = (Initial??0)+(Adjustment??0); cả 2 null → ProEstimate??0 với
// FullIsEstimate=true (FE badge "dự trù PRO"). CanEditPro/CanEditCcm = capability
// flag BE-computed theo role (Procurement / CostControl | Admin — pattern S54).
// Lũy kế: các phiếu cùng cặp, Id != this, CreatedAt < this.CreatedAt:
// PreviousSubmittedTotal = SUM(BudgetPeriodAmount) WHERE Phase IN (ChoDuyet, DaDuyet)
// PreviousSelectedTotal = SUM(quote ThanhTien của SelectedSupplier) WHERE Phase = DaDuyet
// CurrentProposalTotal = SUM(quote ThanhTien của SelectedSupplier) phiếu NÀY (0 khi chưa chọn).
public record PeBudgetSummaryDto(
Guid? BudgetId,
decimal? ProEstimateAmount,
string? ProNote,
decimal? InitialAmount,
decimal? AdjustmentAmount,
decimal FullAmount,
bool FullIsEstimate,
bool CanEditPro,
bool CanEditCcm,
decimal PreviousSubmittedTotal,
int PreviousSubmittedCount,
decimal PreviousSelectedTotal,
int PreviousSelectedCount,
decimal CurrentProposalTotal);

View File

@ -0,0 +1,186 @@
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;
// [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.
// - CCM (CostControl | Admin): InitialAmount ("Ban hành lần đầu") +
// AdjustmentAmount ("V0/hiệu chỉnh tăng giảm" — cho phép ÂM).
// Authz pattern AssignItTicketHandler S54: controller [Authorize] any-auth,
// handler fine-grained ForbiddenException fail-closed (Forbidden TRƯỚC mọi
// side-effect — S56 #5). KHÔNG ràng Phase (CCM "nhập trong khi duyệt" theo lời
// anh = thời điểm nghiệp vụ, không hard-block — bảng ngân sách là tài liệu sống
// per cặp, chỉnh được bất kỳ lúc nào như file Excel; trade-off ghi nhận).
// Record PeWorkItemBudgets resolve qua PE (FE đang mở phiếu) — phiếu cũ chưa gắn
// Hạng mục → Conflict. Record chưa có → auto-create (cùng helper với Create PE).
// ===== Helper dùng chung (Create PE dùng bản pre-check riêng; PUT pro/ccm dùng đây) =====
internal static class PeWorkItemBudgetEnsurer
{
/// <summary>
/// Load record TRACKED của cặp (ProjectId, WorkItemId); chưa có → tạo mới.
/// Race-safe theo advise database-agent S61: UNIQUE filtered index = arbiter
/// cuối; thua race → DbUpdateException → detach + re-fetch record bên thắng;
/// lỗi KHÁC (không phải thua-race) → rethrow, KHÔNG nuốt.
/// </summary>
public static async Task<PeWorkItemBudget> EnsureTrackedAsync(
IApplicationDbContext db, Guid projectId, Guid workItemId, CancellationToken ct)
{
var existing = await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.ProjectId == projectId && b.WorkItemId == workItemId, ct);
if (existing is not null) return existing;
var rec = new PeWorkItemBudget { ProjectId = projectId, WorkItemId = workItemId };
db.PeWorkItemBudgets.Add(rec);
try
{
await db.SaveChangesAsync(ct);
return rec;
}
catch (DbUpdateException)
{
((DbContext)db).Entry(rec).State = EntityState.Detached;
var winner = await db.PeWorkItemBudgets
.FirstOrDefaultAsync(b => b.ProjectId == projectId && b.WorkItemId == workItemId, ct);
if (winner is null) throw; // không phải thua-race → lỗi thật phải nổi
return winner;
}
}
}
// ===== PRO — dự trù lần đầu + ghi chú =====
public record UpdatePeBudgetProCommand(
Guid PeId,
decimal? ProEstimateAmount,
string? ProNote) : IRequest;
public class UpdatePeBudgetProCommandValidator : AbstractValidator<UpdatePeBudgetProCommand>
{
public UpdatePeBudgetProCommandValidator()
{
RuleFor(x => x.ProEstimateAmount).GreaterThanOrEqualTo(0)
.When(x => x.ProEstimateAmount.HasValue);
RuleFor(x => x.ProNote).MaximumLength(1000);
}
}
public class UpdatePeBudgetProCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeBudgetProCommand>
{
public async Task Handle(UpdatePeBudgetProCommand 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 (kể cả auto-create record).
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 dự trù ngân sách gói thầu.");
}
if (pe.WorkItemId is not Guid workItemId)
throw new ConflictException(
"Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục trước khi nhập ngân sách gói thầu.");
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
var oldEstimate = rec.ProEstimateAmount;
var oldNote = rec.ProNote;
rec.ProEstimateAmount = request.ProEstimateAmount; // absolute-set (null = clear)
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 (oldNote != request.ProNote)
parts.Add("ghi chú PRO cập nhật");
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 = $"Ngân sách gói thầu (PRO): {(parts.Count == 0 ? "không đi" : string.Join(", ", parts))}",
});
await db.SaveChangesAsync(ct);
}
}
// ===== CCM — Ban hành lần đầu + V0/hiệu chỉnh (nhập thực tế trong khi duyệt) =====
public record UpdatePeBudgetCcmCommand(
Guid PeId,
decimal? InitialAmount,
decimal? AdjustmentAmount) : IRequest;
public class UpdatePeBudgetCcmCommandValidator : AbstractValidator<UpdatePeBudgetCcmCommand>
{
public UpdatePeBudgetCcmCommandValidator()
{
RuleFor(x => x.InitialAmount).GreaterThanOrEqualTo(0)
.When(x => x.InitialAmount.HasValue);
// AdjustmentAmount KHÔNG ràng dấu — "hiệu chỉnh tăng giảm" cho phép ÂM.
}
}
public class UpdatePeBudgetCcmCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePeBudgetCcmCommand>
{
public async Task Handle(UpdatePeBudgetCcmCommand 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 ngân sách ban hành/hiệu chỉnh.");
}
if (pe.WorkItemId is not Guid workItemId)
throw new ConflictException(
"Phiếu chưa gắn Hạng mục công việc — gắn Hạng mục trước khi nhập ngân sách gói thầu.");
var rec = await PeWorkItemBudgetEnsurer.EnsureTrackedAsync(db, pe.ProjectId, workItemId, ct);
var oldInitial = rec.InitialAmount;
var oldAdjustment = rec.AdjustmentAmount;
rec.InitialAmount = request.InitialAmount; // absolute-set (null = clear)
rec.AdjustmentAmount = request.AdjustmentAmount;
var parts = new List<string>();
if (oldInitial != request.InitialAmount)
parts.Add($"ban hành lần đầu {oldInitial?.ToString("N0") ?? "(trống)"}đ → {request.InitialAmount?.ToString("N0") ?? "(trống)"}đ");
if (oldAdjustment != request.AdjustmentAmount)
parts.Add($"V0/hiệu chỉnh {oldAdjustment?.ToString("N0") ?? "(trống)"}đ → {request.AdjustmentAmount?.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 = $"Ngân sách gói thầu (CCM): {(parts.Count == 0 ? "không đi" : string.Join(", ", parts))}",
});
await db.SaveChangesAsync(ct);
}
}

View File

@ -23,9 +23,7 @@ public record CreatePurchaseEvaluationCommand(
string? DiaDiem,
string? MoTa,
string? PaymentTerms,
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
decimal? BudgetPeriodAmount, // [S61 Mig 50] "Ngân sách - kỳ này" — drafter nhập, optional lúc tạo (guard lúc submit)
Guid? ApprovalWorkflowId = null, // [Mig 23] User chọn quy trình duyệt V2 lúc tạo
Guid? WorkItemId = null) : IRequest<Guid>; // [Mig 49 S57bis] Hạng mục công việc — flow create PHẢI chọn (validator NotEmpty)
@ -44,8 +42,10 @@ public class CreatePurchaseEvaluationCommandValidator : AbstractValidator<Create
.WithMessage("Phải chọn hạng mục công việc.");
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetManualName).MaximumLength(200);
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
// [S61] >0 khi có nhập — KHÔNG bắt buộc lúc tạo, submit guard mới chặn.
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
}
}
@ -93,19 +93,35 @@ public class CreatePurchaseEvaluationCommandHandler(
throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {request.Type}.");
}
// Validate Budget link (nếu có): cùng Project + Phase=DaDuyet (chỉ cho
// pick ngân sách đã duyệt mới được dùng làm reference đối chiếu).
decimal? linkedBudgetTotal = null;
if (request.BudgetId is Guid bid)
// [S61 Mig 50] AUTO-CREATE PeWorkItemBudget cho cặp (ProjectId, WorkItemId)
// nếu chưa có — 1 record/cặp dùng chung mọi phiếu cùng gói thầu. Race-safe:
// pre-check AnyAsync + UNIQUE filtered index làm defense cuối; 2 phiếu cùng
// cặp tạo song song → catch DbUpdateException, detach record local + dùng
// record bên kia thắng race. SaveChanges RIÊNG trước khi add PE — insert
// record fail không lan sang phiếu.
if (request.WorkItemId is Guid wiBudget)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != request.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
linkedBudgetTotal = bg.TongNganSach;
var pairExists = await db.PeWorkItemBudgets
.AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
if (!pairExists)
{
var pairRec = new PeWorkItemBudget { ProjectId = request.ProjectId, WorkItemId = wiBudget };
db.PeWorkItemBudgets.Add(pairRec);
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
((DbContext)db).Entry(pairRec).State = EntityState.Detached;
// [S61 advise database-agent] CHỈ nuốt khi đúng thua-race (record
// đã tồn tại từ request song song — UNIQUE filtered index arbiter).
// Lỗi khác (connection/constraint khác) phải nổi lên.
var nowExists = await db.PeWorkItemBudgets
.AnyAsync(b => b.ProjectId == request.ProjectId && b.WorkItemId == wiBudget && !b.IsDeleted, ct);
if (!nowExists) throw;
}
}
}
var entity = new PurchaseEvaluation
@ -122,9 +138,7 @@ public class CreatePurchaseEvaluationCommandHandler(
WorkflowDefinitionId = activeWfId,
ApprovalWorkflowId = request.ApprovalWorkflowId, // Mig 23 — schema mới V2
PaymentTerms = request.PaymentTerms,
BudgetId = request.BudgetId,
BudgetManualName = request.BudgetManualName,
BudgetManualAmount = request.BudgetManualAmount,
BudgetPeriodAmount = request.BudgetPeriodAmount, // S61 — "Ngân sách - kỳ này"
SlaDeadline = DateTime.UtcNow.Add(
workflow.GetPhaseSla(PurchaseEvaluationPhase.DangSoanThao) ?? TimeSpan.FromDays(3)),
};
@ -149,7 +163,8 @@ public class CreatePurchaseEvaluationCommandHandler(
// Auto-seed 1 Hạng mục mặc định lấy tên + giá trị từ gói thầu / ngân sách
// — user yêu cầu Session 20: hạng mục là đơn vị làm việc chính, NCC expand
// dưới hạng mục → cần có sẵn 1 row khi vào Detail. Có thể edit lại sau.
var defaultBudgetValue = linkedBudgetTotal ?? request.BudgetManualAmount ?? 0m;
// [S61] nguồn giá trị = "Ngân sách - kỳ này" drafter nhập (Budget link cũ đã bỏ).
var defaultBudgetValue = request.BudgetPeriodAmount ?? 0m;
var defaultDetail = new PurchaseEvaluationDetail
{
PurchaseEvaluationId = entity.Id,
@ -190,12 +205,24 @@ public record UpdatePurchaseEvaluationDraftCommand(
string? DiaDiem,
string? MoTa,
string? PaymentTerms,
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount,
decimal? BudgetPeriodAmount = null, // [S61] null-safe: null = GIỮ giá trị cũ (chống null-hóa bug-class S42)
decimal? ExpectedRemainingAmount = null, // [S61] null-safe: null = GIỮ giá trị cũ
Guid? ApprovalWorkflowId = null, // [Mig 23] cho User đổi quy trình khi sửa Nháp
Guid? WorkItemId = null) : IRequest; // [Mig 49 S57bis] cho User đổi hạng mục công việc khi sửa Nháp/Trả lại
public class UpdatePurchaseEvaluationDraftCommandValidator : AbstractValidator<UpdatePurchaseEvaluationDraftCommand>
{
public UpdatePurchaseEvaluationDraftCommandValidator()
{
RuleFor(x => x.TenGoiThau).NotEmpty().MaximumLength(500);
RuleFor(x => x.DiaDiem).MaximumLength(500);
RuleFor(x => x.MoTa).MaximumLength(2000);
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0)
.When(x => x.BudgetPeriodAmount.HasValue)
.WithMessage("Ngân sách kỳ này phải lớn hơn 0.");
}
}
public class UpdatePurchaseEvaluationDraftCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser) : IRequestHandler<UpdatePurchaseEvaluationDraftCommand>
@ -225,18 +252,6 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
throw new ConflictException($"Quy trình {aw.Code} áp dụng cho {aw.ApplicableType}, không khớp với loại phiếu {entity.Type}.");
}
// Validate Budget link nếu thay đổi.
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// [Mig 49 S57bis] FK-invariant guard hạng mục nếu đổi (mirror S43).
if (request.WorkItemId is Guid wiId && wiId != entity.WorkItemId)
{
@ -250,10 +265,13 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
entity.DiaDiem = request.DiaDiem;
entity.MoTa = request.MoTa;
entity.PaymentTerms = request.PaymentTerms;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
entity.ApprovalWorkflowId = request.ApprovalWorkflowId; // Mig 23 — User đổi quy trình
// [S61] null-safe 2 field ngân sách mới (mirror WorkItemId bên dưới):
// client không gửi → GIỮ giá trị cũ, KHÔNG null-hóa (bug-class S42).
if (request.BudgetPeriodAmount is not null)
entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
if (request.ExpectedRemainingAmount is not null)
entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;
// Mig 49 S57bis — null-safe: CHỈ đổi hạng mục khi client gửi giá trị.
// Client cũ / PeDetailTabs inline-edit không gửi field này → GIỮ nguyên
// (tránh null-hóa mất hạng mục vừa chọn lúc create — bug-class S42 picker).
@ -286,18 +304,22 @@ public class UpdatePurchaseEvaluationDraftCommandHandler(
// DangSoanThao/TraLai phase + cập nhật cả Section 1 (TenGoiThau, DiaDiem,
// MoTa, PaymentTerms) — Approver KHÔNG nên được edit các field đó khi đang duyệt.
// AdjustBudget chỉ adjust Budget* — narrow scope hợp lý.
// [S61 Mig 50] Body đổi sang 2 field mới (BudgetId/BudgetManual* DROP cùng module
// Budget cũ). ABSOLUTE-SET cả 2 (FE bảng "Tổng hợp ngân sách" gửi cả 2 giá trị
// state mỗi lần Lưu — null = clear; ExpectedRemaining null → FE hiển thị default
// = NS còn lại row 7). Actor guard GIỮ NGUYÊN 3-scope: Drafter (Nháp/Trả lại) ·
// Approver đúng cấp khi ChoDuyet + AllowApproverEditBudget · Admin mọi lúc.
public record AdjustPurchaseEvaluationBudgetCommand(
Guid Id,
Guid? BudgetId,
string? BudgetManualName,
decimal? BudgetManualAmount) : IRequest;
decimal? BudgetPeriodAmount,
decimal? ExpectedRemainingAmount) : IRequest;
public class AdjustPurchaseEvaluationBudgetCommandValidator : AbstractValidator<AdjustPurchaseEvaluationBudgetCommand>
{
public AdjustPurchaseEvaluationBudgetCommandValidator()
{
RuleFor(x => x.BudgetManualName).MaximumLength(200);
RuleFor(x => x.BudgetManualAmount).GreaterThanOrEqualTo(0).When(x => x.BudgetManualAmount.HasValue);
RuleFor(x => x.BudgetPeriodAmount).GreaterThan(0).When(x => x.BudgetPeriodAmount.HasValue);
RuleFor(x => x.ExpectedRemainingAmount).GreaterThanOrEqualTo(0).When(x => x.ExpectedRemainingAmount.HasValue);
}
}
@ -371,44 +393,26 @@ public class AdjustPurchaseEvaluationBudgetCommandHandler(
actorTag = "[Admin]";
}
// Validate Budget link nếu thay đổi
if (request.BudgetId is Guid bid && bid != entity.BudgetId)
{
var bg = await db.Budgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.Id == bid, ct)
?? throw new NotFoundException("Budget", bid);
if (bg.ProjectId != entity.ProjectId)
throw new ConflictException("Ngân sách phải cùng dự án với phiếu.");
if (bg.Phase != Domain.Budgets.BudgetPhase.DaDuyet)
throw new ConflictException("Chỉ link được ngân sách đã duyệt.");
}
// Capture old + apply (absolute-set cả 2 — xem comment record)
var oldPeriod = entity.BudgetPeriodAmount;
var oldExpected = entity.ExpectedRemainingAmount;
// Capture old + apply
var oldBudgetId = entity.BudgetId;
var oldBudgetManualName = entity.BudgetManualName;
var oldBudgetManualAmount = entity.BudgetManualAmount;
entity.BudgetId = request.BudgetId;
entity.BudgetManualName = request.BudgetManualName;
entity.BudgetManualAmount = request.BudgetManualAmount;
entity.BudgetPeriodAmount = request.BudgetPeriodAmount;
entity.ExpectedRemainingAmount = request.ExpectedRemainingAmount;
// Audit changelog with diff (Vietnamese friendly format)
var parts = new List<string>();
if (oldBudgetId != request.BudgetId)
if (oldPeriod != request.BudgetPeriodAmount)
{
var oldDesc = oldBudgetId is null ? "(chưa link)" : "Budget#" + oldBudgetId.Value.ToString()[..8];
var newDesc = request.BudgetId is null ? "(huỷ link)" : "Budget#" + request.BudgetId.Value.ToString()[..8];
parts.Add($"link {oldDesc} → {newDesc}");
var oldAmt = oldPeriod?.ToString("N0") ?? "(trống)";
var newAmt = request.BudgetPeriodAmount?.ToString("N0") ?? "(trống)";
parts.Add($"NS kỳ này {oldAmt}đ → {newAmt}đ");
}
if (oldBudgetManualName != request.BudgetManualName)
if (oldExpected != request.ExpectedRemainingAmount)
{
parts.Add($"tên \"{oldBudgetManualName ?? "(trng)"}\" → \"{request.BudgetManualName ?? "(trống)"}\"");
}
if (oldBudgetManualAmount != request.BudgetManualAmount)
{
var oldAmt = oldBudgetManualAmount?.ToString("N0") ?? "(trống)";
var newAmt = request.BudgetManualAmount?.ToString("N0") ?? "(trống)";
parts.Add($"số tiền {oldAmt}đ → {newAmt}đ");
var oldAmt = oldExpected?.ToString("N0") ?? "(mặc định = NS còn lại)";
var newAmt = request.ExpectedRemainingAmount?.ToString("N0") ?? "(mặc định = NS còn lại)";
parts.Add($"dự kiến còn lại {oldAmt}đ → {newAmt}đ");
}
var diffSummary = parts.Count == 0 ? "không đổi" : string.Join(", ", parts);
@ -571,7 +575,8 @@ public class ListPurchaseEvaluationsQueryHandler(
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null))
x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount))
.ToListAsync(ct);
return new PagedResult<PurchaseEvaluationListItemDto>(items, total, request.Page, request.PageSize);
@ -660,7 +665,8 @@ public class GetMyPurchaseEvaluationInboxQueryHandler(
x.e.SelectedSupplierId, x.s != null ? x.s.Name : null,
x.e.ContractId, x.e.SlaDeadline, x.e.CreatedAt, x.e.UpdatedAt,
x.e.DrafterUserId, x.u != null ? x.u.FullName : null,
x.e.DepartmentId, x.d != null ? x.d.Name : null))
x.e.DepartmentId, x.d != null ? x.d.Name : null,
x.e.BudgetPeriodAmount, x.e.ExpectedRemainingAmount))
.Take(100)
.ToListAsync(ct);
}
@ -753,15 +759,73 @@ public class GetPurchaseEvaluationQueryHandler(
var department = e.DepartmentId is null ? null : await db.Departments.AsNoTracking().FirstOrDefaultAsync(d => d.Id == e.DepartmentId, ct);
var selectedSupplier = e.SelectedSupplierId is null ? null : await db.Suppliers.AsNoTracking().FirstOrDefaultAsync(s => s.Id == e.SelectedSupplierId, ct);
// Load Budget summary nếu có link
Budgets.Dtos.BudgetSummaryDto? budgetSummary = null;
if (e.BudgetId is Guid budgetId)
// [S61 Mig 50] Bảng "TỔNG HỢP NGÂN SÁCH TRÌNH KÝ" theo Excel anh Kiệt —
// record PeWorkItemBudgets dùng chung mọi phiếu cùng cặp (ProjectId,
// WorkItemId) + lũy kế các phiếu TRƯỚC (CreatedAt < this). Phiếu cũ chưa
// gắn Hạng mục (WorkItemId null) → null → FE banner nhắc gắn hạng mục.
PeBudgetSummaryDto? peBudgetSummary = null;
if (e.WorkItemId is Guid wiKey)
{
budgetSummary = await db.Budgets.AsNoTracking()
.Where(b => b.Id == budgetId)
.Select(b => new Budgets.Dtos.BudgetSummaryDto(
b.Id, b.MaNganSach, b.TenNganSach, b.NamNganSach, b.Phase, b.TongNganSach))
.FirstOrDefaultAsync(ct);
var canEditPro = isAdmin || currentUser.Roles.Contains(AppRoles.Procurement);
var canEditCcm = isAdmin || currentUser.Roles.Contains(AppRoles.CostControl);
var pairRec = await db.PeWorkItemBudgets.AsNoTracking()
.FirstOrDefaultAsync(b => b.ProjectId == e.ProjectId && b.WorkItemId == wiKey, ct);
// Lũy kế phiếu TRƯỚC cùng cặp. Row 1 = đã trình (ChoDuyet + DaDuyet);
// Row 2 = đã chọn thầu (DaDuyet + có đơn vị thắng). TraLai/DangSoanThao
// KHÔNG tính (quay về soạn = chưa trình).
var peers = db.PurchaseEvaluations.AsNoTracking()
.Where(p => p.ProjectId == e.ProjectId && p.WorkItemId == wiKey
&& p.Id != e.Id && p.CreatedAt < e.CreatedAt);
var submitted = await peers
.Where(p => p.Phase == PurchaseEvaluationPhase.ChoDuyet
|| p.Phase == PurchaseEvaluationPhase.DaDuyet)
.Select(p => p.BudgetPeriodAmount)
.ToListAsync(ct);
var prevSubmittedCount = submitted.Count;
var prevSubmittedTotal = submitted.Sum(v => v ?? 0m);
var selectedPeers = peers.Where(p => p.Phase == PurchaseEvaluationPhase.DaDuyet
&& p.SelectedSupplierId != null);
var prevSelectedCount = await selectedPeers.CountAsync(ct);
var prevSelectedTotal = await (
from p in selectedPeers
join s in db.PurchaseEvaluationSuppliers.AsNoTracking()
on p.Id equals s.PurchaseEvaluationId
where s.SupplierId == p.SelectedSupplierId
join q in db.PurchaseEvaluationQuotes.AsNoTracking()
on s.Id equals q.PurchaseEvaluationSupplierId
select (decimal?)q.ThanhTien).SumAsync(ct) ?? 0m;
// Row 4 "Giá trị kỳ này" = tổng giá chào của đơn vị ĐƯỢC CHỌN phiếu này
// (mirror predicate submit-guard WorkflowService — winner quote total).
var currentProposalTotal = 0m;
if (e.SelectedSupplierId is Guid winId)
{
var winnerRowIds = e.Suppliers.Where(s => s.SupplierId == winId)
.Select(s => s.Id).ToList();
currentProposalTotal = await db.PurchaseEvaluationQuotes.AsNoTracking()
.Where(q => winnerRowIds.Contains(q.PurchaseEvaluationSupplierId))
.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").
var hasCcm = pairRec?.InitialAmount is not null || pairRec?.AdjustmentAmount is not null;
var fullAmount = hasCcm
? (pairRec!.InitialAmount ?? 0m) + (pairRec.AdjustmentAmount ?? 0m)
: (pairRec?.ProEstimateAmount ?? 0m);
peBudgetSummary = new PeBudgetSummaryDto(
pairRec?.Id, pairRec?.ProEstimateAmount, pairRec?.ProNote,
pairRec?.InitialAmount, pairRec?.AdjustmentAmount,
fullAmount, !hasCcm,
canEditPro, canEditCcm,
prevSubmittedTotal, prevSubmittedCount,
prevSelectedTotal, prevSelectedCount,
currentProposalTotal);
}
// Load supplier names for PE suppliers + approver names
@ -967,8 +1031,7 @@ public class GetPurchaseEvaluationQueryHandler(
e.SelectedSupplierId, selectedSupplier?.Name,
e.ContractId,
e.PaymentTerms, e.SlaDeadline, e.CreatedAt, e.UpdatedAt,
e.BudgetId, budgetSummary,
e.BudgetManualName, e.BudgetManualAmount,
e.BudgetPeriodAmount, e.ExpectedRemainingAmount, peBudgetSummary,
e.ApprovalWorkflowId, awCode, awName, awVersion, currentLevelOptions,
currentApproval, approvalFlow,
e.Suppliers