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>
371 lines
15 KiB
TypeScript
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
|