[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,94 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SolutionErp.Application.Budgets;
using SolutionErp.Application.Budgets.Dtos;
using SolutionErp.Application.Common.Models;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Api.Controllers;
[ApiController]
[Route("api/budgets")]
[Authorize]
public class BudgetsController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PagedResult<BudgetListItemDto>>> List(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] bool sortDesc = true,
[FromQuery] BudgetPhase? phase = null,
[FromQuery] Guid? projectId = null,
[FromQuery] int? namNganSach = null,
CancellationToken ct = default)
=> Ok(await mediator.Send(new ListBudgetsQuery(phase, projectId, namNganSach)
{ Page = page, PageSize = pageSize, Search = search, SortDesc = sortDesc }, ct));
[HttpGet("{id:guid}")]
public async Task<ActionResult<BudgetDetailBundleDto>> Get(Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new GetBudgetQuery(id), ct));
[HttpPost]
public async Task<ActionResult<object>> Create([FromBody] CreateBudgetCommand cmd, CancellationToken ct)
{
var id = await mediator.Send(cmd, ct);
return CreatedAtAction(nameof(Get), new { id }, new { id });
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateBudgetDraftCommand cmd, CancellationToken ct)
{
if (id != cmd.Id) return BadRequest(new { detail = "ID không khớp" });
await mediator.Send(cmd, ct);
return NoContent();
}
[HttpPost("{id:guid}/transitions")]
public async Task<IActionResult> Transition(Guid id, [FromBody] TransitionBudgetBody body, CancellationToken ct)
{
await mediator.Send(new TransitionBudgetCommand(id, body.TargetPhase, body.Decision, body.Comment), ct);
return NoContent();
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteBudgetCommand(id), ct);
return NoContent();
}
[HttpPost("{id:guid}/details")]
public async Task<ActionResult<object>> AddDetail(Guid id, [FromBody] BudgetDetailBody body, CancellationToken ct)
{
var newId = await mediator.Send(new AddBudgetDetailCommand(
id, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuong, body.DonGia, body.ThanhTien, body.GhiChu), ct);
return Ok(new { id = newId });
}
[HttpPut("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> UpdateDetail(Guid id, Guid detailId, [FromBody] BudgetDetailBody body, CancellationToken ct)
{
await mediator.Send(new UpdateBudgetDetailCommand(
id, detailId, body.GroupCode, body.GroupName, body.ItemCode, body.NoiDung, body.DonViTinh,
body.KhoiLuong, body.DonGia, body.ThanhTien, body.GhiChu), ct);
return NoContent();
}
[HttpDelete("{id:guid}/details/{detailId:guid}")]
public async Task<IActionResult> DeleteDetail(Guid id, Guid detailId, CancellationToken ct)
{
await mediator.Send(new DeleteBudgetDetailCommand(id, detailId), ct);
return NoContent();
}
[HttpGet("{id:guid}/changelogs")]
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
}
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);
public record BudgetDetailBody(
string GroupCode, string GroupName, string? ItemCode, string NoiDung,
string? DonViTinh, decimal KhoiLuong, decimal DonGia, decimal ThanhTien, string? GhiChu);

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);
}

View File

@ -0,0 +1,29 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// Aggregate root quản lý ngân sách. Gắn với Project (required), reference
// từ PurchaseEvaluation + Contract (cả 2 nullable FK Budget.Id).
//
// Workflow đơn giản 3-step: Drafter → CCM → CEO. Pattern hardcoded trong
// BudgetPolicy (chưa versioned, tương lai có thể thêm BudgetWorkflowDefinition
// nếu cần admin config).
public class Budget : AuditableEntity
{
public string? MaNganSach { get; set; } // Auto-gen NS-YYYYMM-XXXX
public string TenNganSach { get; set; } = string.Empty;
public string? Description { get; set; }
public int NamNganSach { get; set; } // Năm áp dụng (vd 2026)
public Guid ProjectId { get; set; } // FK Projects (required)
public Guid? DepartmentId { get; set; }
public Guid? DrafterUserId { get; set; }
public BudgetPhase Phase { get; set; } = BudgetPhase.DangSoanThao;
public decimal TongNganSach { get; set; } // Tổng = sum BudgetDetails.ThanhTien (computed)
public DateTime? SlaDeadline { get; set; }
public bool SlaWarningSent { get; set; }
public List<BudgetDetail> Details { get; set; } = new();
public List<BudgetApproval> Approvals { get; set; } = new();
public List<BudgetChangelog> Changelogs { get; set; } = new();
}

