b6fc93cc61
## 后端 - esign.routes.ts 重写: - 发起签署时校验组织电子签开关(POLICY/PAYSLIP/ONBOARDING) - 自动从模板渲染文件内容(CONTRACT→劳动合同模板,RESIGNATION→离职协议模板) - 创建签署记录时自动创建证据链(发起签署事件) - 新增签署详情接口、证据链查看接口 - 取消签署追加证据链事件 - 签署状态查询自动处理过期记录 - portal.routes.ts 员工端 esign 重写: - 新增验证码发送接口(/esign/:id/send-code) - 签署操作增加验证码校验(5次错误限制) - 签署完成追加证据链(IP/UA/时间戳/验证码/签署人) - 签署完成回写合同 signMethod + attachmentName(签署证据) - 列表和详情接口自动处理过期记录 ## 前端 - ESign.tsx 管理端重写: - 发起签署增加场景选择(5种场景) - 场景选择后自动填充默认文件标题 - 新增签署详情页(文件内容预览 + 证据链时间线) - 签署流程说明 - MyEsign.tsx 员工端重写: - 签署操作增加验证码确认流程 - 60秒倒计时限制 - 文件内容预览 - 签署完成状态展示 - api-services.ts: - esignApi 增加 detail/evidence 接口 - portalApi 增加 esignSendCode 接口 - signEsign 增加 verifyCode 参数 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1392 lines
53 KiB
TypeScript
1392 lines
53 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express'
|
||
import bcrypt from 'bcryptjs'
|
||
import multer from 'multer'
|
||
import path from 'path'
|
||
import fs from 'fs'
|
||
import jwt from 'jsonwebtoken'
|
||
import prisma from '../lib/prisma'
|
||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||
|
||
const router = Router()
|
||
|
||
// 员工端认证中间件
|
||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||
const authHeader = req.headers.authorization
|
||
if (!authHeader?.startsWith('Bearer ')) {
|
||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
|
||
}
|
||
const token = authHeader.substring(7)
|
||
try {
|
||
const payload = verifyAccessToken(token)
|
||
if (!payload || payload.role !== 'EMPLOYEE') {
|
||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
|
||
}
|
||
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
|
||
next()
|
||
} catch {
|
||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
|
||
}
|
||
}
|
||
|
||
// 密码登录
|
||
router.post('/login', async (req, res, next) => {
|
||
try {
|
||
const data = portalLoginSchema.parse(req.body)
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { phone: data.phone, status: 'ACTIVE' },
|
||
})
|
||
if (!employee || !employee.passwordHash) {
|
||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||
}
|
||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||
if (!valid) {
|
||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||
}
|
||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 发送验证码(页面内显示)
|
||
router.post('/send-code', async (req, res, next) => {
|
||
try {
|
||
const data = portalSendCodeSchema.parse(req.body)
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { phone: data.phone, status: 'ACTIVE' },
|
||
})
|
||
if (!employee) {
|
||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||
}
|
||
// 频率限制:60秒内不可重复发送
|
||
const allowed = await checkRateLimit(data.phone)
|
||
if (!allowed) {
|
||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
|
||
}
|
||
const code = Math.random().toString().slice(2, 8)
|
||
await setCode(data.phone, code)
|
||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 验证码登录
|
||
router.post('/verify-code', async (req, res, next) => {
|
||
try {
|
||
const data = portalVerifyCodeSchema.parse(req.body)
|
||
const stored = await getCode(data.phone)
|
||
if (!stored) {
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||
}
|
||
// 错误次数限制:5次后锁定
|
||
if (stored.failCount >= 5) {
|
||
await deleteCode(data.phone)
|
||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||
}
|
||
if (stored.code !== data.code) {
|
||
await updateCode(data.phone, { failCount: stored.failCount + 1 })
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||
}
|
||
await deleteCode(data.phone)
|
||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||
if (!employee) {
|
||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
}
|
||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 工资条
|
||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||
const payslip = await prisma.payslip.findFirst({
|
||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month, publishStatus: 'PUBLISHED' },
|
||
})
|
||
if (!payslip) {
|
||
return res.json({ success: true, data: null })
|
||
}
|
||
// 记录查看时间
|
||
if (!payslip.viewedAt) {
|
||
await prisma.payslip.update({ where: { id: payslip.id }, data: { viewedAt: new Date() } })
|
||
}
|
||
res.json({ success: true, data: { ...payslip, viewedAt: payslip.viewedAt || new Date() } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 工资条历史(最近6个月)
|
||
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const payslips = await prisma.payslip.findMany({
|
||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, publishStatus: 'PUBLISHED' },
|
||
orderBy: { month: 'desc' },
|
||
take: 6,
|
||
})
|
||
res.json({ success: true, data: payslips })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 工资条确认已阅
|
||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const payslip = await prisma.payslip.findFirst({
|
||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||
include: { employee: true },
|
||
})
|
||
if (!payslip) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||
}
|
||
await prisma.payslip.update({
|
||
where: { id: req.params.id },
|
||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||
})
|
||
// 通知 HR
|
||
await prisma.notificationLog.create({
|
||
data: {
|
||
orgId: req.employee.orgId,
|
||
title: '工资条确认通知',
|
||
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip})`,
|
||
type: 'PAYSLIP_CONFIRM',
|
||
channel: 'IN_APP',
|
||
},
|
||
})
|
||
await createEvidence({
|
||
orgId: req.employee.orgId,
|
||
category: 'PAYSLIP_CONFIRM',
|
||
refId: payslip.id,
|
||
employeeId: req.employee.id,
|
||
events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||
createdBy: req.employee.id,
|
||
}).catch(() => {})
|
||
|
||
// 如果开启了工资条电子签,创建电子签记录
|
||
const org = await prisma.organization.findUnique({ where: { id: req.employee.orgId }, select: { esignPayslipEnabled: true } })
|
||
if (org?.esignPayslipEnabled) {
|
||
await prisma.eSignRecord.create({
|
||
data: {
|
||
orgId: req.employee.orgId,
|
||
employeeId: req.employee.id,
|
||
scene: 'PAYSLIP',
|
||
documentTitle: `工资条确认:${payslip.month}`,
|
||
status: 'PENDING',
|
||
initiatedBy: req.employee.id,
|
||
createdBy: req.employee.id,
|
||
remark: '工资条确认时自动发起',
|
||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||
},
|
||
})
|
||
}
|
||
|
||
res.json({ success: true })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 我的合同
|
||
router.get('/contract', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const contract = await prisma.laborContract.findFirst({
|
||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
if (!contract) {
|
||
return res.json({ success: true, data: null })
|
||
}
|
||
res.json({ success: true, data: contract })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 入职填报提交
|
||
router.post('/onboarding', async (req, res, next) => {
|
||
try {
|
||
const data = onboardingSchema.parse(req.body)
|
||
const link = await prisma.onboardingLink.findFirst({
|
||
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||
})
|
||
if (!link) {
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
await prisma.onboardingLink.update({
|
||
where: { id: link.id },
|
||
data: {
|
||
employeeName: data.name,
|
||
phone: data.phone,
|
||
formData: {
|
||
name: data.name,
|
||
phone: data.phone,
|
||
idCard: data.idCard,
|
||
emergencyContact: data.emergencyContact,
|
||
emergencyPhone: data.emergencyPhone,
|
||
address: data.address,
|
||
bankCard: data.bankCard,
|
||
bankName: data.bankName,
|
||
},
|
||
status: 'APPROVED',
|
||
usedAt: new Date(),
|
||
},
|
||
})
|
||
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 合同签署验证码发送
|
||
router.post('/contract-confirm/send-code', async (req, res, next) => {
|
||
try {
|
||
const data = contractSendCodeSchema.parse(req.body)
|
||
const link = await prisma.contractConfirmLink.findFirst({
|
||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||
include: { contract: { include: { employee: true } } },
|
||
})
|
||
if (!link) {
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
const phone = link.contract.employee.phone
|
||
if (!phone) {
|
||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
|
||
}
|
||
const code = Math.random().toString().slice(2, 8)
|
||
await setCode(`contract-${data.token}`, code)
|
||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 合同签署确认
|
||
router.post('/contract-confirm', async (req, res, next) => {
|
||
try {
|
||
const data = contractConfirmSchema.parse(req.body)
|
||
const link = await prisma.contractConfirmLink.findFirst({
|
||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||
include: { contract: { include: { employee: true } } },
|
||
})
|
||
if (!link) {
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
// 验证码校验
|
||
const stored = await getCode(`contract-${data.token}`)
|
||
if (!stored) {
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||
}
|
||
if (stored.failCount >= 5) {
|
||
await deleteCode(`contract-${data.token}`)
|
||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||
}
|
||
if (stored.code !== data.verifyCode) {
|
||
await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 })
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||
}
|
||
await deleteCode(`contract-${data.token}`)
|
||
|
||
const userAgent = req.headers['user-agent'] || ''
|
||
const signEvidence = JSON.stringify({
|
||
ip: req.ip,
|
||
userAgent,
|
||
timestamp: new Date().toISOString(),
|
||
})
|
||
await prisma.contractConfirmLink.update({
|
||
where: { id: link.id },
|
||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||
})
|
||
await prisma.laborContract.update({
|
||
where: { id: link.contractId },
|
||
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
|
||
})
|
||
await createEvidence({
|
||
orgId: link.contract.orgId,
|
||
category: 'CONTRACT_SIGN',
|
||
refId: link.contractId,
|
||
employeeId: link.contract.employeeId,
|
||
events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }],
|
||
createdBy: link.contract.employeeId,
|
||
}).catch(() => {})
|
||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 获取入职填报信息(通过 token)
|
||
router.get('/onboarding/:token', async (req, res, next) => {
|
||
try {
|
||
const link = await prisma.onboardingLink.findFirst({
|
||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||
include: { org: { select: { name: true } } },
|
||
})
|
||
if (!link) {
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
res.json({ success: true, data: { orgName: link.org.name } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 撤回入职链接(HR 端调用,需要认证)
|
||
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const link = await prisma.onboardingLink.findFirst({
|
||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||
})
|
||
if (!link) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||
}
|
||
if (link.status !== 'PENDING') {
|
||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
|
||
}
|
||
await prisma.onboardingLink.update({
|
||
where: { id: link.id },
|
||
data: { status: 'CANCELLED' },
|
||
})
|
||
res.json({ success: true, data: { message: '入职链接已撤回' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 获取合同确认信息(通过 token)
|
||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||
try {
|
||
const link = await prisma.contractConfirmLink.findFirst({
|
||
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||
include: {
|
||
contract: {
|
||
include: {
|
||
employee: { select: { name: true, org: { select: { name: true } } } },
|
||
},
|
||
},
|
||
},
|
||
})
|
||
if (!link) {
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
orgName: link.contract.employee.org.name,
|
||
employeeName: link.contract.employee.name,
|
||
contract: link.contract,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 重发合同确认链接(HR 端调用,需要认证)
|
||
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const link = await prisma.contractConfirmLink.findFirst({
|
||
where: { id: req.params.id, orgId: req.employee.orgId },
|
||
include: { contract: { include: { employee: true } } },
|
||
})
|
||
if (!link) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
|
||
}
|
||
if (link.status === 'CONFIRMED') {
|
||
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
|
||
}
|
||
// 生成新 token 并延长过期时间
|
||
const crypto = await import('crypto')
|
||
const newToken = crypto.randomUUID()
|
||
await prisma.contractConfirmLink.update({
|
||
where: { id: link.id },
|
||
data: {
|
||
token: newToken,
|
||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||
status: 'UNCONFIRMED',
|
||
},
|
||
})
|
||
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 入职文件上传
|
||
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
|
||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
|
||
|
||
const onboardingUpload = multer({
|
||
storage: multer.diskStorage({
|
||
destination: uploadDir,
|
||
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']
|
||
const ext = path.extname(file.originalname).toLowerCase()
|
||
if (allowed.includes(ext)) cb(null, true)
|
||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
|
||
},
|
||
})
|
||
|
||
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
|
||
try {
|
||
if (!req.file) {
|
||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||
}
|
||
const link = await prisma.onboardingLink.findFirst({
|
||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||
})
|
||
if (!link) {
|
||
fs.unlinkSync(req.file.path)
|
||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||
}
|
||
const fileType = (req.body.fileType as string) || 'OTHER'
|
||
const fileUrl = `/uploads/onboarding/${req.file.filename}`
|
||
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// ========== 员工端:规章制度公示 ==========
|
||
|
||
/** 获取已公示制度列表(含当前员工阅读状态) */
|
||
router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { orgId, id: employeeId } = (req as any).employee
|
||
const policies = await prisma.policyDocument.findMany({
|
||
where: { orgId, status: 'PUBLISHED' },
|
||
orderBy: { publishedAt: 'desc' },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
type: true,
|
||
publishedAt: true,
|
||
readRecords: {
|
||
where: { employeeId },
|
||
select: { id: true, readAt: true },
|
||
},
|
||
},
|
||
})
|
||
const result = policies.map(p => ({
|
||
id: p.id,
|
||
title: p.title,
|
||
type: p.type,
|
||
publishedAt: p.publishedAt?.toISOString() || null,
|
||
hasRead: p.readRecords.length > 0,
|
||
readAt: p.readRecords[0]?.readAt?.toISOString() || null,
|
||
}))
|
||
res.json({ success: true, data: result })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
/** 获取制度详情(已公示) */
|
||
router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { orgId, id: employeeId } = (req as any).employee
|
||
const policy = await prisma.policyDocument.findFirst({
|
||
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
type: true,
|
||
publishedAt: true,
|
||
readRecords: {
|
||
where: { employeeId },
|
||
select: { id: true, readAt: true },
|
||
},
|
||
},
|
||
})
|
||
if (!policy) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
|
||
}
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
id: policy.id,
|
||
title: policy.title,
|
||
content: policy.content,
|
||
type: policy.type,
|
||
publishedAt: policy.publishedAt?.toISOString() || null,
|
||
hasRead: policy.readRecords.length > 0,
|
||
readAt: policy.readRecords[0]?.readAt?.toISOString() || null,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
/** 提交阅读确认(签收) */
|
||
router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { orgId, id: employeeId } = (req as any).employee
|
||
const policy = await prisma.policyDocument.findFirst({
|
||
where: { id: req.params.id, orgId, status: 'PUBLISHED' },
|
||
select: { id: true },
|
||
})
|
||
if (!policy) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } })
|
||
}
|
||
|
||
// 幂等:已存在则返回已有记录
|
||
const existing = await prisma.policyReadRecord.findUnique({
|
||
where: { policyId_employeeId: { policyId: req.params.id, employeeId } },
|
||
})
|
||
if (existing) {
|
||
return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } })
|
||
}
|
||
|
||
const record = await prisma.policyReadRecord.create({
|
||
data: {
|
||
policyId: req.params.id,
|
||
orgId,
|
||
employeeId,
|
||
ip: req.ip || req.socket.remoteAddress,
|
||
userAgent: req.headers['user-agent'] || null,
|
||
},
|
||
})
|
||
|
||
// 如果开启了制度电子签,创建电子签记录
|
||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { esignPolicyEnabled: true } })
|
||
if (org?.esignPolicyEnabled) {
|
||
const policyDoc = await prisma.policyDocument.findUnique({ where: { id: req.params.id }, select: { title: true, content: true } })
|
||
await prisma.eSignRecord.create({
|
||
data: {
|
||
orgId,
|
||
employeeId,
|
||
scene: 'POLICY',
|
||
documentTitle: `制度签收:${policyDoc?.title || '未知'}`,
|
||
documentContent: policyDoc?.content || null,
|
||
status: 'PENDING',
|
||
initiatedBy: employeeId,
|
||
createdBy: employeeId,
|
||
remark: '制度阅读确认后自动发起',
|
||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||
},
|
||
})
|
||
}
|
||
|
||
res.json({ success: true, data: { readAt: record.readAt.toISOString() } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// ========== 一次性自动登录 ==========
|
||
|
||
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||
|
||
/**
|
||
* 管理端生成员工一次性自动登录 token(10 分钟有效)
|
||
* POST /portal/auto-login-token body: { employeeId }
|
||
*/
|
||
router.post('/auto-login-token', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
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: '员工不存在或已离职' } })
|
||
}
|
||
const token = jwt.sign(
|
||
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
|
||
AUTO_LOGIN_SECRET,
|
||
{ expiresIn: '10m' }
|
||
)
|
||
res.json({ success: true, data: { token, employeeName: employee.name, phone: employee.phone } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
/**
|
||
* 员工端自动登录(消费一次性 token)
|
||
* GET /portal/auto-login?token=xxx
|
||
*/
|
||
router.get('/auto-login', async (req, res, next) => {
|
||
try {
|
||
const { token } = req.query
|
||
if (!token || typeof token !== 'string') {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 token' } })
|
||
}
|
||
let payload: any
|
||
try {
|
||
payload = jwt.verify(token, AUTO_LOGIN_SECRET)
|
||
} catch {
|
||
return res.status(401).json({ success: false, error: { code: 'TOKEN_EXPIRED', message: '链接已过期,请重新扫码' } })
|
||
}
|
||
if (payload.role !== 'EMPLOYEE_AUTO') {
|
||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '无效的登录链接' } })
|
||
}
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { id: payload.id, orgId: payload.orgId, status: 'ACTIVE' },
|
||
select: { id: true, name: true, department: true, orgId: true },
|
||
})
|
||
if (!employee) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
|
||
}
|
||
const accessToken = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||
res.json({ success: true, data: { token: accessToken, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 员工端查看自己的月度考勤
|
||
router.get('/attendance', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||
// 检查该月份是否已发布
|
||
const publish = await (prisma as any).attendancePublish.findFirst({
|
||
where: { orgId: req.employee.orgId, month, status: 'PUBLISHED' },
|
||
})
|
||
if (!publish) {
|
||
return res.json({ success: true, data: { published: false, records: [] } })
|
||
}
|
||
// 查询该月考勤记录
|
||
const startDate = new Date(`${month}-01`)
|
||
const endDate = new Date(startDate)
|
||
endDate.setMonth(endDate.getMonth() + 1)
|
||
const records = await prisma.attendanceRecord.findMany({
|
||
where: {
|
||
employeeId: req.employee.id,
|
||
orgId: req.employee.orgId,
|
||
date: { gte: startDate, lt: endDate },
|
||
},
|
||
orderBy: { date: 'asc' },
|
||
})
|
||
res.json({ success: true, data: { published: true, records, title: publish.title } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// ========== 员工端:首页概览 ==========
|
||
router.get('/home/overview', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
|
||
// 最新工资条
|
||
const latestPayslip = await prisma.payslip.findFirst({
|
||
where: { employeeId, orgId, publishStatus: 'PUBLISHED' },
|
||
orderBy: { month: 'desc' },
|
||
select: { month: true, totalPay: true, netPay: true },
|
||
})
|
||
|
||
// 合同信息
|
||
const contract = await prisma.laborContract.findFirst({
|
||
where: { employeeId, orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
select: { contractType: true, startDate: true, endDate: true },
|
||
})
|
||
const typeLabels: Record<string, string> = { FIXED: '劳动合同-固定期', UNFIXED: '劳动合同-无固定期', LABOR: '劳务协议', INTERNSHIP: '实习协议', DISPATCH: '劳务派遣', OUTSOURCING: '业务外包', PARTTIME: '兼职协议', UNSIGNED: '未签合同' }
|
||
let daysToExpire: number | null = null
|
||
if (contract?.endDate) {
|
||
const diff = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||
daysToExpire = diff
|
||
}
|
||
|
||
// 本月考勤概览
|
||
const month = new Date().toISOString().slice(0, 7)
|
||
const startDate = new Date(`${month}-01`)
|
||
const endDate = new Date(startDate)
|
||
endDate.setMonth(endDate.getMonth() + 1)
|
||
const attendanceRecords = await prisma.attendanceRecord.findMany({
|
||
where: { employeeId, orgId, date: { gte: startDate, lt: endDate } },
|
||
})
|
||
const attendanceSummary = {
|
||
normalDays: attendanceRecords.filter((r: any) => r.status === 'NORMAL').length,
|
||
lateCount: attendanceRecords.filter((r: any) => r.status === 'LATE').length,
|
||
leaveDays: attendanceRecords.filter((r: any) => r.status === 'LEAVE').length,
|
||
absentDays: attendanceRecords.filter((r: any) => r.status === 'ABSENT').length,
|
||
}
|
||
|
||
// 待办事项
|
||
const pendingTasks: any[] = []
|
||
if (contract && daysToExpire !== null && daysToExpire < 30 && daysToExpire >= 0) {
|
||
pendingTasks.push({ severity: 'high', message: `合同将在 ${daysToExpire} 天后到期,请联系HR确认续签事宜` })
|
||
}
|
||
if (contract && daysToExpire !== null && daysToExpire < 0) {
|
||
pendingTasks.push({ severity: 'high', message: '合同已到期,请尽快联系HR办理续签或离职手续' })
|
||
}
|
||
// 查找未阅读的制度:取所有制度ID,排除已阅读的
|
||
const allPolicies = await prisma.policyDocument.findMany({ where: { orgId }, select: { id: true } })
|
||
const readRecords = await prisma.policyReadRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
select: { policyId: true },
|
||
})
|
||
const readPolicyIds = new Set(readRecords.map(r => r.policyId))
|
||
const unreadPolicies = allPolicies.filter(p => !readPolicyIds.has(p.id))
|
||
if (unreadPolicies.length > 0) {
|
||
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
|
||
}
|
||
// 待签署文件
|
||
const pendingEsign = await prisma.eSignRecord.findMany({
|
||
where: { employeeId, orgId, status: 'PENDING' },
|
||
select: { id: true, documentTitle: true },
|
||
})
|
||
if (pendingEsign.length > 0) {
|
||
pendingTasks.push({ severity: 'high', message: `您有 ${pendingEsign.length} 份文件待签署(${pendingEsign.map(e => e.documentTitle).join('、')})` })
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
latestPayslip,
|
||
contract: contract ? {
|
||
typeLabel: typeLabels[contract.contractType] || contract.contractType,
|
||
startDate: contract.startDate?.toISOString().slice(0, 10),
|
||
endDate: contract.endDate?.toISOString().slice(0, 10),
|
||
daysToExpire,
|
||
} : null,
|
||
attendance: attendanceSummary,
|
||
pendingTasks,
|
||
announcements: [],
|
||
},
|
||
})
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:入职进度 ==========
|
||
router.get('/onboarding/progress', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
|
||
const contract = await prisma.laborContract.findFirst({ where: { employeeId, orgId } })
|
||
const files = await prisma.employeeAttachment.findMany({ where: { employeeId, orgId } })
|
||
|
||
const steps: any = {
|
||
profile: {
|
||
completed: !!(employee.name && employee.idCardNumber && employee.phone),
|
||
description: employee.name ? '基本信息已填写' : '请完善基本信息',
|
||
completedAt: employee.hireDate,
|
||
},
|
||
documents: {
|
||
completed: files.length > 0,
|
||
description: files.length > 0 ? `已上传 ${files.length} 份材料` : '请上传入职材料',
|
||
},
|
||
contract: {
|
||
completed: !!contract,
|
||
description: contract ? '合同已签署' : '等待合同签署',
|
||
completedAt: contract?.createdAt,
|
||
},
|
||
bankcard: {
|
||
completed: !!(employee as any).bankCard,
|
||
description: (employee as any).bankCard ? '银行卡已登记' : '请登记银行卡信息',
|
||
},
|
||
complete: {
|
||
completed: employee.status === 'ACTIVE',
|
||
description: employee.status === 'ACTIVE' ? '入职流程已完成' : '入职流程进行中',
|
||
},
|
||
}
|
||
|
||
const completedCount = Object.values(steps).filter((s: any) => s.completed).length
|
||
const completionRate = Math.round((completedCount / 5) * 100)
|
||
const currentStepIndex = Object.values(steps).findIndex((s: any) => !s.completed)
|
||
|
||
const pendingItems: any[] = []
|
||
Object.entries(steps).forEach(([key, s]: any) => {
|
||
if (!s.completed) pendingItems.push({ message: s.description })
|
||
})
|
||
|
||
res.json({ success: true, data: { steps, completionRate, currentStepIndex, pendingItems } })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:离职申请 ==========
|
||
// 提交离职申请
|
||
router.post('/resignation/submit', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const { reason, expectedDate, remark, attachments } = req.body
|
||
if (!reason || !expectedDate) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请填写离职原因和预计离职日期' } })
|
||
}
|
||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||
if (!employee) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
if (employee.status === 'RESIGNED' || employee.status === 'TERMINATED') {
|
||
return res.status(400).json({ success: false, error: { code: 'ALREADY_RESIGNED', message: '您已离职,无法重复申请' } })
|
||
}
|
||
// 检查是否已有待审批的离职申请
|
||
const existing = await (prisma as any).terminationRecord.findFirst({
|
||
where: { employeeId, orgId, status: { in: ['DRAFT', 'PENDING_APPROVAL'] } },
|
||
})
|
||
if (existing) {
|
||
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '您已有一个待处理的离职申请' } })
|
||
}
|
||
const remarkText = `员工自主申请:${reason}${remark ? ';备注:' + remark : ''}${attachments && attachments.length > 0 ? `;附件:${attachments.length}张辞职信照片` : ''}`
|
||
const record = await (prisma as any).terminationRecord.create({
|
||
data: {
|
||
employeeId, orgId,
|
||
type: 'RESIGNATION',
|
||
reason: 'RESIGNATION',
|
||
resignationReason: reason,
|
||
terminationDate: new Date(expectedDate),
|
||
status: 'PENDING_APPROVAL',
|
||
checklist: attachments && attachments.length > 0 ? attachments : [],
|
||
remark: remarkText,
|
||
createdBy: employeeId,
|
||
},
|
||
})
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 查询自己的离职申请状态
|
||
router.get('/resignation/status', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const records = await (prisma as any).terminationRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 5,
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 撤回离职申请(仅 DRAFT/PENDING_APPROVAL 可撤回)
|
||
router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await (prisma as any).terminationRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职申请不存在' } })
|
||
if (record.status !== 'DRAFT' && record.status !== 'PENDING_APPROVAL') {
|
||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '当前状态无法撤回' } })
|
||
}
|
||
await (prisma as any).terminationRecord.update({
|
||
where: { id: record.id },
|
||
data: { status: 'CANCELLED' },
|
||
})
|
||
res.json({ success: true, data: { id: record.id, status: 'CANCELLED' } })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 下载离职证明(仅已完成的离职记录)
|
||
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await (prisma as any).terminationRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId },
|
||
include: { employee: true },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
|
||
if (record.status !== 'COMPLETED') {
|
||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
|
||
}
|
||
|
||
const org = await prisma.organization.findFirst({ where: { id: orgId } })
|
||
const orgName = org?.name || ''
|
||
|
||
// 查找企业自定义的离职证明模板
|
||
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
||
where: { orgId, category: 'LEAVING_CERT' },
|
||
})
|
||
|
||
const reasonLabel: Record<string, string> = {
|
||
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
|
||
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
|
||
}
|
||
const reason = reasonLabel[record.reason] || record.reason || ''
|
||
|
||
const variables: Record<string, string> = {
|
||
employeeName: record.employee?.name || '',
|
||
idCardNumber: record.employee?.idCardNumber || '',
|
||
department: record.employee?.department || '',
|
||
position: record.employee?.position || '',
|
||
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
|
||
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
|
||
reason,
|
||
companyName: orgName,
|
||
compensation: String(record.compensation || 0),
|
||
socialInsEndMonth: record.socialInsEndMonth || '',
|
||
housingFundEndMonth: record.housingFundEndMonth || '',
|
||
}
|
||
|
||
let content: string
|
||
if (tpl) {
|
||
content = tpl.content
|
||
for (const [key, value] of Object.entries(variables)) {
|
||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||
}
|
||
} else {
|
||
content = `<h1>解除/终止劳动合同证明书</h1>
|
||
<p>兹证明 ${variables.employeeName}(证件号码:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||
<p>特此证明。</p>
|
||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||
}
|
||
|
||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||
<head><meta charset="utf-8"><title>离职证明</title>
|
||
<!--[if gte mso 9]><xml>
|
||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||
</xml><![endif]-->
|
||
<style>
|
||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||
</style></head>
|
||
<body>${content}</body></html>`
|
||
|
||
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
|
||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||
res.send(htmlContent)
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:休假申请 ==========
|
||
// 查看自己的休假申请列表
|
||
router.get('/leaves', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const list = await prisma.leaveRequest.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
res.json({ success: true, data: list })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 提交休假申请
|
||
router.post('/leaves', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const { leaveType, startDate, endDate, days, reason } = req.body
|
||
|
||
if (!leaveType || !startDate || !endDate) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少必填字段' } })
|
||
}
|
||
|
||
const record = await prisma.leaveRequest.create({
|
||
data: {
|
||
orgId,
|
||
employeeId,
|
||
leaveType,
|
||
startDate: new Date(startDate),
|
||
endDate: new Date(endDate),
|
||
days: days || 1,
|
||
reason: reason || null,
|
||
createdBy: employeeId,
|
||
},
|
||
})
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 撤回休假申请(仅待审批可撤回)
|
||
router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.leaveRequest.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId },
|
||
})
|
||
if (!record) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '申请不存在' } })
|
||
}
|
||
if (record.status !== 'PENDING') {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已处理的申请不可撤回' } })
|
||
}
|
||
const updated = await prisma.leaveRequest.update({
|
||
where: { id: record.id },
|
||
data: { status: 'CANCELLED' },
|
||
})
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:电子签署 ==========
|
||
// 查看自己的签署记录列表
|
||
// ========== 员工端:电子签署(验证码确认 + 证据链) ==========
|
||
|
||
/**
|
||
* 我的电子签署列表
|
||
* - 自动将过期的 PENDING 记录标记为 EXPIRED
|
||
*/
|
||
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const records = await prisma.eSignRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
|
||
// 自动处理过期:PENDING 且已超过 expiredAt 的记录标记为 EXPIRED
|
||
const now = new Date()
|
||
const expiredIds = records
|
||
.filter(r => r.status === 'PENDING' && r.expiredAt && r.expiredAt < now)
|
||
.map(r => r.id)
|
||
if (expiredIds.length > 0) {
|
||
await prisma.eSignRecord.updateMany({
|
||
where: { id: { in: expiredIds } },
|
||
data: { status: 'EXPIRED' },
|
||
})
|
||
expiredIds.forEach(id => {
|
||
const r = records.find(rec => rec.id === id)
|
||
if (r) r.status = 'EXPIRED'
|
||
})
|
||
}
|
||
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
/**
|
||
* 查看签署详情(含文件内容)
|
||
*/
|
||
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.eSignRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||
|
||
// 检查过期
|
||
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) }
|
||
})
|
||
|
||
/**
|
||
* 发送签署验证码
|
||
* - 通过手机号发送6位验证码
|
||
* - 验证码5分钟有效,最多5次错误
|
||
*/
|
||
router.post('/esign/:id/send-code', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.eSignRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||
include: { employee: { select: { phone: true, name: true } } },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||
if (!record.employee.phone) {
|
||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '未登记手机号,无法发送验证码' } })
|
||
}
|
||
|
||
// 限流:同一记录60秒内只能发一次
|
||
const rateLimitKey = `esign-rate-${record.id}`
|
||
const allowed = await checkRateLimit(rateLimitKey, 60 * 1000)
|
||
if (!allowed) {
|
||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请60秒后重试' } })
|
||
}
|
||
|
||
const code = Math.random().toString().slice(2, 8)
|
||
await setCode(`esign-${record.id}`, code)
|
||
|
||
// 追加证据链 — 验证码发送事件
|
||
const evidence = await prisma.evidenceChain.findFirst({
|
||
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||
})
|
||
if (evidence) {
|
||
await appendEvidence(orgId, evidence.id, {
|
||
action: '发送签署验证码',
|
||
timestamp: new Date().toISOString(),
|
||
ip: req.ip,
|
||
userAgent: req.headers['user-agent'] as string,
|
||
location: `手机号:${record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}`,
|
||
}).catch(() => {})
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
code, // 开发阶段直接返回,生产环境通过短信发送
|
||
message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)',
|
||
phone: record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||
},
|
||
})
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
/**
|
||
* 员工签署操作(验证码确认 + 证据链 + 回写合同)
|
||
* - 校验验证码
|
||
* - 更新签署状态为 COMPLETED
|
||
* - 追加证据链(签署完成事件,含IP/UA/验证码/时间戳)
|
||
* - 回写合同 signMethod 和 electronicContractUrl
|
||
*/
|
||
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const { verifyCode } = req.body || {}
|
||
|
||
const record = await prisma.eSignRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||
include: { employee: { select: { name: true, phone: true } } },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||
|
||
// 检查过期
|
||
if (record.expiredAt && record.expiredAt < new Date()) {
|
||
await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' } })
|
||
return res.status(400).json({ success: false, error: { code: 'EXPIRED', message: '签署链接已过期' } })
|
||
}
|
||
|
||
// 校验验证码
|
||
if (!verifyCode) {
|
||
return res.status(400).json({ success: false, error: { code: 'NO_CODE', message: '请输入验证码' } })
|
||
}
|
||
const stored = await getCode(`esign-${record.id}`)
|
||
if (!stored) {
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||
}
|
||
if (stored.failCount >= 5) {
|
||
await deleteCode(`esign-${record.id}`)
|
||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||
}
|
||
if (stored.code !== verifyCode) {
|
||
await updateCode(`esign-${record.id}`, { failCount: stored.failCount + 1 })
|
||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||
}
|
||
await deleteCode(`esign-${record.id}`)
|
||
|
||
// 签署完成
|
||
const userAgent = req.headers['user-agent'] || ''
|
||
const signEvidence = JSON.stringify({
|
||
ip: req.ip,
|
||
userAgent,
|
||
timestamp: new Date().toISOString(),
|
||
verifyCode: true,
|
||
employeeName: record.employee.name,
|
||
phone: record.employee.phone,
|
||
})
|
||
|
||
const updated = await prisma.eSignRecord.update({
|
||
where: { id: record.id },
|
||
data: {
|
||
status: 'COMPLETED',
|
||
completedAt: new Date(),
|
||
// 保存签署证据到 callbackData(作为签署凭证)
|
||
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
|
||
},
|
||
})
|
||
|
||
// 回写合同
|
||
if (record.contractId) {
|
||
await prisma.laborContract.update({
|
||
where: { id: record.contractId },
|
||
data: {
|
||
signMethod: 'ELECTRONIC',
|
||
// 签署证据写入 attachmentName
|
||
attachmentName: `e-sign:${new Date().toISOString()}|evidence:${signEvidence}`,
|
||
},
|
||
})
|
||
}
|
||
|
||
// 追加证据链 — 签署完成事件
|
||
const evidence = await prisma.evidenceChain.findFirst({
|
||
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||
})
|
||
if (evidence) {
|
||
await appendEvidence(orgId, evidence.id, {
|
||
action: `员工签署完成:${record.documentTitle}`,
|
||
timestamp: new Date().toISOString(),
|
||
ip: req.ip,
|
||
userAgent,
|
||
smsCode: verifyCode,
|
||
location: `签署人:${record.employee.name},验证码已验证`,
|
||
}).catch(() => {})
|
||
} else {
|
||
// 证据链不存在时创建新的
|
||
await createEvidence({
|
||
orgId,
|
||
category: 'CONTRACT_SIGN',
|
||
refId: record.id,
|
||
employeeId,
|
||
events: [{
|
||
action: `员工签署完成:${record.documentTitle}`,
|
||
timestamp: new Date().toISOString(),
|
||
ip: req.ip,
|
||
userAgent,
|
||
smsCode: verifyCode,
|
||
location: `签署人:${record.employee.name},验证码已验证`,
|
||
}],
|
||
createdBy: employeeId,
|
||
}).catch(() => {})
|
||
}
|
||
|
||
res.json({ success: true, data: updated, message: '签署成功' })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:培训签收 ==========
|
||
// 查看自己的培训记录
|
||
router.get('/training', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const records = await prisma.trainingRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { trainingDate: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 培训签收
|
||
router.post('/training/:id/sign', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.trainingRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签收' } })
|
||
|
||
const updated = await prisma.trainingRecord.update({
|
||
where: { id: record.id },
|
||
data: { ackStatus: 'SIGNED', ackDate: new Date() },
|
||
})
|
||
|
||
await createEvidence({
|
||
orgId,
|
||
category: 'TRAINING',
|
||
refId: record.id,
|
||
employeeId,
|
||
events: [{ action: `培训签收:${record.topic}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||
createdBy: employeeId,
|
||
}).catch(() => {})
|
||
|
||
res.json({ success: true, data: updated, message: '签收成功' })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 培训拒绝签收
|
||
router.post('/training/:id/refuse', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.trainingRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||
|
||
const updated = await prisma.trainingRecord.update({
|
||
where: { id: record.id },
|
||
data: { ackStatus: 'REFUSED', ackDate: new Date() },
|
||
})
|
||
|
||
res.json({ success: true, data: updated, message: '已拒绝签收' })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:绩效签字 ==========
|
||
// 查看自己的绩效记录
|
||
router.get('/performance', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const records = await prisma.performanceRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { period: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 绩效签字确认
|
||
router.post('/performance/:id/sign', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.performanceRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
|
||
|
||
const updated = await prisma.performanceRecord.update({
|
||
where: { id: record.id },
|
||
data: { employeeAck: true, ackDate: new Date() },
|
||
})
|
||
|
||
await createEvidence({
|
||
orgId,
|
||
category: 'PERFORMANCE',
|
||
refId: record.id,
|
||
employeeId,
|
||
events: [{ action: `绩效签字确认:${record.period}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||
createdBy: employeeId,
|
||
}).catch(() => {})
|
||
|
||
res.json({ success: true, data: updated, message: '签字成功' })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 员工端:违纪签字 ==========
|
||
// 查看自己的违纪记录
|
||
router.get('/disciplinary', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const records = await prisma.disciplinaryRecord.findMany({
|
||
where: { employeeId, orgId },
|
||
orderBy: { violationDate: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 违纪签字确认
|
||
router.post('/disciplinary/:id/sign', portalAuth, async (req: any, res, next) => {
|
||
try {
|
||
const { id: employeeId, orgId } = req.employee
|
||
const record = await prisma.disciplinaryRecord.findFirst({
|
||
where: { id: req.params.id, employeeId, orgId, employeeAck: false },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } })
|
||
|
||
const updated = await prisma.disciplinaryRecord.update({
|
||
where: { id: record.id },
|
||
data: { employeeAck: true, ackDate: new Date(), ackMethod: 'SIGN' },
|
||
})
|
||
|
||
await createEvidence({
|
||
orgId,
|
||
category: 'DISCIPLINARY',
|
||
refId: record.id,
|
||
employeeId,
|
||
events: [{ action: `违纪签字确认:${record.violationType}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||
createdBy: employeeId,
|
||
}).catch(() => {})
|
||
|
||
res.json({ success: true, data: updated, message: '签字成功' })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
export default router
|