feat: 发薪批次表格动态化 - 根据薪酬模板动态生成列
- schema: BatchEntry 增加 extraItems Json 字段存储自定义模板项 - calcBatchEntry: 新增 extraItems 参数,合并自定义 INPUT 项取值 - 后端编辑接口: 动态校验自定义字段,预置字段走固定列,自定义走 extraItems - 所有 calcBatchEntry 调用处传入并写回 extraItems - 前端表头/单元格从 templateItems 动态生成,排除 netPay 重复列 - 前端 saveEdit 动态化: 预置字段走固定列,自定义字段走 extraItems - renderCell 支持从 extraItems 取值 - 表格列左右锁定: 员工/合同类型左锁,实发/递延/风险/操作右锁 - 合理列宽 + 横向滚动 + hover 效果
This commit is contained in:
@@ -952,6 +952,8 @@ model BatchEntry {
|
||||
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
|
||||
// 风险提示
|
||||
riskWarnings Json?
|
||||
// 自定义模板项的值(非预置项),如 {"heatAllowance": 300}
|
||||
extraItems Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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 || '导入失败'}`)
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || '重算失败'}`)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user