View File

@ -0,0 +1,17 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ApprovalDecision
namespace SolutionErp.Domain.Budgets;
public class BudgetApproval : BaseEntity
{
public Guid BudgetId { get; set; }
public BudgetPhase FromPhase { get; set; }
public BudgetPhase ToPhase { get; set; }
public Guid? ApproverUserId { get; set; } // null = system SLA auto
public ApprovalDecision Decision { get; set; }
public string? Comment { get; set; }
public DateTime ApprovedAt { get; set; }
public Budget? Budget { get; set; }
}

View File

@ -0,0 +1,28 @@
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts; // reuse ChangelogAction
namespace SolutionErp.Domain.Budgets;
// Audit log unified cho mọi thay đổi trên ngân sách.
public class BudgetChangelog : BaseEntity
{
public Guid BudgetId { get; set; }
public Budget? Budget { get; set; }
public BudgetEntityType EntityType { get; set; }
public Guid? EntityId { get; set; }
public ChangelogAction Action { get; set; }
public BudgetPhase? PhaseAtChange { get; set; }
public Guid? UserId { get; set; }
public string? UserName { get; set; }
public string? Summary { get; set; }
public string? FieldChangesJson { get; set; }
public string? ContextNote { get; set; }
}
public enum BudgetEntityType
{
Header = 1,
Detail = 2,
Workflow = 3,
}

View File

