feat: 电子签署改为「待签合同」,按员工聚合+催办功能

1. 菜单「电子签署」改名为「待签合同」
2. 页面重构为两个 Tab:
   - 待签合同:按员工聚合展示所有未签文件(EsignRecord
     PENDING/SIGNING + LaborContract signDate 为空),展开可
     查看该员工名下所有待签文件详情
   - 签署记录:原有全部签署记录列表(保留筛选功能)
3. 催办功能:点击催办生成一次性自动登录链接(24h有效),
   指向员工端签署页,弹窗展示二维码+可复制链接,HR 发给
   员工扫码直接进入签署
4. 后端新增接口:
   - GET /esign/pending 待签合同按员工聚合列表
   - POST /esign/remind 催办生成自动登录链接

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 11:57:43 +08:00
parent ebd47e4500
commit 71f0619d18
4 changed files with 373 additions and 24 deletions
+138
View File
@@ -11,6 +11,7 @@
*/
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'
@@ -23,6 +24,8 @@ 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, // 合同签署不需要额外开关(默认允许)
@@ -74,6 +77,141 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
} 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 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) }
})
/**
* 发起签署
* - 校验组织电子签开关