12f1cde7c5
## 新增 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>
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
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
|