[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
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:
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 ?? "(trống)"}\" → \"{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
|
||||
|
||||
Reference in New Issue
Block a user