/** * 电子签署路由 — 管理端 * * 完整签署流程: * 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链) * 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署 * 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件 * 4. 易签宝回调(对接后启用):更新状态 + 回写PDF * * 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署 */ import { Router, Response, NextFunction } from 'express' import { z } from 'zod' import jwt from 'jsonwebtoken' import multer from 'multer' import path from 'path' import fs from 'fs' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import { createEvidence, appendEvidence } from '../services/evidence.service' import { renderTemplate, getTemplateById } from '../services/template.service' const router = Router() router.use(authMiddleware) const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret' /** 场景与组织电子签开关的映射 */ const SCENE_ORG_FLAG_MAP: Record = { CONTRACT: null, // 合同签署不需要额外开关(默认允许) RESIGNATION: null, // 离职协议不需要额外开关 POLICY: 'esignPolicyEnabled', PAYSLIP: 'esignPayslipEnabled', ONBOARDING: 'esignOnboardingEnabled', } /** 场景与模板ID的映射(自动渲染文件内容) */ const SCENE_TEMPLATE_MAP: Record = { CONTRACT: 'tpl_fixed_term_contract', RESIGNATION: 'tpl_termination_agreement', POLICY: null, PAYSLIP: null, ONBOARDING: null, } const createSignSchema = z.object({ contractId: z.string().optional(), employeeId: z.string().min(1), documentTitle: z.string().min(1), documentContent: z.string().optional(), remark: z.string().optional(), scene: z.string().optional(), templateId: z.string().optional(), // 可指定模板,不传则按 scene 自动匹配 templateVars: z.record(z.string()).optional(), // 模板变量 }) /** * 签署记录列表 */ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const status = req.query.status as string | undefined const scene = req.query.scene as string | undefined const records = await prisma.eSignRecord.findMany({ where: { orgId: req.user!.orgId, ...(status && { status }), ...(scene && { scene }), }, include: { employee: { select: { id: true, name: true, department: true, phone: true } }, }, orderBy: { createdAt: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) /** * 待签合同列表(按员工聚合) * GET /esign/pending * * 汇总两类未签署记录,按员工聚合: * 1. EsignRecord 中 status 为 PENDING/SIGNING 的(所有场景) * 2. LaborContract 中 signDate 为空且员工在职的(纸质合同未登记签署日期) * * 返回格式:[{ employeeId, name, department, phone, pendingItems: [{ type, title, scene, signMethod, status, createdAt, recordId, contractId }] }] */ router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const orgId = req.user!.orgId // 1. 查询未完成的 EsignRecord const pendingEsign = await prisma.eSignRecord.findMany({ where: { orgId, status: { in: ['PENDING', 'SIGNING'] }, }, include: { employee: { select: { id: true, name: true, department: true, phone: true, status: true } }, }, orderBy: { createdAt: 'desc' }, }) // 2. 查询 signDate 为空的 LaborContract(且员工在职) const pendingContracts = await prisma.laborContract.findMany({ where: { orgId, signDate: null, employee: { status: 'ACTIVE' }, }, include: { employee: { select: { id: true, name: true, department: true, phone: true, status: true } }, }, orderBy: { createdAt: 'desc' }, }) // 3. 按员工聚合 const employeeMap = new Map() // 辅助函数:添加员工到 map const ensureEmployee = (emp: { id: string; name: string; department: string | null; phone: string | null }) => { if (!employeeMap.has(emp.id)) { employeeMap.set(emp.id, { employeeId: emp.id, name: emp.name, department: emp.department, phone: emp.phone, pendingItems: [], }) } return employeeMap.get(emp.id)! } // 汇总 EsignRecord for (const r of pendingEsign) { const emp = ensureEmployee(r.employee) emp.pendingItems.push({ type: 'esign', recordId: r.id, contractId: r.contractId, title: r.documentTitle, scene: r.scene, signMethod: r.signMethod, status: r.status, createdAt: r.createdAt, }) } // 汇总 LaborContract(排除已有 EsignRecord 关联的,避免重复) const esignContractIds = new Set(pendingEsign.filter(r => r.contractId).map(r => r.contractId)) for (const c of pendingContracts) { if (esignContractIds.has(c.id)) continue // 已有电子签署记录的不重复 const emp = ensureEmployee(c.employee) emp.pendingItems.push({ type: 'contract', recordId: null, contractId: c.id, title: `${c.contractType}合同`, scene: 'CONTRACT', signMethod: c.signMethod, status: 'PENDING', createdAt: c.createdAt, }) } // 转为数组,按待签数量降序、姓名排序 const result = Array.from(employeeMap.values()).sort((a, b) => { if (b.pendingItems.length !== a.pendingItems.length) return b.pendingItems.length - a.pendingItems.length return a.name.localeCompare(b.name) }) res.json({ success: true, data: result }) } catch (err) { next(err) } }) /** * 催办 — 生成员工一次性自动登录链接(指向员工端签署页) * POST /esign/remind body: { employeeId } * * 返回自动登录 URL,HR 可复制或生成二维码发给员工 */ router.post('/remind', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const { employeeId } = req.body if (!employeeId) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } }) } const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' }, select: { id: true, name: true, phone: true, orgId: true }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } }) } // 生成一次性 token(24 小时有效,给员工充足时间签署) const token = jwt.sign( { id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name }, AUTO_LOGIN_SECRET, { expiresIn: '24h' }, ) const url = `${process.env.PORTAL_BASE_URL || ''}/portal/auto-login?token=${token}&redirect=/portal/esign` await auditLog(req, 'REMIND', 'ESIGN', employeeId, { employeeName: employee.name }) res.json({ success: true, data: { url, token, employeeName: employee.name, phone: employee.phone } }) } catch (err) { next(err) } }) /** * 发起签署 * - 校验组织电子签开关 * - 自动从模板渲染文件内容 * - 创建 ESignRecord * - 创建证据链(发起签署事件) */ router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const data = createSignSchema.parse(req.body) const scene = data.scene || 'CONTRACT' // 1. 校验组织电子签开关 const orgFlag = SCENE_ORG_FLAG_MAP[scene] if (orgFlag) { const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { [orgFlag]: true, name: true } as any, }) if (org && !(org as any)[orgFlag]) { return res.status(403).json({ success: false, error: { code: 'ESIGN_DISABLED', message: `组织未开启${scene === 'POLICY' ? '规章制度' : scene === 'PAYSLIP' ? '工资条' : scene === 'ONBOARDING' ? '入职文件' : '该场景'}电子签功能,请在系统设置中开启` }, }) } } // 2. 获取员工信息 const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId: req.user!.orgId }, select: { id: true, name: true, phone: true, department: true, idCardNumber: true, position: true, monthlySalary: true, hireDate: true }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } // 3. 自动渲染文件内容(优先使用指定模板,否则按 scene 匹配) let documentContent = data.documentContent || '' if (!documentContent) { const templateId = data.templateId || SCENE_TEMPLATE_MAP[scene] if (templateId) { const template = getTemplateById(templateId) if (template) { // 自动填充模板变量 const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { name: true } }) const vars: Record = { companyName: org?.name || '', employeeName: employee.name || '', idCard: employee.idCardNumber || '', position: employee.position || '', monthlySalary: String(employee.monthlySalary || ''), ...data.templateVars, } documentContent = renderTemplate(templateId, vars) || '' } } } // 4. 创建签署记录 const record = await prisma.eSignRecord.create({ data: { orgId: req.user!.orgId, contractId: data.contractId || null, employeeId: data.employeeId, scene, documentTitle: data.documentTitle, documentContent: documentContent || null, status: 'PENDING', initiatedBy: req.user!.id, createdBy: req.user!.id, remark: data.remark || null, expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), }, }) // 5. 创建证据链 — 发起签署事件 await createEvidence({ orgId: req.user!.orgId, category: 'CONTRACT_SIGN', refId: record.id, employeeId: data.employeeId, events: [{ action: `发起电子签署:${data.documentTitle}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] as string, location: `场景:${scene},发起人:${req.user!.id}`, }], createdBy: req.user!.id, }).catch(() => {}) await auditLog(req, 'ESIGN_CREATE', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, scene, documentTitle: data.documentTitle }) res.json({ success: true, data: record, message: '签署记录已创建,员工可在员工端查看并签署', }) } catch (err) { next(err) } }) /** * 签署记录详情 */ router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const record = await prisma.eSignRecord.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, include: { employee: { select: { id: true, name: true, department: true, phone: true } }, }, }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) /** * 查询签署状态 */ router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const record = await prisma.eSignRecord.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) // 检查是否已过期但状态仍为 PENDING if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) { const updated = await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' }, }) return res.json({ success: true, data: updated }) } res.json({ success: true, data: record }) } catch (err) { next(err) } }) /** * 获取签署记录的证据链 */ router.get('/:id/evidence', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const evidence = await prisma.evidenceChain.findMany({ where: { orgId: req.user!.orgId, refId: req.params.id }, orderBy: { createdAt: 'asc' }, }) res.json({ success: true, data: evidence }) } catch (err) { next(err) } }) /** * 易签宝回调接口(无需认证) * 对接易签宝后启用:验证签名 + 更新状态 + 回写PDF + 追加证据链 */ router.post('/callback', async (req, res: Response, next: NextFunction) => { try { const { flowId, status, signedPdfUrl, ...rest } = req.body // TODO: 对接易签宝后验证回调签名 // if (!verifyEsignCallback(req.headers, req.body)) { // return res.status(401).json({ success: false, error: { message: '无效回调' } }) // } if (flowId) { const record = await prisma.eSignRecord.findFirst({ where: { flowId } }) if (record) { await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: status || 'COMPLETED', signedPdfUrl: signedPdfUrl || null, completedAt: status === 'COMPLETED' ? new Date() : null, callbackData: rest as any, }, }) // 签署完成 → 回写合同 + 追加证据链 if (status === 'COMPLETED') { if (record.contractId) { await prisma.laborContract.update({ where: { id: record.contractId }, data: { signMethod: 'ELECTRONIC', electronicContractUrl: signedPdfUrl || null, }, }) } await appendEvidence(record.orgId, '', { action: '易签宝回调:签署完成', timestamp: new Date().toISOString(), location: `flowId: ${flowId},PDF: ${signedPdfUrl || '无'}`, }).catch(() => {}) } await auditLog({} as any, 'ESIGN_CALLBACK', 'ESIGN_RECORD', record.id, { flowId, status }) } } res.json({ success: true }) } catch (err) { next(err) } }) /** * 取消签署 * - 更新状态为 CANCELLED * - 追加证据链 */ router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const record = await prisma.eSignRecord.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } }) if (record.status === 'COMPLETED') { return res.status(400).json({ success: false, error: { message: '已完成的签署不可取消' } }) } const updated = await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'CANCELLED' }, }) // 追加证据链 const evidence = await prisma.evidenceChain.findFirst({ where: { orgId: req.user!.orgId, refId: record.id }, }) if (evidence) { await appendEvidence(req.user!.orgId, evidence.id, { action: '取消签署', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] as string, location: `操作人:${req.user!.id}`, }).catch(() => {}) } await auditLog(req, 'ESIGN_CANCEL', 'ESIGN_RECORD', record.id, {}) res.json({ success: true, data: updated }) } catch (err) { next(err) } }) // ========== 线下手签登记 ========== /** 线下签署扫描件上传目录 */ const paperSignDir = path.join(process.cwd(), 'uploads', 'paper-sign') if (!fs.existsSync(paperSignDir)) fs.mkdirSync(paperSignDir, { recursive: true }) const paperSignUpload = multer({ storage: multer.diskStorage({ destination: paperSignDir, filename: (_req, file, cb) => { const ext = path.extname(file.originalname) cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`) }, }), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif'] const ext = path.extname(file.originalname).toLowerCase() if (allowed.includes(ext)) cb(null, true) else cb(new Error('仅支持 JPG/PNG/PDF/BMP/WEBP/TIFF 格式')) }, }) /** * 上传线下签署扫描件 * 支持多文件上传,返回文件URL列表 */ router.post('/paper-upload', authMiddleware, paperSignUpload.array('files', 10), async (req: AuthRequest, res: Response, next: NextFunction) => { try { const files = req.files as Express.Multer.File[] if (!files || files.length === 0) { return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } }) } const fileUrls = files.map(f => ({ name: f.originalname, url: `/uploads/paper-sign/${f.filename}`, size: f.size, })) res.json({ success: true, data: fileUrls }) } catch (err) { next(err) } }) /** 线下手签登记 Schema */ const paperSignSchema = z.object({ employeeId: z.string().min(1), contractId: z.string().optional(), scene: z.string().default('CONTRACT'), documentTitle: z.string().min(1), signedAt: z.string().min(1), // 签署日期 signedLocation: z.string().optional(), // 签署地点 witnessName: z.string().optional(), // 见证人姓名 witnessPhone: z.string().optional(), // 见证人手机号 scanFileUrls: z.array(z.object({ name: z.string(), url: z.string(), })).min(1, '至少上传一份签署扫描件'), remark: z.string().optional(), }) /** * 线下手签登记 * - 创建 ESignRecord(signMethod=PAPER, status=COMPLETED) * - 保存签署信息(签署日期/地点/见证人/扫描件) * - 创建证据链(线下签署登记事件) * - 回写合同签署方式 */ router.post('/paper-sign', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { try { const data = paperSignSchema.parse(req.body) // 获取员工信息 const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId: req.user!.orgId }, select: { id: true, name: true, department: true }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } // 创建签署记录(线下手签直接为 COMPLETED 状态) const record = await prisma.eSignRecord.create({ data: { orgId: req.user!.orgId, contractId: data.contractId || null, employeeId: data.employeeId, scene: data.scene, signMethod: 'PAPER', documentTitle: data.documentTitle, status: 'COMPLETED', initiatedBy: req.user!.id, createdBy: req.user!.id, remark: data.remark || null, completedAt: new Date(data.signedAt), expiredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 线下签署记录保留1年 // 线下手签专用字段 signedAt: new Date(data.signedAt), signedLocation: data.signedLocation || null, witnessName: data.witnessName || null, witnessPhone: data.witnessPhone || null, scanFileUrls: data.scanFileUrls as any, // 签署证据 callbackData: { signMethod: 'PAPER', signedAt: data.signedAt, signedLocation: data.signedLocation, witnessName: data.witnessName, witnessPhone: data.witnessPhone, scanFileCount: data.scanFileUrls.length, registeredBy: req.user!.id, registeredAt: new Date().toISOString(), } as any, }, }) // 创建证据链 — 线下签署登记 await createEvidence({ orgId: req.user!.orgId, category: 'CONTRACT_SIGN', refId: record.id, employeeId: data.employeeId, events: [{ action: `线下手签登记:${data.documentTitle}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] as string, location: `签署日期:${data.signedAt.slice(0, 10)},签署地点:${data.signedLocation || '未填写'},见证人:${data.witnessName || '无'},扫描件:${data.scanFileUrls.length}份`, }], createdBy: req.user!.id, }).catch(() => {}) // 回写合同签署方式 if (data.contractId) { await prisma.laborContract.update({ where: { id: data.contractId }, data: { signMethod: 'PAPER', // 第一份扫描件作为合同附件 attachmentUrl: data.scanFileUrls[0]?.url || null, }, }) } await auditLog(req, 'PAPER_SIGN_REGISTER', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, documentTitle: data.documentTitle, signedAt: data.signedAt, scanFileCount: data.scanFileUrls.length, }) res.json({ success: true, data: record, message: '线下手签登记成功,证据链已记录', }) } catch (err) { next(err) } }) export default router