Files
TurboHR/backend/src/routes/employee.routes.ts
T
selfrelease 12f1cde7c5 feat: 花名册操作自动创建电子签署流程
## 新增 esign.service.ts
- autoCreateEsignRecord 公共方法,供其他业务模块调用
- 自动渲染模板文件内容 + 创建证据链

## 自动触发签署
- 增加员工(创建合同时):自动创建劳动合同电子签署记录(scene=CONTRACT)
- 主动离职(createDraft type=RESIGNATION):自动创建离职协议签署(scene=RESIGNATION)
- 公司解聘(executeTermination type=TERMINATION):执行完成后自动创建离职协议签署

## 前端提示
- 添加员工成功后提示"劳动合同电子签署已自动发起",可跳转签署页面
- 主动离职成功后提示"离职协议电子签署已自动发起"

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 16:27:15 +08:00

374 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { createEvidence } from '../services/evidence.service'
import { autoCreateEsignRecord } from '../services/esign.service'
import prisma from '../lib/prisma'
import { sha256, decrypt } from '../lib/crypto'
import {
createEmployeeSchema,
updateEmployeeSchema,
batchRenewSchema,
addContractSchema,
} from '../schemas/contract.schema'
import {
getEmployees,
getEmployeeDetail,
createEmployee,
rehireEmployee,
updateEmployee,
deleteEmployee,
batchRenew,
addContract,
} from '../services/contract.service'
const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getEmployees(req.user!.orgId, {
page: parseInt(req.query.page as string) || 1,
pageSize: Math.min(parseInt(req.query.pageSize as string) || 20, 200),
search: req.query.search as string,
department: req.query.department as string,
})
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
/**
* 轻量级全量员工列表(不分页,仅返回 id/name/department/position/gender/status
* 用于特殊状态台账、发薪批次等需要选择全部员工的场景
* 支持 status 筛选参数,默认返回 ACTIVE
*/
router.get('/all-lite', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status
? String(req.query.status).split(',') as any[]
: ['ACTIVE', 'RESIGNED'] as any[]
const department = req.query.department as string | undefined
const employees = await prisma.employee.findMany({
where: {
orgId: req.user!.orgId,
status: { in: status },
...(department && { department }),
},
select: { id: true, name: true, department: true, position: true, gender: true, status: true },
orderBy: { name: 'asc' },
})
res.json({ success: true, data: employees })
} catch (err) {
next(err)
}
})
/**
* 标准员工列表(不分页,供各页面下拉选择、引用等场景)
* 与 /roster 分离,/roster 专用于花名册分页列表
* 支持 status、department 筛选
*/
router.get('/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status
? String(req.query.status).split(',') as any[]
: ['ACTIVE'] as any[]
const department = req.query.department as string | undefined
const employees = await prisma.employee.findMany({
where: {
orgId: req.user!.orgId,
status: { in: status },
...(department && { department }),
},
select: { id: true, name: true, department: true, position: true, phone: true, gender: true, status: true },
orderBy: { name: 'asc' },
})
res.json({ success: true, data: employees })
} catch (err) {
next(err)
}
})
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: employee })
} catch (err) {
next(err)
}
})
// 身份证查重
router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const idCard = req.query.idCard as string
if (!idCard || idCard.length < 18) {
return res.json({ success: true, data: { exists: false } })
}
const hash = sha256(idCard)
const employee = await prisma.employee.findFirst({
where: { orgId: req.user!.orgId, idCardHash: hash },
select: { id: true, name: true, department: true, status: true },
})
res.json({ success: true, data: { exists: !!employee, employee } })
} catch (err) {
next(err)
}
})
// 手机号查重
router.get('/check-phone', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const phone = req.query.phone as string
if (!phone || phone.length < 11) {
return res.json({ success: true, data: { exists: false } })
}
// 手机号加密存储,需遍历匹配(量小可接受)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId },
select: { id: true, name: true, department: true, status: true, phone: true },
})
const matched = employees.find(e => {
try { return e.phone ? decrypt(e.phone) === phone : false } catch { return false }
})
res.json({ success: true, data: { exists: !!matched, employee: matched ? { id: matched.id, name: matched.name, department: matched.department, status: matched.status } : undefined } })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createEmployeeSchema.parse(req.body)
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
await createEvidence({
orgId: req.user!.orgId,
category: 'ONBOARD',
refId: result.id,
employeeId: result.id,
events: [{ action: '员工入职登记', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = updateEmployeeSchema.parse(req.body)
const result = await updateEmployee(req.user!.orgId, req.params.id, data)
await auditLog(req, 'UPDATE', 'EMPLOYEE', req.params.id, data)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/:id/rehire', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await rehireEmployee(req.user!.orgId, req.user!.id, req.params.id, req.body)
await auditLog(req, 'REHIRE', 'EMPLOYEE', req.params.id, { hireDate: req.body.hireDate })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
if (err?.code === 'VALIDATION_ERROR') {
return res.status(400).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await deleteEmployee(req.user!.orgId, req.params.id)
const emp = await prisma.employee.findFirst({ where: { id: req.params.id }, select: { name: true, department: true } })
await auditLog(req, 'DELETE', 'EMPLOYEE', req.params.id, { employeeName: emp?.name || '', department: emp?.department || '' })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 批量续签合规预检
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractIds } = req.body as { contractIds: string[] }
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
}
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId: req.user!.orgId },
include: { employee: true },
orderBy: { startDate: 'asc' },
})
if (contracts.length === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
}
// 合规检查:按员工分组,检查历史固定期合同次数
const employeeIds = contracts.map((c) => c.employeeId)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: { in: employeeIds },
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
const fixedContractsByEmp = new Map<string, typeof allFixedContracts>()
for (const c of allFixedContracts) {
if (!fixedContractsByEmp.has(c.employeeId)) {
fixedContractsByEmp.set(c.employeeId, [])
}
fixedContractsByEmp.get(c.employeeId)!.push(c)
}
const results = []
for (const contract of contracts) {
const employee = contract.employee
const empFixedContracts = fixedContractsByEmp.get(contract.employeeId) || []
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = empFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
// 2. 员工连续工作满10年
const shouldBeUnfixed = renewalCount >= 2
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
let warning: string | null = null
let suggestion: string | null = null
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
warning = shouldBeUnfixed
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
} else {
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
}
results.push({
contractId: contract.id,
employeeId: contract.employeeId,
employeeName: employee.name,
department: employee.department,
currentContractType: contract.contractType,
renewalCount,
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
warning,
suggestion,
canRenewFixed: !warning,
})
}
res.json({
success: true,
data: {
total: results.length,
warnings: results.filter((r) => r.warning).length,
results,
},
})
} catch (err) {
next(err)
}
})
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = batchRenewSchema.parse(req.body)
const result = await batchRenew(req.user!.orgId, req.user!.id, data.contractIds, data.years)
await auditLog(req, 'BATCH_RENEW', 'CONTRACT', undefined, { count: data.contractIds.length, years: data.years, contractIds: data.contractIds })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = addContractSchema.parse(req.body)
const result = await addContract(req.user!.orgId, req.user!.id, data)
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId }, select: { name: true } })
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeName: emp?.name || '', employeeId: data.employeeId, contractType: data.contractType, startDate: data.startDate, endDate: data.endDate })
await createEvidence({
orgId: req.user!.orgId,
category: 'CONTRACT_SIGN',
refId: result.id,
employeeId: data.employeeId,
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
createdBy: req.user!.id,
}).catch(() => {})
// 自动创建劳动合同电子签署记录
const esignRecord = await autoCreateEsignRecord({
orgId: req.user!.orgId,
employeeId: data.employeeId,
scene: 'CONTRACT',
documentTitle: `${emp?.name || ''}的劳动合同`,
contractId: result.id,
remark: '新增员工合同时自动发起',
createdBy: req.user!.id,
})
res.json({ success: true, data: result, esignRecord })
} catch (err) {
next(err)
}
})
router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const contract = await prisma.laborContract.findFirst({
where: { id: req.params.contractId, orgId: req.user!.orgId },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
// 作废处理:设置结束日期为当前时间,保留记录但不物理删除
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { endDate: new Date() },
})
const emp = await prisma.employee.findFirst({ where: { id: contract.employeeId }, select: { name: true } })
await auditLog(req, 'VOID_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 补充上传合同附件
router.patch('/contracts/:contractId/attachment', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const contract = await prisma.laborContract.findFirst({
where: { id: req.params.contractId, orgId: req.user!.orgId },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
const { attachmentUrl } = req.body as { attachmentUrl: string }
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { attachmentUrl: attachmentUrl || null },
})
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router