feat: 线下手签完整流程 + 证据链体系
## 数据库 - ESignRecord 增加 signMethod 字段(ELECTRONIC/PAPER) - 增加线下手签专用字段:signedAt、signedLocation、witnessName、witnessPhone、scanFileUrls ## 后端 - esign.routes.ts 新增线下手签接口: - POST /esign/paper-upload:上传签署扫描件(multer多文件,10MB限制) - POST /esign/paper-sign:线下手签登记(创建COMPLETED记录+证据链+回写合同) - esign.service.ts:autoCreateEsignRecord 设置 signMethod=ELECTRONIC - 静态文件服务复用 /uploads 统一映射 ## 前端管理端 - ESign.tsx 新增"线下手签登记"按钮和 Modal: - 选择员工、文件类型、签署日期、地点、见证人 - 上传签署扫描件(多文件) - 登记后自动创建证据链 - 列表增加"签署方式"列(电子签/线下手签标签) - 详情页增加签署方式标签 + 线下手签信息卡片 + 扫描件列表 ## 前端员工端 - MyEsign.tsx 列表增加线下手签标签 - 详情页增加线下手签信息卡片(签署日期/地点/见证人/扫描件) - 签署操作区域:线下手签记录显示"线下手签已登记",不显示验证码签署 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
@@ -317,4 +320,161 @@ router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFun
|
||||
} 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
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function autoCreateEsignRecord(params: {
|
||||
contractId: contractId || null,
|
||||
employeeId,
|
||||
scene,
|
||||
signMethod: 'ELECTRONIC',
|
||||
documentTitle,
|
||||
documentContent: documentContent || null,
|
||||
status: 'PENDING',
|
||||
|
||||
Reference in New Issue
Block a user