eb91c2d8fb
后端: - employee.routes.ts: all-lite 接口加上 idCardNumber 并解密 - special-status.routes.ts: 列表和详情查询加上 idCardNumber 并解密 - esign.routes.ts: 列表/pending/detail/remind 接口加上 idCardNumber 并解密 - risk.service.ts: getMonthlyCalendar 所有事件加上 employeeIdCardNumber - termination.service.ts: batchTerminatePreview 加上 employeeIdCardNumber - employee.routes.ts: preview-renew 加上 employeeIdCardNumber 前端: - Roster.tsx: 批量续签/解聘预检结果加上身份证号 - SpecialStatus.tsx: 列表/下拉/删除确认加上身份证号 - Calendar.tsx: 日历事件姓名后显示身份证号 - ESign.tsx: 签署列表/详情/员工列表/提醒弹窗/下拉加上身份证号 - CommercialInsuranceTab.tsx: 参保选择列表加上身份证号 - OvertimeTab.tsx: 导入预览加上身份证号
1038 lines
37 KiB
TypeScript
1038 lines
37 KiB
TypeScript
import prisma from '../lib/prisma'
|
||
import { RiskAssessment, TerminationReason } from '@prisma/client'
|
||
import { autoCreateEsignRecord } from './esign.service'
|
||
import { decrypt } from '../lib/crypto'
|
||
|
||
function dateToMonth(date: Date): string {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
return `${y}-${m}`
|
||
}
|
||
|
||
/** 计算上一个月,格式 YYYY-MM */
|
||
function prevMonth(month: string): string {
|
||
const [y, m] = month.split('-').map(Number)
|
||
if (m === 1) return `${y - 1}-12`
|
||
return `${y}-${String(m - 1).padStart(2, '0')}`
|
||
}
|
||
|
||
export interface ChecklistItem {
|
||
key: string
|
||
label: string
|
||
autoChecked?: boolean | null // null=无法自动判断,true/false=系统判断结果
|
||
autoSource?: string // 系统判断依据说明
|
||
suggestion?: string // 系统建议说明
|
||
suggestionType?: 'info' | 'warning' | 'required'
|
||
}
|
||
|
||
export function getChecklistForReason(reason: string, employee?: any, orgCity?: string): ChecklistItem[] {
|
||
// 北京地区单方解除须通知工会(依据《规范用人单位单方解除劳动合同工作指引》)
|
||
const isBeijing = !orgCity || orgCity === '北京' || orgCity === '北京市' || orgCity?.includes('北京')
|
||
switch (reason) {
|
||
case 'NEGOTIATED':
|
||
return [
|
||
{
|
||
key: 'compensation_paid', label: '是否已支付经济补偿金',
|
||
autoChecked: null,
|
||
suggestion: '协商解除需支付经济补偿金(N),建议在协商协议中明确金额',
|
||
suggestionType: 'required',
|
||
},
|
||
{ key: 'agreement_signed', label: '是否签署协商解除协议', autoChecked: null },
|
||
{ key: 'final_pay_ready', label: '是否结清最后工资', autoChecked: null },
|
||
]
|
||
case 'FAULT':
|
||
return [
|
||
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
|
||
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
|
||
...(isBeijing ? [{
|
||
key: 'notify_union',
|
||
label: '是否提前5个工作日书面通知工会',
|
||
autoChecked: null as any,
|
||
suggestion: '北京地区要求:单方解除劳动合同须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会(用人单位实际经营地的乡镇/街道/园区/开发区总工会)。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
|
||
suggestionType: 'required' as const,
|
||
}] : []),
|
||
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
|
||
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
|
||
]
|
||
case 'NONFAULT': {
|
||
const items: ChecklistItem[] = []
|
||
|
||
// 医疗期是否已届满 — 系统自动判断
|
||
if (employee?.isInMedicalPeriod) {
|
||
items.push({
|
||
key: 'medical_period_end', label: '医疗期是否已届满',
|
||
autoChecked: false,
|
||
autoSource: '系统记录显示该员工正处于医疗期内,医疗期未届满',
|
||
suggestion: '医疗期内不得以非过错理由解除,需等待医疗期届满',
|
||
suggestionType: 'warning',
|
||
})
|
||
} else {
|
||
items.push({
|
||
key: 'medical_period_end', label: '医疗期是否已届满',
|
||
autoChecked: null,
|
||
autoSource: '系统未记录该员工处于医疗期,如实际已届满请勾选确认',
|
||
})
|
||
}
|
||
|
||
// 是否经过培训或调岗 — 系统自动判断
|
||
const hasTraining = employee?.trainingRecords?.length > 0
|
||
items.push({
|
||
key: 'training_given', label: '是否经过培训或调岗',
|
||
autoChecked: hasTraining ? true : null,
|
||
autoSource: hasTraining
|
||
? `系统记录显示该员工有${employee.trainingRecords.length}条培训记录`
|
||
: '系统未找到培训或调岗记录,请人工确认',
|
||
suggestion: hasTraining
|
||
? '已有培训记录,满足"不胜任工作经培训或调岗"的前提条件'
|
||
: '以不胜任工作为由解除前,必须先经过培训或调岗,否则违法解除风险极高',
|
||
suggestionType: hasTraining ? 'info' : 'warning',
|
||
})
|
||
|
||
// 是否支付经济补偿金 — 系统建议
|
||
items.push({
|
||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
||
autoChecked: null,
|
||
suggestion: '非过错解除需支付经济补偿金(N),并在Step 4费用结算中确认金额',
|
||
suggestionType: 'required',
|
||
})
|
||
|
||
// 是否提前30天通知或支付代通知金 — 系统建议
|
||
items.push({
|
||
key: 'advance_notice', label: '是否提前30天通知或支付代通知金',
|
||
autoChecked: null,
|
||
suggestion: '非过错解除需提前30天书面通知,或额外支付1个月工资作为代通知金(N+1)',
|
||
suggestionType: 'required',
|
||
})
|
||
|
||
// 通知工会 — 北京地区单方解除必经程序
|
||
if (isBeijing) {
|
||
items.push({
|
||
key: 'notify_union',
|
||
label: '是否提前5个工作日书面通知工会',
|
||
autoChecked: null,
|
||
suggestion: '北京地区要求:单方解除须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
|
||
suggestionType: 'required',
|
||
})
|
||
items.push({
|
||
key: 'union_receipt',
|
||
label: '是否收到工会书面回执',
|
||
autoChecked: null,
|
||
})
|
||
}
|
||
|
||
return items
|
||
}
|
||
case 'LAYOFF':
|
||
return [
|
||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
|
||
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
|
||
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
|
||
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
|
||
{
|
||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
||
autoChecked: null,
|
||
suggestion: '裁员需支付经济补偿金(N)',
|
||
suggestionType: 'required',
|
||
},
|
||
]
|
||
case 'EXPIRED':
|
||
return [
|
||
{
|
||
key: 'compensation_paid', label: '是否支付经济补偿金(如需)',
|
||
autoChecked: null,
|
||
suggestion: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
|
||
suggestionType: 'info',
|
||
},
|
||
{ key: 'written_notice', label: '是否提前通知员工不续签', autoChecked: null },
|
||
]
|
||
default:
|
||
return []
|
||
}
|
||
}
|
||
|
||
export function assessRisk(employee: any, reason: string): { level: RiskAssessment; warnings: string[] } {
|
||
const warnings: string[] = []
|
||
|
||
if (employee.isPregnant) {
|
||
warnings.push('该员工在孕期/哺乳期,法律禁止以非过错理由解除')
|
||
}
|
||
if (employee.isWorkInjured) {
|
||
warnings.push('工伤期间不得解除劳动合同')
|
||
}
|
||
if (employee.isInMedicalPeriod && reason !== 'FAULT') {
|
||
warnings.push('医疗期内不得解除劳动合同(非过错理由)')
|
||
}
|
||
|
||
let level: RiskAssessment = 'SAFE'
|
||
if (warnings.length > 0) {
|
||
level = 'DANGER'
|
||
}
|
||
|
||
return { level, warnings }
|
||
}
|
||
|
||
async function createTerminationRecord(
|
||
orgId: string,
|
||
userId: string,
|
||
data: any,
|
||
recordData: {
|
||
type: 'TERMINATION' | 'RESIGNATION'
|
||
reason: TerminationReason | 'RESIGNATION'
|
||
compensation?: number
|
||
riskLevel: RiskAssessment
|
||
checklist: any
|
||
remark?: string | null
|
||
resignationReason?: string | null
|
||
},
|
||
conflictMsg: string,
|
||
) {
|
||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||
if (!employee) {
|
||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||
}
|
||
|
||
const latestTerm = await prisma.terminationRecord.findFirst({
|
||
where: { employeeId: data.employeeId },
|
||
orderBy: { terminationDate: 'desc' },
|
||
})
|
||
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
|
||
throw { code: 'CONFLICT', message: conflictMsg }
|
||
}
|
||
|
||
const termDate = new Date(data.terminationDate)
|
||
const termMonth = dateToMonth(termDate)
|
||
const socialInsEndMonth = data.socialInsEndMonth || termMonth
|
||
const housingFundEndMonth = data.housingFundEndMonth || termMonth
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const isResigned = termDate <= today
|
||
|
||
return await prisma.$transaction(async (tx) => {
|
||
const record = await tx.terminationRecord.create({
|
||
data: {
|
||
orgId,
|
||
employeeId: data.employeeId,
|
||
terminationDate: termDate,
|
||
socialInsEndMonth,
|
||
housingFundEndMonth,
|
||
createdBy: userId,
|
||
...recordData,
|
||
},
|
||
})
|
||
|
||
await tx.employeeSocialInsRecord.updateMany({
|
||
where: { employeeId: data.employeeId, endMonth: null },
|
||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||
})
|
||
|
||
await tx.employeeHousingFundRecord.updateMany({
|
||
where: { employeeId: data.employeeId, endMonth: null },
|
||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||
})
|
||
|
||
await tx.employee.update({
|
||
where: { id: data.employeeId },
|
||
data: {
|
||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||
socialInsEndMonth,
|
||
housingFundEndMonth,
|
||
},
|
||
})
|
||
|
||
await tx.riskItem.updateMany({
|
||
where: { employeeId: data.employeeId, status: 'PENDING' },
|
||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||
})
|
||
|
||
return { id: record.id }
|
||
})
|
||
}
|
||
|
||
export async function createTermination(orgId: string, userId: string, data: any) {
|
||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||
if (!employee) {
|
||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||
}
|
||
|
||
// 特殊状态拦截:三期/工伤/医疗期员工不得违法终止
|
||
const activeSpecialStatus = await (prisma as any).employeeSpecialStatus.findFirst({
|
||
where: { orgId, employeeId: data.employeeId, status: 'ACTIVE' },
|
||
})
|
||
if (activeSpecialStatus) {
|
||
const typeLabel = activeSpecialStatus.type === 'PREGNANCY' ? '三期(孕期/产期/哺乳期)'
|
||
: activeSpecialStatus.type === 'WORK_INJURY' ? '工伤'
|
||
: activeSpecialStatus.type === 'MEDICAL_PERIOD' ? '医疗期'
|
||
: '特殊状态'
|
||
throw {
|
||
code: 'SPECIAL_STATUS_BLOCK',
|
||
message: `该员工处于${typeLabel}期间,依法不得终止劳动合同。如需操作,请先在特殊状态台账中结束该状态记录。`,
|
||
}
|
||
}
|
||
|
||
const { level } = assessRisk(employee, data.reason)
|
||
|
||
return createTerminationRecord(
|
||
orgId,
|
||
userId,
|
||
data,
|
||
{
|
||
type: 'TERMINATION',
|
||
reason: data.reason,
|
||
compensation: data.compensation || 0,
|
||
riskLevel: level,
|
||
checklist: data.checklist || {},
|
||
remark: data.remark,
|
||
},
|
||
'该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣',
|
||
)
|
||
}
|
||
|
||
// 员工主动离职
|
||
export async function createResignation(orgId: string, userId: string, data: any) {
|
||
return createTerminationRecord(
|
||
orgId,
|
||
userId,
|
||
data,
|
||
{
|
||
type: 'RESIGNATION',
|
||
reason: 'RESIGNATION',
|
||
resignationReason: data.resignationReason || null,
|
||
compensation: 0,
|
||
riskLevel: 'SAFE',
|
||
checklist: {},
|
||
remark: data.remark || null,
|
||
},
|
||
'该员工已有离职/解聘记录,如需再次办理请先重新雇佣',
|
||
)
|
||
}
|
||
|
||
// 撤回离职/解聘(仅未到日期可撤回)
|
||
export async function revokeTermination(orgId: string, recordId: string) {
|
||
const record = await prisma.terminationRecord.findFirst({
|
||
where: { id: recordId, orgId },
|
||
})
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '离职/解聘记录不存在' }
|
||
}
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
if (record.terminationDate <= today) {
|
||
throw { code: 'CONFLICT', message: '离职/解聘日期已到或已过,无法撤回' }
|
||
}
|
||
|
||
await prisma.terminationRecord.delete({ where: { id: recordId } })
|
||
|
||
// 恢复员工状态为 ACTIVE
|
||
await prisma.employee.update({
|
||
where: { id: record.employeeId },
|
||
data: { status: 'ACTIVE' },
|
||
})
|
||
|
||
return { id: recordId }
|
||
}
|
||
|
||
export async function getTerminations(orgId: string, page: number, pageSize: number) {
|
||
const skip = (page - 1) * pageSize
|
||
|
||
const [total, records] = await Promise.all([
|
||
prisma.terminationRecord.count({ where: { orgId } }),
|
||
prisma.terminationRecord.findMany({
|
||
where: { orgId },
|
||
include: { employee: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: pageSize,
|
||
}),
|
||
])
|
||
|
||
return {
|
||
items: records.map((r) => ({
|
||
id: r.id,
|
||
employeeName: r.employee.name,
|
||
department: r.employee.department,
|
||
type: r.type,
|
||
reason: r.reason,
|
||
resignationReason: r.resignationReason,
|
||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||
compensation: r.compensation,
|
||
riskLevel: r.riskLevel,
|
||
remark: r.remark,
|
||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||
})),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
totalPages: Math.ceil(total / pageSize),
|
||
}
|
||
}
|
||
|
||
export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWage: number, socialAvgWage: number = 0): {
|
||
years: number
|
||
remainingMonths: number
|
||
compMonths: number
|
||
totalPay: number
|
||
capped: boolean
|
||
} {
|
||
const totalMonths = (leaveDate.getFullYear() - hireDate.getFullYear()) * 12 + (leaveDate.getMonth() - hireDate.getMonth())
|
||
const years = Math.floor(totalMonths / 12)
|
||
const remainingMonths = totalMonths % 12
|
||
|
||
let compMonths: number
|
||
if (remainingMonths >= 6) compMonths = years + 1
|
||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||
else compMonths = years
|
||
|
||
if (compMonths <= 0) compMonths = 0.5
|
||
|
||
let wage = monthlyWage
|
||
let capped = false
|
||
if (socialAvgWage > 0 && monthlyWage > socialAvgWage * 3) {
|
||
wage = socialAvgWage * 3
|
||
compMonths = Math.min(compMonths, 12)
|
||
capped = true
|
||
}
|
||
|
||
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
|
||
}
|
||
|
||
// 批量解聘:支持合规预检和执行
|
||
export interface BatchTerminatePreview {
|
||
employeeId: string
|
||
employeeName: string
|
||
employeeIdCardNumber: string | null
|
||
department: string
|
||
reason: string
|
||
terminationDate: string
|
||
riskLevel: RiskAssessment | null
|
||
warnings: string[]
|
||
canTerminate: boolean
|
||
}
|
||
|
||
export async function batchTerminatePreview(
|
||
orgId: string,
|
||
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
|
||
): Promise<BatchTerminatePreview[]> {
|
||
const results: BatchTerminatePreview[] = []
|
||
|
||
for (const item of items) {
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { id: item.employeeId, orgId },
|
||
})
|
||
|
||
if (!employee) {
|
||
results.push({
|
||
employeeId: item.employeeId,
|
||
employeeName: '(未找到)',
|
||
employeeIdCardNumber: null,
|
||
department: '',
|
||
reason: item.reason,
|
||
terminationDate: item.terminationDate,
|
||
riskLevel: null,
|
||
warnings: ['员工不存在或无权操作'],
|
||
canTerminate: false,
|
||
})
|
||
continue
|
||
}
|
||
|
||
const { level, warnings } = assessRisk(employee, item.reason)
|
||
results.push({
|
||
employeeId: item.employeeId,
|
||
employeeName: employee.name,
|
||
employeeIdCardNumber: (() => { try { const v = employee.idCardNumber; return v ? (v.includes(':') ? decrypt(v) : v) : null } catch { return null } })(),
|
||
department: employee.department,
|
||
reason: item.reason,
|
||
terminationDate: item.terminationDate,
|
||
riskLevel: level,
|
||
warnings,
|
||
canTerminate: warnings.length === 0,
|
||
})
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
export interface BatchTerminateResult {
|
||
success: string[]
|
||
failed: Array<{ employeeId: string; reason: string }>
|
||
total: number
|
||
}
|
||
|
||
export async function batchTerminate(
|
||
orgId: string,
|
||
userId: string,
|
||
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
|
||
): Promise<BatchTerminateResult> {
|
||
const success: string[] = []
|
||
const failed: Array<{ employeeId: string; reason: string }> = []
|
||
|
||
const employeeIds = items.map((i) => i.employeeId)
|
||
const employees = await prisma.employee.findMany({
|
||
where: { id: { in: employeeIds }, orgId },
|
||
})
|
||
const empMap = new Map(employees.map((e) => [e.id, e]))
|
||
|
||
const existingTerms = await prisma.terminationRecord.findMany({
|
||
where: { employeeId: { in: employeeIds } },
|
||
orderBy: { terminationDate: 'desc' },
|
||
})
|
||
const latestTermMap = new Map<string, Date>()
|
||
for (const t of existingTerms) {
|
||
if (!latestTermMap.has(t.employeeId)) {
|
||
latestTermMap.set(t.employeeId, t.terminationDate)
|
||
}
|
||
}
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
for (const item of items) {
|
||
try {
|
||
const employee = empMap.get(item.employeeId)
|
||
if (!employee) {
|
||
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
|
||
continue
|
||
}
|
||
const latestTermDate = latestTermMap.get(item.employeeId)
|
||
if (latestTermDate && latestTermDate >= employee.hireDate) {
|
||
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
|
||
continue
|
||
}
|
||
|
||
const termDate = new Date(item.terminationDate)
|
||
const termMonth = dateToMonth(termDate)
|
||
const { level } = assessRisk(employee, item.reason)
|
||
const isResigned = termDate <= today
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.terminationRecord.create({
|
||
data: {
|
||
orgId,
|
||
employeeId: item.employeeId,
|
||
type: 'TERMINATION',
|
||
reason: item.reason as TerminationReason,
|
||
terminationDate: termDate,
|
||
compensation: item.compensation || 0,
|
||
socialInsEndMonth: termMonth,
|
||
housingFundEndMonth: termMonth,
|
||
riskLevel: level,
|
||
checklist: {},
|
||
remark: '批量解聘',
|
||
createdBy: userId,
|
||
},
|
||
})
|
||
|
||
await tx.employeeSocialInsRecord.updateMany({
|
||
where: { employeeId: item.employeeId, endMonth: null },
|
||
data: { endMonth: termMonth },
|
||
})
|
||
await tx.employeeHousingFundRecord.updateMany({
|
||
where: { employeeId: item.employeeId, endMonth: null },
|
||
data: { endMonth: termMonth },
|
||
})
|
||
|
||
await tx.employee.update({
|
||
where: { id: item.employeeId },
|
||
data: {
|
||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||
socialInsEndMonth: termMonth,
|
||
housingFundEndMonth: termMonth,
|
||
},
|
||
})
|
||
|
||
await tx.riskItem.updateMany({
|
||
where: { employeeId: item.employeeId, status: 'PENDING' },
|
||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||
})
|
||
})
|
||
|
||
success.push(item.employeeId)
|
||
} catch (err: any) {
|
||
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
|
||
}
|
||
}
|
||
|
||
return { success, failed, total: items.length }
|
||
}
|
||
|
||
// ============================================================
|
||
// 解聘流程状态机:DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
|
||
// ↘ REJECTED → 可修改重新提交
|
||
// 任意非 COMPLETED → CANCELLED
|
||
// ============================================================
|
||
|
||
/** 标准工作交接清单模板 */
|
||
export function getDefaultHandoverItems(): Array<{ key: string; label: string; done: boolean; remark: string }> {
|
||
return [
|
||
{ key: 'work_handover', label: '工作交接完成', done: false, remark: '' },
|
||
{ key: 'equipment_return', label: '办公设备归还', done: false, remark: '' },
|
||
{ key: 'access_revoke', label: '系统权限收回', done: false, remark: '' },
|
||
{ key: 'docs_signed', label: '离职文件签署', done: false, remark: '' },
|
||
{ key: 'finance_settled', label: '财务结算完成', done: false, remark: '' },
|
||
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
|
||
]
|
||
}
|
||
|
||
/** 创建草稿 */
|
||
export async function createDraft(orgId: string, userId: string, data: any) {
|
||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||
if (!employee) {
|
||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||
}
|
||
|
||
const { level } = assessRisk(employee, data.reason || 'NEGOTIATED')
|
||
|
||
// 自动推导社保/公积金截止月(基于组织 socialInsCutoffDay 配置)
|
||
let socialInsEndMonth = data.socialInsEndMonth || null
|
||
let housingFundEndMonth = data.housingFundEndMonth || null
|
||
if (data.terminationDate && !socialInsEndMonth) {
|
||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { socialInsCutoffDay: true } })
|
||
const cutoffDay = org?.socialInsCutoffDay ?? 15
|
||
const termDate = new Date(data.terminationDate)
|
||
const termDay = termDate.getDate()
|
||
const termMonth = dateToMonth(termDate)
|
||
// cutoffDay 日前离职 → 截止月 = 离职月 - 1;cutoffDay 日后离职 → 截止月 = 离职月
|
||
if (termDay <= cutoffDay) {
|
||
const prevMon = prevMonth(termMonth)
|
||
socialInsEndMonth = prevMon
|
||
housingFundEndMonth = prevMon
|
||
} else {
|
||
socialInsEndMonth = termMonth
|
||
housingFundEndMonth = termMonth
|
||
}
|
||
}
|
||
|
||
const record = await prisma.terminationRecord.create({
|
||
data: {
|
||
orgId,
|
||
employeeId: data.employeeId,
|
||
type: data.type || 'TERMINATION',
|
||
reason: data.reason || 'NEGOTIATED',
|
||
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
|
||
resignationReason: data.resignationReason || null,
|
||
compensation: data.compensation || 0,
|
||
socialInsEndMonth,
|
||
housingFundEndMonth,
|
||
riskLevel: level,
|
||
checklist: data.checklist || {},
|
||
remark: data.remark || null,
|
||
createdBy: userId,
|
||
status: 'DRAFT',
|
||
currentStep: data.currentStep || 0,
|
||
compensationBreakdown: data.compensationBreakdown || null,
|
||
checklistOverrides: data.checklistOverrides || null,
|
||
handoverItems: data.handoverItems || getDefaultHandoverItems(),
|
||
},
|
||
})
|
||
|
||
// 主动离职时自动创建离职协议电子签署记录
|
||
if (data.type === 'RESIGNATION') {
|
||
await autoCreateEsignRecord({
|
||
orgId,
|
||
employeeId: data.employeeId,
|
||
scene: 'RESIGNATION',
|
||
documentTitle: `${employee.name}的离职协议`,
|
||
remark: '员工主动离职时自动发起',
|
||
createdBy: userId,
|
||
})
|
||
}
|
||
|
||
return { id: record.id }
|
||
}
|
||
|
||
/** 更新草稿(仅 DRAFT/REJECTED 状态可编辑) */
|
||
export async function updateDraft(orgId: string, recordId: string, userId: string, data: any) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
|
||
throw { code: 'CONFLICT', message: '当前状态不可编辑' }
|
||
}
|
||
|
||
const updateData: any = { updatedBy: userId }
|
||
if (data.reason !== undefined) {
|
||
updateData.reason = data.reason
|
||
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId, orgId } })
|
||
if (employee) {
|
||
const { level } = assessRisk(employee, data.reason)
|
||
updateData.riskLevel = level
|
||
}
|
||
}
|
||
if (data.terminationDate !== undefined) updateData.terminationDate = new Date(data.terminationDate)
|
||
if (data.compensation !== undefined) updateData.compensation = data.compensation
|
||
if (data.socialInsEndMonth !== undefined) updateData.socialInsEndMonth = data.socialInsEndMonth
|
||
if (data.housingFundEndMonth !== undefined) updateData.housingFundEndMonth = data.housingFundEndMonth
|
||
if (data.checklist !== undefined) updateData.checklist = data.checklist
|
||
if (data.remark !== undefined) updateData.remark = data.remark
|
||
if (data.currentStep !== undefined) updateData.currentStep = data.currentStep
|
||
if (data.compensationBreakdown !== undefined) updateData.compensationBreakdown = data.compensationBreakdown
|
||
if (data.checklistOverrides !== undefined) updateData.checklistOverrides = data.checklistOverrides
|
||
if (data.handoverItems !== undefined) updateData.handoverItems = data.handoverItems
|
||
if (data.resignationReason !== undefined) updateData.resignationReason = data.resignationReason
|
||
|
||
await prisma.terminationRecord.update({ where: { id: recordId }, data: updateData })
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 提交审批 */
|
||
export async function submitForApproval(orgId: string, recordId: string, userId: string) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
|
||
throw { code: 'CONFLICT', message: '仅草稿状态可提交审批' }
|
||
}
|
||
|
||
await prisma.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: { status: 'PENDING_APPROVAL', updatedBy: userId },
|
||
})
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 审批通过 */
|
||
export async function approveTermination(orgId: string, recordId: string, userId: string, comment: string) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status !== 'PENDING_APPROVAL') {
|
||
throw { code: 'CONFLICT', message: '仅待审批状态可审批' }
|
||
}
|
||
|
||
await prisma.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: {
|
||
status: 'APPROVED',
|
||
approvedBy: userId,
|
||
approvedAt: new Date(),
|
||
approvalComment: comment || null,
|
||
updatedBy: userId,
|
||
},
|
||
})
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 审批驳回 */
|
||
export async function rejectTermination(orgId: string, recordId: string, userId: string, comment: string) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status !== 'PENDING_APPROVAL') {
|
||
throw { code: 'CONFLICT', message: '仅待审批状态可驳回' }
|
||
}
|
||
|
||
await prisma.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: {
|
||
status: 'REJECTED',
|
||
approvalComment: comment || '驳回',
|
||
updatedBy: userId,
|
||
},
|
||
})
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 执行解聘(APPROVED → EXECUTING → COMPLETED) */
|
||
export async function executeTermination(orgId: string, recordId: string, userId: string) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status !== 'APPROVED' && record.status !== 'DRAFT') {
|
||
throw { code: 'CONFLICT', message: '仅已审批或草稿状态可执行' }
|
||
}
|
||
|
||
// 标记为执行中
|
||
await prisma.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: { status: 'EXECUTING', updatedBy: userId },
|
||
})
|
||
|
||
const termDate = record.terminationDate
|
||
const termMonth = dateToMonth(termDate)
|
||
const socialInsEndMonth = record.socialInsEndMonth || termMonth
|
||
const housingFundEndMonth = record.housingFundEndMonth || termMonth
|
||
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const isResigned = termDate <= today
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.employeeSocialInsRecord.updateMany({
|
||
where: { employeeId: record.employeeId, endMonth: null },
|
||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||
})
|
||
|
||
await tx.employeeHousingFundRecord.updateMany({
|
||
where: { employeeId: record.employeeId, endMonth: null },
|
||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||
})
|
||
|
||
await tx.employee.update({
|
||
where: { id: record.employeeId },
|
||
data: {
|
||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||
socialInsEndMonth,
|
||
housingFundEndMonth,
|
||
},
|
||
})
|
||
|
||
await tx.riskItem.updateMany({
|
||
where: { employeeId: record.employeeId, status: 'PENDING' },
|
||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||
})
|
||
|
||
await tx.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: { status: 'COMPLETED', updatedBy: userId },
|
||
})
|
||
})
|
||
|
||
// 公司解聘执行完成后自动创建离职协议电子签署记录
|
||
if (record.type === 'TERMINATION') {
|
||
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId }, select: { name: true } })
|
||
await autoCreateEsignRecord({
|
||
orgId,
|
||
employeeId: record.employeeId,
|
||
scene: 'RESIGNATION',
|
||
documentTitle: `${employee?.name || '员工'}的解除劳动合同协议`,
|
||
remark: '公司解聘执行完成时自动发起',
|
||
createdBy: userId,
|
||
})
|
||
}
|
||
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 撤销(状态→CANCELLED,不删除记录) */
|
||
export async function cancelTermination(orgId: string, recordId: string, userId: string) {
|
||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
if (record.status === 'COMPLETED') {
|
||
throw { code: 'CONFLICT', message: '已完成的解聘不可撤销' }
|
||
}
|
||
|
||
await prisma.terminationRecord.update({
|
||
where: { id: recordId },
|
||
data: { status: 'CANCELLED', updatedBy: userId },
|
||
})
|
||
|
||
// 如果之前已执行(社保已关闭),恢复员工状态
|
||
if (record.status === 'EXECUTING' || record.status === 'COMPLETED') {
|
||
await prisma.employee.update({
|
||
where: { id: record.employeeId },
|
||
data: { status: 'ACTIVE' },
|
||
})
|
||
}
|
||
|
||
return { id: recordId }
|
||
}
|
||
|
||
/** 获取草稿列表 */
|
||
export async function getDrafts(orgId: string, status?: string, search?: string, department?: string, page?: number, pageSize?: number) {
|
||
const where: any = { orgId }
|
||
if (status) {
|
||
where.status = status
|
||
} else {
|
||
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED', 'EXECUTING', 'COMPLETED', 'CANCELLED'] }
|
||
}
|
||
if (department) {
|
||
where.employee = { department }
|
||
}
|
||
if (search) {
|
||
where.employee = {
|
||
...where.employee,
|
||
OR: [
|
||
{ name: { contains: search } },
|
||
{ department: { contains: search } },
|
||
],
|
||
}
|
||
}
|
||
|
||
const hasPagination = page && pageSize
|
||
const [records, total] = await Promise.all([
|
||
prisma.terminationRecord.findMany({
|
||
where,
|
||
include: { employee: true },
|
||
orderBy: { updatedAt: 'desc' },
|
||
...(hasPagination ? { skip: (page! - 1) * pageSize!, take: pageSize! } : {}),
|
||
}),
|
||
hasPagination ? prisma.terminationRecord.count({ where }) : Promise.resolve(0),
|
||
])
|
||
|
||
const items = records.map((r) => ({
|
||
id: r.id,
|
||
employeeId: r.employeeId,
|
||
employeeName: r.employee.name,
|
||
department: r.employee.department,
|
||
type: r.type,
|
||
reason: r.reason,
|
||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||
compensation: r.compensation,
|
||
riskLevel: r.riskLevel,
|
||
status: r.status,
|
||
currentStep: r.currentStep,
|
||
remark: r.remark,
|
||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||
updatedAt: r.updatedAt.toISOString().slice(0, 10),
|
||
}))
|
||
|
||
if (hasPagination) {
|
||
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize!) }
|
||
}
|
||
return items
|
||
}
|
||
|
||
/** 获取单条记录详情(含所有流程字段) */
|
||
export async function getTerminationDetail(orgId: string, recordId: string) {
|
||
const record = await prisma.terminationRecord.findFirst({
|
||
where: { id: recordId, orgId },
|
||
include: { employee: true },
|
||
})
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
|
||
return {
|
||
id: record.id,
|
||
employeeId: record.employeeId,
|
||
employeeName: record.employee.name,
|
||
department: record.employee.department,
|
||
type: record.type,
|
||
reason: record.reason,
|
||
terminationDate: record.terminationDate.toISOString().slice(0, 10),
|
||
resignationReason: record.resignationReason,
|
||
compensation: record.compensation,
|
||
socialInsEndMonth: record.socialInsEndMonth,
|
||
housingFundEndMonth: record.housingFundEndMonth,
|
||
riskLevel: record.riskLevel,
|
||
checklist: record.checklist,
|
||
remark: record.remark,
|
||
status: record.status,
|
||
currentStep: record.currentStep,
|
||
compensationBreakdown: record.compensationBreakdown,
|
||
checklistOverrides: record.checklistOverrides,
|
||
handoverItems: record.handoverItems,
|
||
approvedBy: record.approvedBy,
|
||
approvedAt: record.approvedAt?.toISOString().slice(0, 10),
|
||
approvalComment: record.approvalComment,
|
||
createdBy: record.createdBy,
|
||
createdAt: record.createdAt.toISOString().slice(0, 10),
|
||
updatedAt: record.updatedAt.toISOString().slice(0, 10),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解聘流程步骤前置校验
|
||
* 返回是否可以继续、阻断原因、警告信息
|
||
*/
|
||
export async function validateTerminationStep(orgId: string, recordId: string, step: number) {
|
||
const record = await prisma.terminationRecord.findFirst({
|
||
where: { id: recordId, orgId },
|
||
include: { employee: true },
|
||
})
|
||
if (!record) {
|
||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||
}
|
||
|
||
const employee = record.employee
|
||
const reason = record.reason as string
|
||
const result: {
|
||
step: number
|
||
canProceed: boolean
|
||
blockReason?: string
|
||
warning?: string
|
||
legalBasis?: string
|
||
} = { step, canProceed: true }
|
||
|
||
// Step 1: 选择员工 — 检查特殊群体
|
||
if (step === 1) {
|
||
// 三期女职工 + 非过错解除 → 阻止
|
||
if (employee.isPregnant && reason !== 'FAULT' && reason !== 'RESIGNATION') {
|
||
result.canProceed = false
|
||
result.blockReason = '该员工处于孕期/哺乳期,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
|
||
result.legalBasis = '《劳动合同法》第四十二条:女职工在孕期、产期、哺乳期内,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
|
||
}
|
||
// 工伤期间 + 非过错解除 → 阻止
|
||
if (employee.isWorkInjured && reason !== 'FAULT' && reason !== 'RESIGNATION') {
|
||
result.canProceed = false
|
||
result.blockReason = '该员工工伤期间,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
|
||
result.legalBasis = '《劳动合同法》第四十二条:在本单位患职业病或者因工负伤并被确认丧失或者部分丧失劳动能力的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
|
||
}
|
||
// 医疗期内 + 非过错解除 → 阻止
|
||
if (employee.isInMedicalPeriod && reason !== 'FAULT' && reason !== 'RESIGNATION') {
|
||
result.canProceed = false
|
||
result.blockReason = '该员工处于医疗期内,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)'
|
||
result.legalBasis = '《劳动合同法》第四十二条:患病或者非因工负伤,在规定的医疗期内的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同'
|
||
}
|
||
}
|
||
|
||
// Step 3: 合规检查 — 检查关键合规项
|
||
if (step === 3) {
|
||
const checklist = record.checklist as any
|
||
const overrides = record.checklistOverrides as any
|
||
|
||
// 过错解除:必须通知工会
|
||
if (reason === 'FAULT') {
|
||
const notifyUnion = checklist?.notify_union
|
||
const override = overrides?.notify_union
|
||
if (!notifyUnion && !override?.checked) {
|
||
result.warning = '未确认"已通知工会"。如未通知工会即解除,可能被认定为违法解除程序(2N 赔偿风险)'
|
||
result.legalBasis = '《劳动合同法》第四十三条:用人单位单方解除劳动合同,应当事先将理由通知工会'
|
||
}
|
||
}
|
||
|
||
// 非过错解除:必须支付补偿金
|
||
if (reason === 'NONFAULT' || reason === 'NEGOTIATED' || reason === 'LAYOFF') {
|
||
const compPaid = checklist?.compensation_paid
|
||
const override = overrides?.compensation_paid
|
||
if (!compPaid && !override?.checked) {
|
||
result.warning = '未确认"已支付经济补偿金"。非过错解除必须支付经济补偿金(N),未支付将面临劳动监察处罚和仲裁风险'
|
||
result.legalBasis = '《劳动合同法》第四十六条:用人单位依照本法第三十六条、第四十条、第四十一条规定解除劳动合同的,应当向劳动者支付经济补偿'
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 4: 费用结算 — 必须先计算补偿金
|
||
if (step === 4) {
|
||
if (reason !== 'RESIGNATION' && record.compensation === 0) {
|
||
const compPaid = (record.checklist as any)?.compensation_paid
|
||
if (!compPaid) {
|
||
result.canProceed = false
|
||
result.blockReason = '补偿金尚未计算。请先在 Step 4 费用结算中计算经济补偿金,再继续后续流程'
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 5: 工作交接 — 检查交接清单
|
||
if (step === 5) {
|
||
const handoverItems = record.handoverItems as any[]
|
||
if (handoverItems && handoverItems.length > 0) {
|
||
const incomplete = handoverItems.filter(h => !h.done)
|
||
if (incomplete.length > 0) {
|
||
result.warning = `还有 ${incomplete.length} 项工作交接未完成:${incomplete.map(h => h.label).join('、')}。建议完成后再执行解聘`
|
||
}
|
||
}
|
||
}
|
||
|
||
// 合同已到期 + 选择"解除"而非"终止" → 警告
|
||
if (step === 2 && reason !== 'EXPIRED' && reason !== 'RESIGNATION') {
|
||
const latestContract = await prisma.laborContract.findFirst({
|
||
where: { employeeId: employee.id, orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
if (latestContract?.endDate && new Date(latestContract.endDate) < new Date()) {
|
||
result.warning = '该员工合同已到期。建议使用"到期终止"(EXPIRED)而非解除,流程更简单且法律风险更低'
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|