c3ca2c9f50
问题:SocialYearStandard 只有一套 baseMin/baseMax,社保和公积金 共用,但公积金下限通常是最低工资标准(如北京2420),远低于社保 下限(如北京6326)。 修复: - SocialYearStandard 新增 housingBaseMin/housingBaseMax 字段 - clampHousingFundBase 优先用公积金专用上下限,为0时回退到社保 - calcHousingFund 同样优先用公积金专用上下限 - 前端公积金年度标准表单加公积金基数上下限输入 - 前端公积金当前标准展示用公积金专用上下限 同时修复 blank_employees/blank_all 模式跳过试用期工资覆盖 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
951 lines
41 KiB
TypeScript
951 lines
41 KiB
TypeScript
import prisma from '../lib/prisma'
|
||
|
||
// ========== 社保公积金账户辅助函数 ==========
|
||
|
||
/**
|
||
* 通过员工获取适用的社保/公积金账户
|
||
* 优先从员工所属根部门(level=0)关联的账户继承,回退到公司默认账户
|
||
*/
|
||
async function getEmployeeAccounts(orgId: string, employeeId: string) {
|
||
const emp = await prisma.employee.findFirst({
|
||
where: { id: employeeId, orgId },
|
||
include: { dept: true },
|
||
})
|
||
if (!emp) return { socialAccount: null, housingAccount: null }
|
||
|
||
// 向上找到 level=0 的根部门
|
||
let currentDept: any = emp.dept
|
||
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
|
||
currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } })
|
||
}
|
||
const rootDeptId = currentDept?.id || null
|
||
|
||
let socialAccount: any = null
|
||
let housingAccount: any = null
|
||
if (rootDeptId) {
|
||
const rootDept = await prisma.department.findUnique({
|
||
where: { id: rootDeptId },
|
||
include: { socialAccount: true, housingAccount: true },
|
||
})
|
||
socialAccount = rootDept?.socialAccount || null
|
||
housingAccount = rootDept?.housingAccount || null
|
||
}
|
||
|
||
// 回退到公司默认账户
|
||
if (!socialAccount) {
|
||
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
|
||
}
|
||
if (!housingAccount) {
|
||
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
|
||
}
|
||
|
||
return { socialAccount, housingAccount }
|
||
}
|
||
|
||
/**
|
||
* 通过账户获取指定月份的年度标准
|
||
*/
|
||
async function getStandardByAccountAndMonth(accountId: string, month: string) {
|
||
const standard = await prisma.socialYearStandard.findFirst({
|
||
where: {
|
||
accountId,
|
||
effectiveFrom: { lte: month },
|
||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||
},
|
||
orderBy: { effectiveFrom: 'desc' },
|
||
})
|
||
if (!standard) {
|
||
// 回退到当前生效标准
|
||
return prisma.socialYearStandard.findFirst({
|
||
where: { accountId, isCurrent: true },
|
||
orderBy: { effectiveFrom: 'desc' },
|
||
})
|
||
}
|
||
return standard
|
||
}
|
||
|
||
// ========== 薪酬模版 ==========
|
||
|
||
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: 'positionSalary', type: 'INPUT', formula: null, order: 2, isDefault: true, isEditable: true },
|
||
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 5, isDefault: true, isEditable: false },
|
||
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, order: 6, isDefault: true, isEditable: true },
|
||
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, order: 7, isDefault: true, isEditable: true },
|
||
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, order: 8, isDefault: true, isEditable: true },
|
||
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, order: 9, isDefault: true, isEditable: true },
|
||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 10, isDefault: true, isEditable: true },
|
||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 11, isDefault: true, isEditable: true },
|
||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 12, isDefault: true, isEditable: true },
|
||
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, order: 13, isDefault: true, isEditable: true },
|
||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', order: 14, isDefault: true, isEditable: false },
|
||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 15, isDefault: true, isEditable: false },
|
||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 16, isDefault: true, isEditable: false },
|
||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 17, isDefault: true, isEditable: false },
|
||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 18, 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)
|
||
// 医疗/生育保险基数(独立上下限,为 0 时 fallback 到统一基数)
|
||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||
let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 100
|
||
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100
|
||
// 附加险种(大病险/长护险等)
|
||
const extraItems: any[] = []
|
||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||
for (const ins of config.extraInsurances as any[]) {
|
||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||
const orgAmt = ins.fixedAmount
|
||
const empAmt = ins.empFixedAmount || 0
|
||
socialOrg += orgAmt
|
||
socialEmp += empAmt
|
||
extraItems.push({ name: ins.name, orgAmount: orgAmt, empAmount: empAmt })
|
||
} else {
|
||
const orgAmt = insBase * (ins.orgRate || 0) / 100
|
||
const empAmt = insBase * (ins.empRate || 0) / 100
|
||
socialOrg += orgAmt
|
||
socialEmp += empAmt
|
||
extraItems.push({ name: ins.name, orgAmount: orgAmt, empAmount: empAmt })
|
||
}
|
||
}
|
||
}
|
||
return { actualBase, medicalBase, socialEmp, socialOrg, extraItems }
|
||
}
|
||
|
||
export function calcHousingFund(base: number, config: any) {
|
||
// 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
|
||
const min = (config.housingBaseMin && config.housingBaseMin > 0) ? config.housingBaseMin : config.baseMin
|
||
const max = (config.housingBaseMax && config.housingBaseMax > 0) ? config.housingBaseMax : config.baseMax
|
||
const actualBase = Math.min(Math.max(base, min), max)
|
||
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; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
|
||
batchType: string = 'REGULAR',
|
||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } },
|
||
) {
|
||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||
|
||
// 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
|
||
const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
|
||
let socialConfig: any = null
|
||
let housingConfig: any = null
|
||
|
||
if (socialAccount) {
|
||
socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month)
|
||
}
|
||
if (housingAccount) {
|
||
housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month)
|
||
}
|
||
|
||
// 回退到旧配置表(兼容未迁移数据)
|
||
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
|
||
if (!socialConfig) {
|
||
socialConfig = await prisma.socialInsuranceConfig.findFirst({
|
||
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||
orderBy: { effectiveFrom: 'desc' },
|
||
})
|
||
}
|
||
if (!housingConfig) {
|
||
housingConfig = await prisma.housingFundConfig.findFirst({
|
||
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||
orderBy: { effectiveFrom: 'desc' },
|
||
})
|
||
}
|
||
|
||
// 社保基数:优先用员工核定基数(含0,劳务协议人员基数为0不交社保),未设置时回退到基本工资
|
||
const socialBase = employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary
|
||
const housingBase = employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary
|
||
|
||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||
|
||
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
|
||
// 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣
|
||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
|
||
// 1. 计算当月应缴社保公积金全额
|
||
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
|
||
if (socialConfig) {
|
||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||
fullSocialEmp = social.socialEmp
|
||
fullSocialOrg = social.socialOrg
|
||
}
|
||
if (housingConfig) {
|
||
const housing = calcHousingFund(housingBase, housingConfig)
|
||
fullHousingEmp = housing.housingEmp
|
||
fullHousingOrg = housing.housingOrg
|
||
}
|
||
|
||
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额
|
||
const archivedEntries = await prisma.batchEntry.findMany({
|
||
where: {
|
||
orgId,
|
||
employeeId,
|
||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||
},
|
||
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true },
|
||
})
|
||
const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
|
||
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
|
||
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
|
||
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0)
|
||
|
||
// 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0)
|
||
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
|
||
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
|
||
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
|
||
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
||
|
||
// 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
|
||
if (options?.prevDeferred) {
|
||
socialEmp += options.prevDeferred.socialEmp || 0
|
||
housingEmp += options.prevDeferred.housingEmp || 0
|
||
}
|
||
}
|
||
|
||
// 保存系统计算值(覆盖前,含递延)
|
||
const systemSocialEmp = socialEmp
|
||
const systemSocialOrg = socialOrg
|
||
const systemHousingEmp = housingEmp
|
||
const systemHousingOrg = 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.positionSalary || 0)
|
||
+ (inputs.performanceSalary || 0)
|
||
+ (inputs.senioritySalary || 0)
|
||
+ inputs.overtimePay
|
||
+ (inputs.transportAllowance || 0)
|
||
+ (inputs.mealAllowance || 0)
|
||
+ (inputs.housingAllowance || 0)
|
||
+ (inputs.communicationAllowance || 0)
|
||
+ inputs.allowance
|
||
+ inputs.bonus
|
||
- inputs.deduction
|
||
- (inputs.otherDeduction || 0)
|
||
|
||
// 个税计算
|
||
let tax = 0
|
||
let taxBreakdown: any = null
|
||
if (batchType === 'BONUS') {
|
||
// 年终奖单独计税
|
||
tax = calcBonusTax(inputs.bonus)
|
||
taxBreakdown = { method: '单独计税(年终奖)', bonus: inputs.bonus, tax }
|
||
} else {
|
||
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
|
||
const year = month.slice(0, 4)
|
||
// 从已归档批次的 BatchEntry 获取当年历史数据(不依赖工资条是否已生成)
|
||
const archivedEntries = await prisma.batchEntry.findMany({
|
||
where: {
|
||
orgId,
|
||
employeeId,
|
||
batch: {
|
||
month: { startsWith: year },
|
||
status: 'ARCHIVED',
|
||
},
|
||
},
|
||
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
|
||
})
|
||
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
|
||
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
||
|
||
// 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加
|
||
// 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据)
|
||
const deductionRecords = await prisma.specialDeductionRecord.findMany({
|
||
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
|
||
select: { amount: true, month: true },
|
||
})
|
||
let ytdSpecialDeduction: number
|
||
if (deductionRecords.length > 0) {
|
||
// 按月实际填报金额累加
|
||
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
|
||
} else {
|
||
// 回退:员工便捷字段 × 月数(兼容未填报按月记录的旧数据)
|
||
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||
}
|
||
|
||
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
||
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
||
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
||
taxBreakdown = {
|
||
method: '累计预扣法',
|
||
month: Number(month.slice(5, 7)),
|
||
ytdIncome,
|
||
deductionAmount,
|
||
ytdSocialEmp,
|
||
ytdHousingEmp,
|
||
ytdSpecialDeduction,
|
||
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
|
||
specialDeductionRecords: deductionRecords.length,
|
||
ytdTaxableIncome,
|
||
ytdTaxDeducted,
|
||
currentMonthTax: tax,
|
||
archivedCount: archivedEntries.length,
|
||
}
|
||
}
|
||
|
||
// ── 最低工资保护 + 递延扣款逻辑 ──
|
||
// 读取最低工资标准(从年度标准或旧配置表)
|
||
let minWage = 0
|
||
if (socialConfig && (socialConfig as any).minWage) {
|
||
minWage = (socialConfig as any).minWage
|
||
}
|
||
|
||
// 上月递延的最低工资补齐差额(本批次需扣回)
|
||
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
||
|
||
// 初始实发 = 应发 - 社保 - 公积金 - 个税 - 上月递延最低工资补扣
|
||
let netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage
|
||
|
||
// 递延金额(本批次扣不动、递延到次月的部分)
|
||
let deferredSocialEmp = 0
|
||
let deferredHousingEmp = 0
|
||
let deferredMinWage = 0
|
||
let minWageApplied = 0 // 当月实际补齐到最低工资的金额
|
||
|
||
// 最低工资保护:当月累计实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
|
||
// 第二批次时需判断"当月累计实发"(已归档批次 netPay 之和 + 本批次 netPay)是否低于 minWage
|
||
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE') {
|
||
// 查当月已归档批次的累计实发(同月已归档的 REGULAR/TERMINATION 批次)
|
||
const archivedNetPayEntries = await prisma.batchEntry.findMany({
|
||
where: {
|
||
orgId,
|
||
employeeId,
|
||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||
},
|
||
select: { netPay: true },
|
||
})
|
||
const archivedNetPay = archivedNetPayEntries.reduce((s, e) => s + e.netPay, 0)
|
||
|
||
// 当月累计实发 = 已归档批次实发 + 本批次实发
|
||
const monthlyCumulativeNetPay = archivedNetPay + netPay
|
||
|
||
// 仅当当月累计实发低于最低工资时才触发保护
|
||
if (monthlyCumulativeNetPay < minWage) {
|
||
// 需要补齐的金额 = 最低工资 - 当月累计实发
|
||
// 但本批次最多补齐到本批次实发为正,且不超过 minWage - archivedNetPay
|
||
const targetNetPay = Math.max(0, minWage - archivedNetPay)
|
||
const shortfall = targetNetPay - netPay // 需要补齐的金额(正数表示需要补齐)
|
||
|
||
if (shortfall > 0) {
|
||
// 优先递延社保个人部分(减少当月社保扣款)
|
||
if (shortfall <= socialEmp) {
|
||
// 只递延社保就够了
|
||
deferredSocialEmp = shortfall
|
||
socialEmp -= shortfall
|
||
netPay = targetNetPay
|
||
minWageApplied = shortfall
|
||
} else if (shortfall <= socialEmp + housingEmp) {
|
||
// 递延全部社保 + 部分公积金
|
||
deferredSocialEmp = socialEmp
|
||
deferredHousingEmp = shortfall - socialEmp
|
||
socialEmp = 0
|
||
housingEmp -= deferredHousingEmp
|
||
netPay = targetNetPay
|
||
minWageApplied = shortfall
|
||
} else {
|
||
// 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延
|
||
deferredSocialEmp = socialEmp
|
||
deferredHousingEmp = housingEmp
|
||
const remainingShortfall = shortfall - socialEmp - housingEmp
|
||
socialEmp = 0
|
||
housingEmp = 0
|
||
deferredMinWage = remainingShortfall
|
||
netPay = targetNetPay
|
||
minWageApplied = shortfall
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
|
||
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
|
||
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
|
||
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
|
||
tax,
|
||
taxBreakdown,
|
||
totalPay: Math.round(totalPay * 100) / 100,
|
||
netPay: Math.round(netPay * 100) / 100,
|
||
// 最低工资保护 + 递延信息
|
||
minWage,
|
||
minWageApplied: Math.round(minWageApplied * 100) / 100,
|
||
deferredSocialEmp: Math.round(deferredSocialEmp * 100) / 100,
|
||
deferredHousingEmp: Math.round(deferredHousingEmp * 100) / 100,
|
||
deferredMinWage: Math.round(deferredMinWage * 100) / 100,
|
||
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
|
||
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
|
||
prevDeferredMinWage,
|
||
}
|
||
}
|
||
|
||
// ========== 风险提示 ==========
|
||
|
||
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,
|
||
positionSalary: 0, performanceSalary: 0, senioritySalary: 0,
|
||
transportAllowance: 0, mealAllowance: 0, housingAllowance: 0, communicationAllowance: 0,
|
||
otherDeduction: 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.positionSalary += entry.positionSalary || 0
|
||
existing.performanceSalary += entry.performanceSalary || 0
|
||
existing.senioritySalary += entry.senioritySalary || 0
|
||
existing.transportAllowance += entry.transportAllowance || 0
|
||
existing.mealAllowance += entry.mealAllowance || 0
|
||
existing.housingAllowance += entry.housingAllowance || 0
|
||
existing.communicationAllowance += entry.communicationAllowance || 0
|
||
existing.otherDeduction += entry.otherDeduction || 0
|
||
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 != null ? 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 != null ? 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,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 11. 实发工资低于最低工资标准(剔除加班费后比较)
|
||
for (const entry of entries) {
|
||
const minWage = (entry as any).minWage || 0
|
||
if (minWage > 0 && batch.type !== 'BONUS' && batch.type !== 'SEVERANCE') {
|
||
// 最低工资剔除项:加班费、高温/夜班津贴等不计入
|
||
const comparablePay = entry.totalPay - entry.overtimePay
|
||
if (comparablePay < minWage && entry.netPay < minWage) {
|
||
const applied = (entry as any).minWageApplied || 0
|
||
if (applied > 0) {
|
||
// 已触发最低工资保护,检查递延情况
|
||
const deferredTotal = ((entry as any).deferredSocialEmp || 0) + ((entry as any).deferredHousingEmp || 0) + ((entry as any).deferredMinWage || 0)
|
||
checks.push({
|
||
code: 'MIN_WAGE_DEFERRED',
|
||
name: '最低工资保护已触发(递延扣款)',
|
||
status: 'WARNING',
|
||
message: `${entry.employee.name} 应发 ¥${comparablePay}(剔除加班费)低于最低工资 ¥${minWage},已补齐 ¥${applied},递延扣款 ¥${deferredTotal} 将在次月补扣`,
|
||
employeeId: entry.employeeId,
|
||
employeeName: entry.employee.name,
|
||
detail: { comparablePay, minWage, applied, deferredTotal },
|
||
})
|
||
} else {
|
||
checks.push({
|
||
code: 'BELOW_MIN_WAGE',
|
||
name: '实发低于最低工资标准',
|
||
status: 'FAIL',
|
||
message: `${entry.employee.name} 实发 ¥${entry.netPay} 低于当地最低工资标准 ¥${minWage},请检查社保基数或启用最低工资保护`,
|
||
employeeId: entry.employeeId,
|
||
employeeName: entry.employee.name,
|
||
detail: { netPay: entry.netPay, minWage, comparablePay },
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 12. 上月递延扣款待补扣
|
||
for (const entry of entries) {
|
||
const prevDeferred = ((entry as any).prevDeferredSocialEmp || 0) + ((entry as any).prevDeferredHousingEmp || 0) + ((entry as any).prevDeferredMinWage || 0)
|
||
if (prevDeferred > 0) {
|
||
checks.push({
|
||
code: 'PREV_DEFERRED_RECOVERED',
|
||
name: '上月递延扣款已补扣',
|
||
status: 'WARNING',
|
||
message: `${entry.employee.name} 本月补扣上月递延 ¥${prevDeferred}(社保 ¥${(entry as any).prevDeferredSocialEmp || 0} + 公积金 ¥${(entry as any).prevDeferredHousingEmp || 0} + 最低工资补齐 ¥${(entry as any).prevDeferredMinWage || 0})`,
|
||
employeeId: entry.employeeId,
|
||
employeeName: entry.employee.name,
|
||
detail: { prevDeferred },
|
||
})
|
||
}
|
||
}
|
||
|
||
// 汇总
|
||
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 }
|
||
}
|