Files
TurboHR/backend/src/services/payroll.service.ts
T
freedakgmail 20f920686e feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列
- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项
- calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值
- 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems
- 所有 calcBatchEntry 调用处传入并写回 extraItems
- 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列
- 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems
- renderCell 支持从 extraItems 取值
- 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁
- 合理列宽 + 横向滚动 + hover 效果
2026-08-19 09:15:54 +08:00

1120 lines
49 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'
import { evalFormula, topologicalSort } from './formula-engine'
// ========== 社保公积金账户辅助函数 ==========
/**
* 通过员工获取适用的社保/公积金账户
* 优先从员工所属根部门(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; calcType: string; dependencies: string; dataSource: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 1, isDefault: true, isEditable: true },
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 2, isDefault: true, isEditable: true },
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'performance', order: 3, isDefault: true, isEditable: true },
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 4, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'overtime', order: 5, isDefault: true, isEditable: true },
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 6, isDefault: true, isEditable: true },
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 7, isDefault: true, isEditable: true },
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 8, isDefault: true, isEditable: true },
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 9, isDefault: true, isEditable: true },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 10, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'bonus', order: 11, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'deduction', order: 12, isDefault: true, isEditable: true },
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 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', calcType: 'FORMULA', dependencies: '["baseSalary","positionSalary","performanceSalary","senioritySalary","overtimePay","transportAllowance","mealAllowance","housingAllowance","communicationAllowance","allowance","bonus","deduction","otherDeduction"]', dataSource: null, order: 14, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', calcType: 'SYSTEM', dependencies: '[]', dataSource: null, order: 15, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', calcType: 'SYSTEM', dependencies: '[]', dataSource: null, order: 16, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', calcType: 'SYSTEM', dependencies: '["totalPay","socialEmp","housingEmp"]', dataSource: null, order: 17, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', calcType: 'FORMULA', dependencies: '["totalPay","socialEmp","housingEmp","tax"]', dataSource: null, 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 })),
})
} else {
// 迁移:为已有记录补充 calcType、dependencies、dataSource 字段,并修正 overtimePay 类型
for (const item of DEFAULT_ITEMS) {
const existing = await prisma.payslipItem.findFirst({ where: { orgId, code: item.code } })
if (!existing) continue
const updates: any = {}
// 补充 SYSTEM calcType
if ((!existing.calcType || existing.calcType === 'FORMULA') && item.calcType === 'SYSTEM') {
updates.calcType = 'SYSTEM'
updates.dependencies = item.dependencies
}
// 补充 dataSource
if (!existing.dataSource && item.dataSource) {
updates.dataSource = item.dataSource
}
// 修正 overtimePay:从 CALCULATED 改为 INPUT
if (item.code === 'overtimePay' && existing.type === 'CALCULATED') {
updates.type = 'INPUT'
updates.formula = null
updates.calcType = 'FORMULA'
updates.dependencies = '[]'
updates.isEditable = true
}
if (Object.keys(updates).length > 0) {
await prisma.payslipItem.update({ where: { id: existing.id }, data: updates })
}
}
}
}
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 + (config.medicalEmpExtra || 0)
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100 + (config.medicalOrgExtra || 0)
// 附加险种(大病险/长护险等)
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)
}
// ========== 系统计算器注册表 ==========
interface SystemCalcContext {
orgId: string
employeeId: string
month: string
batchType: string
employee: any
socialConfig: any
housingConfig: any
supplementaryHousingConfig: any
socialBase: number
housingBase: number
isNoSocialContract: boolean
options: any
values: Record<string, number>
// Side-effect outputs (org-side and supplementary amounts not in template)
socialOrg: number
housingOrg: number
supplementaryHousingEmp: number
supplementaryHousingOrg: number
taxBreakdown: any
// System values (before override)
systemSocialEmp: number
systemSocialOrg: number
systemHousingEmp: number
systemHousingOrg: number
systemSuppHousingEmp: number
systemSuppHousingOrg: number
// Internal cache flag
_socialCalculated: boolean
}
type SystemCalcFn = (ctx: SystemCalcContext) => Promise<number>
/**
* 社保+公积金联合计算(缓存结果,避免重复查询数据库)
*/
async function ensureSocialHousingCalculated(ctx: SystemCalcContext) {
if (ctx._socialCalculated) return
ctx._socialCalculated = true
const { orgId, employeeId, month, batchType, socialConfig, housingConfig, supplementaryHousingConfig, socialBase, housingBase, isNoSocialContract, options } = ctx
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
let suppHousingEmp = 0, suppHousingOrg = 0
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 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
}
if (supplementaryHousingConfig) {
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
fullSuppHousingEmp = suppHousing.housingEmp
fullSuppHousingOrg = suppHousing.housingOrg
}
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, supplementaryHousingEmp: true, supplementaryHousingOrg: 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)
const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0)
const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 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)
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
if (options?.prevDeferred) {
socialEmp += options.prevDeferred.socialEmp || 0
housingEmp += options.prevDeferred.housingEmp || 0
}
}
ctx.values.socialEmp = socialEmp
ctx.values.housingEmp = housingEmp
ctx.socialOrg = socialOrg
ctx.housingOrg = housingOrg
ctx.supplementaryHousingEmp = suppHousingEmp
ctx.supplementaryHousingOrg = suppHousingOrg
ctx.systemSocialEmp = socialEmp
ctx.systemSocialOrg = socialOrg
ctx.systemHousingEmp = housingEmp
ctx.systemHousingOrg = housingOrg
ctx.systemSuppHousingEmp = suppHousingEmp
ctx.systemSuppHousingOrg = suppHousingOrg
}
/**
* 个税系统计算函数
*/
async function calcTaxSystem(ctx: SystemCalcContext): Promise<number> {
const { orgId, employeeId, month, batchType, employee, values } = ctx
const totalPay = values.totalPay || 0
const socialEmp = values.socialEmp || 0
const housingEmp = values.housingEmp || 0
const suppHousingEmp = ctx.supplementaryHousingEmp
if (batchType === 'BONUS') {
const tax = calcBonusTax(values.bonus || 0)
ctx.taxBreakdown = { method: '单独计税(年终奖)', bonus: values.bonus || 0, tax }
return tax
}
const year = month.slice(0, 4)
const archivedEntries = await prisma.batchEntry.findMany({
where: { orgId, employeeId, batch: { month: { startsWith: year }, status: 'ARCHIVED' } },
select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: 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 + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
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)
const tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
ctx.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,
}
return tax
}
/**
* 系统计算器注册表
* key 对应 PayslipItem.formula 中的 SYSTEM 标识
*/
const systemCalculators: Record<string, SystemCalcFn> = {
SOCIAL_EMP: async (ctx) => {
await ensureSocialHousingCalculated(ctx)
return ctx.values.socialEmp
},
HOUSING_EMP: async (ctx) => {
await ensureSocialHousingCalculated(ctx)
return ctx.values.housingEmp
},
TAX: calcTaxSystem,
}
// ========== 批次计算 ==========
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; [key: string]: any },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } },
extraItems?: Record<string, number> | null,
) {
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
// 劳务协议/实习协议人员:不缴纳社保公积金,基数强制 0
const latestContract = employee.contracts[0]
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(latestContract.contractType)
// 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
let socialConfig: any = null
let housingConfig: any = null
let supplementaryHousingConfig: any = null
if (socialAccount) {
socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month)
}
if (housingAccount) {
housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month)
}
// 补充公积金:员工单独关联的补充公积金账户
if (employee.supplementaryHousingAccountId) {
supplementaryHousingConfig = await getStandardByAccountAndMonth(employee.supplementaryHousingAccountId, 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 = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
// ── 模板驱动计算 ──
const templateItems = await getTemplate(orgId)
// 构建系统计算上下文
const ctx: SystemCalcContext = {
orgId, employeeId, month, batchType, employee,
socialConfig, housingConfig, supplementaryHousingConfig,
socialBase, housingBase, isNoSocialContract, options,
values: {},
socialOrg: 0, housingOrg: 0,
supplementaryHousingEmp: 0, supplementaryHousingOrg: 0,
taxBreakdown: null,
systemSocialEmp: 0, systemSocialOrg: 0,
systemHousingEmp: 0, systemHousingOrg: 0,
systemSuppHousingEmp: 0, systemSuppHousingOrg: 0,
_socialCalculated: false,
}
// 1. 初始化输入项:先从固定列 inputs 取,再从 extraItems 补充自定义项
for (const item of templateItems) {
if (item.type === 'INPUT') {
ctx.values[item.code] = (inputs as any)[item.code] ?? (extraItems?.[item.code] ?? 0)
}
}
// 2. 按拓扑排序计算所有项
const sorted = topologicalSort(templateItems)
for (const item of sorted) {
if (item.type === 'INPUT') continue
// 在计算 tax 前应用手动覆盖(覆盖社保值)
if (item.code === 'tax' && options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) ctx.values.socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.housingEmp !== undefined) ctx.values.housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.socialOrg !== undefined) ctx.socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingOrg !== undefined) ctx.housingOrg = options.overrideSocial.housingOrg
}
if (item.calcType === 'SYSTEM' && item.formula) {
const fn = systemCalculators[item.formula]
if (fn) {
ctx.values[item.code] = await fn(ctx)
}
} else if (item.formula) {
ctx.values[item.code] = evalFormula(item.formula, ctx.values)
}
}
// 3. 提取计算结果
let socialEmp = ctx.values.socialEmp || 0
let socialOrg = ctx.socialOrg
let housingEmp = ctx.values.housingEmp || 0
let housingOrg = ctx.housingOrg
let suppHousingEmp = ctx.supplementaryHousingEmp
let suppHousingOrg = ctx.supplementaryHousingOrg
const systemSocialEmp = ctx.systemSocialEmp
const systemSocialOrg = ctx.systemSocialOrg
const systemHousingEmp = ctx.systemHousingEmp
const systemHousingOrg = ctx.systemHousingOrg
const systemSupplementaryHousingEmp = ctx.systemSuppHousingEmp
const systemSupplementaryHousingOrg = ctx.systemSuppHousingOrg
let tax = ctx.values.tax || 0
const taxBreakdown = ctx.taxBreakdown
let totalPay = ctx.values.totalPay || 0
let netPay = ctx.values.netPay || 0
// ── 最低工资保护 + 递延扣款逻辑 ──
// 读取最低工资标准(从年度标准或旧配置表)
let minWage = 0
if (socialConfig && (socialConfig as any).minWage) {
minWage = (socialConfig as any).minWage
}
// 上月递延的最低工资补齐差额(本批次需扣回)
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
// netPay 已由模板公式计算(公式中不含补充公积金和递延项),此处补扣
netPay = netPay - suppHousingEmp - 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 + suppHousingEmp) {
// 递延全部社保 + 部分公积金(含补充)
deferredSocialEmp = socialEmp
socialEmp = 0
const housingShortfall = shortfall - deferredSocialEmp
// 先递延基本公积金,再递延补充公积金
if (housingShortfall <= housingEmp) {
deferredHousingEmp = housingShortfall
housingEmp -= housingShortfall
} else {
deferredHousingEmp = housingEmp
const suppShortfall = housingShortfall - housingEmp
housingEmp = 0
suppHousingEmp -= suppShortfall
}
netPay = targetNetPay
minWageApplied = shortfall
} else {
// 递延全部社保 + 全部公积金(含补充),仍不足 → 差额作为最低工资补齐递延
deferredSocialEmp = socialEmp
deferredHousingEmp = housingEmp
const remainingShortfall = shortfall - socialEmp - housingEmp - suppHousingEmp
socialEmp = 0
housingEmp = 0
suppHousingEmp = 0
deferredMinWage = remainingShortfall
netPay = targetNetPay
minWageApplied = shortfall
}
}
}
}
// 提取非预置项的计算结果到 extraItems
const presetCodes = new Set(DEFAULT_ITEMS.map(i => i.code))
const resultExtraItems: Record<string, number> = {}
for (const item of templateItems) {
if (!presetCodes.has(item.code) && ctx.values[item.code] !== undefined) {
resultExtraItems[item.code] = Math.round((ctx.values[item.code] || 0) * 100) / 100
}
}
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,
supplementaryHousingEmp: Math.round(suppHousingEmp * 100) / 100,
supplementaryHousingOrg: Math.round(suppHousingOrg * 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,
systemSupplementaryHousingEmp: Math.round(systemSupplementaryHousingEmp * 100) / 100,
systemSupplementaryHousingOrg: Math.round(systemSupplementaryHousingOrg * 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,
extraItems: resultExtraItems,
}
}
// ========== 风险提示 ==========
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,
performanceSalary: Math.round(summary.performanceSalary * 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,
performanceSalary: Math.round(summary.performanceSalary * 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 }
}