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' import { encrypt, decrypt, sha256 } from '../lib/crypto' 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 employees = await prisma.employee.findMany({ where: { phone: data.phone, status: 'ACTIVE' }, select: { id: true, name: true, department: true, orgId: true, passwordHash: true }, }) if (employees.length === 0) { return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } }) } // 逐个校验密码,找到匹配的员工 let matchedEmployee = null for (const emp of employees) { if (emp.passwordHash) { const valid = await bcrypt.compare(data.password, emp.passwordHash) if (valid) { matchedEmployee = emp break } } } if (!matchedEmployee) { return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } }) } const token = signAccessToken({ id: matchedEmployee.id, orgId: matchedEmployee.orgId, role: 'EMPLOYEE' }) res.json({ success: true, data: { token, employee: { id: matchedEmployee.id, name: matchedEmployee.name, department: matchedEmployee.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 employees = await prisma.employee.findMany({ where: { phone: data.phone, status: 'ACTIVE' }, select: { id: true, name: true, department: true, orgId: true }, }) if (employees.length === 0) { return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } // 如果只有一个匹配,直接登录 const employee = employees[0] 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) } }) /** * 员工修改自己的密码 * POST /portal/change-password body: { oldPassword, newPassword } */ router.post('/change-password', portalAuth, async (req: any, res, next) => { try { const { oldPassword, newPassword } = req.body if (!oldPassword || !newPassword) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入旧密码和新密码' } }) } if (newPassword.length < 6) { return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '新密码至少6位' } }) } const employee = await prisma.employee.findFirst({ where: { id: req.employee.id }, select: { id: true, passwordHash: true }, }) if (!employee || !employee.passwordHash) { return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '当前未设置密码,请联系管理员重置' } }) } const valid = await bcrypt.compare(oldPassword, employee.passwordHash) if (!valid) { return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '旧密码错误' } }) } const passwordHash = await bcrypt.hash(newPassword, 10) await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } }) res.json({ success: true, data: { message: '密码修改成功' } }) } 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 = { 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.get('/profile', portalAuth, async (req: any, res, next) => { try { const { id: employeeId, orgId } = req.employee const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) // 解密敏感字段 let idCardNo = '' let bankAccount = '' try { if (emp.idCardNumber) idCardNo = decrypt(emp.idCardNumber) } catch {} try { if (emp.bankAccount) bankAccount = decrypt(emp.bankAccount) } catch {} res.json({ success: true, data: { name: emp.name, gender: emp.gender || '', phone: emp.phone || '', idCardNo, idType: emp.idType || 'ID_CARD', department: emp.department, position: emp.position || '', hireDate: emp.hireDate ? emp.hireDate.toISOString().slice(0, 10) : '', emergencyContact: emp.emergencyContact || '', emergencyPhone: emp.emergencyPhone || '', address: emp.address || '', bankAccount, bankName: emp.bankName || '', education: emp.education || '', city: emp.city || '', birthDate: emp.birthDate ? emp.birthDate.toISOString().slice(0, 10) : '', }, }) } catch (err) { next(err) } }) // 更新个人资料(员工可编辑字段:紧急联系人、地址、银行卡、学历等) router.put('/profile', portalAuth, async (req: any, res, next) => { try { const { id: employeeId, orgId } = req.employee const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId } }) if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) const { emergencyContact, emergencyPhone, address, bankAccount, bankName, education, city, } = req.body // 构建更新数据(仅允许员工自行编辑的字段) const updateData: any = {} if (emergencyContact !== undefined) updateData.emergencyContact = emergencyContact || null if (emergencyPhone !== undefined) updateData.emergencyPhone = emergencyPhone || null if (address !== undefined) updateData.address = address || null if (bankName !== undefined) updateData.bankName = bankName || null if (education !== undefined) updateData.education = education || null if (city !== undefined) updateData.city = city || null // 银行卡号需要加密 if (bankAccount !== undefined && bankAccount) { updateData.bankAccount = encrypt(bankAccount) } await prisma.employee.update({ where: { id: employeeId }, data: updateData }) // 记录证据链 await createEvidence({ orgId, category: 'PROFILE_UPDATE', refId: employeeId, employeeId, events: [{ action: '员工更新个人资料', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'], location: `更新字段:${Object.keys(updateData).join(', ')}`, }], createdBy: employeeId, }).catch(() => {}) res.json({ success: true, data: { message: '资料更新成功' } }) } 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 = { RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退', NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡', } const reason = reasonLabel[record.reason] || record.reason || '' const variables: Record = { 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 = `

解除/终止劳动合同证明书

兹证明 ${variables.employeeName}(证件号码:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。

经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。

特此证明。

公司(盖章)
${new Date().toISOString().slice(0, 10)}
` } const htmlContent = ` 离职证明 ${content}` 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