@ -0,0 +1,22 @@
using SolutionErp.Domain.Common;
namespace SolutionErp.Domain.Budgets;
// Chi tiết ngân sách — pattern flat row giống PurchaseEvaluationDetail.
// Group A.I/A.II/... cho hạng mục cha, NoiDung cho item con.
public class BudgetDetail : BaseEntity
{
public Guid BudgetId { get; set; }
public string GroupCode { get; set; } = string.Empty; // "A.I", "A.II", ...
public string GroupName { get; set; } = string.Empty; // "Bê tông", "Phụ gia", ...
public string? ItemCode { get; set; }
public string NoiDung { get; set; } = string.Empty;
public string? DonViTinh { get; set; }
public decimal KhoiLuong { get; set; }
public decimal DonGia { get; set; }
public decimal ThanhTien { get; set; } // = KhoiLuong × DonGia (hoặc nhập tay)
public int Order { get; set; }
public string? GhiChu { get; set; }
public Budget? Budget { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace SolutionErp.Domain.Budgets;
// State machine ngân sách — đơn giản 3 bước duyệt + 2 terminal.
// DangSoanThao → ChoCCM → ChoCEO → DaDuyet
// Bất kỳ phase duyệt → DangSoanThao (reject)
// DangSoanThao → TuChoi
public enum BudgetPhase
{
DangSoanThao = 1,
ChoCCM = 2,
ChoCEO = 3,
DaDuyet = 4,
TuChoi = 99,
}

View File

@ -0,0 +1,63 @@
using SolutionErp.Domain.Identity;
namespace SolutionErp.Domain.Budgets;
// Policy hardcoded đơn giản — chưa versioned (theo user "tạm thời simple
// default"). Tương lai nếu admin cần config qua UI: thêm BudgetWorkflow
// Definition tables tương tự PE workflow.
public sealed record BudgetPolicy(
string Name,
string Description,
IReadOnlyDictionary<(BudgetPhase From, BudgetPhase To), string[]> Transitions,
IReadOnlyDictionary<BudgetPhase, TimeSpan?> PhaseSla,
IReadOnlyList<BudgetPhase> ActivePhases)
{
public bool HasPhase(BudgetPhase phase) => ActivePhases.Contains(phase);
public bool IsTransitionAllowed(
BudgetPhase from, BudgetPhase to,
IReadOnlyList<string> actorRoles)
{
if (!Transitions.TryGetValue((from, to), out var roles)) return false;
return actorRoles.Any(r => roles.Contains(r));
}
public IReadOnlyList<BudgetPhase> NextPhasesFrom(BudgetPhase from) =>
Transitions.Keys.Where(k => k.From == from).Select(k => k.To).Distinct().ToList();
}
public static class BudgetPolicies
{
private static readonly Dictionary<BudgetPhase, TimeSpan?> DefaultSla = new()
{
[BudgetPhase.DangSoanThao] = TimeSpan.FromDays(5),
[BudgetPhase.ChoCCM] = TimeSpan.FromDays(3),
[BudgetPhase.ChoCEO] = TimeSpan.FromDays(2),
[BudgetPhase.DaDuyet] = null,
[BudgetPhase.TuChoi] = null,
};
public static readonly BudgetPolicy Default = new(
Name: "Default",
Description: "Quy trình ngân sách 3-step (Drafter → CCM → CEO).",
Transitions: new Dictionary<(BudgetPhase, BudgetPhase), string[]>
{
[(BudgetPhase.DangSoanThao, BudgetPhase.ChoCCM)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.DangSoanThao, BudgetPhase.TuChoi)] = [AppRoles.Drafter, AppRoles.DeptManager],
[(BudgetPhase.ChoCCM, BudgetPhase.ChoCEO)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCCM, BudgetPhase.DangSoanThao)] = [AppRoles.CostControl],
[(BudgetPhase.ChoCEO, BudgetPhase.DaDuyet)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
[(BudgetPhase.ChoCEO, BudgetPhase.DangSoanThao)] = [AppRoles.Director, AppRoles.AuthorizedSigner],
},
PhaseSla: DefaultSla,
ActivePhases:
[
BudgetPhase.DangSoanThao,
BudgetPhase.ChoCCM,
BudgetPhase.ChoCEO,
BudgetPhase.DaDuyet,
BudgetPhase.TuChoi,
]);
}

View File

@ -23,6 +23,7 @@ public class Contract : AuditableEntity
public DateTime? SlaDeadline { get; set; } // Hết hạn phase hiện tại
public string? DraftData { get; set; } // JSON field values (render template)
public bool SlaWarningSent { get; set; } // Flag để không gửi warning 2 lần
public Guid? BudgetId { get; set; } // Reference Budget (Phase 7) — đối chiếu chi phí HĐ vs ngân sách
public List<ContractApproval> Approvals { get; set; } = new();
public List<ContractComment> Comments { get; set; } = new();

View File

@ -51,6 +51,15 @@ public static class MenuKeys
public const string PurchaseEvaluations = "PurchaseEvaluations"; // root group
public const string PeWorkflows = "PeWorkflows"; // workflow admin root
// ============================================================
// Module Ngân sách (Phase 7) — 4 bảng quản lý ngân sách dự án/gói thầu.
// 1 root + 3 leaf action (Danh sách / Thao tác / Duyệt).
// ============================================================
public const string Budgets = "Budgets";
public const string BudgetList = "Bg_List";
public const string BudgetCreate = "Bg_Create";
public const string BudgetPending = "Bg_Pending";
public static readonly string[] PurchaseEvaluationTypeCodes =
["DuyetNcc", "DuyetNccPhuongAn"];
@ -70,6 +79,7 @@ public static class MenuKeys
Catalogs, CatalogUnits, CatalogMaterials, CatalogServices, CatalogWorkItems,
Contracts, Forms, Reports,
PurchaseEvaluations,
Budgets, BudgetList, BudgetCreate, BudgetPending,
System, Users, Roles, Permissions, Workflows, PeWorkflows,
];

View File

@ -26,6 +26,7 @@ public class PurchaseEvaluation : AuditableEntity
public string? PaymentTerms { get; set; } // JSON {tamUng, thanhToanTam, quyetToan, baoHanh, hanMucCongNo, danhGia}
public Guid? ContractId { get; set; } // FK Contracts — set khi user gen HĐ từ phiếu
public Guid? BudgetId { get; set; } // FK Budget (Phase 7) — đối chiếu báo giá vs ngân sách gói thầu
public List<PurchaseEvaluationSupplier> Suppliers { get; set; } = new();
public List<PurchaseEvaluationDetail> Details { get; set; } = new();

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Domain.Budgets;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Contracts.Details;
using SolutionErp.Domain.Forms;
@ -59,6 +60,12 @@ public class ApplicationDbContext
public DbSet<PurchaseEvaluationWorkflowStepApprover> PurchaseEvaluationWorkflowStepApprovers => Set<PurchaseEvaluationWorkflowStepApprover>();
public DbSet<PurchaseEvaluationCodeSequence> PurchaseEvaluationCodeSequences => Set<PurchaseEvaluationCodeSequence>();
// Module Ngân sách (Phase 7) — 4 bảng: Budget header + Details + Approvals + Changelogs.
public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<BudgetDetail> BudgetDetails => Set<BudgetDetail>();
public DbSet<BudgetApproval> BudgetApprovals => Set<BudgetApproval>();
public DbSet<BudgetChangelog> BudgetChangelogs => Set<BudgetChangelog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);

