feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税
- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化 - 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口 - 所有导入模板表头标注必填项(*后缀)并含示例行 - 导入逻辑统一改用getField兼容*后缀列名 - 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题 - 更新需求梳理文档
This commit is contained in:
@@ -217,25 +217,18 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
employeeIds: z.array(z.string()).optional(),
|
||||
name: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查当月是否有未归档批次,有则拒绝创建(确保个税按批次累计计算)
|
||||
const draftBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'DRAFT' },
|
||||
})
|
||||
if (draftBatches > 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月存在未归档的批次,请先归档后再创建新批次' } })
|
||||
}
|
||||
|
||||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||||
const lastBatch = await prisma.payrollBatch.findFirst({
|
||||
where: { orgId, month },
|
||||
@@ -261,6 +254,12 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
if (mode === 'blank_all') {
|
||||
// 全空白:不拉入员工
|
||||
employees = []
|
||||
} else if (mode === 'custom' && employeeIds && employeeIds.length > 0) {
|
||||
// 自定义选择:仅包含指定员工
|
||||
employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds }, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
} else if (mode === 'copy_batch' && sourceBatchId) {
|
||||
// 复制指定批次:从源批次复制条目
|
||||
const sourceBatch = await prisma.payrollBatch.findFirst({
|
||||
@@ -655,7 +654,7 @@ router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next:
|
||||
}
|
||||
})
|
||||
|
||||
// 归档批次
|
||||
// 归档批次(归档前重算所有条目,确保累计个税/社保包含先前已归档批次的数据)
|
||||
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
@@ -665,12 +664,73 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
|
||||
|
||||
// 1. 重算本批次所有条目(此时 calcBatchEntry 会包含所有已归档批次的累计数据)
|
||||
const entries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const recalcErrors: string[] = []
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const inputs = {
|
||||
baseSalary: entry.baseSalary,
|
||||
overtimePay: entry.overtimePay,
|
||||
allowance: entry.allowance,
|
||||
deduction: entry.deduction,
|
||||
bonus: entry.bonus,
|
||||
positionSalary: entry.positionSalary || undefined,
|
||||
performanceSalary: entry.performanceSalary || undefined,
|
||||
senioritySalary: entry.senioritySalary || undefined,
|
||||
transportAllowance: entry.transportAllowance || undefined,
|
||||
mealAllowance: entry.mealAllowance || undefined,
|
||||
housingAllowance: entry.housingAllowance || undefined,
|
||||
communicationAllowance: entry.communicationAllowance || undefined,
|
||||
otherDeduction: entry.otherDeduction || undefined,
|
||||
}
|
||||
// 社保如被手动覆盖,保留覆盖值
|
||||
const overrideSocial: any = {}
|
||||
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp
|
||||
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg
|
||||
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp
|
||||
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...calcResult },
|
||||
})
|
||||
} catch (e: any) {
|
||||
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 更新批次汇总
|
||||
const recalcedEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = recalcedEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.totalPay,
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
// 3. 标记为已归档
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'ARCHIVED', archivedAt: new Date() },
|
||||
data: {
|
||||
status: 'ARCHIVED',
|
||||
archivedAt: new Date(),
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { archived: true } })
|
||||
res.json({ success: true, data: { archived: true, recalculated: entries.length, errors: recalcErrors.length > 0 ? recalcErrors : undefined } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user