feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式
This commit is contained in:
@@ -222,11 +222,21 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月已有批次数
|
||||
const existingBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month },
|
||||
// 检查当月是否有未归档批次,有则拒绝创建(确保个税按批次累计计算)
|
||||
const draftBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'DRAFT' },
|
||||
})
|
||||
const batchNo = existingBatches + 1
|
||||
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 },
|
||||
orderBy: { batchNo: 'desc' },
|
||||
select: { batchNo: true },
|
||||
})
|
||||
const batchNo = (lastBatch?.batchNo || 0) + 1
|
||||
|
||||
// 获取在职员工 + 本月离职员工
|
||||
const monthStart = new Date(`${month}-01`)
|
||||
@@ -340,18 +350,10 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
|
||||
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
|
||||
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
})
|
||||
|
||||
// 计算社保、个税等
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||||
let calcResult: any
|
||||
try {
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
} catch (calcErr: any) {
|
||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||||
@@ -458,7 +460,11 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
|
||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
|
||||
const options = Object.keys(overrideSocial).length > 0
|
||||
? { overrideSocial }
|
||||
: undefined
|
||||
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
@@ -536,6 +542,7 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
})
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||
|
||||
@@ -549,9 +556,30 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
results.push(entry)
|
||||
}
|
||||
|
||||
// 更新批次人数
|
||||
const count = await prisma.batchEntry.count({ where: { batchId } })
|
||||
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
|
||||
// 更新批次人数和汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.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 })
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
employeeCount: allEntries.length,
|
||||
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: { added: results.length } })
|
||||
} catch (err) {
|
||||
@@ -571,8 +599,30 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest
|
||||
|
||||
await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } })
|
||||
|
||||
const count = await prisma.batchEntry.count({ where: { batchId } })
|
||||
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
|
||||
// 更新批次人数和汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.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 })
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
employeeCount: allEntries.length,
|
||||
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 })
|
||||
} catch (err) {
|
||||
@@ -620,6 +670,63 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
}
|
||||
})
|
||||
|
||||
// 取消归档(只能取消最后一个归档批次,依次取消)
|
||||
router.post('/batches/:batchId/unarchive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
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: '仅已归档批次可取消归档' } })
|
||||
|
||||
// 检查是否是最后一个归档批次(按 batchNo 倒序)
|
||||
const lastArchived = await prisma.payrollBatch.findFirst({
|
||||
where: { orgId, month: batch.month, status: 'ARCHIVED' },
|
||||
orderBy: { batchNo: 'desc' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (lastArchived?.id !== batchId) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '只能依次取消归档,请先取消最后归档的批次' } })
|
||||
}
|
||||
|
||||
// 取消归档
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'DRAFT', archivedAt: null },
|
||||
})
|
||||
|
||||
// 如果当月已无归档批次,删除对应工资条(取消个税累计)
|
||||
const remainingArchived = await prisma.payrollBatch.count({
|
||||
where: { orgId, month: batch.month, status: 'ARCHIVED' },
|
||||
})
|
||||
if (remainingArchived === 0) {
|
||||
// 查找该月所有批次的员工,删除其工资条
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month: batch.month } },
|
||||
select: { employeeId: true },
|
||||
distinct: ['employeeId'],
|
||||
})
|
||||
if (entries.length > 0) {
|
||||
await prisma.payslip.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId: { in: entries.map(e => e.employeeId) },
|
||||
month: batch.month,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 还有归档批次,重新生成工资条(基于剩余归档批次汇总)
|
||||
await generatePayslipFromBatches(orgId, batch.month)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { unarchived: true } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 从已归档批次汇总生成工资条
|
||||
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user