Files
TurboHR/backend/src/routes/esign.routes.ts
T
selfrelease c66ea290eb fix: 签署日期不能晚于当前日期
后端 /sign-date 和 /paper-sign 均增加校验,签署日期晚于今天则报错。
前端日期选择器增加 max 属性限制为今天。
2026-08-17 12:48:07 +08:00

719 lines
26 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.
/**
* 电子签署路由 — 管理端
*
* 完整签署流程:
* 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<string, string | null> = {
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
RESIGNATION: null, // 离职协议不需要额外开关
POLICY: 'esignPolicyEnabled',
PAYSLIP: 'esignPayslipEnabled',
ONBOARDING: 'esignOnboardingEnabled',
}
/** 场景与模板ID的映射(自动渲染文件内容) */
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
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<string, {
employeeId: string
name: string
department: string | null
phone: string | null
pendingItems: any[]
}>()
// 辅助函数:添加员工到 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 CONTRACT_TYPE_LABEL: Record<string, string> = {
FIXED: '固定期限劳动合同',
UNFIXED: '无固定期限劳动合同',
UNSIGNED: '未签合同',
LABOR: '劳务协议',
INTERNSHIP: '实习协议',
DISPATCH: '劳务派遣合同',
OUTSOURCING: '外包合同',
PARTTIME: '非全日制合同',
}
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: CONTRACT_TYPE_LABEL[c.contractType] || `${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/sign-date body: { contractId, signDate }
*
* 适用于纸质合同(signMethod=PAPER)未登记签署日期的情况。
* 电子签合同的签署日期由电签系统回写,不通过此接口修改。
*/
router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { contractId, signDate } = req.body
if (!contractId || !signDate) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractId 或 signDate' } })
}
const contract = await prisma.laborContract.findFirst({
where: { id: contractId, orgId: req.user!.orgId },
select: { id: true, signMethod: true, contractType: true, employeeId: true, employee: { select: { name: true } } },
})
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
if (contract.signMethod === 'ELECTRONIC') {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
}
// 解析日期字符串(YYYY-MM-DD),手动构造本地中午时间避免时区偏移
const dateStr = String(signDate).slice(0, 10)
const [y, m, d] = dateStr.split('-').map(Number)
const parsedDate = new Date(y, (m || 1) - 1, d || 1, 12, 0, 0, 0)
if (isNaN(parsedDate.getTime())) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
}
// 签署日期不能晚于当前日期
const today = new Date()
today.setHours(23, 59, 59, 999)
if (parsedDate > today) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期不能晚于当前日期' } })
}
// 更新合同签署日期
await prisma.laborContract.update({
where: { id: contractId },
data: { signDate: parsedDate },
})
// 同时创建一条已完成的线下手签 EsignRecord,使签署记录 Tab 可见
const existingRecord = await prisma.eSignRecord.findFirst({
where: { contractId, status: 'COMPLETED' },
select: { id: true },
})
if (!existingRecord) {
// 合同类型枚举转中文
const CONTRACT_TYPE_LABEL: Record<string, string> = {
FIXED: '固定期限劳动合同',
UNFIXED: '无固定期限劳动合同',
UNSIGNED: '未签合同',
LABOR: '劳务协议',
INTERNSHIP: '实习协议',
DISPATCH: '劳务派遣合同',
OUTSOURCING: '外包合同',
PARTTIME: '非全日制合同',
}
const contractLabel = CONTRACT_TYPE_LABEL[contract.contractType] || '合同'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId,
employeeId: contract.employeeId,
scene: 'CONTRACT',
signMethod: 'PAPER',
documentTitle: `${contractLabel}线下签署登记`,
status: 'COMPLETED',
completedAt: parsedDate, // 完成时间 = 实际签署日期
signedAt: parsedDate, // 签署时间 = HR 选择的签署日期
signedLocation: null,
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: 'HR 登记线下签署日期',
},
})
}
await auditLog(req, 'SIGN_DATE', 'CONTRACT', contractId, { employeeName: contract.employee.name, signDate: parsedDate.toISOString() })
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
} 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<string, string> = {
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(),
})
/**
* 线下手签登记
* - 创建 ESignRecordsignMethod=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: '员工不存在' } })
}
// 签署日期不能晚于当前日期
const signDate = new Date(data.signedAt)
const todayEnd = new Date()
todayEnd.setHours(23, 59, 59, 999)
if (signDate > todayEnd) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', 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