View File

@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using SolutionErp.Domain.Budgets;
namespace SolutionErp.Infrastructure.Persistence.Configurations;
public class BudgetConfiguration : IEntityTypeConfiguration<Budget>
{
public void Configure(EntityTypeBuilder<Budget> b)
{
b.ToTable("Budgets");
b.HasKey(x => x.Id);
b.Property(x => x.MaNganSach).HasMaxLength(100);
b.Property(x => x.TenNganSach).HasMaxLength(500).IsRequired();
b.Property(x => x.Description).HasMaxLength(2000);
b.Property(x => x.Phase).HasConversion<int>();
b.Property(x => x.TongNganSach).HasPrecision(18, 2);
b.HasIndex(x => x.MaNganSach).IsUnique().HasFilter("[MaNganSach] IS NOT NULL");
b.HasIndex(x => new { x.Phase, x.IsDeleted });
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.NamNganSach);
b.HasIndex(x => x.SlaDeadline);
b.HasMany(x => x.Details).WithOne(d => d.Budget).HasForeignKey(d => d.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Approvals).WithOne(a => a.Budget).HasForeignKey(a => a.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Changelogs).WithOne(c => c.Budget).HasForeignKey(c => c.BudgetId).OnDelete(DeleteBehavior.Cascade);
b.HasQueryFilter(x => !x.IsDeleted);
}
}
public class BudgetDetailConfiguration : IEntityTypeConfiguration<BudgetDetail>
{
public void Configure(EntityTypeBuilder<BudgetDetail> b)
{
b.ToTable("BudgetDetails");
b.HasKey(x => x.Id);
b.Property(x => x.GroupCode).HasMaxLength(50).IsRequired();
b.Property(x => x.GroupName).HasMaxLength(200).IsRequired();
b.Property(x => x.ItemCode).HasMaxLength(100);
b.Property(x => x.NoiDung).HasMaxLength(500).IsRequired();
b.Property(x => x.DonViTinh).HasMaxLength(50);
b.Property(x => x.GhiChu).HasMaxLength(1000);
b.Property(x => x.KhoiLuong).HasPrecision(18, 4);
b.Property(x => x.DonGia).HasPrecision(18, 2);
b.Property(x => x.ThanhTien).HasPrecision(18, 2);
b.HasIndex(x => new { x.BudgetId, x.Order });
}
}
public class BudgetApprovalConfiguration : IEntityTypeConfiguration<BudgetApproval>
{
public void Configure(EntityTypeBuilder<BudgetApproval> b)
{
b.ToTable("BudgetApprovals");
b.HasKey(x => x.Id);
b.Property(x => x.FromPhase).HasConversion<int>();
b.Property(x => x.ToPhase).HasConversion<int>();
b.Property(x => x.Decision).HasConversion<int>();
b.Property(x => x.Comment).HasMaxLength(1000);
b.HasIndex(x => new { x.BudgetId, x.ApprovedAt });
}
}
public class BudgetChangelogConfiguration : IEntityTypeConfiguration<BudgetChangelog>
{
public void Configure(EntityTypeBuilder<BudgetChangelog> b)
{
b.ToTable("BudgetChangelogs");
b.HasKey(x => x.Id);
b.Property(x => x.EntityType).HasConversion<int>();
b.Property(x => x.Action).HasConversion<int>();
b.Property(x => x.PhaseAtChange).HasConversion<int>();
b.Property(x => x.UserName).HasMaxLength(200);
b.Property(x => x.Summary).HasMaxLength(500);
b.Property(x => x.ContextNote).HasMaxLength(2000);
b.Property(x => x.FieldChangesJson).HasColumnType("nvarchar(max)");
b.HasIndex(x => new { x.BudgetId, x.CreatedAt });
b.HasIndex(x => new { x.BudgetId, x.EntityType });
}
}

View File

@ -24,6 +24,7 @@ public class ContractConfiguration : IEntityTypeConfiguration<Contract>
b.HasIndex(x => x.SupplierId);
b.HasIndex(x => x.ProjectId);
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.BudgetId);
b.HasMany(x => x.Approvals).WithOne(a => a.Contract).HasForeignKey(a => a.ContractId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Comments).WithOne(c => c.Contract).HasForeignKey(c => c.ContractId).OnDelete(DeleteBehavior.Cascade);

View File

