feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强

- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
This commit is contained in:
freedakgmail
2026-07-29 08:35:29 +08:00
parent d020d04a8a
commit fb36b10402
45 changed files with 3756 additions and 169 deletions
+212 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service'
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'
@@ -803,4 +803,215 @@ router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res,
}
})
// ========== 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()
}
})
// ========== 人工咨询服务 ==========
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