[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:
@ -2,19 +2,39 @@
|
||||
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
|
||||
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
|
||||
// MyContracts 3-panel và ContractDetailPage fullpage.
|
||||
import { ArrowRight, Clock, History } from 'lucide-react'
|
||||
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
|
||||
import type { ContractDetail } from '@/types/contracts'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
ContractPhaseColor,
|
||||
ContractPhaseLabel,
|
||||
type ContractDepartmentApproval,
|
||||
type ContractDetail,
|
||||
} from '@/types/contracts'
|
||||
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
|
||||
queryKey: ['contract-dept-approvals', c.id],
|
||||
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
|
||||
// pattern PE — block phase transition khi current phase có Review nhưng chưa
|
||||
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: ContractDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Users2 className="h-4 w-4" />
|
||||
Tiến trình duyệt 2-cấp phòng ban
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,6 +48,29 @@ export const ApprovalDecision = {
|
||||
|
||||
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (HĐ, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type ContractDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type ContractListItem = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
|
||||
@ -2,19 +2,39 @@
|
||||
// approval history (ai/khi/quyết định gì) + ContractChangelogsTab (mọi thay
|
||||
// đổi header/detail/comment/attachment/transition) vào 1 stack. Dùng cho cả
|
||||
// MyContracts 3-panel và ContractDetailPage fullpage.
|
||||
import { ArrowRight, Clock, History } from 'lucide-react'
|
||||
import { ArrowRight, Clock, History, Users2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PhaseBadge } from '@/components/PhaseBadge'
|
||||
import { WorkflowSummaryCard } from '@/components/WorkflowSummaryCard'
|
||||
import { ContractChangelogsTab } from '@/components/contracts/ContractChangelogsTab'
|
||||
import type { ContractDetail } from '@/types/contracts'
|
||||
import { api } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import {
|
||||
ApprovalStage,
|
||||
ContractPhaseColor,
|
||||
ContractPhaseLabel,
|
||||
type ContractDepartmentApproval,
|
||||
type ContractDetail,
|
||||
} from '@/types/contracts'
|
||||
|
||||
const fmt = (s: string) => new Date(s).toLocaleString('vi-VN')
|
||||
const fmtTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString('vi-VN', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail }) {
|
||||
const { data: deptApprovals = [] } = useQuery<ContractDepartmentApproval[]>({
|
||||
queryKey: ['contract-dept-approvals', c.id],
|
||||
queryFn: async () => (await api.get(`/contracts/${c.id}/department-approvals`)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{c.workflow && <WorkflowSummaryCard workflow={c.workflow} currentPhase={c.phase} />}
|
||||
|
||||
{deptApprovals.length > 0 && (
|
||||
<DeptApprovalsSection rows={deptApprovals} currentPhase={c.phase} />
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Clock className="h-4 w-4" />
|
||||
@ -54,3 +74,78 @@ export function WorkflowHistoryPanel({ contract: c }: { contract: ContractDetail
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2-stage dept approval timeline (Migration 16). Group by phase × dept. Same
|
||||
// pattern PE — block phase transition khi current phase có Review nhưng chưa
|
||||
// Confirm → highlight amber để user biết "đang chờ TPB confirm".
|
||||
function DeptApprovalsSection({
|
||||
rows,
|
||||
currentPhase,
|
||||
}: {
|
||||
rows: ContractDepartmentApproval[]
|
||||
currentPhase: number
|
||||
}) {
|
||||
const grouped = new Map<number, Map<string, ContractDepartmentApproval[]>>()
|
||||
for (const r of rows) {
|
||||
if (!grouped.has(r.phaseAtApproval)) grouped.set(r.phaseAtApproval, new Map())
|
||||
const byDept = grouped.get(r.phaseAtApproval)!
|
||||
if (!byDept.has(r.departmentId)) byDept.set(r.departmentId, [])
|
||||
byDept.get(r.departmentId)!.push(r)
|
||||
}
|
||||
const phaseOrder = [...grouped.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-1 flex items-center gap-2 text-sm font-semibold text-slate-700">
|
||||
<Users2 className="h-4 w-4" />
|
||||
Tiến trình duyệt 2-cấp phòng ban
|
||||
</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">NV Review → TPB Confirm. Phase chỉ chuyển khi có Confirm.</p>
|
||||
<div className="space-y-3">
|
||||
{phaseOrder.map(phase => {
|
||||
const byDept = grouped.get(phase)!
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className={cn(
|
||||
'inline-block rounded px-1.5 py-0.5 text-[10px] font-medium',
|
||||
ContractPhaseColor[phase] ?? 'bg-slate-100 text-slate-700',
|
||||
)}>
|
||||
{ContractPhaseLabel[phase] ?? `Phase ${phase}`}
|
||||
</div>
|
||||
<div className="mt-1 space-y-1.5">
|
||||
{[...byDept.entries()].map(([deptId, stages]) => {
|
||||
const review = stages.find(s => s.stage === ApprovalStage.Review)
|
||||
const confirm = stages.find(s => s.stage === ApprovalStage.Confirm)
|
||||
const deptName = stages[0]?.departmentName ?? '(không rõ phòng)'
|
||||
const isPending = phase === currentPhase && review && !confirm
|
||||
return (
|
||||
<div key={deptId} className={cn(
|
||||
'rounded border px-2 py-1.5 text-[11px]',
|
||||
isPending ? 'border-amber-300 bg-amber-50' : 'border-slate-200 bg-slate-50',
|
||||
)}>
|
||||
<div className="font-medium text-slate-700">{deptName}</div>
|
||||
<div className="mt-1 grid grid-cols-[60px_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-slate-500">Review:</span>
|
||||
<span className={review ? 'text-slate-700' : 'text-slate-400'}>
|
||||
{review
|
||||
? <>✓ {review.approverName} <span className="text-slate-500">— {fmtTime(review.approvedAt)}</span>{review.comment && <span className="text-slate-500"> · "{review.comment}"</span>}</>
|
||||
: '— chưa có'}
|
||||
</span>
|
||||
<span className="text-slate-500">Confirm:</span>
|
||||
<span className={confirm ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{confirm
|
||||
? <>✓ {confirm.approverName}{confirm.isBypassed && <span className="ml-1 rounded bg-fuchsia-100 px-1 text-[9px] text-fuchsia-700">bypass</span>} <span className="text-slate-500">— {fmtTime(confirm.approvedAt)}</span>{confirm.comment && <span className="text-slate-500"> · "{confirm.comment}"</span>}</>
|
||||
: '⏳ chờ TPB confirm'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@ -48,6 +48,29 @@ export const ApprovalDecision = {
|
||||
|
||||
export type ApprovalDecision = typeof ApprovalDecision[keyof typeof ApprovalDecision]
|
||||
|
||||
// 2-stage department approval (Migration 16) — Stage 1=Review NV, 2=Confirm TPB.
|
||||
// BLOCK transition khi NV review chưa có TPB confirm cùng (HĐ, Phase, Dept).
|
||||
// CanBypassReview=true → NV được Stage=Confirm + IsBypassed=true (skip Review).
|
||||
export const ApprovalStage = {
|
||||
Review: 1,
|
||||
Confirm: 2,
|
||||
} as const
|
||||
export type ApprovalStage = typeof ApprovalStage[keyof typeof ApprovalStage]
|
||||
|
||||
export type ContractDepartmentApproval = {
|
||||
id: string
|
||||
phaseAtApproval: number
|
||||
departmentId: string
|
||||
departmentName: string | null
|
||||
stage: number // 1=Review, 2=Confirm
|
||||
approverUserId: string
|
||||
approverName: string | null
|
||||
approverRoleSnapshot: string | null // "TPB" | "NV" | "NV(bypass)"
|
||||
comment: string | null
|
||||
approvedAt: string
|
||||
isBypassed: boolean
|
||||
}
|
||||
|
||||
export type ContractListItem = {
|
||||
id: string
|
||||
maHopDong: string | null
|
||||
|
||||
@ -218,6 +218,12 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("{id:guid}/changelogs")]
|
||||
public async Task<List<ContractChangelogDto>> GetChangelogs(Guid id, CancellationToken ct)
|
||||
=> await mediator.Send(new ListContractChangelogsQuery(id), ct);
|
||||
|
||||
// 2-stage department approval list (Phase 9 — Migration 16).
|
||||
[HttpGet("{id:guid}/department-approvals")]
|
||||
public async Task<ActionResult<List<ContractDepartmentApprovalDto>>> ListDepartmentApprovals(
|
||||
Guid id, CancellationToken ct)
|
||||
=> Ok(await mediator.Send(new ListContractDepartmentApprovalsQuery(id), ct));
|
||||
}
|
||||
|
||||
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Common;
|
||||
using SolutionErp.Domain.Contracts;
|
||||
|
||||
namespace SolutionErp.Application.Contracts;
|
||||
|
||||
// 2-stage department approval list cho HĐ (Phase 9 — Migration 16).
|
||||
// Mirror PE — query để FE hiển thị progress per phase × dept.
|
||||
//
|
||||
// FE Workflow Panel HĐ render dạng timeline:
|
||||
// - Phase DangGopY (CCM):
|
||||
// - Stage Review: nv.cao (NV) — 14:30
|
||||
// - Stage Confirm: ccm.tran (TPB) — 14:35 → unlock transition
|
||||
//
|
||||
// Insertion + Block logic ở ContractWorkflowService.TransitionAsync.
|
||||
|
||||
public record ListContractDepartmentApprovalsQuery(Guid ContractId)
|
||||
: IRequest<List<ContractDepartmentApprovalDto>>;
|
||||
|
||||
public record ContractDepartmentApprovalDto(
|
||||
Guid Id,
|
||||
int PhaseAtApproval,
|
||||
Guid DepartmentId,
|
||||
string? DepartmentName,
|
||||
ApprovalStage Stage,
|
||||
Guid ApproverUserId,
|
||||
string? ApproverName,
|
||||
string? ApproverRoleSnapshot,
|
||||
string? Comment,
|
||||
DateTime ApprovedAt,
|
||||
bool IsBypassed);
|
||||
|
||||
public class ListContractDepartmentApprovalsQueryHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<ListContractDepartmentApprovalsQuery, List<ContractDepartmentApprovalDto>>
|
||||
{
|
||||
public async Task<List<ContractDepartmentApprovalDto>> Handle(
|
||||
ListContractDepartmentApprovalsQuery request, CancellationToken ct)
|
||||
{
|
||||
var rows = await (
|
||||
from a in db.ContractDepartmentApprovals.AsNoTracking()
|
||||
join d in db.Departments.AsNoTracking() on a.DepartmentId equals d.Id into deptJoin
|
||||
from d in deptJoin.DefaultIfEmpty()
|
||||
where a.ContractId == request.ContractId
|
||||
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 ContractDepartmentApprovalDto(
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user