import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream, generateHRReportStream } from '../services/ai.service' import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searchHelp, seedHelpKnowledge } from '../services/rag.service' import prisma from '../lib/prisma' import { z } from 'zod' import { decrypt } from '../lib/crypto' import multer from 'multer' import path from 'path' import mammoth from 'mammoth' const router = Router() const PLAN_LIMITS: Record = { FREE: { chat: 10, review: 3, case: 3 }, PRO: { chat: 100, review: 20, case: 20 }, ENTERPRISE: { chat: 0, review: 0, case: 0 }, } async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise { const org = await prisma.organization.findUnique({ where: { id: orgId } }) if (!org) return const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE const limit = limits[type] if (limit === 0) return const now = new Date() const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const count = await prisma.auditLog.count({ where: { orgId, action: `AI_${type.toUpperCase()}`, createdAt: { gte: monthStart }, }, }) if (count >= limit) { throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` } } } async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise { const month = new Date().toISOString().slice(0, 7) await prisma.auditLog.create({ data: { orgId, userId, action: `AI_${type.toUpperCase()}`, entity: 'AI', entityId: null, detail: { month, type } as any, ip: '', }, }) } // 日期计算工具:计算两个日期之间的年月日差,以及相对当前的天数差 function calcDuration(start: Date, end: Date): string { let years = end.getFullYear() - start.getFullYear() let months = end.getMonth() - start.getMonth() let days = end.getDate() - start.getDate() if (days < 0) { months-- const prevMonth = new Date(end.getFullYear(), end.getMonth(), 0) days += prevMonth.getDate() } if (months < 0) { years-- months += 12 } const parts: string[] = [] if (years > 0) parts.push(`${years}年`) if (months > 0) parts.push(`${months}个月`) if (days > 0) parts.push(`${days}天`) return parts.length > 0 ? parts.join('') : '不足1天' } function daysFromNow(date: Date): number { return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) } async function buildOrgContext(orgId: string): Promise { const [employees, risks] = await Promise.all([ prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, }), prisma.riskItem.findMany({ where: { orgId, status: 'PENDING' }, include: { employee: true }, }), ]) const now = new Date() const empSummary = employees.map((e) => { const contract = e.contracts[0] const daysToExpire = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null const tenure = calcDuration(new Date(e.hireDate), now) const specialStatus: string[] = [] if (e.isPregnant) specialStatus.push('孕期/哺乳期') if (e.isInMedicalPeriod) specialStatus.push('医疗期') if (e.isWorkInjured) specialStatus.push('工伤') return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}` }).join('\n') const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n') return `员工列表(${employees.length}人): ${empSummary} 当前风险项(${risks.length}项): ${riskSummary}` } router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => { try { const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] } if (!messages || !Array.isArray(messages)) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } }) } await checkUsageLimit(req.user!.orgId, 'chat') const orgContext = await buildOrgContext(req.user!.orgId) const reply = await chat(messages, orgContext) await recordUsage(req.user!.orgId, req.user!.id, 'chat') res.json({ success: true, data: { reply } }) } catch (err) { next(err) } }) router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => { try { const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] } if (!messages || !Array.isArray(messages)) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } }) } await checkUsageLimit(req.user!.orgId, 'chat') const orgContext = await buildOrgContext(req.user!.orgId) res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() let usageRecorded = false try { for await (const delta of chatStream(messages, orgContext)) { res.write(`data: ${JSON.stringify({ delta })}\n\n`) if (typeof (res as any).flush === 'function') (res as any).flush() } res.write('data: [DONE]\n\n') } catch (streamErr: any) { res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`) res.write('data: [DONE]\n\n') } finally { if (!usageRecorded) { await recordUsage(req.user!.orgId, req.user!.id, 'chat') usageRecorded = true } } res.end() } catch (err) { if (!res.headersSent) next(err) else res.end() } }) router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => { try { const { contractText } = req.body as { contractText: string } if (!contractText) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } }) } await checkUsageLimit(req.user!.orgId, 'review') const result = await reviewContract(contractText) await recordUsage(req.user!.orgId, req.user!.id, 'review') res.json({ success: true, data: { text: result.text, structured: result.structured } }) } catch (err) { next(err) } }) router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => { try { const { scenario } = req.body as { scenario: string } if (!scenario) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } }) } await checkUsageLimit(req.user!.orgId, 'case') const result = await matchCase(scenario) await recordUsage(req.user!.orgId, req.user!.id, 'case') res.json({ success: true, data: { result } }) } catch (err) { next(err) } }) // 案例匹配结果转待办(RiskItem) router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => { try { const schema = z.object({ employeeId: z.string().min(1), title: z.string().min(1), description: z.string().min(1), level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'), type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'), }) const data = schema.parse(req.body) const risk = await prisma.riskItem.create({ data: { orgId: req.user!.orgId, employeeId: data.employeeId, title: data.title, description: data.description, level: data.level, type: data.type, status: 'PENDING', }, }) res.json({ success: true, data: risk }) } catch (err) { next(err) } }) router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => { try { const department = req.query.department as string const employeeId = req.query.employeeId as string const riskType = req.query.riskType as string let orgContext = await buildOrgContext(req.user!.orgId) let scopeHint = '' if (employeeId) { const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { createdAt: 'desc' }, take: 1 } } }) if (emp) { const contract = emp.contracts[0] const term = emp.terminations[0] const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } const typeMap: Record = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' } const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } const checklist = term?.checklist as any || {} const handoverItems = (term?.handoverItems as any[]) || [] const handoverDone = handoverItems.length > 0 ? handoverItems.every((h: any) => h.done) : false const agreementSigned = !!checklist.agreement_signed const socialInsEnd = term?.socialInsEndMonth ? `,社保截止${term.socialInsEndMonth}` : '' const housingFundEnd = term?.housingFundEndMonth ? `,公积金截止${term.housingFundEndMonth}` : '' const tenure = calcDuration(new Date(emp.hireDate), term ? new Date(term.terminationDate) : new Date()) const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} 员工详情: - 姓名:${emp.name} - 部门:${emp.department} - 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}) - 状态:${emp.status} - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} - 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` } } else if (department) { const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` } if (riskType && riskType !== 'all') { const riskLabel = riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType scopeHint += `\n请重点关注【${riskLabel}】类风险,其他类型风险可简要提及。` } if (scopeHint) { orgContext = `${scopeHint}\n\n${orgContext}` } const result = await predictRisks(orgContext) res.json({ success: true, data: { result } }) } catch (err) { next(err) } }) router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next) => { try { const department = req.query.department as string const employeeId = req.query.employeeId as string const riskType = req.query.riskType as string let orgContext = await buildOrgContext(req.user!.orgId) let scopeHint = '' if (employeeId) { const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { createdAt: 'desc' }, take: 1 } } }) if (emp) { const contract = emp.contracts[0] const term = emp.terminations[0] const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' } const typeMap: Record = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' } const reasonMap: Record = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' } const checklist = term?.checklist as any || {} const handoverItems = (term?.handoverItems as any[]) || [] const handoverDone = handoverItems.length > 0 ? handoverItems.every((h: any) => h.done) : false const agreementSigned = !!checklist.agreement_signed const socialInsEnd = term?.socialInsEndMonth ? `,社保截止${term.socialInsEndMonth}` : '' const housingFundEnd = term?.housingFundEndMonth ? `,公积金截止${term.housingFundEndMonth}` : '' const tenure = calcDuration(new Date(emp.hireDate), term ? new Date(term.terminationDate) : new Date()) const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null orgContext = `当前日期:${new Date().toISOString().slice(0, 10)} 员工详情: - 姓名:${emp.name} - 部门:${emp.department} - 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}) - 状态:${emp.status} - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} - 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}` scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。` } } else if (department) { const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } }) const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n') orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}` scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。` } if (riskType && riskType !== 'all') { const riskLabel = riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType scopeHint += `\n请重点关注【${riskLabel}】类风险,其他类型风险可简要提及。` } if (scopeHint) { orgContext = `${scopeHint}\n\n${orgContext}` } await checkUsageLimit(req.user!.orgId, 'chat') res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() let usageRecorded = false try { for await (const delta of predictRisksStream(orgContext)) { res.write(`data: ${JSON.stringify({ delta })}\n\n`) if (typeof (res as any).flush === 'function') (res as any).flush() } res.write('data: [DONE]\n\n') } catch (streamErr: any) { res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`) res.write('data: [DONE]\n\n') } finally { if (!usageRecorded) { await recordUsage(req.user!.orgId, req.user!.id, 'chat') usageRecorded = true } } res.end() } catch (err) { if (!res.headersSent) next(err) else res.end() } }) // ========== 结构化判赔预测 ========== router.post('/predict-structured', authMiddleware, async (req: AuthRequest, res, next) => { try { const { scenarioType, keyFacts } = req.body as { scenarioType: string keyFacts: { employeeName?: string violationFact?: string region?: string monthlySalary?: string democracyStatus?: string disciplinaryRecord?: string extraInfo?: string } } if (!scenarioType) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议场景类型' } }) } // 构建企业上下文:如果指定了员工则获取员工详情,否则用全量上下文 let orgContext = await buildOrgContext(req.user!.orgId) if (keyFacts.employeeName) { const emp = await prisma.employee.findFirst({ where: { orgId: req.user!.orgId, name: keyFacts.employeeName, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, disciplinaryRecords: { orderBy: { violationDate: 'desc' }, take: 10 }, }, }) if (emp) { const contract = emp.contracts[0] const tenure = calcDuration(new Date(emp.hireDate), new Date()) const contractDaysLeft = contract?.endDate ? daysFromNow(new Date(contract.endDate)) : null // 自动补全 keyFacts:系统已有数据优先,HR 手动填写覆盖 let salary = 0 try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 } if (!keyFacts.monthlySalary && salary > 0) { keyFacts.monthlySalary = String(salary) } if (!keyFacts.region && emp.city) { keyFacts.region = emp.city } // 特殊状态已在 orgContext 中展示,不再重复追加到 extraInfo // 自动补充违纪记录 if (emp.disciplinaryRecords.length > 0) { const recordText = emp.disciplinaryRecords.map((r: any) => { const typeMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' } const severityMap: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' } const actionMap: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '辞退' } return `${r.violationDate.toISOString().slice(0, 10)} ${typeMap[r.violationType] || r.violationType}(${severityMap[r.severity] || r.severity}),处理:${actionMap[r.action] || r.action}${r.employeeAck ? ',员工已签字确认' : ',员工未签字'}` }).join(';') const discText = `系统违纪记录(${emp.disciplinaryRecords.length}条):${recordText}` keyFacts.extraInfo = keyFacts.extraInfo ? `${keyFacts.extraInfo};${discText}` : discText } orgContext = `员工详情: - 姓名:${emp.name} - 部门:${emp.department} - 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure}) - 状态:${emp.status} - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} - 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'} - 违纪记录:${emp.disciplinaryRecords.length}条${emp.disciplinaryRecords.length > 0 ? `(最近:${emp.disciplinaryRecords[0].violationDate.toISOString().slice(0, 10)})` : ''}\n\n${orgContext}` } } await checkUsageLimit(req.user!.orgId, 'chat') res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() let usageRecorded = false try { for await (const delta of predictStructuredStream(scenarioType, keyFacts, orgContext)) { res.write(`data: ${JSON.stringify({ delta })}\n\n`) if (typeof (res as any).flush === 'function') (res as any).flush() } res.write('data: [DONE]\n\n') } catch (streamErr: any) { res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`) res.write('data: [DONE]\n\n') } finally { if (!usageRecorded) { await recordUsage(req.user!.orgId, req.user!.id, 'chat') usageRecorded = true } } res.end() } catch (err) { if (!res.headersSent) next(err) else res.end() } }) // ========== AI 会话历史 ========== router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => { try { const type = req.query.type as string | undefined const where: any = { orgId: req.user!.orgId, userId: req.user!.id } if (type) { const prefixes: Record = { chat: 'chat:', predict: 'predict:', review: 'review:', case: 'case:', } const prefix = prefixes[type] || type where.title = { startsWith: prefix } } const conversations = await prisma.aIConversation.findMany({ where, orderBy: { updatedAt: 'desc' }, take: 50, select: { id: true, title: true, createdAt: true, updatedAt: true }, }) res.json({ success: true, data: conversations }) } catch (err) { next(err) } }) router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const conv = await prisma.aIConversation.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id }, }) if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } }) res.json({ success: true, data: conv }) } catch (err) { next(err) } }) router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => { try { const { title, messages } = req.body as { title?: string; messages: any[] } const conv = await prisma.aIConversation.create({ data: { orgId: req.user!.orgId, userId: req.user!.id, title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'), messages: messages || [], }, }) res.json({ success: true, data: conv }) } catch (err) { next(err) } }) router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const { title, messages } = req.body as { title?: string; messages?: any[] } const conv = await prisma.aIConversation.updateMany({ where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id }, data: { ...(title ? { title } : {}), ...(messages ? { messages } : {}), }, }) if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } }) res.json({ success: true }) } catch (err) { next(err) } }) router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const conv = await prisma.aIConversation.deleteMany({ where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id }, }) if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } }) res.json({ success: true }) } catch (err) { next(err) } }) // ========== AI 审查记录保存到员工档案 ========== router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => { try { const schema = z.object({ employeeId: z.string(), type: z.enum(['REVIEW', 'CASE']), input: z.string(), result: z.string(), }) const data = schema.parse(req.body) const record = await prisma.aIReviewRecord.create({ data: { orgId: req.user!.orgId, employeeId: data.employeeId, type: data.type, input: data.input, result: data.result, createdBy: req.user!.id, }, }) res.json({ success: true, data: record }) } catch (err) { next(err) } }) router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => { try { const records = await prisma.aIReviewRecord.findMany({ where: { orgId: req.user!.orgId, employeeId: req.params.employeeId }, orderBy: { createdAt: 'desc' }, take: 20, }) res.json({ success: true, data: records }) } catch (err) { next(err) } }) // RAG 知识库管理 router.post('/rag/seed', authMiddleware, async (_req: AuthRequest, res, next) => { try { await seedKnowledgeBase() res.json({ success: true, data: { message: '知识库初始化完成' } }) } catch (err) { next(err) } }) router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => { try { const { title, content, source, category } = req.body if (!title || !content) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } }) } const result = await addKnowledge(title, content, source || '自定义', category || '其他') res.json({ success: true, data: result }) } catch (err) { next(err) } }) router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => { try { const { query, topK } = req.body if (!query) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } }) } const results = await searchKnowledge(query, topK || 5) res.json({ success: true, data: { results } }) } catch (err) { next(err) } }) // 知识库列表 router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => { try { await ensureRAGTable() const category = req.query.category as string | undefined const items = category ? await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge WHERE category = ${category} ORDER BY created_at DESC LIMIT 200` as any[] : await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge ORDER BY created_at DESC LIMIT 200` as any[] res.json({ success: true, data: items }) } catch (err) { next(err) } }) // 帮助文档 RAG 搜索(语义匹配) router.post('/rag/help-search', authMiddleware, async (req: AuthRequest, res, next) => { try { const { query, topK } = req.body if (!query) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } }) } const results = await searchHelp(query, topK || 5) res.json({ success: true, data: { results } }) } catch (err) { next(err) } }) // 帮助文档初始化(注入帮助种子数据到 RAG 知识库) router.post('/rag/help-seed', authMiddleware, async (_req: AuthRequest, res, next) => { try { await seedHelpKnowledge() res.json({ success: true, data: { message: '帮助文档已注入知识库' } }) } catch (err) { next(err) } }) // 删除知识条目 router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { await ensureRAGTable() await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}` res.json({ success: true }) } catch (err) { next(err) } }) // ========== 合同到期决策助手 ========== router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res, next) => { try { const { employeeId } = req.body as { employeeId: string } if (!employeeId) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } }) } const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, }) if (!emp) { return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) } const contract = emp.contracts[0] if (!contract || !contract.endDate) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工无固定期限合同或未签合同,不适用到期决策' } }) } // 解密薪资 let salary = 0 try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 } const today = new Date() const hireDate = emp.hireDate const endDate = contract.endDate const totalMonths = (endDate.getFullYear() - hireDate.getFullYear()) * 12 + (endDate.getMonth() - hireDate.getMonth()) const years = Math.floor(totalMonths / 12) const remainingMonths = totalMonths % 12 let n = years if (remainingMonths >= 6) n = years + 1 else if (remainingMonths > 0) n = years + 0.5 if (n <= 0) n = 0.5 const daysToExpiry = Math.ceil((endDate.getTime() - today.getTime()) / 86400000) // 三选项成本对比 const options = [ { key: 'RENEW', title: '续签合同', cost: 0, description: '与员工续签劳动合同,保持劳动关系延续', legalRisk: '低', details: { compensation: 0, noticePeriod: '无需通知', notes: '续签时如维持或提高条件,员工拒绝则无需补偿;降低条件员工拒绝需支付 N', }, }, { key: 'EXPIRE_NO_RENEW', title: '到期不续签', cost: salary * n, description: '合同到期后公司决定不续签,需支付经济补偿金 N', legalRisk: '中', details: { compensation: salary * n, n, monthlySalary: salary, noticePeriod: '建议提前30天书面通知', notes: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付', }, }, { key: 'EXPIRE_WAIT', title: '逾期不处理(风险最高)', cost: salary * (n + 1), description: '合同到期后继续用工但不签新合同,可能被认定为事实劳动关系', legalRisk: '高', details: { compensation: salary * (n + 1), n, monthlySalary: salary, notes: '逾期超过1个月未续签,员工可主张双倍工资;满1年视为已订立无固定期限劳动合同', }, }, ] // 构建 AI 建议请求 const decisionContext = `员工合同到期决策分析: - 姓名:${emp.name} - 部门:${emp.department} - 入职日期:${hireDate.toISOString().slice(0, 10)} - 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天) - 月薪:¥${salary.toFixed(2)} - 工龄:${years}年${remainingMonths}月(经济补偿月数 N=${n}) - 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'} 三选项成本对比: 1. 续签:成本 ¥0 2. 到期不续签:成本 ¥${(salary * n).toFixed(2)}(经济补偿 N=${n}) 3. 逾期不处理:风险成本 ¥${(salary * (n + 1)).toFixed(2)}(双倍工资风险) 请给出专业建议,分析每个选项的法律风险和实际影响,推荐最优方案。` let aiAdvice = '' try { await checkUsageLimit(req.user!.orgId, 'chat') const result = await chat([{ role: 'user', content: decisionContext }], '') aiAdvice = result await recordUsage(req.user!.orgId, req.user!.id, 'chat') } catch { aiAdvice = 'AI 建议生成失败,请参考以上成本对比数据自行判断。' } res.json({ success: true, data: { employee: { id: emp.id, name: emp.name, department: emp.department, hireDate: hireDate.toISOString().slice(0, 10), contractEndDate: endDate.toISOString().slice(0, 10), daysToExpiry, monthlySalary: salary, workYears: years, workMonths: remainingMonths, n, isPregnant: emp.isPregnant, isInMedicalPeriod: emp.isInMedicalPeriod, isWorkInjured: emp.isWorkInjured, }, options, aiAdvice, }, }) } catch (err) { next(err) } }) // ========== AI 人力分析报告 ========== router.post('/hr-report-stream', authMiddleware, async (req: AuthRequest, res, next) => { try { const orgId = req.user!.orgId const month = new Date().toISOString().slice(0, 7) // 聚合企业数据 const [employees, risks, batches] = await Promise.all([ prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, select: { name: true, department: true, gender: true, hireDate: true, birthDate: true, education: true, city: true, isPregnant: true, isInMedicalPeriod: true, isWorkInjured: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true, endDate: true, startDate: true } }, }, }), prisma.riskItem.findMany({ where: { orgId, status: 'PENDING' }, select: { title: true, level: true, type: true, description: true, employee: { select: { name: true } } }, }), prisma.payrollBatch.findMany({ where: { orgId, month, status: 'ARCHIVED' }, select: { totalPay: true, totalSocialOrg: true, totalHousingOrg: true, totalTax: true, employeeCount: true }, }), ]) const now = new Date() // 员工概况 const genderDist: Record = {} const eduDist: Record = {} const deptDist: Record = {} let totalAge = 0, ageCount = 0 let totalTenure = 0 for (const e of employees) { const g = e.gender || '未知' genderDist[g] = (genderDist[g] || 0) + 1 const edu = e.education || '未知' eduDist[edu] = (eduDist[edu] || 0) + 1 deptDist[e.department] = (deptDist[e.department] || 0) + 1 if (e.birthDate) { totalAge += now.getFullYear() - e.birthDate.getFullYear() ageCount++ } totalTenure += (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000) } const avgAge = ageCount > 0 ? (totalAge / ageCount).toFixed(1) : '未知' const avgTenure = employees.length > 0 ? (totalTenure / employees.length).toFixed(1) : '0' // 成本数据 const monthCost = batches.reduce((acc, b) => ({ totalPay: acc.totalPay + b.totalPay, totalSocialOrg: acc.totalSocialOrg + b.totalSocialOrg, totalHousingOrg: acc.totalHousingOrg + b.totalHousingOrg, totalTax: acc.totalTax + b.totalTax, employeeCount: acc.employeeCount + b.employeeCount, }), { totalPay: 0, totalSocialOrg: 0, totalHousingOrg: 0, totalTax: 0, employeeCount: 0 }) const totalCost = monthCost.totalPay + monthCost.totalSocialOrg + monthCost.totalHousingOrg const perCapita = monthCost.employeeCount > 0 ? totalCost / monthCost.employeeCount : 0 // 特殊状态员工 const specialEmployees = employees .filter(e => e.isPregnant || e.isInMedicalPeriod || e.isWorkInjured) .map(e => { const tags: string[] = [] if (e.isPregnant) tags.push('孕期/哺乳期') if (e.isInMedicalPeriod) tags.push('医疗期') if (e.isWorkInjured) tags.push('工伤') return `${e.name}(${e.department}):${tags.join('、')}` }) // 合同即将到期(30天内) const expiringContracts = employees .filter(e => { const c = e.contracts[0] if (!c?.endDate) return false const days = Math.floor((c.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) return days >= 0 && days <= 30 }) .map(e => `${e.name}(${e.department}),到期日:${e.contracts[0].endDate?.toISOString().slice(0, 10)}`) const orgData = `企业人力数据概览(截至 ${now.toISOString().slice(0, 10)}): 【员工概况】 - 在职员工总数:${employees.length} 人 - 性别分布:${Object.entries(genderDist).map(([k, v]) => `${k} ${v}人`).join('、')} - 学历分布:${Object.entries(eduDist).map(([k, v]) => `${k} ${v}人`).join('、')} - 平均年龄:${avgAge} 岁 - 平均司龄:${avgTenure} 年 - 部门分布:${Object.entries(deptDist).map(([k, v]) => `${k} ${v}人`).join('、')} 【本月人力成本】 - 工资总额:¥${monthCost.totalPay.toFixed(2)} - 企业社保:¥${monthCost.totalSocialOrg.toFixed(2)} - 企业公积金:¥${monthCost.totalHousingOrg.toFixed(2)} - 个人所得税:¥${monthCost.totalTax.toFixed(2)} - 企业总成本:¥${totalCost.toFixed(2)} - 人均成本:¥${perCapita.toFixed(2)} - 覆盖人数:${monthCost.employeeCount} 人 【当前风险项】(${risks.length} 项) ${risks.map(r => `- [${r.level}] ${r.title}(${r.employee?.name || '通用'}):${r.description || '无描述'}`).join('\n')} 【特殊状态员工】(${specialEmployees.length} 人) ${specialEmployees.length > 0 ? specialEmployees.join('\n') : '无'} 【合同即将到期】(30天内,${expiringContracts.length} 人) ${expiringContracts.length > 0 ? expiringContracts.join('\n') : '无'}` await checkUsageLimit(orgId, 'chat') res.setHeader('Content-Type', 'text/event-stream') res.setHeader('Cache-Control', 'no-cache') res.setHeader('Connection', 'keep-alive') res.setHeader('X-Accel-Buffering', 'no') res.flushHeaders() let usageRecorded = false try { for await (const delta of generateHRReportStream(orgData)) { res.write(`data: ${JSON.stringify({ delta })}\n\n`) if (typeof (res as any).flush === 'function') (res as any).flush() } res.write('data: [DONE]\n\n') } catch (streamErr: any) { res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`) res.write('data: [DONE]\n\n') } finally { if (!usageRecorded) { await recordUsage(orgId, req.user!.id, 'chat') usageRecorded = true } } res.end() } catch (err) { if (!res.headersSent) next(err) else res.end() } }) // ========== AI 文件审查上传 ========== const reviewUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 100 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() if (ext !== '.docx' && ext !== '.doc') { return cb(null, false) } cb(null, true) }, }) router.post('/review/upload', authMiddleware, reviewUpload.single('file'), async (req: AuthRequest, res, next) => { try { if (!req.file) { return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx 文件' } }) } const ext = path.extname(req.file.originalname).toLowerCase() let text = '' if (ext === '.docx') { const result = await mammoth.extractRawText({ buffer: req.file.buffer }) text = result.value } else { return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } }) } if (text.length > 50000) { text = text.slice(0, 50000) + '\n\n[文本过长,已截断]' } res.json({ success: true, data: { text, fileName: req.file.originalname } }) } catch (err) { next(err) } }) // ========== 人工咨询服务 ========== router.post('/consultation', authMiddleware, async (req: AuthRequest, res, next) => { try { const schema = z.object({ type: z.enum(['LEGAL', 'ARBITRATION', 'COURT']), title: z.string().min(1, '标题不能为空'), description: z.string().min(1, '描述不能为空'), contactName: z.string().min(1, '联系人不能为空'), contactPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'), aiConversationId: z.string().optional(), remark: z.string().optional(), }) const data = schema.parse(req.body) const consultation = await (prisma as any).consultation.create({ data: { orgId: req.user!.orgId, type: data.type, title: data.title, description: data.description, contactName: data.contactName, contactPhone: data.contactPhone, aiConversationId: data.aiConversationId || null, remark: data.remark || null, createdBy: req.user!.id, }, }) res.json({ success: true, data: consultation }) } catch (err) { next(err) } }) router.get('/consultations', authMiddleware, async (req: AuthRequest, res, next) => { try { const consultations = await (prisma as any).consultation.findMany({ where: { orgId: req.user!.orgId }, orderBy: { createdAt: 'desc' }, take: 50, }) res.json({ success: true, data: consultations }) } catch (err) { next(err) } }) router.patch('/consultations/:id', authMiddleware, async (req: AuthRequest, res, next) => { try { const schema = z.object({ status: z.enum(['PENDING', 'CONTACTED', 'COMPLETED', 'CANCELLED']), remark: z.string().optional(), }) const data = schema.parse(req.body) const result = await (prisma as any).consultation.updateMany({ where: { id: req.params.id, orgId: req.user!.orgId }, data: { status: data.status, ...(data.remark !== undefined ? { remark: data.remark } : {}), }, }) if (result.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '咨询记录不存在' } }) res.json({ success: true }) } catch (err) { next(err) } }) export default router