[CLAUDE] Domain+App+Api: Module Ngan sach (Budget) - 4 bang + workflow simple
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m11s

User request: 'Them cho tao 4 bang luu ve ngan sach: Header / Chi tiet
/ Quy trinh duyet / Lich su thay doi'.

Domain (5 file + 1 enum):
 - Budget (header) — Aggregate root, AuditableEntity. Field: MaNganSach,
   TenNganSach, Description, NamNganSach, ProjectId FK, DepartmentId?,
   DrafterUserId, Phase (BudgetPhase 5-state), TongNganSach (sum auto
   tu Details), SlaDeadline, SlaWarningSent.
 - BudgetDetail — flat row pattern (GroupCode/GroupName + Item +
   KhoiLuong/DonGia/ThanhTien). 18,4 precision KhoiLuong, 18,2 money.
 - BudgetApproval — workflow history (FromPhase/ToPhase/Decision/Comment)
 - BudgetChangelog — audit log unified (EntityType: Header/Detail/Workflow)
 - BudgetPhase enum 5 state: DangSoanThao(1) → ChoCCM(2) → ChoCEO(3) →
   DaDuyet(4) | TuChoi(99)
 - BudgetPolicy hardcoded (no versioned WF, simple default per user
   confirm 'tam thoi don gian'): Drafter/DeptManager → CCM → CEO/
   AuthorizedSigner. Reject path back to DangSoanThao.

Migration 14 AddBudgets:
 - 4 bang moi: Budgets + BudgetDetails + BudgetApprovals + BudgetChangelogs
 - Index: Phase+IsDeleted, ProjectId, NamNganSach, SlaDeadline,
   MaNganSach unique filtered. Cascade delete child.
 - +2 cot FK ngoai bang (per user 'lien ket ca 3'):
   * Contracts.BudgetId Guid? + index
   * PurchaseEvaluations.BudgetId Guid? + index
   Cho phep doi chieu chi phi HD/PE vs ngan sach goi thau.

Application CQRS (BudgetFeatures.cs ~340 line):
 - CreateBudget + UpdateBudgetDraft + TransitionBudget + ListBudgets
   (filter Phase/Project/Year + search + paging) + GetBudget bundle
   (Header + Details + Approvals + Workflow summary)
 - DeleteBudget (only DangSoanThao/TuChoi)
 - AddBudgetDetail + UpdateBudgetDetail + DeleteBudgetDetail (auto
   recompute TongNganSach = sum Details.ThanhTien)
 - ListBudgetChangelogs

Api: BudgetsController 11 endpoint REST /api/budgets:
 - GET /  /{id}  /{id}/changelogs
 - POST /  /{id}/transitions  /{id}/details
 - PUT /{id}  /{id}/details/{detailId}
 - DELETE /{id}  /{id}/details/{detailId}

DbContext + IApplicationDbContext: 4 DbSet new (Budgets/Details/
Approvals/Changelogs).

MenuKeys + DbInitializer: 4 menu key (Budgets root + Bg_List/Create/
Pending leaves) seed dau order=27 'Ngan sach' icon Wallet. Auto-grant
admin permission via SeedAdminPermissionsAsync (MenuKeys.All).

MaNganSach format don gian 'NS-YYYYMM-NNNN' Random.Shared (chua atomic
sequence - user said 'tam thoi chua co').

Workflow chua versioned, hardcode BudgetPolicy.Default. Tuong lai admin
config qua UI: them BudgetWorkflowDefinition tables tuong tu PE.
This commit is contained in:
pqhuy1987
2026-04-28 11:37:45 +07:00
parent 8097892d20
commit a05c57b081
21 changed files with 4632 additions and 0 deletions

View File

@ -0,0 +1,388 @@
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.Domain.Budgets;
using SolutionErp.Domain.Contracts; // ApprovalDecision + ChangelogAction
using SolutionErp.Domain.Identity;
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) : 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);
var policy = BudgetPolicies.Default;
var isAdmin = currentUser.Roles.Contains(AppRoles.Admin);
if (!isAdmin && !policy.IsTransitionAllowed(entity.Phase, request.TargetPhase, currentUser.Roles))
throw new ForbiddenException(
$"Role không đủ quyền chuyển {entity.Phase} → {request.TargetPhase}.");
var fromPhase = entity.Phase;
entity.SlaWarningSent = false;
entity.Phase = request.TargetPhase;
var sla = policy.PhaseSla.GetValueOrDefault(request.TargetPhase);
entity.SlaDeadline = sla is null ? null : DateTime.UtcNow.Add(sla.Value);
db.BudgetApprovals.Add(new BudgetApproval
{
BudgetId = entity.Id,
FromPhase = fromPhase,
ToPhase = request.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 = request.TargetPhase,
UserId = currentUser.UserId,
UserName = actorName ?? "Hệ thống",
Summary = $"Chuyển phase {fromPhase} → {request.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);
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 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;
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct);
if (bg != null)
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 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);
var bg = await db.Budgets.FirstOrDefaultAsync(b => b.Id == request.BudgetId, ct);
if (bg != null)
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

@ -0,0 +1,79 @@
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);
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,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
@ -58,5 +59,11 @@ public interface IApplicationDbContext
DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers { get; }
DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences { get; }
// Module Ngân sách (Phase 7)
DbSet<Budget> Budgets { get; }
DbSet<BudgetDetail> BudgetDetails { get; }
DbSet<BudgetApproval> BudgetApprovals { get; }
DbSet<BudgetChangelog> BudgetChangelogs { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}