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:
@@ -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) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 发起签署
|
||||
* - 校验组织电子签开关
|
||||
|
||||
Reference in New Issue
Block a user