c66ea290eb
后端 /sign-date 和 /paper-sign 均增加校验,签署日期晚于今天则报错。 前端日期选择器增加 max 属性限制为今天。
719 lines
26 KiB
TypeScript
719 lines
26 KiB
TypeScript
/**
|
||
* 电子签署路由 — 管理端
|
||
*
|
||
* 完整签署流程:
|
||
* 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(),
|
||
})
|
||
|
||
/**
|
||
* 线下手签登记
|
||
* - 创建 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: '员工不存在' } })
|
||
}
|
||
|
||
// 签署日期不能晚于当前日期
|
||
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
|