feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税

- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化
- 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口
- 所有导入模板表头标注必填项(*后缀)并含示例行
- 导入逻辑统一改用getField兼容*后缀列名
- 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题
- 更新需求梳理文档
This commit is contained in:
freedakgmail
2026-07-29 19:08:45 +08:00
parent 7c24ebe3d9
commit 0372cbe243
14 changed files with 1275 additions and 171 deletions
+73 -13
View File
@@ -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)
}