feat: TurboHR 14项优化与功能增强

- #1 Dashboard风险提醒增加立刻办理按钮
- #2 Calendar月份选择器改为input month
- #3 Termination增加7种解聘原因法律依据和操作步骤
- #5 合同审查支持PDF TXT格式
- #6 AI合同审查prompt优化为具体修改建议
- #7 知识库添加更新机制说明
- #9 SpecialStatus员工选择改用all-lite接口
- #10 Termination增加详细法律条款引用
- #11 Money发薪批次增加社保公积金合计列
- #12 EmployeeAttachment扩展文件类型
- #13 花名册增加女职工干部工人选项加退休提醒
- #14 新增公司备用文件上传模块
This commit is contained in:
selfrelease
2026-08-01 13:47:49 +08:00
parent d9e2c610cf
commit f1a02f0439
20 changed files with 542 additions and 32 deletions
+2
View File
@@ -61,6 +61,7 @@ import platformRoutes from './routes/platform.routes'
import workProcessRoutes from './routes/work-process.routes'
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
import specialStatusRoutes from './routes/special-status.routes'
import companyFileRoutes from './routes/company-file.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -86,6 +87,7 @@ app.use('/api/v1/platform', platformRoutes)
app.use('/api/v1/work-processes', workProcessRoutes)
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
app.use('/api/v1/special-statuses', specialStatusRoutes)
app.use('/api/v1/company-files', companyFileRoutes)
app.use(errorHandler)
+15 -3
View File
@@ -957,7 +957,7 @@ const reviewUpload = multer({
limits: { fileSize: 100 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase()
if (ext !== '.docx' && ext !== '.doc') {
if (ext !== '.docx' && ext !== '.txt' && ext !== '.pdf') {
return cb(null, false)
}
cb(null, true)
@@ -967,15 +967,27 @@ const reviewUpload = multer({
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 文件' } })
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx / .txt / .pdf 文件' } })
}
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 if (ext === '.txt') {
text = req.file.buffer.toString('utf-8')
} else if (ext === '.pdf') {
// PDF 简单文本提取:提取括号内的文本流内容
const raw = req.file.buffer.toString('latin1')
const textMatches = raw.match(/\(([^)]+)\)/g)
if (textMatches) {
text = textMatches.map(m => m.slice(1, -1).replace(/\\[nrt()\\]/g, ' ')).join(' ')
}
if (!text || text.trim().length < 10) {
return res.status(400).json({ success: false, error: { code: 'PDF_PARSE_FAIL', message: 'PDF 文件无法提取文本,可能是扫描件或图片格式。建议将文件另存为 .docx 后上传' } })
}
} else {
return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } })
return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持该格式,请上传 .docx / .txt / .pdf 文件' } })
}
if (text.length > 50000) {
text = text.slice(0, 50000) + '\n\n[文本过长,已截断]'
+1 -1
View File
@@ -23,7 +23,7 @@ router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFun
const attachmentSchema = z.object({
employeeId: z.string().min(1),
fileName: z.string().min(1),
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']),
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'TERMINATION_DOC', 'RETIREMENT_DOC', 'INJURY_CERT', 'MEDICAL_CERT', 'PREGNANCY_CERT', 'DISCIPLINARY', 'OTHER']),
fileUrl: z.string().min(1),
fileSize: z.number().int().default(0),
})
+72
View File
@@ -0,0 +1,72 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
/**
* 公司备用文件管理路由
* 支持营业执照、工时备案、制度文件、合同模板等公司级文件上传
*/
// 获取公司文件列表
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const fileType = req.query.fileType as string | undefined
const files = await prisma.companyFile.findMany({
where: { orgId: req.user!.orgId, ...(fileType ? { fileType } : {}) },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: files })
} catch (err) {
next(err)
}
})
// 添加公司文件记录
const companyFileSchema = z.object({
fileName: z.string().min(1),
fileType: z.enum(['BUSINESS_LICENSE', 'WORK_HOURS', 'HR_POLICY', 'LABOR_CONTRACT_TEMPLATE', 'OTHER']),
fileUrl: z.string().min(1),
fileSize: z.number().int().default(0),
remark: z.string().optional(),
expiryDate: z.string().optional(),
})
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = companyFileSchema.parse(req.body)
const { expiryDate, ...rest } = data
const file = await prisma.companyFile.create({
data: {
orgId: req.user!.orgId,
...rest,
...(expiryDate ? { expiryDate: new Date(expiryDate) } : {}),
uploadedBy: req.user!.id,
},
})
res.json({ success: true, data: file })
} catch (err) {
next(err)
}
})
// 删除公司文件
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const file = await prisma.companyFile.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!file) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '文件不存在' } })
}
await prisma.companyFile.delete({ where: { id: file.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+17
View File
@@ -36,6 +36,23 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
/**
* 轻量级全量员工列表(不分页,仅返回 id/name/department/gender/status
* 用于特殊状态台账、发薪批次等需要选择全部员工的场景
*/
router.get('/all-lite', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: { in: ['ACTIVE', 'RESIGNED'] } },
select: { id: true, name: true, department: true, gender: true, status: true },
orderBy: { name: 'asc' },
})
res.json({ success: true, data: employees })
} catch (err) {
next(err)
}
})
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
+11 -6
View File
@@ -84,28 +84,33 @@ export async function* chatStream(messages: { role: 'user' | 'assistant'; conten
}
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> {
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
const prompt = `请审查以下劳动合同文本的合法性和合规性,逐条检查并标注风险等级(红/黄/绿),对每个风险点必须给出:
1. 问题说明:具体哪一条款存在什么问题
2. 法律依据:引用《劳动合同法》具体条款
3. 具体修改建议:给出可以直接替换的修改后条款文本
最后给出合规评分(0-100分)。
合同文本:
${contractText}
请按以下格式输出:
严格按以下格式输出:
【风险项】
🔴/🟡/🟢 [问题标题] - [说明] - [修改建议]
🔴/🟡/🟢 [问题标题] - [问题说明+法律依据] - [具体修改建议:应将xxx修改为yyy]
【合规评分】XX/100
【总体建议】
一段话总结`
一段话总结,指出最需要优先修改的3个问题`
const response = await client.chat.completions.create({
model: 'qwen-max',
messages: [
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' },
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。对每个风险点必须给出法律依据和可直接替换的具体修改建议文本。' },
{ role: 'user', content: prompt },
],
temperature: 0.3,
max_tokens: 3000,
max_tokens: 4000,
})
const text = response.choices[0]?.message?.content || ''
+63 -5
View File
@@ -1,6 +1,7 @@
import prisma from '../lib/prisma'
import type { RiskLevel, RiskType } from '@prisma/client'
import { decrypt } from '../lib/crypto'
import { calcIndividualRetireAge, calcRetirementDaysLeft } from './retirement.service'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
@@ -119,6 +120,17 @@ function estimateRiskCost(
}
}
// 退休提醒:未及时办理退休可能导致多缴社保公积金
if (title.includes('退休')) {
const daysMatch = title.match(/(\d+)天/)
const days = daysMatch ? parseInt(daysMatch[1]) : 0
return {
estimatedLoss: days > 0 ? salary * (days / 30) : salary,
lossRange: [500, salary * 6],
deadline: new Date(today.getTime() + (days > 0 ? days : 7) * 86400000),
}
}
// 默认
return {
estimatedLoss: 0,
@@ -452,6 +464,49 @@ export async function detectMonthlyTasks(orgId: string) {
return risks
}
/**
* 退休提醒:检测即将退休的员工(距退休180天内)
*/
export async function detectRetirementRisks(orgId: string) {
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', birthDate: { not: null } },
select: { id: true, name: true, gender: true, birthDate: true, femaleWorkerType: true },
})
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const emp of employees) {
if (!emp.birthDate) continue
const gender = emp.gender || '男'
const fwt = emp.femaleWorkerType
const bd = new Date(emp.birthDate)
const { retireDate } = calcIndividualRetireAge(bd, gender, fwt)
const daysLeft = calcRetirementDaysLeft(bd, gender, fwt) ?? 0
if (daysLeft <= 180 && daysLeft > 0) {
risks.push({
employeeId: emp.id,
type: 'RETIREMENT',
level: daysLeft <= 30 ? 'HIGH' : 'MEDIUM',
title: `${emp.name}距退休仅剩${daysLeft}`,
description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},预计退休日期 ${retireDate.toISOString().slice(0, 10)}。请提前准备退休手续。`,
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
})
} else if (daysLeft <= 0) {
risks.push({
employeeId: emp.id,
type: 'RETIREMENT',
level: 'HIGH',
title: `${emp.name}已达退休年龄`,
description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},已超过退休日期 ${retireDate.toISOString().slice(0, 10)}。请尽快办理退休手续。`,
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
})
}
}
return risks
}
export async function runRiskDetection(orgId: string) {
// 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建
// 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
@@ -481,9 +536,10 @@ export async function runRiskDetection(orgId: string) {
const onboardingRisks = await detectOnboardingRisks(orgId)
const monthlyTasks = await detectMonthlyTasks(orgId)
const specialStatusRisks = await detectSpecialStatusRisks(orgId)
const retirementRisks = await detectRetirementRisks(orgId)
// 获取所有相关员工数据用于风险量化
const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks]
.map(r => r.employeeId)
.filter(Boolean) as string[]
const employees = allEmployeeIds.length > 0
@@ -492,7 +548,7 @@ export async function runRiskDetection(orgId: string) {
const empMap = new Map(employees.map(e => [e.id, e]))
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}`)),
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
@@ -790,7 +846,7 @@ export async function getDashboardData(orgId: string) {
const daysUntilDeadline = r.deadline ? daysBetween(r.deadline, new Date()) : null
return {
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
priority,
title: r.title,
@@ -821,6 +877,7 @@ export async function getDashboardData(orgId: string) {
if (title.includes('公积金')) return 'MONTHLY_HOUSING'
if (title.includes('工资')) return 'MONTHLY_PAYROLL'
if (title.includes('个税')) return 'MONTHLY_TAX'
if (title.includes('退休')) return 'RETIREMENT'
return title.replace(/\d+/g, '').trim()
}
@@ -872,7 +929,7 @@ export async function getDashboardData(orgId: string) {
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
title: r.title,
description: r.description,
@@ -906,7 +963,7 @@ export async function getDashboardData(orgId: string) {
greeting,
stats: {
employeeCount,
highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION')).length,
highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'RETIREMENT')).length,
todoCount: todos.length,
monthlyOvertimePay,
},
@@ -1818,6 +1875,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
if (title.includes('公积金')) return 'MONTHLY_HOUSING'
if (title.includes('工资')) return 'MONTHLY_PAYROLL'
if (title.includes('个税')) return 'MONTHLY_TAX'
if (title.includes('退休')) return 'RETIREMENT'
return title.replace(/\d+/g, '').trim()
}