feat: AI判赔预测表单UI优化 - 卡片布局/左右分栏/Word导出
- 结构化判赔预测表单改为卡片式布局(员工信息/争议事实/制度合规) - 左右分栏:左栏表单(2/5),右栏AI结果(3/5),结果区固定高度可滚动 - 系统带出字段用Database图标替代文字标注 - 移除违纪记录摘要重复内容 - 三期信息整合到员工信息卡片内 - 新增AI分析结果导出Word文档功能(docx库) - 后端roster API返回三期字段,修复特殊状态拼接逻辑 - 修复markdown渲染样式冲突(移除index.css中h1/h2/h3覆盖)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream } from '../services/ai.service'
|
||||
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service'
|
||||
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
@@ -248,7 +248,7 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
|
||||
- 特殊状态:${[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}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
}
|
||||
@@ -306,7 +306,7 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
|
||||
- 特殊状态:${[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}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
}
|
||||
@@ -356,6 +356,106 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 结构化判赔预测 ==========
|
||||
|
||||
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) => {
|
||||
@@ -633,7 +733,7 @@ router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res,
|
||||
- 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天)
|
||||
- 月薪:¥${salary.toFixed(2)}
|
||||
- 工龄:${years}年${remainingMonths}月(经济补偿月数 N=${n})
|
||||
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
|
||||
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
|
||||
|
||||
三选项成本对比:
|
||||
1. 续签:成本 ¥0
|
||||
|
||||
@@ -23,7 +23,7 @@ function safeDecrypt(encrypted: string): number {
|
||||
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, 100)
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 999)
|
||||
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.
|
||||
@@ -124,6 +124,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
isPregnant: e.isPregnant,
|
||||
isInMedicalPeriod: e.isInMedicalPeriod,
|
||||
isWorkInjured: e.isWorkInjured,
|
||||
latestContract,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
|
||||
@@ -224,7 +224,120 @@ ${orgContext}`
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 2000,
|
||||
max_tokens: 8000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content
|
||||
if (delta) yield delta
|
||||
}
|
||||
}
|
||||
|
||||
/** 12 类争议场景中文映射 */
|
||||
const SCENARIO_LABELS: Record<string, string> = {
|
||||
discipline: '违纪解除',
|
||||
incompetence: '不胜任解除',
|
||||
probation: '试用期解除',
|
||||
layoff: '经济性裁员',
|
||||
expiry: '合同到期不续签',
|
||||
negotiated: '协商解除',
|
||||
transfer: '调岗调薪争议',
|
||||
overtime: '加班费争议',
|
||||
injury: '工伤待遇争议',
|
||||
noncompete: '竞业限制争议',
|
||||
confidentiality: '保密协议争议',
|
||||
social_insurance: '社保公积金争议',
|
||||
}
|
||||
|
||||
/**
|
||||
* 结构化判赔预测流式输出
|
||||
* @param scenarioType 争议场景类型
|
||||
* @param keyFacts HR 填写的关键事实
|
||||
* @param orgContext 企业上下文(员工信息等)
|
||||
*/
|
||||
export async function* predictStructuredStream(
|
||||
scenarioType: string,
|
||||
keyFacts: {
|
||||
employeeName?: string
|
||||
violationFact?: string
|
||||
region?: string
|
||||
monthlySalary?: string
|
||||
democracyStatus?: string
|
||||
disciplinaryRecord?: string
|
||||
extraInfo?: string
|
||||
},
|
||||
orgContext: string,
|
||||
) {
|
||||
const scenarioLabel = SCENARIO_LABELS[scenarioType] || scenarioType
|
||||
|
||||
const factsText = [
|
||||
keyFacts.employeeName ? `- 涉及员工:${keyFacts.employeeName}` : '',
|
||||
keyFacts.violationFact ? `- 违纪/争议事实:${keyFacts.violationFact}` : '',
|
||||
keyFacts.region ? `- 所在地区:${keyFacts.region}` : '',
|
||||
keyFacts.monthlySalary ? `- 员工月薪:${keyFacts.monthlySalary} 元` : '',
|
||||
keyFacts.democracyStatus ? `- 制度民主公示状态:${keyFacts.democracyStatus}` : '',
|
||||
keyFacts.disciplinaryRecord ? `- 违纪记录留痕情况:${keyFacts.disciplinaryRecord}` : '',
|
||||
keyFacts.extraInfo ? `- 补充信息:${keyFacts.extraInfo}` : '',
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
const prompt = `你是一位资深劳动法律师,请基于以下争议场景和企业数据,进行专业的判赔风险分析。
|
||||
|
||||
## 争议场景
|
||||
${scenarioLabel}
|
||||
|
||||
## 关键事实(由 HR 提供)
|
||||
${factsText}
|
||||
|
||||
## 企业数据
|
||||
${orgContext}
|
||||
|
||||
请使用 Markdown 格式输出,包含以下部分:
|
||||
|
||||
## 风险分析
|
||||
|
||||
分析企业在当前场景下的主要法律风险点,引用具体法律条文。
|
||||
|
||||
## 预估赔偿范围
|
||||
|
||||
根据员工月薪、工龄、地区裁判口径,给出经济补偿/赔偿金的预估范围(注意:此为参考值,实际以仲裁裁决为准):
|
||||
|
||||
| 项目 | 金额范围(元) | 计算依据 |
|
||||
|------|--------------|----------|
|
||||
|
||||
## 建议方案
|
||||
|
||||
给出 2-3 个可行方案,按推荐度排序:
|
||||
|
||||
### 方案一(推荐)
|
||||
- **操作步骤**:...
|
||||
- **预估成本**:...
|
||||
- **风险等级**:低/中/高
|
||||
- **法律依据**:...
|
||||
|
||||
### 方案二
|
||||
- **操作步骤**:...
|
||||
- **预估成本**:...
|
||||
- **风险等级**:低/中/高
|
||||
- **法律依据**:...
|
||||
|
||||
## 关键注意事项
|
||||
|
||||
列出操作中必须注意的法律细节和证据要求。
|
||||
|
||||
> ⚠️ 以上分析基于当前提供的信息,仅供参考。实际仲裁结果受具体事实、证据、地区裁判口径等因素影响,建议在操作前咨询专业律师。`
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是劳动用工法律风险评估专家,精通中国劳动法、劳动合同法及相关司法解释。请基于企业提供的事实和数据,给出专业、客观的风险分析和方案建议。请使用 Markdown 格式输出,善用表格、加粗、列表等格式。所有金额预估必须注明"仅供参考"。',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 8000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
isPregnant: emp.isPregnant,
|
||||
isInMedicalPeriod: emp.isInMedicalPeriod,
|
||||
isWorkInjured: emp.isWorkInjured,
|
||||
city: emp.city,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user