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() 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