feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全

- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+137
View File
@@ -4,6 +4,7 @@ import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisks
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
const router = Router()
@@ -542,4 +543,140 @@ router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) =>
}
})
// ========== 合同到期决策助手 ==========
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 ? '工伤' : '无'}
三选项成本对比:
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)
}
})
export default router