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:
@@ -351,3 +351,259 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
|
||||
return { generated }
|
||||
}
|
||||
|
||||
// ========== 算薪前 AI 校验 ==========
|
||||
|
||||
export interface PayrollCheckItem {
|
||||
code: string
|
||||
name: string
|
||||
status: 'PASS' | 'FAIL' | 'WARNING'
|
||||
message: string
|
||||
employeeId?: string
|
||||
employeeName?: string
|
||||
detail?: any
|
||||
}
|
||||
|
||||
export interface PayrollCheckResult {
|
||||
checks: PayrollCheckItem[]
|
||||
passedCount: number
|
||||
failedCount: number
|
||||
warningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 算薪前校验:检查 10+ 项异常,把错误拦截在发放前
|
||||
*/
|
||||
export async function prePayrollCheck(orgId: string, batchId: string): Promise<PayrollCheckResult> {
|
||||
const batch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: batchId, orgId },
|
||||
include: { entries: { include: { employee: true } } },
|
||||
})
|
||||
if (!batch) {
|
||||
throw { code: 'NOT_FOUND', message: '工资批次不存在' }
|
||||
}
|
||||
|
||||
const month = batch.month
|
||||
const entries = batch.entries
|
||||
const checks: PayrollCheckItem[] = []
|
||||
|
||||
// 获取社保公积金配置
|
||||
const [socialConfig, housingConfig] = await Promise.all([
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
|
||||
// 1. 社保基数是否在上下限范围内
|
||||
if (socialConfig) {
|
||||
for (const entry of entries) {
|
||||
const base = entry.employee.socialInsBase || entry.baseSalary
|
||||
if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
|
||||
checks.push({
|
||||
code: 'SOCIAL_BASE_OUT_OF_RANGE',
|
||||
name: '社保基数超出范围',
|
||||
status: 'FAIL',
|
||||
message: `${entry.employee.name} 的社保基数 ¥${base} 不在范围内(${socialConfig.baseMin} ~ ${socialConfig.baseMax})`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { base, min: socialConfig.baseMin, max: socialConfig.baseMax },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 公积金基数是否在上下限范围内
|
||||
if (housingConfig) {
|
||||
for (const entry of entries) {
|
||||
const base = entry.employee.housingFundBase || entry.baseSalary
|
||||
if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
|
||||
checks.push({
|
||||
code: 'HOUSING_BASE_OUT_OF_RANGE',
|
||||
name: '公积金基数超出范围',
|
||||
status: 'FAIL',
|
||||
message: `${entry.employee.name} 的公积金基数 ¥${base} 不在范围内(${housingConfig.baseMin} ~ ${housingConfig.baseMax})`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { base, min: housingConfig.baseMin, max: housingConfig.baseMax },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 加班时数是否超过法定上限(月 36 小时)
|
||||
for (const entry of entries) {
|
||||
const overtimeRecords = await prisma.overtimeRecord.findMany({
|
||||
where: { orgId, employeeId: entry.employeeId, month },
|
||||
})
|
||||
for (const ot of overtimeRecords) {
|
||||
const totalOT = ot.weekdayHours + ot.weekendHours + ot.holidayHours
|
||||
if (totalOT > 36) {
|
||||
checks.push({
|
||||
code: 'OVERTIME_EXCEED_LIMIT',
|
||||
name: '加班超法定上限',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 本月加班 ${totalOT} 小时,超过法定月上限 36 小时`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { totalHours: totalOT, limit: 36 },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 个税累计预扣跳档检测
|
||||
const year = month.slice(0, 4)
|
||||
for (const entry of entries) {
|
||||
const prevPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, employeeId: entry.employeeId, month: { startsWith: year, lt: month } },
|
||||
select: { tax: true, totalPay: true },
|
||||
})
|
||||
if (prevPayslips.length >= 2) {
|
||||
const avgTax = prevPayslips.reduce((s, p) => s + p.tax, 0) / prevPayslips.length
|
||||
if (entry.tax > avgTax * 3 && entry.tax > 1000) {
|
||||
checks.push({
|
||||
code: 'TAX_BRACKET_JUMP',
|
||||
name: '个税跳档警告',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 本月个税 ¥${entry.tax} 明显高于往月均值 ¥${avgTax.toFixed(0)},可能存在累计预扣跳档`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { currentTax: entry.tax, avgTax },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 试用期工资是否低于合同工资 80%
|
||||
for (const entry of entries) {
|
||||
const latestContract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId: entry.employeeId, orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (latestContract && latestContract.probationMonths > 0 && latestContract.probationSalary > 0) {
|
||||
const probationEnd = new Date(latestContract.startDate)
|
||||
probationEnd.setMonth(probationEnd.getMonth() + latestContract.probationMonths)
|
||||
if (probationEnd > new Date() && entry.baseSalary < latestContract.probationSalary * 0.8) {
|
||||
checks.push({
|
||||
code: 'PROBATION_SALARY_TOO_LOW',
|
||||
name: '试用期工资低于法定下限',
|
||||
status: 'FAIL',
|
||||
message: `${entry.employee.name} 试用期工资 ¥${entry.baseSalary} 低于合同工资的 80%(¥${latestContract.probationSalary * 0.8})`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { actualSalary: entry.baseSalary, minSalary: latestContract.probationSalary * 0.8 },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 离职员工是否多算了一个月
|
||||
for (const entry of entries) {
|
||||
if (entry.employee.status === 'RESIGNED') {
|
||||
const termination = await prisma.terminationRecord.findFirst({
|
||||
where: { employeeId: entry.employeeId, status: { notIn: ['CANCELLED', 'DRAFT'] } },
|
||||
orderBy: { terminationDate: 'desc' },
|
||||
})
|
||||
if (termination) {
|
||||
const termMonth = termination.terminationDate.toISOString().slice(0, 7)
|
||||
if (month > termMonth) {
|
||||
checks.push({
|
||||
code: 'RESIGNED_OVERPAY',
|
||||
name: '离职员工多算工资',
|
||||
status: 'FAIL',
|
||||
message: `${entry.employee.name} 已于 ${termination.terminationDate.toISOString().slice(0, 10)} 离职,但 ${month} 仍有工资记录`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { terminationDate: termination.terminationDate, payrollMonth: month },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 新入职员工是否按实际入职日折算
|
||||
for (const entry of entries) {
|
||||
const hireDate = entry.employee.hireDate
|
||||
const hireMonth = hireDate.toISOString().slice(0, 7)
|
||||
if (hireMonth === month) {
|
||||
const daysInMonth = new Date(hireDate.getFullYear(), hireDate.getMonth() + 1, 0).getDate()
|
||||
const actualWorkDays = daysInMonth - hireDate.getDate() + 1
|
||||
// 如果基本工资等于整月工资,提示可能未折算
|
||||
const fullMonthSalary = Number(entry.employee.monthlySalary) || entry.baseSalary
|
||||
if (Math.abs(entry.baseSalary - fullMonthSalary) < 1 && actualWorkDays < daysInMonth) {
|
||||
checks.push({
|
||||
code: 'NEW_HIRE_NO_PRORATE',
|
||||
name: '新入职未折算工资',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 本月 ${hireDate.getDate()} 日入职,工资可能未按实际天数折算(实际工作 ${actualWorkDays} 天)`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { hireDate: hireDate.toISOString().slice(0, 10), actualWorkDays, daysInMonth },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 社保是否在入职 30 天内参保
|
||||
for (const entry of entries) {
|
||||
const hireDate = entry.employee.hireDate
|
||||
const socialStart = entry.employee.socialInsStartMonth
|
||||
if (socialStart) {
|
||||
const socialStartDate = new Date(socialStart + '-01')
|
||||
const daysDiff = Math.floor((socialStartDate.getTime() - hireDate.getTime()) / 86400000)
|
||||
if (daysDiff > 30) {
|
||||
checks.push({
|
||||
code: 'SOCIAL_INS_LATE_ENROLL',
|
||||
name: '社保参保延迟',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 入职 ${daysDiff} 天后才参保社保,超过 30 天法定期限`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { hireDate: hireDate.toISOString().slice(0, 10), socialStart, daysDiff },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. 离职当月社保是否已停保
|
||||
for (const entry of entries) {
|
||||
if (entry.employee.status === 'RESIGNED' && entry.employee.socialInsEndMonth) {
|
||||
// 正常,已停保
|
||||
} else if (entry.employee.status === 'RESIGNED' && !entry.employee.socialInsEndMonth) {
|
||||
checks.push({
|
||||
code: 'SOCIAL_INS_NOT_STOPPED',
|
||||
name: '离职未停保',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 已离职但社保未停保,可能产生多缴费用`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 基本工资为 0
|
||||
for (const entry of entries) {
|
||||
if (entry.baseSalary === 0 && batch.type === 'REGULAR') {
|
||||
checks.push({
|
||||
code: 'ZERO_BASE_SALARY',
|
||||
name: '基本工资为 0',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 的基本工资为 0,请确认是否正确`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总
|
||||
const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
|
||||
const failedCount = checks.filter(c => c.status === 'FAIL').length
|
||||
const warningCount = checks.filter(c => c.status === 'WARNING').length
|
||||
|
||||
return { checks, passedCount, failedCount, warningCount }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user