Files
TurboHR/backend/src/routes/portal.routes.ts
T
selfrelease b6fc93cc61 feat: 电子签署完整流程 + 证据链体系
## 后端
- 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>
2026-08-15 16:19:37 +08:00

1392 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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