Files
TurboHR/backend/src/services/payroll.service.ts
T
selfrelease ea2dfe3b4b feat: 最低工资保护 + 社保/最低工资递延补扣机制
新增功能:
1. SocialYearStandard / SocialInsuranceConfig 增加 minWage 字段
2. BatchEntry 增加递延扣款字段(deferredSocialEmp/HousingEmp/MinWage)
3. calcBatchEntry 增加最低工资保护逻辑:
   - 实发 < 最低工资时,优先递延社保 → 递延公积金 → 递延最低工资补齐
   - 递延金额记录到 BatchEntry,次月创建批次时自动补扣
4. prePayrollCheck 增加最低工资检查(第 11/12 项)
5. 前端社保配置增加最低工资输入框
6. 前端 BatchTab 质量门禁增加最低工资/递延扣款提示

处理场景:
- 入职当月工资不足扣社保个人部分 → 递延到次月补扣
- 实发低于最低工资 → 补齐到最低工资,差额递延次月扣
- 次月创建批次时自动读取上月递延金额并补扣

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:15:39 +08:00

927 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
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; 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' },
})
}
// 社保基数:优先用员工核定基数,否则用基本工资
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) {
// 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 批次)
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE' && netPay < minWage) {
const shortfall = minWage - netPay // 需要补齐的金额
// 优先递延社保个人部分(减少当月社保扣款)
if (shortfall <= socialEmp) {
// 只递延社保就够了
deferredSocialEmp = shortfall
socialEmp -= shortfall
netPay = minWage
minWageApplied = shortfall
} else if (shortfall <= socialEmp + housingEmp) {
// 递延全部社保 + 部分公积金
deferredSocialEmp = socialEmp
deferredHousingEmp = shortfall - socialEmp
socialEmp = 0
housingEmp -= deferredHousingEmp
netPay = minWage
minWageApplied = shortfall
} else {
// 递延全部社保 + 全部公积金,仍不足 → 差额作为最低工资补齐递延
deferredSocialEmp = socialEmp
deferredHousingEmp = housingEmp
const remainingShortfall = shortfall - socialEmp - housingEmp
socialEmp = 0
housingEmp = 0
// 此时 netPay = totalPay - tax - prevDeferredMinWage
// 差额 = minWage - (totalPay - tax - prevDeferredMinWage)
deferredMinWage = remainingShortfall
netPay = minWage
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 || 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,
})
}
}
// 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 }
}