@ -25,6 +25,7 @@ public class PurchaseEvaluationConfiguration : IEntityTypeConfiguration<Purchase
b.HasIndex(x => x.SlaDeadline);
b.HasIndex(x => x.WorkflowDefinitionId);
b.HasIndex(x => x.ContractId);
b.HasIndex(x => x.BudgetId);
b.HasMany(x => x.Suppliers).WithOne(s => s.PurchaseEvaluation).HasForeignKey(s => s.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);
b.HasMany(x => x.Details).WithOne(d => d.PurchaseEvaluation).HasForeignKey(d => d.PurchaseEvaluationId).OnDelete(DeleteBehavior.Cascade);

View File

@ -1308,6 +1308,11 @@ public static class DbInitializer
// Module Duyệt NCC (tiền-HĐ)
(MenuKeys.PurchaseEvaluations, "Quy trình chọn Thầu phụ - NCC", null, 25, "ClipboardCheck"),
(MenuKeys.PeWorkflows, "Quy trình Duyệt NCC", MenuKeys.System, 95, "GitCompareArrows"),
// Module Ngân sách (Phase 7)
(MenuKeys.Budgets, "Ngân sách", null, 27, "Wallet"),
(MenuKeys.BudgetList, "Danh sách", MenuKeys.Budgets, 1, "List"),
(MenuKeys.BudgetCreate, "Thao tác", MenuKeys.Budgets, 2, "Plus"),
(MenuKeys.BudgetPending, "Duyệt", MenuKeys.Budgets, 3, "CheckCircle2"),
};
// Per-type sub-menu under Contracts: 1 group + 3 leaves each

View File

