feat: P2+P3 薪酬模板驱动计算 - SYSTEM注册表 + 前端动态化
P2: 社保/公积金/个税改为 SYSTEM 注册表驱动 - 新增 SystemCalcContext 和 systemCalculators 注册表 - ensureSocialHousingCalculated 缓存社保公积金计算 - calcTaxSystem 提取个税计算为系统函数 - calcBatchEntry 重构为模板拓扑排序调度 P3: 前端批次表格动态化 + 模板编辑器增强 - 后端模板 CRUD 支持 calcType/dependencies 字段 - TemplateTab 表格增加计算方式列 - TemplateTab 编辑表单增加 calcType 选择(公式/系统) - BatchTab editableFields 从模板 isEditable 动态获取
This commit is contained in:
@@ -162,6 +162,8 @@ router.get('/template', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
const updateTemplateItemSchema = z.object({
|
const updateTemplateItemSchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
formula: z.string().nullable().optional(),
|
formula: z.string().nullable().optional(),
|
||||||
|
calcType: z.enum(['FORMULA', 'SYSTEM']).optional(),
|
||||||
|
dependencies: z.string().optional(),
|
||||||
order: z.number().int().optional(),
|
order: z.number().int().optional(),
|
||||||
isEditable: z.boolean().optional(),
|
isEditable: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
@@ -179,6 +181,8 @@ router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFu
|
|||||||
if (data.formula !== undefined) updateData.formula = data.formula
|
if (data.formula !== undefined) updateData.formula = data.formula
|
||||||
if (data.order !== undefined) updateData.order = data.order
|
if (data.order !== undefined) updateData.order = data.order
|
||||||
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
|
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
|
||||||
|
if (data.calcType !== undefined) updateData.calcType = data.calcType
|
||||||
|
if (data.dependencies !== undefined) updateData.dependencies = data.dependencies
|
||||||
|
|
||||||
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
|
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
|
||||||
res.json({ success: true, data: updated })
|
res.json({ success: true, data: updated })
|
||||||
@@ -193,6 +197,8 @@ const createTemplateItemSchema = z.object({
|
|||||||
code: z.string().min(1),
|
code: z.string().min(1),
|
||||||
type: z.enum(['INPUT', 'CALCULATED']),
|
type: z.enum(['INPUT', 'CALCULATED']),
|
||||||
formula: z.string().nullable().optional(),
|
formula: z.string().nullable().optional(),
|
||||||
|
calcType: z.enum(['FORMULA', 'SYSTEM']).default('FORMULA'),
|
||||||
|
dependencies: z.string().default('[]'),
|
||||||
order: z.number().int().default(99),
|
order: z.number().int().default(99),
|
||||||
isEditable: z.boolean().default(true),
|
isEditable: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -205,6 +205,177 @@ export function calcBonusTax(bonusAmount: number): number {
|
|||||||
return Math.max(0, Math.round(tax * 100) / 100)
|
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(
|
export async function calcBatchEntry(
|
||||||
@@ -261,179 +432,72 @@ export async function calcBatchEntry(
|
|||||||
const socialBase = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
|
const socialBase = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
|
||||||
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
|
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
|
||||||
|
|
||||||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
// ── 模板驱动计算 ──
|
||||||
let suppHousingEmp = 0, suppHousingOrg = 0
|
|
||||||
|
|
||||||
// 年终奖/奖金批次、补偿金批次、劳务协议/实习协议人员:不扣社保公积金
|
|
||||||
// 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣
|
|
||||||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
|
|
||||||
// 1. 计算当月应缴社保公积金全额
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, 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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
|
|
||||||
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
const systemSupplementaryHousingEmp = suppHousingEmp
|
|
||||||
const systemSupplementaryHousingOrg = suppHousingOrg
|
|
||||||
|
|
||||||
// 手动覆盖社保值
|
|
||||||
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 templateItems = await getTemplate(orgId)
|
const templateItems = await getTemplate(orgId)
|
||||||
const totalPayItem = templateItems.find(i => i.code === 'totalPay')
|
|
||||||
let totalPay: number
|
// 构建系统计算上下文
|
||||||
if (totalPayItem && totalPayItem.formula) {
|
const ctx: SystemCalcContext = {
|
||||||
const formulaVars: Record<string, number> = {
|
orgId, employeeId, month, batchType, employee,
|
||||||
baseSalary: inputs.baseSalary,
|
socialConfig, housingConfig, supplementaryHousingConfig,
|
||||||
positionSalary: inputs.positionSalary || 0,
|
socialBase, housingBase, isNoSocialContract, options,
|
||||||
performanceSalary: inputs.performanceSalary || 0,
|
values: {},
|
||||||
senioritySalary: inputs.senioritySalary || 0,
|
socialOrg: 0, housingOrg: 0,
|
||||||
overtimePay: inputs.overtimePay,
|
supplementaryHousingEmp: 0, supplementaryHousingOrg: 0,
|
||||||
transportAllowance: inputs.transportAllowance || 0,
|
taxBreakdown: null,
|
||||||
mealAllowance: inputs.mealAllowance || 0,
|
systemSocialEmp: 0, systemSocialOrg: 0,
|
||||||
housingAllowance: inputs.housingAllowance || 0,
|
systemHousingEmp: 0, systemHousingOrg: 0,
|
||||||
communicationAllowance: inputs.communicationAllowance || 0,
|
systemSuppHousingEmp: 0, systemSuppHousingOrg: 0,
|
||||||
allowance: inputs.allowance,
|
_socialCalculated: false,
|
||||||
bonus: inputs.bonus,
|
|
||||||
deduction: inputs.deduction,
|
|
||||||
otherDeduction: inputs.otherDeduction || 0,
|
|
||||||
}
|
|
||||||
totalPay = evalFormula(totalPayItem.formula, formulaVars)
|
|
||||||
} else {
|
|
||||||
// 回退到硬编码(兼容模板未配置的情况)
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 个税计算
|
// 1. 初始化输入项
|
||||||
let tax = 0
|
for (const item of templateItems) {
|
||||||
let taxBreakdown: any = null
|
if (item.type === 'INPUT') {
|
||||||
if (batchType === 'BONUS') {
|
ctx.values[item.code] = (inputs as any)[item.code] || 0
|
||||||
// 年终奖单独计税
|
|
||||||
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, 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
|
|
||||||
|
|
||||||
// 专项附加扣除:按月读取 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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
let minWage = 0
|
||||||
@@ -444,21 +508,8 @@ export async function calcBatchEntry(
|
|||||||
// 上月递延的最低工资补齐差额(本批次需扣回)
|
// 上月递延的最低工资补齐差额(本批次需扣回)
|
||||||
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
||||||
|
|
||||||
// 初始实发:优先从模板公式计算,再减去补充公积金和递延项(模板公式不含这两个系统内部字段)
|
// netPay 已由模板公式计算(公式中不含补充公积金和递延项),此处补扣
|
||||||
const netPayItem = templateItems.find(i => i.code === 'netPay')
|
netPay = netPay - suppHousingEmp - prevDeferredMinWage
|
||||||
let netPay: number
|
|
||||||
if (netPayItem && netPayItem.formula) {
|
|
||||||
const formulaVars: Record<string, number> = {
|
|
||||||
totalPay,
|
|
||||||
socialEmp,
|
|
||||||
housingEmp,
|
|
||||||
tax,
|
|
||||||
}
|
|
||||||
netPay = evalFormula(netPayItem.formula, formulaVars) - suppHousingEmp - prevDeferredMinWage
|
|
||||||
} else {
|
|
||||||
// 回退到硬编码
|
|
||||||
netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递延金额(本批次扣不动、递延到次月的部分)
|
// 递延金额(本批次扣不动、递延到次月的部分)
|
||||||
let deferredSocialEmp = 0
|
let deferredSocialEmp = 0
|
||||||
|
|||||||
@@ -497,6 +497,13 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: templateItems } = useQuery<any[]>({
|
||||||
|
queryKey: ['payslip-template'],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await payrollApi.template()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const updateEntryMutation = useMutation({
|
const updateEntryMutation = useMutation({
|
||||||
mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) =>
|
mutationFn: ({ employeeId, data }: { employeeId: string; data: any }) =>
|
||||||
payrollApi.updateBatchEntry(batchId, employeeId, data),
|
payrollApi.updateBatchEntry(batchId, employeeId, data),
|
||||||
@@ -729,10 +736,19 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可编辑的输入项字段
|
// 可编辑的输入项字段:从模板动态获取,回退到硬编码
|
||||||
const editableFields = isBonus
|
const editableFields = (() => {
|
||||||
? ['bonus']
|
if (templateItems && templateItems.length > 0) {
|
||||||
: ['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
|
const editable = templateItems.filter((i: any) => i.isEditable).map((i: any) => i.code)
|
||||||
|
// 奖金批次只允许编辑 bonus
|
||||||
|
if (isBonus) return ['bonus']
|
||||||
|
// 社保/公积金允许手动覆盖
|
||||||
|
return [...editable, 'socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
|
||||||
|
}
|
||||||
|
return isBonus
|
||||||
|
? ['bonus']
|
||||||
|
: ['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'housingEmp', 'socialOrg', 'housingOrg']
|
||||||
|
})()
|
||||||
|
|
||||||
// 点击单元格进入编辑
|
// 点击单元格进入编辑
|
||||||
const startEdit = (employeeId: string, field: string, currentValue: number) => {
|
const startEdit = (employeeId: string, field: string, currentValue: number) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function TemplateManager() {
|
|||||||
code: '',
|
code: '',
|
||||||
type: 'INPUT' as 'INPUT' | 'CALCULATED',
|
type: 'INPUT' as 'INPUT' | 'CALCULATED',
|
||||||
formula: '',
|
formula: '',
|
||||||
|
calcType: 'FORMULA' as 'FORMULA' | 'SYSTEM',
|
||||||
order: 99,
|
order: 99,
|
||||||
isEditable: true,
|
isEditable: true,
|
||||||
})
|
})
|
||||||
@@ -67,7 +68,7 @@ export function TemplateManager() {
|
|||||||
/** 打开新增表单 */
|
/** 打开新增表单 */
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setEditingItem(null)
|
setEditingItem(null)
|
||||||
setForm({ name: '', code: '', type: 'INPUT', formula: '', order: items?.length ? items.length + 1 : 99, isEditable: true })
|
setForm({ name: '', code: '', type: 'INPUT', formula: '', calcType: 'FORMULA', order: items?.length ? items.length + 1 : 99, isEditable: true })
|
||||||
setShowForm(true)
|
setShowForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export function TemplateManager() {
|
|||||||
code: item.code,
|
code: item.code,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
formula: item.formula || '',
|
formula: item.formula || '',
|
||||||
|
calcType: item.calcType || 'FORMULA',
|
||||||
order: item.order,
|
order: item.order,
|
||||||
isEditable: item.isEditable,
|
isEditable: item.isEditable,
|
||||||
})
|
})
|
||||||
@@ -91,10 +93,11 @@ export function TemplateManager() {
|
|||||||
toast.error('名称和字段代码不能为空')
|
toast.error('名称和字段代码不能为空')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload: any = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
type: form.type,
|
type: form.type,
|
||||||
formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null,
|
formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null,
|
||||||
|
calcType: form.type === 'CALCULATED' ? form.calcType : 'FORMULA',
|
||||||
order: Number(form.order),
|
order: Number(form.order),
|
||||||
isEditable: form.isEditable,
|
isEditable: form.isEditable,
|
||||||
}
|
}
|
||||||
@@ -128,6 +131,7 @@ export function TemplateManager() {
|
|||||||
<th className="py-2 px-2 w-24">名称</th>
|
<th className="py-2 px-2 w-24">名称</th>
|
||||||
<th className="py-2 px-2 w-32">字段代码</th>
|
<th className="py-2 px-2 w-32">字段代码</th>
|
||||||
<th className="py-2 px-2 w-20">类型</th>
|
<th className="py-2 px-2 w-20">类型</th>
|
||||||
|
<th className="py-2 px-2 w-24">计算方式</th>
|
||||||
<th className="py-2 px-2 w-48">计算公式</th>
|
<th className="py-2 px-2 w-48">计算公式</th>
|
||||||
<th className="py-2 px-2 w-16">可编辑</th>
|
<th className="py-2 px-2 w-16">可编辑</th>
|
||||||
<th className="py-2 px-2 text-right w-24">操作</th>
|
<th className="py-2 px-2 text-right w-24">操作</th>
|
||||||
@@ -144,6 +148,13 @@ export function TemplateManager() {
|
|||||||
{item.type === 'INPUT' ? '输入项' : '计算项'}
|
{item.type === 'INPUT' ? '输入项' : '计算项'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-2 px-2">
|
||||||
|
{item.type === 'CALCULATED' && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs whitespace-nowrap ${(item.calcType || 'FORMULA') === 'SYSTEM' ? 'bg-amber-50 text-amber-600' : 'bg-teal-50 text-teal-600'}`}>
|
||||||
|
{(item.calcType || 'FORMULA') === 'SYSTEM' ? '系统计算' : '公式计算'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-2 px-2 text-gray-500 font-mono text-xs max-w-48 truncate" title={item.formula || ''}>{item.formula || '—'}</td>
|
<td className="py-2 px-2 text-gray-500 font-mono text-xs max-w-48 truncate" title={item.formula || ''}>{item.formula || '—'}</td>
|
||||||
<td className="py-2 px-2">
|
<td className="py-2 px-2">
|
||||||
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
|
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
|
||||||
@@ -240,15 +251,36 @@ export function TemplateManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.type === 'CALCULATED' && (
|
{form.type === 'CALCULATED' && (
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<Label>计算公式</Label>
|
<div>
|
||||||
<Input
|
<Label>计算方式</Label>
|
||||||
type="text"
|
<Select
|
||||||
value={form.formula}
|
value={form.calcType}
|
||||||
onChange={(e) => setForm({ ...form, formula: e.target.value })}
|
onChange={(e) => setForm({ ...form, calcType: e.target.value as 'FORMULA' | 'SYSTEM' })}
|
||||||
placeholder="如:baseSalary + overtimePay + allowance - deduction"
|
disabled={!!editingItem && editingItem.isDefault}
|
||||||
/>
|
>
|
||||||
<p className="text-xs text-gray-500 mt-1">可引用其他字段代码进行加减乘除运算</p>
|
<option value="FORMULA">公式计算(加减乘除表达式)</option>
|
||||||
|
<option value="SYSTEM">系统计算(社保/公积金/个税等)</option>
|
||||||
|
</Select>
|
||||||
|
{editingItem && editingItem.isDefault && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">预置项的计算方式不可修改</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{form.calcType === 'SYSTEM' ? '系统函数标识' : '计算公式'}</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={form.formula}
|
||||||
|
onChange={(e) => setForm({ ...form, formula: e.target.value })}
|
||||||
|
placeholder={form.calcType === 'SYSTEM' ? '如:SOCIAL_EMP' : '如:baseSalary + overtimePay + allowance - deduction'}
|
||||||
|
disabled={!!editingItem && editingItem.isDefault && form.calcType === 'SYSTEM'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{form.calcType === 'SYSTEM'
|
||||||
|
? '系统计算标识:SOCIAL_EMP(个人社保)、HOUSING_EMP(个人公积金)、TAX(个税)'
|
||||||
|
: '可引用其他字段代码进行加减乘除运算,支持 min()、max()、round() 函数'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user