feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列

- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项
- calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值
- 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems
- 所有 calcBatchEntry 调用处传入并写回 extraItems
- 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列
- 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems
- renderCell 支持从 extraItems 取值
- 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁
- 合理列宽 + 横向滚动 + hover 效果
This commit is contained in:
freedakgmail
2026-08-19 09:15:54 +08:00
parent 64ae633857
commit 20f920686e
6 changed files with 171 additions and 121 deletions
+2
View File
@@ -952,6 +952,8 @@ model BatchEntry {
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
// 风险提示
riskWarnings Json?
// 自定义模板项的值(非预置项),如 {"heatAllowance": 300}
extraItems Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+4 -3
View File
@@ -890,9 +890,10 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
}
// 重新计算税费
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
const entry = await prisma.batchEntry.findUnique({ where: { id: entryId } })
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type, undefined, (entry?.extraItems as Record<string, number>) || null)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 2}行:${e?.message || '导入失败'}`)
+3 -3
View File
@@ -790,11 +790,11 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res:
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
}
const calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type, undefined, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: entryData,
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
}
+50 -20
View File
@@ -80,12 +80,12 @@ async function recalcEntry(orgId: string, batchId: string, batchMonth: string, b
}
const prevDeferred = await getPrevDeferred(orgId, employeeId, batchMonth)
const calcResult = await calcBatchEntry(orgId, employeeId, batchMonth, inputs, batchType as any, { prevDeferred })
const calcResult = await calcBatchEntry(orgId, employeeId, batchMonth, inputs, batchType as any, { prevDeferred }, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entryId },
data: entryData,
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
}
@@ -652,6 +652,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0,
prevDeferredMinWage: calcResult.prevDeferredMinWage || 0,
riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
},
})
entries.push(entry)
@@ -688,7 +689,8 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
}
})
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖 + 自定义模板项
// 预置字段通过固定 schema 校验,自定义字段通过模板 isEditable 动态校验
const updateEntrySchema = z.object({
baseSalary: z.number().optional(),
performanceSalary: z.number().optional(),
@@ -700,7 +702,16 @@ const updateEntrySchema = z.object({
socialOrg: z.number().optional(),
housingEmp: z.number().optional(),
housingOrg: z.number().optional(),
})
positionSalary: z.number().optional(),
senioritySalary: z.number().optional(),
transportAllowance: z.number().optional(),
mealAllowance: z.number().optional(),
housingAllowance: z.number().optional(),
communicationAllowance: z.number().optional(),
otherDeduction: z.number().optional(),
// 自定义字段通过 extraItems 传递
extraItems: z.record(z.string(), z.number()).optional(),
}).passthrough()
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@@ -717,6 +728,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 获取模板,确定哪些自定义字段可编辑
const templateItems = await getTemplate(orgId)
const presetCodes = new Set(['baseSalary', 'performanceSalary', 'overtimePay', 'allowance', 'deduction', 'bonus', 'socialEmp', 'socialOrg', 'housingEmp', 'housingOrg', 'positionSalary', 'senioritySalary', 'transportAllowance', 'mealAllowance', 'housingAllowance', 'communicationAllowance', 'otherDeduction'])
const customEditableCodes = new Set(templateItems.filter(i => i.isEditable && !presetCodes.has(i.code)).map(i => i.code))
// 合并 extraItems:已有值 + 新传入的可编辑自定义字段
const existingExtraItems = (entry.extraItems as Record<string, number>) || {}
const newExtraItems = { ...existingExtraItems }
if (data.extraItems) {
for (const [code, val] of Object.entries(data.extraItems)) {
if (customEditableCodes.has(code)) {
newExtraItems[code] = val
}
}
}
// 合并输入项(包含细化薪资字段,避免编辑时丢失岗位工资/绩效工资等)
const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary,
@@ -724,14 +751,14 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus,
positionSalary: (data as any).positionSalary ?? entry.positionSalary ?? undefined,
positionSalary: data.positionSalary ?? entry.positionSalary ?? undefined,
performanceSalary: data.performanceSalary ?? entry.performanceSalary ?? undefined,
senioritySalary: (data as any).senioritySalary ?? entry.senioritySalary ?? undefined,
transportAllowance: entry.transportAllowance || undefined,
mealAllowance: entry.mealAllowance || undefined,
housingAllowance: entry.housingAllowance || undefined,
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
senioritySalary: data.senioritySalary ?? entry.senioritySalary ?? undefined,
transportAllowance: data.transportAllowance ?? (entry.transportAllowance || undefined),
mealAllowance: data.mealAllowance ?? (entry.mealAllowance || undefined),
housingAllowance: data.housingAllowance ?? (entry.housingAllowance || undefined),
communicationAllowance: data.communicationAllowance ?? (entry.communicationAllowance || undefined),
otherDeduction: data.otherDeduction ?? (entry.otherDeduction || undefined),
}
// 构建社保覆盖参数(如果请求中包含社保字段)
@@ -748,13 +775,15 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
const options: any = { prevDeferred }
if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
// 重新计算(传入 extraItems 供自定义 INPUT 项取值)
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options, newExtraItems)
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, extraItems: calcExtraItems, ...entryData } = calcResult
// 合并计算结果中的 extraItems(包含自定义计算项的值)
const finalExtraItems = { ...newExtraItems, ...(calcExtraItems || {}) }
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...entryData },
data: { ...inputs, ...entryData, extraItems: Object.keys(finalExtraItems).length > 0 ? finalExtraItems : undefined },
})
// 更新批次汇总
@@ -1242,12 +1271,13 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...entryData, riskWarnings,
extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined,
},
})
results.push(entry)
@@ -1384,11 +1414,11 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
const options: any = { prevDeferred }
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options, (entry.extraItems as Record<string, number>) || null)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, extraItems: _ei, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...entryData },
data: { ...entryData, extraItems: calcResult.extraItems && Object.keys(calcResult.extraItems).length > 0 ? calcResult.extraItems : undefined },
})
} catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
+14 -3
View File
@@ -401,9 +401,10 @@ 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 },
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 },
@@ -469,10 +470,10 @@ export async function calcBatchEntry(
_socialCalculated: false,
}
// 1. 初始化输入项
// 1. 初始化输入项:先从固定列 inputs 取,再从 extraItems 补充自定义项
for (const item of templateItems) {
if (item.type === 'INPUT') {
ctx.values[item.code] = (inputs as any)[item.code] || 0
ctx.values[item.code] = (inputs as any)[item.code] ?? (extraItems?.[item.code] ?? 0)
}
}
@@ -601,6 +602,15 @@ export async function calcBatchEntry(
}
}
// 提取非预置项的计算结果到 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,
@@ -627,6 +637,7 @@ export async function calcBatchEntry(
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
prevDeferredMinWage,
extraItems: resultExtraItems,
}
}