import { Router, Response } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' import { decrypt, encrypt } from '../lib/crypto' import { getContractStatus } from '../services/contract.service' import { calcSocialInsurance, calcHousingFund } from '../services/payroll.service' import ExcelJS from 'exceljs' const router = Router() function safeDecrypt(encrypted: string): number { try { if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0 return Number(decrypt(encrypted)) } catch { return Number(encrypted) || 0 } } function safeDecryptStr(encrypted: string | null): string | null { if (!encrypted) return null try { if (!encrypted.includes(':')) return encrypted return decrypt(encrypted) } catch { return encrypted } } // ========== 花名册聚合 API ========== // 获取部门列表(去重) router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) => { try { const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, status: 'ACTIVE' }, select: { department: true }, distinct: 'department', }) const departments = employees.map((e) => e.department).filter(Boolean).sort() res.json({ success: true, data: departments }) } catch (err) { next(err) } }) /** 花名册可选职务列表(从在职员工中提取去重) */ router.get('/positions', authMiddleware, async (req: AuthRequest, res, next) => { try { const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, status: 'ACTIVE' }, select: { position: true }, distinct: 'position', }) const positions = employees.map((e) => e.position).filter(Boolean).sort() res.json({ success: true, data: positions }) } catch (err) { next(err) } }) // 花名册列表(含汇总信息,支持分页和过滤) router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { try { const page = parseInt(req.query.page as string) || 1 const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200) const search = req.query.search as string const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc. const department = req.query.department as string const position = req.query.position as string const skip = (page - 1) * pageSize // 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职 const today = new Date() today.setHours(0, 0, 0, 0) // hireDate 可能以 UTC 午夜存储(如 new Date('2026-07-24')),在 UTC+8 下为 08:00 // 将 today 前移一天作为比较基准,确保当天入职的员工不被误判为预入职 const todayEnd = new Date(today) todayEnd.setDate(todayEnd.getDate() + 1) // 先查询满足 orgId 和搜索条件的员工 const isIdCardSearch = search && /^\d{2,}$/.test(search) const whereBase: any = { orgId: req.user!.orgId } if (department) { whereBase.department = department } if (position) { whereBase.position = position } if (search && !isIdCardSearch) { whereBase.OR = [ { name: { contains: search } }, { department: { contains: search } }, ] } // 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤) if (status === 'RESIGNED') { whereBase.status = 'RESIGNED' } else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') { // 预入职:ACTIVE 状态但入职日期在未来(按入职日期自动判定) whereBase.status = 'ACTIVE' whereBase.hireDate = { gt: todayEnd } } else if (status === 'ACTIVE') { whereBase.status = 'ACTIVE' whereBase.hireDate = { lte: todayEnd } } // unsigned 合同状态可以在 DB 层过滤 if (contractStatus === 'unsigned') { whereBase.contracts = { none: {} } } // 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页 const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch const [dbTotal, employees] = await Promise.all([ prisma.employee.count({ where: whereBase }), prisma.employee.findMany({ where: whereBase, orderBy: { createdAt: 'desc' }, ...(needPostFilter ? {} : { skip, take: pageSize }), include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { terminationDate: 'desc' }, take: 1 }, socialInsRecords: { orderBy: { startMonth: 'desc' }, take: 1 }, _count: { select: { disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, payslips: true, overtimeRecords: true, }, }, }, }), ]) // 获取社保和公积金配置(按城市缓存) const currentMonth = new Date().toISOString().slice(0, 7) const configCache = new Map() const getConfigsForCity = async (city?: string) => { const key = city || '_default' if (configCache.has(key)) return configCache.get(key)! const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId } const [socialCfg, housingCfg] = await Promise.all([ prisma.socialInsuranceConfig.findFirst({ where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] }, orderBy: { effectiveFrom: 'desc' }, }), prisma.housingFundConfig.findFirst({ where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] }, orderBy: { effectiveFrom: 'desc' }, }), ]) const result = { social: socialCfg, housing: housingCfg } configCache.set(key, result) return result } // 预加载所有涉及城市的配置 const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[] await Promise.all(cities.map((c) => getConfigsForCity(c))) // 计算动态状态和合同状态 let result = employees.map((e) => { const latestContract = e.contracts[0] || null const contractInfo = latestContract ? getContractStatus({ signDate: latestContract.signDate, startDate: latestContract.startDate, endDate: latestContract.endDate, contractType: latestContract.contractType, hireDate: e.hireDate, hasRecord: true, }) : getContractStatus({ signDate: null, startDate: e.hireDate, endDate: null, contractType: 'UNSIGNED', hireDate: e.hireDate, hasRecord: false, }) const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) const isPreHire = !isResigned && e.hireDate > todayEnd const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE') // 证件号码脱敏显示 let idCardMasked: string | null = null if (e.idCardNumber) { try { const idCard = decrypt(e.idCardNumber) if (idCard.length >= 11) { idCardMasked = idCard.slice(0, 3) + '****' + idCard.slice(-4) } else { idCardMasked = '****' } } catch {} } return { id: e.id, name: e.name, department: e.department, position: e.position, city: e.city, status: dynamicStatus, hasTermination: e.terminations.length > 0, latestTerminationDate: e.terminations[0]?.terminationDate || null, latestTerminationType: e.terminations[0]?.type || null, latestTerminationId: e.terminations[0]?.id || null, latestTerminationStatus: e.terminations[0]?.status || null, hireDate: e.hireDate, gender: e.gender, phone: e.phone, idCardMasked, idCardNumber: safeDecryptStr(e.idCardNumber), monthlySalary: safeDecrypt(e.monthlySalary), baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null, performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null, socialInsBase: (() => { const ct = latestContract?.contractType if (!ct || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(ct)) return null return e.socialInsBase })(), housingFundBase: (() => { const ct = latestContract?.contractType if (!ct || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(ct)) return null return e.housingFundBase })(), socialInsCalc: (() => { const contractType = latestContract?.contractType // 劳务协议、实习协议、兼职协议、业务外包等非劳动合同不缴纳社保 if (!contractType || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(contractType)) return null const cfgs = configCache.get(e.city || '_default') if (!cfgs?.social || !e.socialInsBase) return null const r = calcSocialInsurance(e.socialInsBase, cfgs.social) return { socialEmp: r.socialEmp, socialOrg: r.socialOrg } })(), housingFundCalc: (() => { const contractType = latestContract?.contractType if (!contractType || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(contractType)) return null const cfgs = configCache.get(e.city || '_default') if (!cfgs?.housing || !e.housingFundBase) return null const r = calcHousingFund(e.housingFundBase, cfgs.housing) return { housingEmp: r.housingEmp, housingOrg: r.housingOrg } })(), isPregnant: e.isPregnant, isInMedicalPeriod: e.isInMedicalPeriod, isWorkInjured: e.isWorkInjured, latestContract, contractStatus: contractInfo.status, contractStatusText: contractInfo.statusText, riskLevel: contractInfo.riskLevel, socialInsuranceStatus: (() => { const sr = (e as any).socialInsRecords?.[0] // 劳务协议/实习协议:不缴纳社保,返回 null(前端显示"—") const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType) if (isNoSocialContract) return null if (!sr) { // 在职且应缴社保但无社保记录 → 待办理 return 'PENDING' } // endMonth 为 null 表示在保,否则已停保 if (sr.endMonth) return 'SUSPENDED' return 'ACTIVE' })(), probationInfo: (() => { if (!latestContract || latestContract.probationMonths === 0) return null const probEnd = new Date(e.hireDate) probEnd.setMonth(probEnd.getMonth() + latestContract.probationMonths) const daysToConfirm = Math.ceil((probEnd.getTime() - today.getTime()) / 86400000) return { months: latestContract.probationMonths, endDate: probEnd.toISOString().slice(0, 10), daysToConfirm, isProbation: daysToConfirm > 0 && dynamicStatus === 'ACTIVE', isExpiring: daysToConfirm <= 7 && daysToConfirm > 0, } })(), counts: e._count, } }) // 前端过滤:合同状态(非 unsigned 的需要后处理计算) if (contractStatus && contractStatus !== 'unsigned') { result = result.filter((e) => e.contractStatus === contractStatus) } // 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文) if (isIdCardSearch) { result = result.filter((e: any) => { if (!e.idCardNumber) return false return String(e.idCardNumber).endsWith(search!) }) } // 计算过滤后的总数和分页 const needMemoryPaging = needPostFilter || isIdCardSearch const filteredTotal = needMemoryPaging ? result.length : dbTotal if (needMemoryPaging) { result = result.slice(skip, skip + pageSize) } // 全局合同风险统计(基于 RiskItem,与风险中心同口径) const [globalExpiring, globalExpired, globalUnsigned] = await Promise.all([ prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'CONTRACT', level: 'MEDIUM' } }), prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'CONTRACT', level: 'HIGH' } }), prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'ONBOARDING' } }), ]) res.json({ success: true, data: result, pagination: { page, pageSize, total: filteredTotal, totalPages: Math.ceil(filteredTotal / pageSize), }, globalRiskStats: { expiring: globalExpiring, expired: globalExpired, unsigned: globalUnsigned, }, }) } catch (err) { next(err) } }) // 员工完整档案(花名册详情) router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) => { try { const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' } }, payslips: { orderBy: { month: 'desc' } }, overtimeRecords: { orderBy: { month: 'desc' } }, disciplinaryRecords: { orderBy: { violationDate: 'desc' } }, attendanceRecords: { orderBy: { date: 'desc' }, take: 90 }, trainingRecords: { orderBy: { trainingDate: 'desc' } }, performanceRecords: { orderBy: { period: 'desc' } }, terminations: { orderBy: { createdAt: 'desc' } }, attachments: true, socialInsRecords: { orderBy: { startMonth: 'desc' } }, housingFundRecords: { orderBy: { startMonth: 'desc' } }, salaryChanges: { orderBy: { effectiveDate: 'desc' } }, departmentRecords: { orderBy: { effectiveMonth: 'desc' } }, }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } // 查询该员工相关的月度办理记录(从快照中筛选该员工) const allProcesses = await prisma.socialMonthlyProcess.findMany({ where: { orgId: req.user!.orgId }, orderBy: { month: 'desc' }, }) const employeeId = req.params.id const monthlyProcessRecords: any[] = [] for (const p of allProcesses) { const snap = p.snapshot as any // 从增减员快照中筛选 const changes = snap.changes const active = snap.active const type = p.type let found = false let recordData: any = { month: p.month, type, processedAt: p.processedAt, status: p.status } if (changes?.additions) { const item = changes.additions.find((a: any) => a.employeeId === employeeId) if (item) { recordData.changeType = '新增'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } } if (!found && changes?.reductions) { const item = changes.reductions.find((a: any) => a.employeeId === employeeId) if (item) { recordData.changeType = '减少'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } } if (!found && active?.items) { const item = active.items.find((a: any) => a.employeeId === employeeId) if (item) { recordData.changeType = '正常在保'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true } } if (found) monthlyProcessRecords.push(recordData) } const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee const today = new Date() today.setHours(0, 0, 0, 0) const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE' res.json({ success: true, data: { ...rest, status: dynamicStatus, monthlySalary: safeDecrypt(monthlySalary), baseSalary: baseSalary ? safeDecrypt(baseSalary) : null, performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null, bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null, idCardNumber: safeDecryptStr(idCardNumber), monthlyProcessRecords, }, }) } catch (err) { next(err) } }) // 仲裁证据链导出 router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, next) => { try { const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' } }, payslips: { orderBy: { month: 'desc' } }, overtimeRecords: { orderBy: { month: 'desc' } }, disciplinaryRecords: { orderBy: { violationDate: 'desc' } }, attendanceRecords: { orderBy: { date: 'desc' } }, trainingRecords: { orderBy: { trainingDate: 'desc' } }, performanceRecords: { orderBy: { period: 'desc' } }, terminations: true, }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } const evidence: any[] = [] const empName = employee.name const empDept = employee.department const hireDate = employee.hireDate.toISOString().slice(0, 10) // 风险检测 const risks: any[] = [] const today = new Date() today.setHours(0, 0, 0, 0) // 1. 劳动关系证据 evidence.push({ category: '劳动关系', title: '入职登记', date: hireDate, description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`, evidenceType: 'EMPLOYMENT', }) // 无合同风险检测 if (employee.contracts.length === 0) { const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / (86400000)) if (daysSinceHire > 30) { risks.push({ level: daysSinceHire > 365 ? 'DANGER' : 'HIGH', category: '劳动关系', title: '未签订书面劳动合同', description: `入职已${Math.round(daysSinceHire / 30)}个月仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险(《劳动合同法》第82条)。${daysSinceHire > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}`, }) } } employee.contracts.forEach((c) => { const contractTypeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record)[c.contractType] || '未签订' const isSigned = !!c.signDate evidence.push({ category: '劳动关系', title: `合同(${contractTypeText})`, date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10), description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。${isSigned ? '' : '⚠ 该合同尚未签订。'}`, evidenceType: 'CONTRACT', signed: isSigned, riskLevel: !isSigned ? 'HIGH' : undefined, }) // 合同未签字风险 if (!isSigned) { risks.push({ level: 'HIGH', category: '劳动关系', title: '合同未签字', description: `合同(${contractTypeText})期限${c.startDate.toISOString().slice(0, 10)}至${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},尚未签订。未签合同在仲裁中无法证明劳动关系约定内容。`, }) } // 合同过期风险 if (c.endDate && c.endDate < today) { const expiredDays = Math.floor((today.getTime() - c.endDate.getTime()) / 86400000) risks.push({ level: expiredDays > 30 ? 'HIGH' : 'MEDIUM', category: '劳动关系', title: '合同已过期', description: `合同已于${c.endDate.toISOString().slice(0, 10)}过期,过期${expiredDays}天。过期后继续用工满一个月未续签的,面临双倍工资风险。`, }) } else if (c.endDate) { const daysToExpire = Math.floor((c.endDate.getTime() - today.getTime()) / 86400000) if (daysToExpire <= 30 && daysToExpire >= 0) { risks.push({ level: 'MEDIUM', category: '劳动关系', title: '合同即将到期', description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。请及时办理续签或终止手续。`, }) } } // 试用期工资为0风险 if (c.probationMonths > 0 && c.probationSalary === 0) { risks.push({ level: 'MEDIUM', category: '劳动关系', title: '试用期工资为0', description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条(试用期工资不得低于本单位相同岗位最低档工资的80%或劳动合同约定工资的80%)。`, }) } }) // 无工资条风险检测(入职超过1个月但无工资条) const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000) if (daysSinceHire > 30 && employee.payslips.length === 0) { risks.push({ level: 'MEDIUM', category: '薪酬发放', title: '无工资条记录', description: `入职已${Math.round(daysSinceHire / 30)}个月但无任何工资条记录,在仲裁中难以证明已按时足额支付工资。建议尽快创建发薪批次并归档。`, }) } // 2. 薪酬证据 employee.payslips.forEach((p) => { evidence.push({ category: '薪酬发放', title: `${p.month}月工资条`, date: p.month, description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`, evidenceType: 'PAYSLIP', confirmed: !!p.confirmedAt, }) }) employee.overtimeRecords.forEach((o) => { if (o.totalPay > 0) { evidence.push({ category: '薪酬发放', title: `${o.month}月加班费记录`, date: o.month, description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`, evidenceType: 'OVERTIME', }) } }) // 3. 考勤证据 const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL') abnormalAttendance.forEach((a) => { const statusMap: Record = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' } evidence.push({ category: '考勤记录', title: `${a.date.toISOString().slice(0, 10)} 考勤异常`, date: a.date.toISOString().slice(0, 10), description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`, evidenceType: 'ATTENDANCE', }) }) // 4. 违纪证据 employee.disciplinaryRecords.forEach((d) => { const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' } evidence.push({ category: '违纪处理', title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`, date: d.violationDate.toISOString().slice(0, 10), description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认(${d.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`, evidenceType: 'DISCIPLINARY', acknowledged: d.employeeAck, }) }) // 5. 培训签收证据 employee.trainingRecords.forEach((t) => { const ackMap: Record = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' } evidence.push({ category: '培训签收', title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`, date: t.trainingDate.toISOString().slice(0, 10), description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`, evidenceType: 'TRAINING', acknowledged: t.ackStatus === 'SIGNED', }) }) // 6. 绩效证据 employee.performanceRecords.forEach((p) => { const resultMap: Record = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' } evidence.push({ category: '绩效考核', title: `${p.period} 绩效考核`, date: p.period, description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`, evidenceType: 'PERFORMANCE', acknowledged: p.employeeAck, }) }) // 7. 解聘证据(排除已撤销的记录) employee.terminations.filter((t) => t.status !== 'CANCELLED').forEach((t) => { const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED' ? '协商一致离职' : t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘' const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } evidence.push({ category: '解聘记录', title: `${t.terminationDate.toISOString().slice(0, 10)} ${typeLabel}记录`, date: t.terminationDate.toISOString().slice(0, 10), description: `类型:${typeLabel}。原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。流程状态:${statusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`, evidenceType: 'TERMINATION', acknowledged: t.status === 'COMPLETED', }) // 离职流程未完成风险 if (t.status !== 'COMPLETED') { risks.push({ level: 'HIGH', category: '解聘记录', title: '离职流程未完成', description: `${typeLabel}流程当前状态为「${statusMap[t.status] || t.status}」,尚未完成闭环。离职日期${t.terminationDate.toISOString().slice(0, 10)},若未签署解除协议/离职确认书、未完成工作交接、未结清工资、未办理社保减员,在仲裁中无法证明离职的合法性与完整性,面临继续履行合同或违法解除赔偿(2N)风险。`, }) } // 协商解除但补偿金为0 if (t.reason === 'NEGOTIATED' && t.compensation === 0) { risks.push({ level: 'MEDIUM', category: '解聘记录', title: '协商解除但补偿金为0', description: `解聘原因为协商解除但经济补偿金为0,若员工事后主张非自愿离职,企业需举证协商一致且员工自愿放弃补偿,否则可能被认定为单方违法解除,面临2N赔偿风险。`, }) } }) res.json({ success: true, data: { employee: { name: empName, department: empDept, hireDate, status: employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE', gender: employee.gender, phone: employee.phone, }, evidence, risks, summary: { total: evidence.length, signed: evidence.filter((e) => e.acknowledged === true).length, unsigned: evidence.filter((e) => e.acknowledged === false).length, riskCount: risks.length, }, }, }) } catch (err) { next(err) } }) // 仲裁证据链 Excel 导出 router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest, res: Response, next) => { try { const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' } }, payslips: { orderBy: { month: 'desc' } }, overtimeRecords: { orderBy: { month: 'desc' } }, disciplinaryRecords: { orderBy: { violationDate: 'desc' } }, attendanceRecords: { orderBy: { date: 'desc' } }, trainingRecords: { orderBy: { trainingDate: 'desc' } }, performanceRecords: { orderBy: { period: 'desc' } }, terminations: { where: { status: { not: 'CANCELLED' } }, orderBy: { createdAt: 'desc' } }, }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } const empName = employee.name const empDept = employee.department const hireDate = employee.hireDate.toISOString().slice(0, 10) const today = new Date(); today.setHours(0, 0, 0, 0) const workbook = new ExcelJS.Workbook() // Sheet 1: 员工信息 const wsInfo = workbook.addWorksheet('员工信息') wsInfo.columns = [ { header: '项目', key: 'label', width: 16 }, { header: '内容', key: 'value', width: 40 }, ] wsInfo.getRow(1).font = { bold: true } const empStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? '离职' : '在职' wsInfo.addRows([ { label: '姓名', value: empName }, { label: '部门', value: empDept }, { label: '入职日期', value: hireDate }, { label: '状态', value: empStatus }, { label: '导出时间', value: new Date().toLocaleString('zh-CN') }, ]) // Sheet 2: 证据清单 const wsEv = workbook.addWorksheet('证据清单') wsEv.columns = [ { header: '序号', key: 'no', width: 6 }, { header: '类别', key: 'category', width: 12 }, { header: '标题', key: 'title', width: 28 }, { header: '日期', key: 'date', width: 12 }, { header: '描述', key: 'description', width: 60 }, { header: '签字状态', key: 'ack', width: 10 }, ] wsEv.getRow(1).font = { bold: true } let evNo = 0 // 劳动关系 evNo++; wsEv.addRow({ no: evNo, category: '劳动关系', title: '入职登记', date: hireDate, description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`, ack: '' }) employee.contracts.forEach((c) => { const typeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record)[c.contractType] || '未签订' evNo++ wsEv.addRow({ no: evNo, category: '劳动关系', title: `合同(${typeText})`, date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10), description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。${c.signDate ? '' : '⚠ 该合同尚未签订。'}`, ack: c.signDate ? '已签字' : '未签字', }) }) // 薪酬发放 employee.payslips.forEach((p) => { evNo++ wsEv.addRow({ no: evNo, category: '薪酬发放', title: `${p.month}月工资条`, date: p.month, description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`, ack: p.confirmedAt ? '已签字' : '未签字', }) }) employee.overtimeRecords.forEach((o) => { if (o.totalPay > 0) { evNo++ wsEv.addRow({ no: evNo, category: '薪酬发放', title: `${o.month}月加班费记录`, date: o.month, description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`, ack: '' }) } }) // 考勤记录 const statusMap: Record = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' } employee.attendanceRecords.filter((a) => a.status !== 'NORMAL').forEach((a) => { evNo++ wsEv.addRow({ no: evNo, category: '考勤记录', title: `${a.date.toISOString().slice(0, 10)} 考勤异常`, date: a.date.toISOString().slice(0, 10), description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`, ack: '' }) }) // 违纪处理 const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' } employee.disciplinaryRecords.forEach((d) => { evNo++ wsEv.addRow({ no: evNo, category: '违纪处理', title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`, date: d.violationDate.toISOString().slice(0, 10), description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`, ack: d.employeeAck ? '已签字' : '未签字' }) }) // 培训签收 const ackMap: Record = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' } employee.trainingRecords.forEach((t) => { evNo++ wsEv.addRow({ no: evNo, category: '培训签收', title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`, date: t.trainingDate.toISOString().slice(0, 10), description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`, ack: t.ackStatus === 'SIGNED' ? '已签字' : '未签字' }) }) // 绩效考核 const resultMap: Record = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' } employee.performanceRecords.forEach((p) => { evNo++ wsEv.addRow({ no: evNo, category: '绩效考核', title: `${p.period} 绩效考核`, date: p.period, description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`, ack: p.employeeAck ? '已签字' : '未签字' }) }) // 解聘记录 const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } const termStatusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } employee.terminations.forEach((t) => { const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED' ? '协商一致离职' : t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘' evNo++ wsEv.addRow({ no: evNo, category: '解聘记录', title: `${t.terminationDate.toISOString().slice(0, 10)} ${typeLabel}记录`, date: t.terminationDate.toISOString().slice(0, 10), description: `类型:${typeLabel}。原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。流程状态:${termStatusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`, ack: t.status === 'COMPLETED' ? '已签字' : '未签字' }) }) // Sheet 3: 风险提醒 const risks: any[] = [] if (employee.contracts.length === 0) { const days = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000) if (days > 30) risks.push({ level: days > 365 ? '危险' : '高', category: '劳动关系', title: '未签订书面劳动合同', description: `入职已${Math.round(days / 30)}个月仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险。${days > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}` }) } employee.contracts.forEach((c) => { if (!c.signDate) risks.push({ level: '高', category: '劳动关系', title: '合同未签字', description: `合同期限${c.startDate.toISOString().slice(0, 10)}至${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},尚未签订。` }) if (c.endDate && c.endDate < today) { const expiredDays = Math.floor((today.getTime() - c.endDate.getTime()) / 86400000) risks.push({ level: expiredDays > 30 ? '高' : '中', category: '劳动关系', title: '合同已过期', description: `合同已于${c.endDate.toISOString().slice(0, 10)}过期,过期${expiredDays}天。` }) } else if (c.endDate) { const daysToExpire = Math.floor((c.endDate.getTime() - today.getTime()) / 86400000) if (daysToExpire <= 30 && daysToExpire >= 0) risks.push({ level: '中', category: '劳动关系', title: '合同即将到期', description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。` }) } if (c.probationMonths > 0 && c.probationSalary === 0) risks.push({ level: '中', category: '劳动关系', title: '试用期工资为0', description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条。` }) }) const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000) if (daysSinceHire > 30 && employee.payslips.length === 0) risks.push({ level: '中', category: '薪酬发放', title: '无工资条记录', description: `入职已${Math.round(daysSinceHire / 30)}个月但无任何工资条记录。` }) employee.terminations.forEach((t) => { if (t.status !== 'COMPLETED') risks.push({ level: '高', category: '解聘记录', title: '离职流程未完成', description: `流程当前状态为「${termStatusMap[t.status] || t.status}」,尚未完成闭环。` }) if (t.reason === 'NEGOTIATED' && t.compensation === 0) risks.push({ level: '中', category: '解聘记录', title: '协商解除但补偿金为0', description: '解聘原因为协商解除但经济补偿金为0,面临2N赔偿风险。' }) }) const wsRisk = workbook.addWorksheet('风险提醒') wsRisk.columns = [ { header: '序号', key: 'no', width: 6 }, { header: '风险等级', key: 'level', width: 10 }, { header: '类别', key: 'category', width: 12 }, { header: '标题', key: 'title', width: 24 }, { header: '描述', key: 'description', width: 60 }, ] wsRisk.getRow(1).font = { bold: true } risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r })) const fullFileName = `${empName}_证据链.xlsx` const encodedName = encodeURIComponent(fullFileName) const asciiFallback = `evidence_chain_${employee.id.slice(-8)}.xlsx` res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') res.setHeader('Content-Disposition', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedName}`) const buffer = await workbook.xlsx.writeBuffer() res.send(Buffer.from(buffer)) } catch (err: any) { console.error('证据链导出失败:', err?.message || err) if (!res.headersSent) { res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } }) } else { res.end() } } }) // ========== 组织级列表查询 ========== // 培训记录列表(全员) router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) => { try { const orgId = req.user!.orgId const page = parseInt(req.query.page as string) || 1 const pageSize = parseInt(req.query.pageSize as string) || 20 const keyword = (req.query.keyword as string) || '' const ackStatus = (req.query.ackStatus as string) || '' const where: any = { orgId } if (ackStatus) { where.ackStatus = ackStatus } if (keyword) { const employees = await prisma.employee.findMany({ where: { orgId, name: { contains: keyword } }, select: { id: true }, }) where.employeeId = { in: employees.map(e => e.id) } } const [records, total] = await Promise.all([ prisma.trainingRecord.findMany({ where, include: { employee: { select: { id: true, name: true, department: true } } }, orderBy: { trainingDate: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.trainingRecord.count({ where }), ]) res.json({ success: true, data: { records, total, page, pageSize } }) } catch (err) { next(err) } }) // 培训记录催办(发送通知给未签收员工) router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const orgId = req.user!.orgId const record = await prisma.trainingRecord.findFirst({ where: { id: req.params.recordId, orgId }, include: { employee: { select: { id: true, name: true, department: true, phone: true } } }, }) if (!record) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } }) } if (record.ackStatus !== 'PENDING') { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } }) } // 记录催办通知日志 await prisma.notificationLog.create({ data: { orgId, type: 'TRAINING_REMIND', title: `培训签收催办:${record.topic}`, content: `员工 ${record.employee.name}(${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`, channel: 'SYSTEM', status: 'SENT', }, }) res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}」` } }) } catch (err) { next(err) } }) // 绩效记录列表(全员) router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => { try { const orgId = req.user!.orgId const page = parseInt(req.query.page as string) || 1 const pageSize = parseInt(req.query.pageSize as string) || 20 const keyword = (req.query.keyword as string) || '' const where: any = { orgId } if (keyword) { const employees = await prisma.employee.findMany({ where: { orgId, name: { contains: keyword } }, select: { id: true }, }) where.employeeId = { in: employees.map(e => e.id) } } const [records, total] = await Promise.all([ prisma.performanceRecord.findMany({ where, include: { employee: { select: { id: true, name: true, department: true } } }, orderBy: { period: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.performanceRecord.count({ where }), ]) res.json({ success: true, data: { records, total, page, pageSize } }) } catch (err) { next(err) } }) // 违纪记录列表(全员) router.get('/disciplinary/list', authMiddleware, async (req: AuthRequest, res, next) => { try { const orgId = req.user!.orgId const page = parseInt(req.query.page as string) || 1 const pageSize = parseInt(req.query.pageSize as string) || 20 const keyword = (req.query.keyword as string) || '' const where: any = { orgId } if (keyword) { const employees = await prisma.employee.findMany({ where: { orgId, name: { contains: keyword } }, select: { id: true }, }) where.employeeId = { in: employees.map(e => e.id) } } const [records, total] = await Promise.all([ prisma.disciplinaryRecord.findMany({ where, include: { employee: { select: { id: true, name: true, department: true } } }, orderBy: { violationDate: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), prisma.disciplinaryRecord.count({ where }), ]) res.json({ success: true, data: { records, total, page, pageSize } }) } catch (err) { next(err) } }) // ========== 违纪记录 CRUD ========== router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.disciplinaryRecord.findMany({ where: { employeeId: req.params.employeeId, orgId: req.user!.orgId }, orderBy: { violationDate: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => { try { const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const record = await prisma.disciplinaryRecord.create({ data: { orgId: req.user!.orgId, employeeId: req.params.employeeId, violationDate: new Date(violationDate), violationType, description, severity: severity || 'WARNING', action: action || 'ORAL_WARNING', actionDetail, deductionAmount: Number(deductionAmount) || 0, employeeAck: employeeAck || false, ackDate: ackDate ? new Date(ackDate) : null, ackMethod, witness, attachmentUrl, createdBy: req.user!.id, }, }) await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId, violationType, severity, action }) await createEvidence({ orgId: req.user!.orgId, category: 'DISCIPLINARY', refId: record.id, employeeId: req.params.employeeId, events: [{ action: '违纪记录创建', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], createdBy: req.user!.id, }).catch(() => {}) res.json({ success: true, data: record }) } catch (err) { next(err) } }) router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body const record = await prisma.disciplinaryRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) const updated = await prisma.disciplinaryRecord.update({ where: { id: req.params.recordId }, data: { violationDate: violationDate ? new Date(violationDate) : undefined, violationType, description, severity, action, actionDetail, deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, ackMethod, witness, attachmentUrl, }, }) res.json({ success: true, data: updated }) } catch (err) { next(err) } }) router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const record = await prisma.disciplinaryRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } }) res.json({ success: true }) } catch (err) { next(err) } }) // 违纪确认证明导出 router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, async (req: AuthRequest, res: Response, next) => { try { const record = await prisma.disciplinaryRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, include: { employee: true }, }) if (!record) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) } const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' } const severityMap: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' } let idCard = '' try { if (record.employee.idCardNumber) idCard = decrypt(record.employee.idCardNumber) } catch { idCard = record.employee.idCardNumber || '' } const content = `违纪确认证明 兹证明 ${record.employee.name}(证件号码:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为: 违纪类型:${typeMap[record.violationType] || record.violationType} 严重程度:${severityMap[record.severity] || record.severity} 违纪事实:${record.description} 处理结果:${actionMap[record.action] || record.action}${record.actionDetail ? `(${record.actionDetail})` : ''} ${record.employeeAck ? `该员工已于 ${record.ackDate ? new Date(record.ackDate).toISOString().slice(0, 10) : '___'} 签字确认上述违纪事实及处理结果。${record.witness ? `见证人:${record.witness}。` : ''}` : '该员工尚未签字确认。'} 特此证明。 ${org?.name || ''} ${new Date().toLocaleDateString('zh-CN')}` const blob = Buffer.from('\ufeff' + content, 'utf8') const certFileName = `${record.employee.name}_违纪确认证明.doc` const encodedCertName = encodeURIComponent(certFileName) const asciiCertFallback = `disciplinary_cert_${record.id.slice(-8)}.doc` res.setHeader('Content-Type', 'application/msword;charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="${asciiCertFallback}"; filename*=UTF-8''${encodedCertName}`) res.send(blob) } catch (err: any) { console.error('违纪确认证明导出失败:', err?.message || err) if (!res.headersSent) { res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } }) } } }) // ========== 考勤记录 CRUD ========== router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.attendanceRecord.findMany({ where: { employeeId: req.params.employeeId, orgId: req.user!.orgId }, orderBy: { date: 'desc' }, take: 90, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => { try { const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body const record = await prisma.attendanceRecord.upsert({ where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } }, create: { orgId: req.user!.orgId, employeeId: req.params.employeeId, date: new Date(date), checkInTime, checkOutTime, status: status || 'NORMAL', lateMinutes: lateMinutes || 0, earlyMinutes: earlyMinutes || 0, workHours: workHours || 0, overtimeHours: overtimeHours || 0, remark, createdBy: req.user!.id, }, update: { checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark, }, }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const record = await prisma.attendanceRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } }) res.json({ success: true }) } catch (err) { next(err) } }) // ========== 培训签收记录 CRUD ========== router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.trainingRecord.findMany({ where: { employeeId: req.params.employeeId, orgId: req.user!.orgId }, orderBy: { trainingDate: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => { try { const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body const record = await prisma.trainingRecord.create({ data: { orgId: req.user!.orgId, employeeId: req.params.employeeId, trainingDate: new Date(trainingDate), topic, content, trainer, duration: duration || 0, ackStatus: ackStatus || 'PENDING', ackDate: ackDate ? new Date(ackDate) : null, attachmentUrl, remark, createdBy: req.user!.id, }, }) await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId, topic, trainer, duration }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) // 批量创建培训记录 router.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => { try { const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) { return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } }) } const results = await Promise.all(employeeIds.map((empId: string) => prisma.trainingRecord.create({ data: { orgId: req.user!.orgId, employeeId: empId, trainingDate: new Date(trainingDate), topic, content, trainer, duration: duration || 0, ackStatus: 'PENDING', remark, createdBy: req.user!.id, }, }) )) res.json({ success: true, data: { count: results.length } }) } catch (err) { next(err) } }) router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body const record = await prisma.trainingRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) const updated = await prisma.trainingRecord.update({ where: { id: req.params.recordId }, data: { trainingDate: trainingDate ? new Date(trainingDate) : undefined, topic, content, trainer, duration, ackStatus, ackDate: ackDate ? new Date(ackDate) : null, attachmentUrl, remark, }, }) res.json({ success: true, data: updated }) } catch (err) { next(err) } }) router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const record = await prisma.trainingRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) await prisma.trainingRecord.delete({ where: { id: req.params.recordId } }) res.json({ success: true }) } catch (err) { next(err) } }) // ========== 绩效记录 CRUD ========== router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.performanceRecord.findMany({ where: { employeeId: req.params.employeeId, orgId: req.user!.orgId }, orderBy: { period: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => { try { const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body const record = await prisma.performanceRecord.upsert({ where: { employeeId_period: { employeeId: req.params.employeeId, period } }, create: { orgId: req.user!.orgId, employeeId: req.params.employeeId, period, periodType: periodType || 'MONTHLY', score: score || 0, grade: grade || 'B', result: result || 'QUALIFIED', summary, improvementPlan, templateId: templateId || null, dimensionScores: dimensionScores || undefined, employeeAck: employeeAck || false, ackDate: ackDate ? new Date(ackDate) : null, reviewer, createdBy: req.user!.id, }, update: { periodType, score, grade, result, summary, improvementPlan, templateId: templateId || null, dimensionScores: dimensionScores || undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, reviewer, }, }) await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId, period, score, grade, reviewer }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const { period, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = req.body const record = await prisma.performanceRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) const updated = await prisma.performanceRecord.update({ where: { id: req.params.recordId }, data: { period, periodType, score, grade, result, summary, improvementPlan, templateId: templateId || null, dimensionScores: dimensionScores || undefined, employeeAck, ackDate: ackDate ? new Date(ackDate) : null, reviewer, }, }) res.json({ success: true, data: updated }) } catch (err) { next(err) } }) router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => { try { const record = await prisma.performanceRecord.findFirst({ where: { id: req.params.recordId, orgId: req.user!.orgId }, }) if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } }) await prisma.performanceRecord.delete({ where: { id: req.params.recordId } }) res.json({ success: true }) } catch (err) { next(err) } }) // ========== 绩效模板 CRUD ========== // 获取模板列表 router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => { try { const templates = await prisma.performanceTemplate.findMany({ where: { orgId: req.user!.orgId }, orderBy: { createdAt: 'desc' }, }) res.json({ success: true, data: templates }) } catch (err) { next(err) } }) // 创建模板 router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => { try { const { name, description, dimensions, gradeRules, isDefault } = req.body if (!name || !dimensions || !Array.isArray(dimensions)) { return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } }) } // 如果设为默认,先取消其他默认 if (isDefault) { await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } }) } const template = await prisma.performanceTemplate.create({ data: { orgId: req.user!.orgId, name, description, dimensions, gradeRules: gradeRules || undefined, isDefault: isDefault || false, createdBy: req.user!.id, }, }) res.json({ success: true, data: template }) } catch (err) { next(err) } }) // 更新模板 router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const { name, description, dimensions, gradeRules, isDefault } = req.body const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } }) if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) if (isDefault) { await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } }) } const updated = await prisma.performanceTemplate.update({ where: { id: req.params.id }, data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault }, }) res.json({ success: true, data: updated }) } catch (err) { next(err) } }) // 删除模板 router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } }) if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) await prisma.performanceTemplate.delete({ where: { id: req.params.id } }) res.json({ success: true }) } catch (err) { next(err) } }) // ========== 调薪/调部门 API ========== function dateToMonth(date: Date): string { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') return `${y}-${m}` } function prevMonth(month: string): string { const [y, m] = month.split('-').map(Number) const d = new Date(y, m - 2, 1) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` } // 调薪 router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => { try { const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } const oldSalary = safeDecrypt(employee.monthlySalary) const effMonth = effectiveMonth || dateToMonth(new Date()) const prevEffMonth = prevMonth(effMonth) // 关闭之前有效记录 await prisma.salaryChangeRecord.updateMany({ where: { employeeId: req.params.id, endMonth: null }, data: { endMonth: prevEffMonth }, }) // 创建新薪资记录 const record = await prisma.salaryChangeRecord.create({ data: { orgId: req.user!.orgId, employeeId: req.params.id, oldSalary, newSalary: Number(newSalary), effectiveDate: new Date(`${effMonth}-01`), effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: reason || null, createdBy: req.user!.id, }, }) // 同步 Employee 便捷字段(含工资结构) const updateData: any = { monthlySalary: encrypt(String(newSalary)) } if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0)) if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0)) await prisma.employee.update({ where: { id: req.params.id }, data: updateData, }) await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) // 调薪历史 router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.salaryChangeRecord.findMany({ where: { employeeId: req.params.id, orgId: req.user!.orgId }, orderBy: { effectiveDate: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) // 调动(部门+职务变动) router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => { try { const { newDepartment, newPosition, departmentId, effectiveMonth, reason } = req.body const employee = await prisma.employee.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId }, }) if (!employee) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } const oldDepartment = employee.department const oldPosition = employee.position || null const effMonth = effectiveMonth || dateToMonth(new Date()) const prevEffMonth = prevMonth(effMonth) // 校验 departmentId 是否属于当前组织 let deptName = newDepartment if (departmentId) { const dept = await prisma.department.findFirst({ where: { id: departmentId, orgId: req.user!.orgId } }) if (!dept) { return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '目标部门不存在' } }) } deptName = dept.name } // 关闭之前有效记录 await prisma.employeeDepartmentRecord.updateMany({ where: { employeeId: req.params.id, endMonth: null }, data: { endMonth: prevEffMonth }, }) // 创建新调动记录 const record = await prisma.employeeDepartmentRecord.create({ data: { orgId: req.user!.orgId, employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition: newPosition || null, effectiveMonth: effMonth, endMonth: null, changeType: 'TRANSFER', reason: reason || null, createdBy: req.user!.id, }, }) // 同步 Employee 字段(department 文本 + departmentId 关联 + position 职务) await prisma.employee.update({ where: { id: req.params.id }, data: { department: deptName, departmentId: departmentId || null, position: newPosition || employee.position, }, }) await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) // 调部门历史 router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.employeeDepartmentRecord.findMany({ where: { employeeId: req.params.id, orgId: req.user!.orgId }, orderBy: { effectiveMonth: 'desc' }, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) // 30天内合同到期列表 router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => { try { const days = parseInt(req.query.days as string) || 30 const today = new Date() today.setHours(0, 0, 0, 0) const future = new Date(today) future.setDate(future.getDate() + days) const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, status: 'ACTIVE' }, include: { contracts: { where: { endDate: { gte: today, lte: future }, contractType: 'FIXED', }, orderBy: { endDate: 'asc' }, take: 1, }, }, }) const result = employees .filter(e => e.contracts.length > 0) .map(e => { const contract = e.contracts[0] const endDate = new Date(contract.endDate!) const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)) return { employeeId: e.id, employeeName: e.name, department: e.department, contractEndDate: contract.endDate, daysLeft, } }) .sort((a, b) => a.daysLeft - b.daysLeft) res.json({ success: true, data: result }) } catch (err) { next(err) } }) // 合同类型列表(供前端动态获取) router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => { const types = [ { value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true }, { value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false }, { value: 'LABOR', label: '劳务协议', hasEndDate: true }, { value: 'INTERNSHIP', label: '实习协议', hasEndDate: true }, { value: 'DISPATCH', label: '劳务派遣', hasEndDate: true }, { value: 'OUTSOURCING', label: '业务外包', hasEndDate: true }, { value: 'PARTTIME', label: '兼职协议', hasEndDate: true }, { value: 'UNSIGNED', label: '未签合同', hasEndDate: false }, ] res.json({ success: true, data: types }) }) export default router