[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:
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Reference in New Issue
Block a user