[CLAUDE] Infra+App+Api+FE: Chunk E4 — HĐ 2-stage dept approval (mirror PE)

Mở rộng 2-stage logic từ PE sang Contract workflow (Migration 16 đã có schema):

BE Service:
- ContractWorkflowService thêm UserManager<User> DI
- Mirror logic 2-stage từ PurchaseEvaluationWorkflowService.TransitionAsync
  Sau policy guard, trước gen mã HĐ:
  - User.DepartmentId != null + actor không admin/system + KHÔNG resume
    - DeptManager (TPB) → Stage=Confirm trực tiếp
    - CanBypassReview=true → Stage=Confirm + IsBypassed=true
    - Else (NV) → Stage=Review only, BLOCK transition
  - Insert ContractDepartmentApproval row (UPSERT theo UNIQUE)
  - Block transition khi chưa có Stage=Confirm:
    - Insert ContractApproval (FromPhase=ToPhase=fromPhase, [Review NV] comment)
    - Insert ContractChangelog "đã review, chờ TPB confirm"
    - Notify TPB cùng dept (UserManager filter DeptManager role)
    - Return early — phase KHÔNG đổi

App + Api:
- ContractDepartmentApprovalFeatures.cs (List query mirror PE)
- ContractsController endpoint GET /contracts/{id}/department-approvals

FE (cả fe-admin + fe-user):
- types/contracts.ts thêm ApprovalStage const + ContractDepartmentApproval type
- WorkflowHistoryPanel 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" khi current phase có Review chưa Confirm
  - Badge fuchsia "bypass" khi NV.CanBypassReview=true
  - Insert giữa WorkflowSummaryCard và Lịch sử duyệt
- Mirror cả 2 app (rule §3.9)

Use case mirror PE: HĐ ở phase DangGopY (P.CCM) — nv.cao (NV) duyệt thì
phase KHÔNG đổi (Review only), chờ ccm.tran (TPB) confirm mới sang DangXetDuyet.

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:43:05 +07:00
parent 4380bdc075
commit b6f5a16420
7 changed files with 447 additions and 5 deletions

View File

@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Services;
using SolutionErp.Application.Notifications;
using SolutionErp.Domain.Common;
using SolutionErp.Domain.Contracts;
using SolutionErp.Domain.Identity;
using SolutionErp.Domain.Notifications;
@ -17,7 +19,8 @@ public class ContractWorkflowService(
IContractCodeGenerator codeGenerator,
IDateTime dateTime,
INotificationService notifications,
IChangelogService changelog) : IContractWorkflowService
IChangelogService changelog,
UserManager<User> userManager) : IContractWorkflowService
{
// Expose per-policy SLA via the contract — accepts optional contract so the
// caller (CreateContractCommand) can ask for a specific type's SLA even
@ -105,6 +108,123 @@ public class ContractWorkflowService(
}
}
// ===== 2-stage department approval (Phase 9 — Migration 16) =====
// Mirror PE workflow service. NV thuộc dept review → BLOCK transition
// cho đến khi TPB cùng dept confirm. CanBypassReview cho NV → đẩy thẳng
// Confirm (skip Review). Skip với reject + resume + admin + system.
if (decision == ApprovalDecision.Approve
&& targetPhase != ContractPhase.DangSoanThao
&& targetPhase != ContractPhase.TuChoi
&& !isResumingAfterReject
&& !isAdmin && !isSystem
&& actorUserId is Guid actorUid)
{
var actor = await userManager.FindByIdAsync(actorUid.ToString());
if (actor?.DepartmentId is Guid deptId)
{
var isManager = actorRoles.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");
// Upsert: 1 row mỗi (ContractId, phase, dept, stage). UNIQUE index enforce.
var existing = await db.ContractDepartmentApprovals
.FirstOrDefaultAsync(a =>
a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == stage, ct);
if (existing is null)
{
db.ContractDepartmentApprovals.Add(new ContractDepartmentApproval
{
ContractId = contract.Id,
PhaseAtApproval = (int)fromPhase,
DepartmentId = deptId,
Stage = stage,
ApproverUserId = actorUid,
ApproverRoleSnapshot = roleSnapshot,
Comment = comment,
ApprovedAt = dateTime.UtcNow,
IsBypassed = isBypassed,
});
}
else
{
existing.ApproverUserId = actorUid;
existing.ApproverRoleSnapshot = roleSnapshot;
existing.Comment = comment;
existing.ApprovedAt = dateTime.UtcNow;
existing.IsBypassed = isBypassed;
}
var hasConfirm = stage == ApprovalStage.Confirm
|| await db.ContractDepartmentApprovals.AnyAsync(a =>
a.ContractId == contract.Id
&& a.PhaseAtApproval == (int)fromPhase
&& a.DepartmentId == deptId
&& a.Stage == ApprovalStage.Confirm, ct);
if (!hasConfirm)
{
// NV review xong, chưa có TPB confirm → BLOCK phase transition.
db.ContractApprovals.Add(new ContractApproval
{
ContractId = contract.Id,
FromPhase = fromPhase,
ToPhase = fromPhase, // không đổi phase
ApproverUserId = actorUid,
Decision = ApprovalDecision.Approve,
Comment = $"[Review NV] {comment ?? ""}",
ApprovedAt = dateTime.UtcNow,
});
string? reviewerName = (actor.FullName ?? actor.Email);
db.ContractChangelogs.Add(new ContractChangelog
{
ContractId = contract.Id,
EntityType = ChangelogEntityType.Workflow,
Action = ChangelogAction.Transition,
PhaseAtChange = fromPhase,
UserId = actorUid,
UserName = reviewerName ?? "Hệ thống",
Summary = $"{reviewerName} (NV) đã review phase {fromPhase}, chờ TPB confirm",
ContextNote = comment,
});
// Notify TPB cùng dept để confirm. 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: $"HĐ {contract.MaHopDong ?? contract.TenHopDong ?? ""} chờ TPB confirm",
description: $"NV {reviewerName} đã review phase {fromPhase}. Vui lòng vào confirm để workflow tiếp tục.",
href: $"/contracts/{contract.Id}",
refId: contract.Id,
ct: ct);
}
}
catch { /* notification fail non-critical */ }
await db.SaveChangesAsync(ct);
return;
}
}
}
// Defensive — gen mã HĐ nếu chưa có khi chuyển sang DangDongDau.
// Nominal flow (sau user feedback): mã đã gen sẵn từ CreateContract → skip.
// Fallback chỉ trigger cho HĐ legacy chưa qua backfill, hoặc HĐ tạo bằng