d79e3baa34
- 面包屑导航组件,集成至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
610 lines
25 KiB
TypeScript
610 lines
25 KiB
TypeScript
import prisma from '../lib/prisma'
|
||
|
||
// ========== 薪酬模版 ==========
|
||
|
||
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
|
||
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
|
||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
|
||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
|
||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
|
||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
|
||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
|
||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
|
||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
|
||
]
|
||
|
||
export async function ensureDefaultTemplate(orgId: string) {
|
||
const existing = await prisma.payslipItem.count({ where: { orgId } })
|
||
if (existing === 0) {
|
||
await prisma.payslipItem.createMany({
|
||
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
|
||
})
|
||
}
|
||
}
|
||
|
||
export async function getTemplate(orgId: string) {
|
||
await ensureDefaultTemplate(orgId)
|
||
return prisma.payslipItem.findMany({
|
||
where: { orgId },
|
||
orderBy: { order: 'asc' },
|
||
})
|
||
}
|
||
|
||
// ========== 社保计算 ==========
|
||
|
||
export function calcSocialInsurance(base: number, config: any) {
|
||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
|
||
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
|
||
return { actualBase, socialEmp, socialOrg }
|
||
}
|
||
|
||
export function calcHousingFund(base: number, config: any) {
|
||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||
const housingEmp = actualBase * config.housingEmp / 100
|
||
const housingOrg = actualBase * config.housingOrg / 100
|
||
return { actualBase, housingEmp, housingOrg }
|
||
}
|
||
|
||
// ========== 累计预扣个税 ==========
|
||
|
||
export function calcTax(taxableIncome: number): number {
|
||
if (taxableIncome <= 0) return 0
|
||
let tax = 0
|
||
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
|
||
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
|
||
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
|
||
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
|
||
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
|
||
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
|
||
else tax = taxableIncome * 0.45 - 181920
|
||
return Math.max(0, Math.round(tax * 100) / 100)
|
||
}
|
||
|
||
/**
|
||
* 累计预扣法计算当月个税
|
||
* @param ytdTaxableIncome 当年累计应纳税所得额(含当月)
|
||
* @param ytdTaxDeducted 当年累计已预扣税额
|
||
* @returns 当月应预扣税额
|
||
*/
|
||
export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number {
|
||
const ytdTax = calcTax(ytdTaxableIncome)
|
||
const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted)
|
||
return Math.round(currentMonthTax * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* 年终奖单独计税
|
||
* @param bonusAmount 奖金金额
|
||
* @returns 应纳税额
|
||
*/
|
||
export function calcBonusTax(bonusAmount: number): number {
|
||
if (bonusAmount <= 0) return 0
|
||
const monthlyBonus = bonusAmount / 12
|
||
let rate = 0.03
|
||
let quickDeduction = 0
|
||
if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 }
|
||
else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 }
|
||
else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 }
|
||
else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 }
|
||
else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 }
|
||
else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 }
|
||
else { rate = 0.45; quickDeduction = 15160 }
|
||
const tax = bonusAmount * rate - quickDeduction
|
||
return Math.max(0, Math.round(tax * 100) / 100)
|
||
}
|
||
|
||
// ========== 批次计算 ==========
|
||
|
||
export async function calcBatchEntry(
|
||
orgId: string,
|
||
employeeId: string,
|
||
month: string,
|
||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
|
||
batchType: string = 'REGULAR',
|
||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||
) {
|
||
const [employee, socialConfig, housingConfig] = await Promise.all([
|
||
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
|
||
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' },
|
||
}),
|
||
])
|
||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||
|
||
// 社保基数:优先用员工核定基数,否则用基本工资
|
||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
||
const housingBase = employee.housingFundBase || inputs.baseSalary
|
||
|
||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||
|
||
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
|
||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
|
||
if (socialConfig) {
|
||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||
socialEmp = social.socialEmp
|
||
socialOrg = social.socialOrg
|
||
}
|
||
if (housingConfig) {
|
||
const housing = calcHousingFund(housingBase, housingConfig)
|
||
housingEmp = housing.housingEmp
|
||
housingOrg = housing.housingOrg
|
||
}
|
||
}
|
||
|
||
// 手动覆盖社保值
|
||
if (options?.overrideSocial) {
|
||
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
|
||
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
|
||
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
|
||
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
||
}
|
||
|
||
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
|
||
|
||
// 个税计算
|
||
let tax = 0
|
||
if (batchType === 'BONUS') {
|
||
// 年终奖单独计税
|
||
tax = calcBonusTax(inputs.bonus)
|
||
} else {
|
||
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
||
const year = month.slice(0, 4)
|
||
const prevPayslips = await prisma.payslip.findMany({
|
||
where: {
|
||
orgId,
|
||
employeeId,
|
||
month: { startsWith: year, lt: month },
|
||
},
|
||
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
|
||
})
|
||
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay
|
||
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp
|
||
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp
|
||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0)
|
||
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
||
}
|
||
|
||
const netPay = totalPay - socialEmp - housingEmp - tax
|
||
|
||
return {
|
||
socialEmp: Math.round(socialEmp * 100) / 100,
|
||
socialOrg: Math.round(socialOrg * 100) / 100,
|
||
housingEmp: Math.round(housingEmp * 100) / 100,
|
||
housingOrg: Math.round(housingOrg * 100) / 100,
|
||
tax,
|
||
totalPay: Math.round(totalPay * 100) / 100,
|
||
netPay: Math.round(netPay * 100) / 100,
|
||
}
|
||
}
|
||
|
||
// ========== 风险提示 ==========
|
||
|
||
export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise<string[]> {
|
||
const warnings: string[] = []
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { id: employeeId, orgId },
|
||
include: {
|
||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||
terminations: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||
},
|
||
})
|
||
if (!employee) return warnings
|
||
|
||
if (employee.status === 'RESIGNED') {
|
||
warnings.push('该员工已离职,需进行离职结算')
|
||
}
|
||
if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') {
|
||
warnings.push('未签订书面劳动合同')
|
||
}
|
||
if (employee.contracts.length) {
|
||
const contract = employee.contracts[0]
|
||
if (contract.endDate) {
|
||
const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||
if (daysToExpiry <= 30 && daysToExpiry > 0) {
|
||
warnings.push(`合同将于 ${daysToExpiry} 天后到期`)
|
||
}
|
||
}
|
||
if (contract.probationMonths > 0 && contract.startDate) {
|
||
const probationEnd = new Date(contract.startDate)
|
||
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
|
||
if (probationEnd > new Date()) {
|
||
warnings.push('试用期员工,薪资可能不同')
|
||
}
|
||
}
|
||
}
|
||
if (!employee.socialInsBase) {
|
||
warnings.push('未设置社保缴费基数')
|
||
}
|
||
if (!employee.housingFundBase) {
|
||
warnings.push('未设置公积金缴费基数')
|
||
}
|
||
if (employee.terminations.some((t) => t.status !== 'CANCELLED')) {
|
||
warnings.push('已有解聘记录,请注意结算')
|
||
}
|
||
|
||
return warnings
|
||
}
|
||
|
||
// ========== 工资条汇总生成 ==========
|
||
|
||
export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||
// 获取当月所有已归档批次
|
||
const batches = await prisma.payrollBatch.findMany({
|
||
where: { orgId, month, status: 'ARCHIVED' },
|
||
include: { entries: true },
|
||
})
|
||
if (batches.length === 0) return { generated: 0 }
|
||
|
||
// 按员工汇总
|
||
const employeeMap = new Map<string, any>()
|
||
for (const batch of batches) {
|
||
for (const entry of batch.entries) {
|
||
const existing = employeeMap.get(entry.employeeId) || {
|
||
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
|
||
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
|
||
totalPay: 0, netPay: 0,
|
||
}
|
||
existing.baseSalary += entry.baseSalary
|
||
existing.overtimePay += entry.overtimePay
|
||
existing.allowance += entry.allowance
|
||
existing.deduction += entry.deduction
|
||
existing.bonus += entry.bonus
|
||
existing.socialEmp += entry.socialEmp
|
||
existing.socialOrg += entry.socialOrg
|
||
existing.housingEmp += entry.housingEmp
|
||
existing.housingOrg += entry.housingOrg
|
||
existing.tax += entry.tax
|
||
existing.totalPay += entry.totalPay
|
||
existing.netPay += entry.netPay
|
||
employeeMap.set(entry.employeeId, existing)
|
||
}
|
||
}
|
||
|
||
// 计算累计数据
|
||
const year = month.slice(0, 4)
|
||
const employeeIds = Array.from(employeeMap.keys())
|
||
|
||
const allPrevPayslips = await prisma.payslip.findMany({
|
||
where: { orgId, employeeId: { in: employeeIds }, month: { startsWith: year, lt: month } },
|
||
select: { employeeId: true, totalPay: true, tax: true, socialEmp: true, housingEmp: true },
|
||
})
|
||
const prevMap = new Map<string, { totalPay: number; tax: number; socialEmp: number; housingEmp: number }>()
|
||
for (const p of allPrevPayslips) {
|
||
const existing = prevMap.get(p.employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 }
|
||
existing.totalPay += p.totalPay
|
||
existing.tax += p.tax
|
||
existing.socialEmp += p.socialEmp
|
||
existing.housingEmp += p.housingEmp
|
||
prevMap.set(p.employeeId, existing)
|
||
}
|
||
|
||
let generated = 0
|
||
for (const [employeeId, summary] of employeeMap) {
|
||
const prev = prevMap.get(employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 }
|
||
const ytdIncome = prev.totalPay + summary.totalPay
|
||
const ytdTaxDeducted = prev.tax + summary.tax
|
||
const ytdSocialEmp = prev.socialEmp + summary.socialEmp
|
||
const ytdHousingEmp = prev.housingEmp + summary.housingEmp
|
||
|
||
await prisma.payslip.upsert({
|
||
where: { employeeId_month: { employeeId, month } },
|
||
update: {
|
||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||
allowance: Math.round(summary.allowance * 100) / 100,
|
||
deduction: Math.round(summary.deduction * 100) / 100,
|
||
bonus: Math.round(summary.bonus * 100) / 100,
|
||
totalPay: Math.round(summary.totalPay * 100) / 100,
|
||
socialEmp: Math.round(summary.socialEmp * 100) / 100,
|
||
housingEmp: Math.round(summary.housingEmp * 100) / 100,
|
||
tax: Math.round(summary.tax * 100) / 100,
|
||
netPay: Math.round(summary.netPay * 100) / 100,
|
||
ytdIncome: Math.round(ytdIncome * 100) / 100,
|
||
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
|
||
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
|
||
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
|
||
status: 'PUBLISHED',
|
||
publishedAt: new Date(),
|
||
},
|
||
create: {
|
||
orgId,
|
||
employeeId,
|
||
month,
|
||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||
allowance: Math.round(summary.allowance * 100) / 100,
|
||
deduction: Math.round(summary.deduction * 100) / 100,
|
||
bonus: Math.round(summary.bonus * 100) / 100,
|
||
totalPay: Math.round(summary.totalPay * 100) / 100,
|
||
socialEmp: Math.round(summary.socialEmp * 100) / 100,
|
||
housingEmp: Math.round(summary.housingEmp * 100) / 100,
|
||
tax: Math.round(summary.tax * 100) / 100,
|
||
netPay: Math.round(summary.netPay * 100) / 100,
|
||
ytdIncome: Math.round(ytdIncome * 100) / 100,
|
||
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
|
||
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
|
||
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
|
||
status: 'PUBLISHED',
|
||
publishedAt: new Date(),
|
||
},
|
||
})
|
||
generated++
|
||
}
|
||
|
||
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 }
|
||
}
|