[CLAUDE] App+Api+FE: Chunk E5 — Budget 2-stage dept approval (mirror PE/Contract)

Budget complete the trifecta — đồng bộ pattern 2-stage cho 3 module
(Contract + PE + Budget) cùng UX cho user khi UAT.

BE App:
- TransitionBudgetCommandHandler thêm INotificationService + IDateTime DI
- Mirror logic 2-stage từ ContractWorkflowService:
  - actor.DepartmentId != null + KHÔNG admin/system + KHÔNG resume
    - DeptManager (TPB) hoặc CanBypassReview → Stage=Confirm
    - Else (NV) → Stage=Review only, BLOCK transition
  - Upsert BudgetDepartmentApproval (UNIQUE BudgetId+Phase+Dept+Stage)
  - Block khi !hasConfirm: insert Approval + Changelog + Notify TPB → return early
- BudgetDepartmentApprovalFeatures.cs (List query mirror PE/Contract)

Api:
- BudgetsController endpoint GET /budgets/{id}/department-approvals

FE (cả fe-admin + fe-user):
- types/budget.ts thêm ApprovalStage const + BudgetDepartmentApproval type
- BudgetWorkflowPanel section "Tiến trình duyệt 2-cấp phòng ban":
  - Group by phase × dept, show Review NV + Confirm TPB
  - Highlight amber "chờ TPB confirm" + badge fuchsia bypass

Note: low-priority cho Budget (ít user duyệt budget per dept) nhưng giữ
consistent UX 3 module.

Build: BE pass + FE pass cả 2 + 77 test pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-05-04 13:46:56 +07:00
parent b6f5a16420
commit 1fc439b978
7 changed files with 425 additions and 3 deletions

View File

@ -86,6 +86,12 @@ public class BudgetsController(IMediator mediator) : ControllerBase
[HttpGet("{id:guid}/changelogs")]
public async Task<List<BudgetChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
=> await mediator.Send(new ListBudgetChangelogsQuery(id), ct);
// 2-stage department approval list (Phase 9 — Migration 16).
[HttpGet("{id:guid}/department-approvals")]
public async Task<ActionResult<List<BudgetDepartmentApprovalDto>>> ListDepartmentApprovals(
Guid id, CancellationToken ct)
=> Ok(await mediator.Send(new ListBudgetDepartmentApprovalsQuery(id), ct));
}
public record TransitionBudgetBody(BudgetPhase TargetPhase, ApprovalDecision Decision, string? Comment);

View File

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

View File

@ -6,9 +6,12 @@ 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;
@ -121,7 +124,9 @@ public record TransitionBudgetCommand(
public class TransitionBudgetCommandHandler(
IApplicationDbContext db,
ICurrentUser currentUser,
UserManager<User> userManager) : IRequestHandler<TransitionBudgetCommand>
UserManager<User> userManager,
INotificationService notifications,
IDateTime dateTime) : IRequestHandler<TransitionBudgetCommand>
{
public async Task Handle(TransitionBudgetCommand request, CancellationToken ct)
{
@ -158,6 +163,121 @@ public class TransitionBudgetCommandHandler(
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.TuChoi
&& !isResumingAfterReject
&& !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);