42e0c650a4
- AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过
1057 lines
44 KiB
TypeScript
1057 lines
44 KiB
TypeScript
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<string, { chat: number; review: number; case: number }> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<string> {
|
||
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<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||
const typeMap: Record<string, string> = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' }
|
||
const reasonMap: Record<string, string> = { 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<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||
const typeMap: Record<string, string> = { RESIGNATION: '员工离职', TERMINATION: '公司解聘' }
|
||
const reasonMap: Record<string, string> = { 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<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反制度', OTHER: '其他' }
|
||
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
|
||
const actionMap: Record<string, string> = { 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<string, string> = {
|
||
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<string, number> = {}
|
||
const eduDist: Record<string, number> = {}
|
||
const deptDist: Record<string, number> = {}
|
||
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
|