Files
TurboHR/backend/src/routes/work-process.routes.ts
T
selfrelease e1b5ae9aab feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
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>
2026-08-15 12:37:27 +08:00

371 lines
15 KiB
TypeScript

import { Router, Response, NextFunction } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { parsePagination } from '../lib/pagination'
import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service'
import { createWorkProcessSchema, updateWorkProcessSchema } from '../schemas/work-process.schema'
const router = Router()
// 各流程类型的必填字段映射
const REQUIRED_FIELDS: Record<string, string[]> = {
HIRE: ['name', 'department', 'hireDate', 'phone', 'idCardNumber'],
ONBOARD: ['employeeId', 'hireDate'],
CUSTOM_CONTRACT: ['employeeId', 'contractStartDate'],
INFO_SUBMIT: ['employeeId'],
CONFIRM: ['employeeId', 'confirmDate'],
CHANGE: ['contractId', 'newEndDate'],
RENEW: ['employeeId', 'newStartDate'],
SUSPEND: ['contractId', 'suspendDate'],
INCOME_CERT: ['employeeName', 'idCardNumber'],
TERMINATE: ['employeeId', 'terminateDate', 'reason'],
RESCIND: ['employeeId', 'rescindDate', 'reason'],
LEAVING_CERT: ['employeeName', 'idCardNumber', 'leaveDate'],
FLEXIBLE: ['name', 'idCardNumber', 'agreementStartDate'],
}
// 必填字段中文标签映射
const FIELD_LABELS: Record<string, string> = {
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
contractStartDate: '合同开始日期', confirmDate: '转正日期',
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
suspendDate: '中止日期', employeeName: '员工姓名',
terminateDate: '终止日期', rescindDate: '解除日期', reason: '原因',
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
}
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
}
/** 校验日期前后关系:结束日期不能早于开始日期 */
function validateWorkProcessDateRange(type: string, formData: Record<string, any>): string | null {
const pairs = DATE_RANGE_FIELDS[type]
if (!pairs) return null
for (const pair of pairs) {
const start = formData[pair.start]
const end = formData[pair.end]
if (start && end && new Date(end) < new Date(start)) {
return `${pair.endLabel}不能早于${pair.startLabel}`
}
}
return null
}
// 创建办理(含草稿)
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createWorkProcessSchema.parse(req.body)
const { type, title, employeeId, formData, status, remark } = data
// 后端日期前后关系校验(双保险)
const dateError = validateWorkProcessDateRange(type, formData || {})
if (dateError) {
return res.status(400).json({ success: false, error: { code: 'INVALID_DATE_RANGE', message: dateError } })
}
const process = await (prisma as any).workProcess.create({
data: {
orgId: req.user!.orgId,
type,
title: title || PROCESS_TYPES[type].label,
employeeId: employeeId || null,
formData: formData || {},
status,
remark: remark || null,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: process })
} catch (err) {
next(err)
}
})
// 列表查询
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { type, status } = req.query
const { page, pageSize } = parsePagination(req.query)
const where: any = { orgId: req.user!.orgId }
if (type) where.type = type
if (status) where.status = status
const total = await (prisma as any).workProcess.count({ where })
const items = await (prisma as any).workProcess.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
})
res.json({ success: true, data: { items, total, page, pageSize } })
} catch (err) {
next(err)
}
})
// 详情
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: { employee: { select: { id: true, name: true, department: true, status: true } } },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
res.json({ success: true, data: process })
} catch (err) {
next(err)
}
})
// 更新草稿
router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const existing = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (existing.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } })
}
const { title, employeeId, formData, remark } = updateWorkProcessSchema.parse(req.body)
const updated = await (prisma as any).workProcess.update({
where: { id: req.params.id },
data: {
...(title !== undefined && { title }),
...(employeeId !== undefined && { employeeId: employeeId || null }),
...(formData !== undefined && { formData }),
...(remark !== undefined && { remark }),
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 提交办理
router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (process.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可提交' } })
}
// 后端必填字段校验
const required = REQUIRED_FIELDS[process.type] || []
const fd = process.formData || {}
const missing = required.filter((key) => !fd[key] || String(fd[key]).trim() === '')
if (missing.length > 0) {
const labels = missing.map((k) => FIELD_LABELS[k] || k).join('、')
return res.status(400).json({ success: false, error: { code: 'MISSING_REQUIRED', message: `请填写必填项:${labels}` } })
}
// 执行业务联动
let execResult: any = {}
try {
execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id)
} catch (execErr: any) {
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
}
// 生成文书
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = await generateDocument(process.type, process.formData, org?.name || '')
const documents = doc.content ? [doc] : []
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
data: {
status: 'COMPLETED',
documents: documents.length > 0 ? documents : null,
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 审批通过
router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (process.status !== 'PENDING_APPROVAL') {
return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可审批' } })
}
let execResult: any = {}
try {
execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id)
} catch (execErr: any) {
return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = await generateDocument(process.type, process.formData, org?.name || '')
const documents = doc.content ? [doc] : []
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
data: {
status: 'COMPLETED',
approverId: req.user!.id,
approvedAt: new Date(),
documents: documents.length > 0 ? documents : null,
...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }),
},
})
// 入职审批通过且开启了入职文件电子签,创建电子签记录
if (execResult.employeeId && process.type === 'ONBOARDING') {
const orgSettings = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { esignOnboardingEnabled: true } })
if (orgSettings?.esignOnboardingEnabled) {
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: 'ONBOARDING',
documentTitle: '入职文件签署',
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '入职审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
})
}
}
// 离职证明/收入证明审批通过后,如果有关联员工,创建电子签记录
if (execResult.employeeId && (process.type === 'LEAVING_CERT' || process.type === 'INCOME_CERT')) {
const docTitle = process.type === 'LEAVING_CERT' ? '离职证明签署' : '收入证明签署'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: process.type === 'LEAVING_CERT' ? 'RESIGNATION' : 'OTHER',
documentTitle: docTitle,
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '文书审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}).catch(() => {})
}
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 驳回
router.post('/:id/reject', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (process.status !== 'PENDING_APPROVAL') {
return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可驳回' } })
}
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
data: {
status: 'REJECTED',
approverId: req.user!.id,
approvedAt: new Date(),
remark: req.body.reason || '驳回',
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 撤销
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (['COMPLETED', 'CANCELLED'].includes(process.status)) {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '已完成或已撤销的记录不可撤销' } })
}
const updated = await (prisma as any).workProcess.update({
where: { id: process.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 删除草稿
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
if (process.status !== 'DRAFT') {
return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可删除' } })
}
await (prisma as any).workProcess.delete({ where: { id: process.id } })
res.json({ success: true, data: { message: '已删除' } })
} catch (err) {
next(err)
}
})
// 预览文书
router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const process = await (prisma as any).workProcess.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!process) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const doc = await generateDocument(process.type, process.formData, org?.name || '')
res.json({ success: true, data: doc })
} catch (err) {
next(err)
}
})
// 获取流程类型列表
router.get('/meta/types', authMiddleware, (_req: AuthRequest, res: Response) => {
res.json({ success: true, data: PROCESS_TYPES })
})
// 获取状态列表
router.get('/meta/statuses', authMiddleware, (_req: AuthRequest, res: Response) => {
res.json({ success: true, data: PROCESS_STATUS })
})
export default router