diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index a0cdb8c..0fd5ca4 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -702,6 +702,7 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: try { const { batchId } = req.params const orgId = req.user!.orgId + const force = (req.body as any)?.force === true const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) if (!batch) return res.status(404).json({ success: false, message: '批次不存在' }) @@ -718,9 +719,33 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: }) if (overtimeRecords.length === 0) { - return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' }) + return res.json({ success: false, message: '没有可获取的加班记录(所有记录已关联批次或无数据)' }) } + // 检查跨批次重复(非 force 模式) + const toFillEmployeeIds = new Set(overtimeRecords.map(ot => ot.employeeId)) + if (!force && toFillEmployeeIds.size > 0) { + const otherEntries = await prisma.batchEntry.findMany({ + where: { orgId, batch: { month: batch.month, id: { not: batchId } }, overtimePay: { gt: 0 } }, + select: { employeeId: true }, + }) + const dupIds = new Set(otherEntries.map(e => e.employeeId)) + const overlap = [...toFillEmployeeIds].filter(id => dupIds.has(id)) + if (overlap.length > 0) { + const overlapNames = await prisma.employee.findMany({ where: { id: { in: overlap } }, select: { name: true } }) + return res.json({ + success: true, + data: { + needConfirm: true, + duplicateCount: overlap.length, + duplicateNames: overlapNames.map(e => e.name), + message: `${overlap.length} 人在其他批次中已有加班费(${overlapNames.map(e => e.name).join('、')}),确认是否再次获取?`, + }, + }) + } + } + + const { calcBatchEntry } = await import('../services/payroll.service') const results: any[] = [] for (const ot of overtimeRecords) { // 获取员工月工资 @@ -745,26 +770,61 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId }, }) - // 更新批次条目的加班费 + // 更新批次条目的加班费并重算 const entry = await prisma.batchEntry.findUnique({ where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } }, }) if (entry) { + const inputs = { + baseSalary: entry.baseSalary, + performanceSalary: entry.performanceSalary || undefined, + overtimePay: totalPay, + allowance: entry.allowance, + deduction: entry.deduction, + bonus: entry.bonus, + positionSalary: entry.positionSalary || 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 calcResult = await calcBatchEntry(orgId, ot.employeeId, batch.month, inputs, batch.type) + const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult await prisma.batchEntry.update({ where: { id: entry.id }, - data: { overtimePay: totalPay }, - }) - // 重新计算条目 - const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction - await prisma.batchEntry.update({ - where: { id: entry.id }, - data: { totalPay: newTotalPay }, + data: entryData, }) } results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay }) } + // 重算批次汇总 + 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: { + 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: { imported: results.length, details: results } }) } catch (err) { next(err) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 934aaf2..9cb42c3 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -118,7 +118,26 @@ async function recalcBatchTotals(batchId: string) { }) } -// RFC 5987 编码中文文件名 +/** + * 检查同月其他批次中哪些员工已有非零值 + * 返回有重复的员工ID集合 + */ +async function checkOtherBatches(orgId: string, currentBatchId: string, month: string, field: string): Promise> { + const otherEntries = await prisma.batchEntry.findMany({ + where: { + orgId, + batch: { month, id: { not: currentBatchId } }, + }, + select: { employeeId: true, [field]: true }, + }) + const duplicateIds = new Set() + for (const e of otherEntries) { + if ((e as any)[field] > 0) duplicateIds.add(e.employeeId) + } + return duplicateIds +} + + function contentDisposition(filename: string): string { const encoded = encodeURIComponent(filename) return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}` @@ -430,12 +449,17 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti employees = terminations.map(t => t.employee) } } else { + // 预入职判定:用当前日期而非批次月末,避免预入职人员被拉入 + const today = new Date() + today.setHours(0, 0, 0, 0) + const todayEnd = new Date(today) + todayEnd.setDate(todayEnd.getDate() + 1) employees = await prisma.employee.findMany({ where: { orgId, OR: [ - // 在职且已入职(hireDate <= 批次月末,排除预入职) - { status: 'ACTIVE', hireDate: { lte: monthEnd } }, + // 在职且已入职(hireDate < 明天,排除预入职) + { status: 'ACTIVE', hireDate: { lt: todayEnd } }, // 本月离职的员工(离职当月仍需结算) { status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } }, ], @@ -760,6 +784,7 @@ router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Respo try { const { batchId } = req.params const orgId = req.user!.orgId + const force = (req.body as any)?.force === true const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) @@ -778,6 +803,25 @@ router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Respo // 按批次月份查询提成奖金 const bonusMap = await getBonusByMonthAndEmployeeIds(orgId, batch.month, entries.map((e) => e.employeeId)) + // 检查跨批次重复(非 force 模式) + const toFillEmployeeIds = new Set(entries.filter(e => bonusMap.has(e.employeeId)).map(e => e.employeeId)) + if (!force && toFillEmployeeIds.size > 0) { + const dupIds = await checkOtherBatches(orgId, batchId, batch.month, 'bonus') + const overlap = [...toFillEmployeeIds].filter(id => dupIds.has(id)) + if (overlap.length > 0) { + const overlapNames = await prisma.employee.findMany({ where: { id: { in: overlap } }, select: { name: true } }) + return res.json({ + success: true, + data: { + needConfirm: true, + duplicateCount: overlap.length, + duplicateNames: overlapNames.map(e => e.name), + message: `${overlap.length} 人在其他批次中已有提成奖金(${overlapNames.map(e => e.name).join('、')}),确认是否再次获取?`, + }, + }) + } + } + let filled = 0 let totalAmount = 0 for (const entry of entries) { @@ -815,6 +859,7 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: try { const { batchId } = req.params const orgId = req.user!.orgId + const force = (req.body as any)?.force === true const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) @@ -855,6 +900,25 @@ router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: } } + // 检查跨批次重复(非 force 模式) + const toFillEmployeeIds = new Set(entries.filter(e => (empPerfSalaryMap.get(e.employeeId) || 0) > 0).map(e => e.employeeId)) + if (!force && toFillEmployeeIds.size > 0) { + const dupIds = await checkOtherBatches(orgId, batchId, batch.month, 'performanceSalary') + const overlap = [...toFillEmployeeIds].filter(id => dupIds.has(id)) + if (overlap.length > 0) { + const overlapNames = await prisma.employee.findMany({ where: { id: { in: overlap } }, select: { name: true } }) + return res.json({ + success: true, + data: { + needConfirm: true, + duplicateCount: overlap.length, + duplicateNames: overlapNames.map(e => e.name), + message: `${overlap.length} 人在其他批次中已有绩效工资(${overlapNames.map(e => e.name).join('、')}),确认是否再次获取?`, + }, + }) + } + } + // 绩效系数映射 const gradeCoefficients: Record = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 } @@ -904,6 +968,7 @@ router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res try { const { batchId } = req.params const orgId = req.user!.orgId + const force = (req.body as any)?.force === true const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) @@ -941,6 +1006,25 @@ router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res deductionMap.set(r.employeeId, (deductionMap.get(r.employeeId) || 0) + r.deductionAmount) } + // 检查跨批次重复(非 force 模式) + const toFillEmployeeIds = new Set([...deductionMap.keys()]) + if (!force && toFillEmployeeIds.size > 0) { + const dupIds = await checkOtherBatches(orgId, batchId, batch.month, 'deduction') + const overlap = [...toFillEmployeeIds].filter(id => dupIds.has(id)) + if (overlap.length > 0) { + const overlapNames = await prisma.employee.findMany({ where: { id: { in: overlap } }, select: { name: true } }) + return res.json({ + success: true, + data: { + needConfirm: true, + duplicateCount: overlap.length, + duplicateNames: overlapNames.map(e => e.name), + message: `${overlap.length} 人在其他批次中已有扣款(${overlapNames.map(e => e.name).join('、')}),确认是否再次获取?`, + }, + }) + } + } + let filled = 0 let totalAmount = 0 for (const entry of entries) { @@ -979,6 +1063,7 @@ router.post('/batches/:batchId/fetch-attendance-deduction', async (req: AuthRequ try { const { batchId } = req.params const orgId = req.user!.orgId + const force = (req.body as any)?.force === true const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) @@ -1010,6 +1095,25 @@ router.post('/batches/:batchId/fetch-attendance-deduction', async (req: AuthRequ deductionMap.set(c.employeeId, c.deductionAmount) } + // 检查跨批次重复(非 force 模式) + const toFillEmployeeIds = new Set([...deductionMap.keys()]) + if (!force && toFillEmployeeIds.size > 0) { + const dupIds = await checkOtherBatches(orgId, batchId, batch.month, 'deduction') + const overlap = [...toFillEmployeeIds].filter(id => dupIds.has(id)) + if (overlap.length > 0) { + const overlapNames = await prisma.employee.findMany({ where: { id: { in: overlap } }, select: { name: true } }) + return res.json({ + success: true, + data: { + needConfirm: true, + duplicateCount: overlap.length, + duplicateNames: overlapNames.map(e => e.name), + message: `${overlap.length} 人在其他批次中已有扣款(${overlapNames.map(e => e.name).join('、')}),确认是否再次获取?`, + }, + }) + } + } + let filled = 0 let totalAmount = 0 for (const entry of entries) { diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 46e3e16..6b2e4ef 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -488,20 +488,20 @@ export const payrollApi = { syncOvertimeFromAttendance: (month: string) => post('/payroll/overtime/sync-from-attendance', { month }).then(unwrap()), /** 导入加班费到批次 */ - importOvertimeToBatch: (batchId: string) => - post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap()), + importOvertimeToBatch: (batchId: string, force?: boolean) => + post(`/payroll/overtime/import-to-batch/${batchId}`, force ? { force: true } : {}).then(unwrap()), /** 获取提成奖金到批次 */ - fetchBonusToBatch: (batchId: string) => - post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap()), + fetchBonusToBatch: (batchId: string, force?: boolean) => + post(`/payroll2/batches/${batchId}/fetch-bonus`, force ? { force: true } : {}).then(unwrap()), /** 获取绩效工资到批次(按考核系数计算) */ - fetchPerformanceToBatch: (batchId: string) => - post(`/payroll2/batches/${batchId}/fetch-performance`).then(unwrap()), + fetchPerformanceToBatch: (batchId: string, force?: boolean) => + post(`/payroll2/batches/${batchId}/fetch-performance`, force ? { force: true } : {}).then(unwrap()), /** 获取违纪扣款到批次 */ - fetchDisciplinaryToBatch: (batchId: string) => - post(`/payroll2/batches/${batchId}/fetch-disciplinary`).then(unwrap()), + fetchDisciplinaryToBatch: (batchId: string, force?: boolean) => + post(`/payroll2/batches/${batchId}/fetch-disciplinary`, force ? { force: true } : {}).then(unwrap()), /** 获取考勤扣款到批次 */ - fetchAttendanceDeductionToBatch: (batchId: string) => - post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`).then(unwrap()), + fetchAttendanceDeductionToBatch: (batchId: string, force?: boolean) => + post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`, force ? { force: true } : {}).then(unwrap()), /** 加班费配置 */ overtimeConfig: () => get('/payroll/overtime/config').then(unwrap()), diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index c142a45..3f6a1ea 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -562,25 +562,37 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) const importOvertimeMutation = useMutation({ - mutationFn: () => payrollApi.importOvertimeToBatch(batchId), - onSuccess: (res: any) => { + mutationFn: (force?: boolean) => payrollApi.importOvertimeToBatch(batchId, force), + onSuccess: async (res: any) => { + if (res.data?.needConfirm) { + if (await confirm({ title: '跨批次重复提醒', message: res.data.message, variant: 'primary' })) { + importOvertimeMutation.mutate(true) + } + return + } queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['overtime-records'] }) if (res.data?.imported > 0) { - toast.success(`成功导入 ${res.data.imported} 条加班费记录`) + toast.success(`成功获取 ${res.data.imported} 条加班费记录`) } else { - toast.info(res.data?.message || '没有可导入的加班记录') + toast.info(res.data?.message || '没有可获取的加班记录') } }, onError: () => { - toast.error('导入失败,请重试') + toast.error('获取失败,请重试') }, }) const fetchBonusMutation = useMutation({ - mutationFn: () => payrollApi.fetchBonusToBatch(batchId), - onSuccess: (res: any) => { + mutationFn: (force?: boolean) => payrollApi.fetchBonusToBatch(batchId, force), + onSuccess: async (res: any) => { + if (res.data?.needConfirm) { + if (await confirm({ title: '跨批次重复提醒', message: res.data.message, variant: 'primary' })) { + fetchBonusMutation.mutate(true) + } + return + } queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) if (res.data?.filled > 0) { @@ -595,8 +607,14 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) const fetchPerformanceMutation = useMutation({ - mutationFn: () => payrollApi.fetchPerformanceToBatch(batchId), - onSuccess: (res: any) => { + mutationFn: (force?: boolean) => payrollApi.fetchPerformanceToBatch(batchId, force), + onSuccess: async (res: any) => { + if (res.data?.needConfirm) { + if (await confirm({ title: '跨批次重复提醒', message: res.data.message, variant: 'primary' })) { + fetchPerformanceMutation.mutate(true) + } + return + } queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) if (res.data?.filled > 0) { @@ -611,8 +629,14 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) const fetchDisciplinaryMutation = useMutation({ - mutationFn: () => payrollApi.fetchDisciplinaryToBatch(batchId), - onSuccess: (res: any) => { + mutationFn: (force?: boolean) => payrollApi.fetchDisciplinaryToBatch(batchId, force), + onSuccess: async (res: any) => { + if (res.data?.needConfirm) { + if (await confirm({ title: '跨批次重复提醒', message: res.data.message, variant: 'primary' })) { + fetchDisciplinaryMutation.mutate(true) + } + return + } queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) if (res.data?.filled > 0) { @@ -627,8 +651,14 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) const fetchAttendanceDeductionMutation = useMutation({ - mutationFn: () => payrollApi.fetchAttendanceDeductionToBatch(batchId), - onSuccess: (res: any) => { + mutationFn: (force?: boolean) => payrollApi.fetchAttendanceDeductionToBatch(batchId, force), + onSuccess: async (res: any) => { + if (res.data?.needConfirm) { + if (await confirm({ title: '跨批次重复提醒', message: res.data.message, variant: 'primary' })) { + fetchAttendanceDeductionMutation.mutate(true) + } + return + } queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) queryClient.invalidateQueries({ queryKey: ['batches'] }) if (res.data?.filled > 0) { @@ -854,16 +884,16 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void