e1b5ae9aab
P0紧急修复(6项): - 草稿保存完整恢复所有字段(含socialAvgWage) - 补偿金批次从compensationBreakdown读取 - 违法解除风险确认UI - 合同结束日期前后校验(前后端双保险) P1高优先级(14项): - 离职日期联动社保/公积金截止月(15号规则) - 合规检查+工作交接改为软阻断(生成待办) - 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义) - 解聘并入花名册操作栏(类型选择跳转向导) - 合同续签开始日期自动推导(原合同结束日+1天) - 年龄合规筛查(童工阻断/未成年工/退休警告) - 编辑入职日期后状态联动(待入职↔在职) - 转正移植到花名册操作栏+薪资回写 - 男职工无法选择三期 P2体验优化(9项): - "劳动合同"调整为"用工关系" - 费用结算新增剩余年假折算(300%日工资) - 身份证号全域改为"证件号码"(前后端18个文件) - 手机号查重 - 开具证明+合同续签移植到花名册操作栏 - 批量转正+批量开具证明 - 去掉用工办理模块 P3规划(2项): - 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance) - 客服工作台(Ticket/ChatSession+SUPPORT角色) 新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage 新增字段: Employee.departmentId/supervisorId 新增角色: SUPPORT Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
327 lines
14 KiB
TypeScript
327 lines
14 KiB
TypeScript
import prisma from '../lib/prisma'
|
|
import { encrypt, decrypt } from '../lib/crypto'
|
|
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
|
|
import { createEmployee, addContract, prevMonth } from './contract.service'
|
|
import { runRiskDetection } from './risk.service'
|
|
|
|
// 13类流程定义
|
|
export const PROCESS_TYPES: Record<string, { label: string; description: string; icon: string }> = {
|
|
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同', icon: 'user-plus' },
|
|
ONBOARD: { label: '员工入职', description: '办理员工入职手续', icon: 'log-in' },
|
|
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署', icon: 'file-signature' },
|
|
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更', icon: 'edit' },
|
|
CONFIRM: { label: '员工转正', description: '试用期员工转正', icon: 'check-circle' },
|
|
CHANGE: { label: '合同变更', description: '变更合同内容', icon: 'refresh-cw' },
|
|
RENEW: { label: '合同续签', description: '到期合同续签', icon: 'repeat' },
|
|
SUSPEND: { label: '合同中止', description: '中止履行合同', icon: 'pause' },
|
|
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明', icon: 'file-text' },
|
|
TERMINATE: { label: '合同终止', description: '合同到期终止', icon: 'x-circle' },
|
|
RESCIND: { label: '合同解除', description: '协商或单方解除合同', icon: 'user-x' },
|
|
LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明', icon: 'file-minus' },
|
|
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署', icon: 'briefcase' },
|
|
}
|
|
|
|
export const PROCESS_STATUS: Record<string, { label: string; color: string }> = {
|
|
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
|
|
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
|
|
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
|
|
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
|
|
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
|
|
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
|
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
|
|
}
|
|
|
|
// 提交后业务联动
|
|
export async function executeWorkProcess(processId: string, type: string, formData: any, orgId: string, userId: string) {
|
|
switch (type) {
|
|
case 'HIRE': {
|
|
const { name, department, hireDate, monthlySalary, phone, idCardNumber, gender, contractStartDate, contractEndDate, contractType = 'FIXED' } = formData
|
|
const result = await createEmployee(orgId, userId, {
|
|
name,
|
|
department: department || '未分配',
|
|
hireDate,
|
|
monthlySalary: monthlySalary || 0,
|
|
phone,
|
|
idCardNumber,
|
|
gender,
|
|
contract: contractStartDate ? {
|
|
contractType,
|
|
startDate: contractStartDate,
|
|
endDate: contractEndDate || undefined,
|
|
contractYears: 3,
|
|
probationMonths: 0,
|
|
probationSalary: 0,
|
|
} : undefined,
|
|
})
|
|
return { employeeId: result.id }
|
|
}
|
|
case 'ONBOARD': {
|
|
const { employeeId, hireDate } = formData
|
|
if (employeeId) {
|
|
await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { hireDate: new Date(hireDate), status: 'ACTIVE' },
|
|
})
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'CONFIRM': {
|
|
const { employeeId, regularSalary } = formData
|
|
if (employeeId) {
|
|
if (regularSalary) {
|
|
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
|
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
|
const newSalary = Number(regularSalary) || 0
|
|
await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { monthlySalary: encrypt(String(regularSalary)) },
|
|
})
|
|
// 记录薪资变更(试用期薪资 → 转正薪资)
|
|
if (oldSalary !== newSalary) {
|
|
const now = new Date()
|
|
const nowMonth = now.toISOString().slice(0, 7)
|
|
await prisma.salaryChangeRecord.updateMany({
|
|
where: { employeeId, endMonth: null },
|
|
data: { endMonth: prevMonth(nowMonth) },
|
|
})
|
|
await prisma.salaryChangeRecord.create({
|
|
data: {
|
|
orgId,
|
|
employeeId,
|
|
oldSalary,
|
|
newSalary,
|
|
effectiveDate: now,
|
|
effectiveMonth: nowMonth,
|
|
endMonth: null,
|
|
changeType: 'CONFIRM',
|
|
reason: '试用期转正薪资调整',
|
|
createdBy: userId,
|
|
},
|
|
})
|
|
}
|
|
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
|
|
const latestContract = await prisma.laborContract.findFirst({
|
|
where: { employeeId, orgId },
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
|
|
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
|
|
}
|
|
await runRiskDetection(orgId)
|
|
}
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'RENEW': {
|
|
const { employeeId, oldContractId, newStartDate, newEndDate, newSalary, contractType = 'FIXED', contractYears = 3 } = formData
|
|
// 截止旧合同
|
|
if (oldContractId) {
|
|
const oldEndDate = new Date(newStartDate)
|
|
oldEndDate.setDate(oldEndDate.getDate() - 1)
|
|
await prisma.laborContract.update({
|
|
where: { id: oldContractId },
|
|
data: { endDate: oldEndDate },
|
|
})
|
|
}
|
|
// 创建新合同(复用 addContract 的验证逻辑)
|
|
if (employeeId) {
|
|
const result = await addContract(orgId, userId, {
|
|
employeeId,
|
|
startDate: newStartDate,
|
|
endDate: newEndDate || undefined,
|
|
contractType,
|
|
contractYears: Number(contractYears) || 3,
|
|
probationMonths: 0,
|
|
probationSalary: 0,
|
|
})
|
|
if (newSalary) {
|
|
await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { monthlySalary: encrypt(String(newSalary)) },
|
|
})
|
|
}
|
|
return { employeeId, newContractId: result.id }
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'TERMINATE': {
|
|
const { employeeId, contractId, terminateDate, reason = 'EXPIRED', compensation = 0 } = formData
|
|
// 截止合同
|
|
if (contractId) {
|
|
await prisma.laborContract.update({
|
|
where: { id: contractId },
|
|
data: { endDate: new Date(terminateDate) },
|
|
})
|
|
}
|
|
// 通过 termination service 创建解聘记录(含风险评级、合规清单)
|
|
if (employeeId) {
|
|
const draft = await createTerminationDraft(orgId, userId, {
|
|
employeeId,
|
|
type: 'TERMINATION',
|
|
reason,
|
|
terminationDate: terminateDate,
|
|
compensation,
|
|
})
|
|
// 直接执行(WorkProcess 流程审批已通过)
|
|
await executeTermination(orgId, draft.id, userId)
|
|
return { employeeId, terminationRecordId: draft.id }
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'RESCIND': {
|
|
const { employeeId, contractId, rescindDate, reason = 'NEGOTIATED', compensation = 0 } = formData
|
|
// 截止合同
|
|
if (contractId) {
|
|
await prisma.laborContract.update({
|
|
where: { id: contractId },
|
|
data: { endDate: new Date(rescindDate) },
|
|
})
|
|
}
|
|
// 通过 termination service 创建解聘记录(含风险评级、合规清单)
|
|
if (employeeId) {
|
|
const draft = await createTerminationDraft(orgId, userId, {
|
|
employeeId,
|
|
type: 'TERMINATION',
|
|
reason,
|
|
terminationDate: rescindDate,
|
|
compensation,
|
|
})
|
|
await executeTermination(orgId, draft.id, userId)
|
|
return { employeeId, terminationRecordId: draft.id }
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'CHANGE': {
|
|
const { contractId, newEndDate } = formData
|
|
if (contractId && newEndDate) {
|
|
await prisma.laborContract.update({
|
|
where: { id: contractId },
|
|
data: { endDate: new Date(newEndDate) },
|
|
})
|
|
}
|
|
return { contractId }
|
|
}
|
|
case 'SUSPEND': {
|
|
const { contractId, suspendDate } = formData
|
|
if (contractId && suspendDate) {
|
|
await prisma.laborContract.update({
|
|
where: { id: contractId },
|
|
data: { endDate: new Date(suspendDate) },
|
|
})
|
|
}
|
|
return { contractId }
|
|
}
|
|
case 'CUSTOM_CONTRACT': {
|
|
const { employeeId, contractStartDate, contractEndDate, contractType = 'FIXED', signMethod = 'PAPER', contractYears = 3 } = formData
|
|
if (employeeId) {
|
|
const result = await addContract(orgId, userId, {
|
|
employeeId,
|
|
startDate: contractStartDate,
|
|
endDate: contractEndDate || undefined,
|
|
contractType,
|
|
signMethod,
|
|
contractYears: Number(contractYears) || 3,
|
|
probationMonths: 0,
|
|
probationSalary: 0,
|
|
})
|
|
return { employeeId, newContractId: result.id }
|
|
}
|
|
return {}
|
|
}
|
|
case 'FLEXIBLE': {
|
|
const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate } = formData
|
|
const empResult = await createEmployee(orgId, userId, {
|
|
name,
|
|
department: department || '灵活用工',
|
|
hireDate: agreementStartDate,
|
|
monthlySalary: 0,
|
|
phone,
|
|
idCardNumber,
|
|
})
|
|
const contractResult = await addContract(orgId, userId, {
|
|
employeeId: empResult.id,
|
|
startDate: agreementStartDate,
|
|
endDate: agreementEndDate || undefined,
|
|
contractType: 'LABOR',
|
|
contractYears: 1,
|
|
probationMonths: 0,
|
|
probationSalary: 0,
|
|
})
|
|
return { employeeId: empResult.id, newContractId: contractResult.id }
|
|
}
|
|
case 'INFO_SUBMIT': {
|
|
const { employeeId, ...updateFields } = formData
|
|
if (employeeId) {
|
|
const allowedFields: Record<string, any> = {}
|
|
if (updateFields.department) allowedFields.department = updateFields.department
|
|
if (updateFields.phone) allowedFields.phone = updateFields.phone
|
|
if (updateFields.address) allowedFields.address = updateFields.address
|
|
if (updateFields.emergencyContact) allowedFields.emergencyContact = updateFields.emergencyContact
|
|
if (updateFields.emergencyPhone) allowedFields.emergencyPhone = updateFields.emergencyPhone
|
|
if (updateFields.bankAccount) allowedFields.bankAccount = encrypt(updateFields.bankAccount)
|
|
if (updateFields.bankName) allowedFields.bankName = updateFields.bankName
|
|
if (Object.keys(allowedFields).length > 0) {
|
|
await prisma.employee.update({ where: { id: employeeId }, data: allowedFields })
|
|
}
|
|
}
|
|
return { employeeId }
|
|
}
|
|
case 'INCOME_CERT':
|
|
case 'LEAVING_CERT': {
|
|
// 这两类只生成文书,不改变业务数据
|
|
return {}
|
|
}
|
|
default:
|
|
return {}
|
|
}
|
|
}
|
|
|
|
// 生成文书预览
|
|
export async function generateDocument(type: string, formData: any, orgName: string): Promise<{ name: string; content: string }> {
|
|
// 如果指定了企业自定义模板,使用企业模板渲染
|
|
if (formData.enterpriseTemplateId) {
|
|
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
|
where: { id: formData.enterpriseTemplateId },
|
|
})
|
|
if (tpl) {
|
|
let content = tpl.content
|
|
// 替换变量 {{var}}
|
|
const allVars: Record<string, string> = { ...formData, companyName: orgName }
|
|
for (const [key, value] of Object.entries(allVars)) {
|
|
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(value ?? ''))
|
|
}
|
|
return { name: `${tpl.name}.doc`, content }
|
|
}
|
|
}
|
|
|
|
const wrapHtml = (title: string, body: string) => `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
|
<head><meta charset="utf-8"><title>${title}</title>
|
|
<style>
|
|
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: center; }
|
|
.title { font-size: 22pt; font-weight: bold; margin-bottom: 30pt; }
|
|
.body { text-align: justify; text-indent: 2em; margin: 0 20pt; }
|
|
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; }
|
|
</style></head>
|
|
<body>
|
|
<div class="title">${title}</div>
|
|
${body}
|
|
</body></html>`
|
|
|
|
const templates: Record<string, (data: any, org: string) => string> = {
|
|
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
|
|
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
|
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
|
|
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
|
<div class="body">特此证明。</div>
|
|
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
|
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
|
|
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
|
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
|
|
<div class="body">特此证明。</div>
|
|
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
|
}
|
|
const generator = templates[type]
|
|
if (!generator) return { name: '', content: '' }
|
|
return { name: `${PROCESS_TYPES[type]?.label || '文书'}.doc`, content: generator(formData, orgName) }
|
|
}
|