@ -0,0 +1,236 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SolutionErp.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddBudgets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BudgetId",
table: "PurchaseEvaluations",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "BudgetId",
table: "Contracts",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "Budgets",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MaNganSach = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
TenNganSach = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Description = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
NamNganSach = table.Column<int>(type: "int", nullable: false),
ProjectId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DepartmentId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
DrafterUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Phase = table.Column<int>(type: "int", nullable: false),
TongNganSach = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
SlaDeadline = table.Column<DateTime>(type: "datetime2", nullable: true),
SlaWarningSent = table.Column<bool>(type: "bit", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Budgets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "BudgetApprovals",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
FromPhase = table.Column<int>(type: "int", nullable: false),
ToPhase = table.Column<int>(type: "int", nullable: false),
ApproverUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Decision = table.Column<int>(type: "int", nullable: false),
Comment = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
ApprovedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetApprovals", x => x.Id);
table.ForeignKey(
name: "FK_BudgetApprovals_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BudgetChangelogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EntityType = table.Column<int>(type: "int", nullable: false),
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Action = table.Column<int>(type: "int", nullable: false),
PhaseAtChange = table.Column<int>(type: "int", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UserName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
Summary = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
FieldChangesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
ContextNote = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetChangelogs", x => x.Id);
table.ForeignKey(
name: "FK_BudgetChangelogs_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BudgetDetails",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BudgetId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
GroupCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
GroupName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
ItemCode = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
NoiDung = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
DonViTinh = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
KhoiLuong = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
DonGia = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
ThanhTien = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
Order = table.Column<int>(type: "int", nullable: false),
GhiChu = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
UpdatedBy = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BudgetDetails", x => x.Id);
table.ForeignKey(
name: "FK_BudgetDetails_Budgets_BudgetId",
column: x => x.BudgetId,
principalTable: "Budgets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_PurchaseEvaluations_BudgetId",
table: "PurchaseEvaluations",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_Contracts_BudgetId",
table: "Contracts",
column: "BudgetId");
migrationBuilder.CreateIndex(
name: "IX_BudgetApprovals_BudgetId_ApprovedAt",
table: "BudgetApprovals",
columns: new[] { "BudgetId", "ApprovedAt" });
migrationBuilder.CreateIndex(
name: "IX_BudgetChangelogs_BudgetId_CreatedAt",
table: "BudgetChangelogs",
columns: new[] { "BudgetId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_BudgetChangelogs_BudgetId_EntityType",
table: "BudgetChangelogs",
columns: new[] { "BudgetId", "EntityType" });
migrationBuilder.CreateIndex(
name: "IX_BudgetDetails_BudgetId_Order",
table: "BudgetDetails",
columns: new[] { "BudgetId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_Budgets_MaNganSach",
table: "Budgets",
column: "MaNganSach",
unique: true,
filter: "[MaNganSach] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Budgets_NamNganSach",
table: "Budgets",
column: "NamNganSach");
migrationBuilder.CreateIndex(
name: "IX_Budgets_Phase_IsDeleted",
table: "Budgets",
columns: new[] { "Phase", "IsDeleted" });
migrationBuilder.CreateIndex(
name: "IX_Budgets_ProjectId",
table: "Budgets",
column: "ProjectId");
migrationBuilder.CreateIndex(
name: "IX_Budgets_SlaDeadline",
table: "Budgets",
column: "SlaDeadline");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BudgetApprovals");
migrationBuilder.DropTable(
name: "BudgetChangelogs");
migrationBuilder.DropTable(
name: "BudgetDetails");
migrationBuilder.DropTable(
name: "Budgets");
migrationBuilder.DropIndex(
name: "IX_PurchaseEvaluations_BudgetId",
table: "PurchaseEvaluations");
migrationBuilder.DropIndex(
name: "IX_Contracts_BudgetId",
table: "Contracts");
migrationBuilder.DropColumn(
name: "BudgetId",
table: "PurchaseEvaluations");
migrationBuilder.DropColumn(
name: "BudgetId",
table: "Contracts");
}
}
}

View File

@ -125,12 +125,274 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.ToTable("UserTokens", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("DeletedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("DepartmentId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<Guid?>("DrafterUserId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("MaNganSach")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("NamNganSach")
.HasColumnType("int");
b.Property<int>("Phase")
.HasColumnType("int");
b.Property<Guid>("ProjectId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("SlaDeadline")
.HasColumnType("datetime2");
b.Property<bool>("SlaWarningSent")
.HasColumnType("bit");
b.Property<string>("TenNganSach")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<decimal>("TongNganSach")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("MaNganSach")
.IsUnique()
.HasFilter("[MaNganSach] IS NOT NULL");
b.HasIndex("NamNganSach");
b.HasIndex("ProjectId");
b.HasIndex("SlaDeadline");
b.HasIndex("Phase", "IsDeleted");
b.ToTable("Budgets", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ApprovedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("ApproverUserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<int>("Decision")
.HasColumnType("int");
b.Property<int>("FromPhase")
.HasColumnType("int");
b.Property<int>("ToPhase")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("BudgetId", "ApprovedAt");
b.ToTable("BudgetApprovals", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("Action")
.HasColumnType("int");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ContextNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<int>("EntityType")
.HasColumnType("int");
b.Property<string>("FieldChangesJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PhaseAtChange")
.HasColumnType("int");
b.Property<string>("Summary")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("UserName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("BudgetId", "CreatedAt");
b.HasIndex("BudgetId", "EntityType");
b.ToTable("BudgetChangelogs", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("CreatedBy")
.HasColumnType("uniqueidentifier");
b.Property<decimal>("DonGia")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("DonViTinh")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("GhiChu")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("GroupCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("GroupName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ItemCode")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal>("KhoiLuong")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("NoiDung")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Order")
.HasColumnType("int");
b.Property<decimal>("ThanhTien")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("BudgetId", "Order");
b.ToTable("BudgetDetails", (string)null);
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<bool>("BypassProcurementAndCCM")
.HasColumnType("bit");
@ -206,6 +468,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("MaHopDong")
.IsUnique()
.HasFilter("[MaHopDong] IS NOT NULL");
@ -1910,6 +2174,9 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("BudgetId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("ContractId")
.HasColumnType("uniqueidentifier");
@ -1983,6 +2250,8 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("BudgetId");
b.HasIndex("ContractId");
b.HasIndex("MaPhieu")
@ -2558,6 +2827,39 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetApproval", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Approvals")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetChangelog", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Changelogs")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.BudgetDetail", b =>
{
b.HasOne("SolutionErp.Domain.Budgets.Budget", "Budget")
.WithMany("Details")
.HasForeignKey("BudgetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Budget");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.ContractApproval", b =>
{
b.HasOne("SolutionErp.Domain.Contracts.Contract", "Contract")
@ -2838,6 +3140,15 @@ namespace SolutionErp.Infrastructure.Persistence.Migrations
b.Navigation("Step");
});
modelBuilder.Entity("SolutionErp.Domain.Budgets.Budget", b =>
{
b.Navigation("Approvals");
b.Navigation("Changelogs");
b.Navigation("Details");
});
modelBuilder.Entity("SolutionErp.Domain.Contracts.Contract", b =>
{
b.Navigation("Approvals");