From 9ac07eba0f9f26f446929edf3064f978eff0ade7 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Tue, 18 Aug 2026 14:15:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=96=AA=E8=B5=84=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E9=A2=84=E5=85=A5=E8=81=8C=E5=91=98=E5=B7=A5?= =?UTF-8?q?=EF=BC=88hireDate=20>=20=E6=89=B9=E6=AC=A1=E6=9C=88=E6=9C=AB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原查询只过滤status=ACTIVE,预入职员工status也是ACTIVE会被错误纳入。 增加hireDate <= monthEnd条件,确保只拉入已入职员工。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/src/routes/import.routes.ts | 9 +- backend/src/routes/payroll2.routes.ts | 6 +- backend/src/routes/social.routes.ts | 254 ++++++++++++++++-- backend/src/services/contract.service.ts | 63 +---- frontend/src/lib/api-services.ts | 6 + frontend/src/pages/Roster.tsx | 72 +++-- frontend/src/pages/SocialInsurance.tsx | 67 ++++- frontend/src/pages/money/BatchTab.tsx | 24 ++ frontend/src/pages/roster/BasicInfo.tsx | 8 +- frontend/src/pages/roster/modals.tsx | 77 +++++- .../pages/social-insurance/MonthlyRows.tsx | 8 +- 11 files changed, 474 insertions(+), 120 deletions(-) diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 5580217..8d0196e 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -341,14 +341,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async }, }) - // 仅在未 opt-out 时创建社保记录 - if (!socialInsOptOut) { - await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: socialInsBase, changeType: 'ONBOARDING', createdBy: userId } }) - } - // 仅在未 opt-out 时创建公积金记录 - if (!housingFundOptOut) { - await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: housingFundBase, changeType: 'ONBOARDING', createdBy: userId } }) - } + // 社保公积金记录不在导入时创建,由 HR 在社保模块办理增员后创建 await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } }) await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } }) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index d4a12bf..34ba9a5 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -222,7 +222,7 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun include: { entries: { include: { - employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true, idCardNumber: true } }, + employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true, idCardNumber: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } }, }, orderBy: { employee: { name: 'asc' } }, }, @@ -352,7 +352,9 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti where: { orgId, OR: [ - { status: 'ACTIVE' }, + // 在职且已入职(hireDate <= 批次月末,排除预入职) + { status: 'ACTIVE', hireDate: { lte: monthEnd } }, + // 本月离职的员工(离职当月仍需结算) { status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } }, ], }, diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index a3b7926..2b41194 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -1285,12 +1285,24 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) const orgId = req.user!.orgId - // 增员:startMonth == month(排除劳务/实习协议员工) - const additions = await prisma.employeeSocialInsRecord.findMany({ - where: { orgId, startMonth: month, employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } } }, - include: { employee: { select: { name: true, department: true, idCardNumber: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } }, - orderBy: { createdAt: 'asc' }, + // 增员:入职月 == month + 有社保基数 + 无社保记录 + 非劳务/实习协议 + // 查询该月入职的在职员工 + const monthStart = new Date(`${month}-01T00:00:00.000Z`) + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1) + const hiredEmployees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + hireDate: { gte: monthStart, lt: monthEnd }, + contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } }, + }, + include: { + contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } }, + socialInsRecords: { where: { endMonth: null }, take: 1 }, + }, }) + // 过滤掉已有社保记录的(已办理增员) + const pendingAdditions = hiredEmployees.filter(e => e.socialInsRecords.length === 0) // 减员:endMonth == month 且 changeType 为 TERMINATION 或 CITY_CHANGE const reductions = await prisma.employeeSocialInsRecord.findMany({ @@ -1308,6 +1320,32 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex return configCache.get(city) } + // 增员:从员工信息构造 + const mapAddition = async (e: any) => { + const city = e.city || '北京' + const config = await getConfigForCity(city) + const base = e.socialInsBase || 0 + const detail = config ? calcSocialDetail(base, config) : null + return { + recordId: null, + employeeId: e.id, + name: e.name, + idCardNumber: decryptIdCard(e.idCardNumber), + department: e.department, + city, + base, + startMonth: month, + endMonth: null, + changeType: 'PENDING', + detail: detail ? { + items: detail.items, + totalOrg: detail.totalOrg, + totalEmp: detail.totalEmp, + total: detail.totalOrg + detail.totalEmp, + } : null, + } + } + const mapRecord = async (r: any) => { const config = await getConfigForCity(r.city) const detail = config ? calcSocialDetail(r.base, config) : null @@ -1332,20 +1370,26 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex } // 按城市分组 - const allRecords = [...additions, ...reductions] - const cities = [...new Set(allRecords.map((r) => r.city))] + const allCities = [...new Set([ + ...pendingAdditions.map((e) => e.city || '北京'), + ...reductions.map((r) => r.city), + ])] const configs: Record = {} - for (const c of cities) { + for (const c of allCities) { const cfg = await getConfigForCity(c) if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax } } + const additionsResult = await Promise.all(pendingAdditions.map(mapAddition)) + console.log(`[monthly-changes] orgId=${orgId} month=${month} hiredCount=${hiredEmployees.length} pendingCount=${pendingAdditions.length} additions=${additionsResult.length}`) + + res.set('Cache-Control', 'no-cache, no-store, must-revalidate') res.json({ success: true, data: { month, configs, - additions: await Promise.all(additions.map(mapRecord)), + additions: additionsResult, reductions: await Promise.all(reductions.map(mapRecord)), }, }) @@ -1354,17 +1398,66 @@ router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: Nex } }) +// 调试:查看增员列表原始数据 +router.get('/debug-pending', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + const monthStart = new Date(`${month}-01T00:00:00.000Z`) + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1) + const hiredEmployees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + hireDate: { gte: monthStart, lt: monthEnd }, + contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } }, + }, + include: { + contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } }, + socialInsRecords: { where: { endMonth: null }, take: 1 }, + }, + }) + const pendingAdditions = hiredEmployees.filter(e => e.socialInsRecords.length === 0) + res.json({ + success: true, + data: { + orgId, + month, + monthStart: monthStart.toISOString(), + monthEnd: monthEnd.toISOString(), + hiredCount: hiredEmployees.length, + pendingCount: pendingAdditions.length, + pending: pendingAdditions.map(e => ({ name: e.name, hireDate: e.hireDate, city: e.city, socialInsBase: e.socialInsBase, contractType: e.contracts[0]?.contractType })), + }, + }) + } catch (err) { + next(err) + } +}) + // 公积金月度增减员 router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => { try { const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) const orgId = req.user!.orgId - const additions = await prisma.employeeHousingFundRecord.findMany({ - where: { orgId, startMonth: month, employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } } }, - include: { employee: { select: { name: true, department: true, idCardNumber: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } } } } }, - orderBy: { createdAt: 'asc' }, + // 增员:入职月 == month + 有公积金基数 + 无公积金记录 + 非劳务/实习协议 + const monthStart = new Date(`${month}-01T00:00:00.000Z`) + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1) + const hiredEmployees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + hireDate: { gte: monthStart, lt: monthEnd }, + contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } }, + }, + include: { + contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true } }, + housingFundRecords: { where: { endMonth: null }, take: 1 }, + }, }) + // 过滤掉已有公积金记录的(已办理增员) + const pendingAdditions = hiredEmployees.filter(e => e.housingFundRecords.length === 0) const reductions = await prisma.employeeHousingFundRecord.findMany({ where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] }, employee: { contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } } } }, @@ -1380,6 +1473,27 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n return configCache.get(city) } + // 增员:从员工信息构造 + const mapAddition = async (e: any) => { + const city = e.city || '北京' + const config = await getConfigForCity(city) + const base = e.housingFundBase || 0 + const detail = config ? calcHousingDetail(base, config) : null + return { + recordId: null, + employeeId: e.id, + name: e.name, + idCardNumber: decryptIdCard(e.idCardNumber), + department: e.department, + city, + base, + startMonth: month, + endMonth: null, + changeType: 'PENDING', + detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null, + } + } + const mapRecord = async (r: any) => { const config = await getConfigForCity(r.city) const detail = config ? calcHousingDetail(r.base, config) : null @@ -1398,10 +1512,12 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n } } - const allRecords = [...additions, ...reductions] - const cities = [...new Set(allRecords.map((r) => r.city))] + const allCities = [...new Set([ + ...pendingAdditions.map((e) => e.city || '北京'), + ...reductions.map((r) => r.city), + ])] const configs: Record = {} - for (const c of cities) { + for (const c of allCities) { const cfg = await getConfigForCity(c) if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp } } @@ -1411,7 +1527,7 @@ router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, n data: { month, configs, - additions: await Promise.all(additions.map(mapRecord)), + additions: await Promise.all(pendingAdditions.map(mapAddition)), reductions: await Promise.all(reductions.map(mapRecord)), }, }) @@ -1486,6 +1602,110 @@ router.get('/active-declaration', async (req: AuthRequest, res: Response, next: } }) +// ========== 办理增员(批量创建社保/公积金记录) ========== + +// 办理社保增员 +router.post('/enroll-social', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const userId = req.user!.id + const { employeeIds, startMonth } = req.body as { employeeIds: string[]; startMonth: string } + + if (!employeeIds || !employeeIds.length || !startMonth) { + return res.status(400).json({ success: false, message: '缺少必要参数' }) + } + + let enrolled = 0 + let skipped = 0 + for (const employeeId of employeeIds) { + // 检查是否已有在保社保记录 + const existing = await prisma.employeeSocialInsRecord.findFirst({ + where: { employeeId, endMonth: null }, + }) + if (existing) { skipped++; continue } + + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + select: { socialInsBase: true, city: true }, + }) + if (!emp) { skipped++; continue } + + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId, + startMonth, + endMonth: null, + base: emp.socialInsBase || 0, + changeType: 'ONBOARDING', + createdBy: userId, + city: emp.city || '北京', + }, + }) + // 同步员工便捷字段 + await prisma.employee.update({ + where: { id: employeeId }, + data: { socialInsStartMonth: startMonth, socialInsEndMonth: null }, + }) + enrolled++ + } + + res.json({ success: true, data: { enrolled, skipped } }) + } catch (err) { + next(err) + } +}) + +// 办理公积金增员 +router.post('/enroll-housing', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const userId = req.user!.id + const { employeeIds, startMonth } = req.body as { employeeIds: string[]; startMonth: string } + + if (!employeeIds || !employeeIds.length || !startMonth) { + return res.status(400).json({ success: false, message: '缺少必要参数' }) + } + + let enrolled = 0 + let skipped = 0 + for (const employeeId of employeeIds) { + const existing = await prisma.employeeHousingFundRecord.findFirst({ + where: { employeeId, endMonth: null }, + }) + if (existing) { skipped++; continue } + + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId }, + select: { housingFundBase: true, city: true }, + }) + if (!emp) { skipped++; continue } + + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId, + startMonth, + endMonth: null, + base: emp.housingFundBase || 0, + changeType: 'ONBOARDING', + createdBy: userId, + city: emp.city || '北京', + }, + }) + await prisma.employee.update({ + where: { id: employeeId }, + data: { housingFundStartMonth: startMonth, housingFundEndMonth: null }, + }) + enrolled++ + } + + res.json({ success: true, data: { enrolled, skipped } }) + } catch (err) { + next(err) + } +}) + // 公积金在保人员 router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 72f4382..73069f1 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -436,36 +436,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) { }, }) - // 劳务/实习协议不创建社保公积金记录 - if (!isNoSocialContract) { - await tx.employeeSocialInsRecord.create({ - data: { - orgId, - employeeId: emp.id, - startMonth: socialInsStartMonth, - endMonth: null, - base: socialInsBase, - changeType: 'ONBOARDING', - createdBy: userId, - city: data.city || '北京', - accountId: data.socialAccountId || null, - }, - }) - - await tx.employeeHousingFundRecord.create({ - data: { - orgId, - employeeId: emp.id, - startMonth: housingFundStartMonth, - endMonth: null, - base: housingFundBase, - changeType: 'ONBOARDING', - createdBy: userId, - city: data.city || '北京', - accountId: data.housingAccountId || null, - }, - }) - } + // 社保公积金记录不在录入时创建,由 HR 在社保模块办理增员后创建 + // 录入时仅保存社保基数到 Employee 表,工资计算直接用 employee.socialInsBase await tx.salaryChangeRecord.create({ data: { @@ -620,36 +592,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, }, }) - // 劳务/实习协议不创建社保公积金记录 - if (!isNoSocialContract) { - // 创建新社保缴费记录 - await prisma.employeeSocialInsRecord.create({ - data: { - orgId, - employeeId: id, - startMonth: socialInsStartMonth, - endMonth: null, - base: socialInsBase, - changeType: 'REHIRE', - createdBy: userId, - city: data.city || employee.city || '北京', - }, - }) - - // 创建新公积金缴费记录 - await prisma.employeeHousingFundRecord.create({ - data: { - orgId, - employeeId: id, - startMonth: housingFundStartMonth, - endMonth: null, - base: housingFundBase, - changeType: 'REHIRE', - createdBy: userId, - city: data.city || employee.city || '北京', - }, - }) - } + // 社保公积金记录不在重新入职时创建,由 HR 在社保模块办理增员后创建 // 创建新薪资记录 await prisma.salaryChangeRecord.create({ diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index cd669dc..46e3e16 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -677,6 +677,12 @@ export const socialInsuranceApi = { /** 员工参保信息列表 */ employeeEnrollment: (keyword?: string) => get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap()), + /** 办理社保增员(批量创建社保记录) */ + enrollSocial: (employeeIds: string[], startMonth: string) => + post('/social/enroll-social', { employeeIds, startMonth }).then(unwrap()), + /** 办理公积金增员(批量创建公积金记录) */ + enrollHousing: (employeeIds: string[], startMonth: string) => + post('/social/enroll-housing', { employeeIds, startMonth }).then(unwrap()), } // ========== 商业保险 ========== diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 7679667..7527fb8 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -6,7 +6,7 @@ import { toastError } from '../lib/errorToast' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useConfirm } from '../hooks/useConfirm' import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react' -import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services' +import { rosterApi, employeeApi, terminationApi, workProcessApi, esignApi } from '../lib/api-services' import api from '../lib/api' import { copyToClipboard } from '../lib/clipboard' import { useAuthStore } from '../store/authStore' @@ -170,15 +170,38 @@ export default function Roster() { const addMutation = useMutation({ mutationFn: async (data: any) => { const res = await employeeApi.create(data) - return res + return { res, data } }, - onSuccess: () => { + onSuccess: async ({ res, data }) => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) localStorage.removeItem('add-employee-draft') setShowAddModal(false) - toast.success('员工已添加,请前往员工档案签订合同') + // 电子签:自动创建电子签署记录 + if (data.contract?.signMethod === 'ELECTRONIC' && res?.id) { + try { + // 查询员工档案获取刚创建的合同 + const profile = await rosterApi.profile(res.id) as any + const contract = profile?.contracts?.[0] + if (contract?.id) { + await esignApi.create({ + contractId: contract.id, + employeeId: res.id, + documentTitle: `${data.name || ''}的劳动合同`, + remark: '新增员工时自动发起', + scene: 'CONTRACT', + }) + toast.success('员工已添加,电子签署记录已创建') + } else { + toast.success('员工已添加,请前往员工档案发起电子签署') + } + } catch { + toast.success('员工已添加,电子签署记录创建失败(可稍后手动发起)') + } + } else { + toast.success('员工已添加,请前往员工档案签订合同') + } }, onError: (err: any) => toastError(err, '创建失败'), }) @@ -216,23 +239,38 @@ export default function Roster() { }) const rehireMutation = useMutation({ - mutationFn: (data: any) => employeeApi.rehire(rehireEmployee?.id, data), - onSuccess: () => { + mutationFn: async (data: any) => { + const res = await employeeApi.rehire(rehireEmployee?.id, data) + return { res, data } + }, + onSuccess: async ({ res, data }) => { queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowRehireModal(false) - // 弹出签署方式选择 - setSignChoice({ - open: true, - employeeId: rehireEmployee?.id || '', - employeeName: rehireEmployee?.name || '', - employeeIdCardNumber: rehireEmployee?.idCardNumber, - scene: 'CONTRACT', - documentTitle: `${rehireEmployee?.name || ''}的劳动合同`, - remark: '重新入职时发起', - actionName: '重新入职', - }) + // 电子签:自动创建电子签署记录 + if (data.contract?.signMethod === 'ELECTRONIC' && rehireEmployee?.id) { + try { + const profile = await rosterApi.profile(rehireEmployee.id) as any + const contract = profile?.contracts?.[0] + if (contract?.id) { + await esignApi.create({ + contractId: contract.id, + employeeId: rehireEmployee.id, + documentTitle: `${rehireEmployee?.name || ''}的劳动合同`, + remark: '重新入职时自动发起', + scene: 'CONTRACT', + }) + toast.success('重新入职成功,电子签署记录已创建') + } else { + toast.success('重新入职成功,请前往员工档案发起电子签署') + } + } catch { + toast.success('重新入职成功,电子签署记录创建失败(可稍后手动发起)') + } + } else { + toast.success('重新入职成功,请前往员工档案签订合同') + } setRehireEmployee(null) }, }) diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 2528129..7285efa 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -117,6 +117,28 @@ export default function SocialInsurance() { } } + // 办理社保增员 + const enrollSocialMutation = useMutation({ + mutationFn: ({ employeeIds, startMonth }: { employeeIds: string[]; startMonth: string }) => + socialInsuranceApi.enrollSocial(employeeIds, startMonth), + onSuccess: () => { + toast.success('社保增员已办理') + handleMonthlyProcess() + }, + onError: () => toast.error('办理失败'), + }) + + // 办理公积金增员 + const enrollHousingMutation = useMutation({ + mutationFn: ({ employeeIds, startMonth }: { employeeIds: string[]; startMonth: string }) => + socialInsuranceApi.enrollHousing(employeeIds, startMonth), + onSuccess: () => { + toast.success('公积金增员已办理') + handleMonthlyProcess() + }, + onError: () => toast.error('办理失败'), + }) + const completeProcessMutation = useMutation({ mutationFn: async (type: 'SOCIAL' | 'HOUSING') => { const snapshot = type === 'SOCIAL' ? monthlyChanges.social : monthlyChanges.housing @@ -346,7 +368,7 @@ export default function SocialInsurance() { )} - 展示当月社保/公积金新增(入职/重新入职)、减少(离职/解聘)及正常在保人员列表,用于经办机构申报。 + 展示当月社保/公积金待办理增员(入职/重新入职但尚未参保)、减员(离职/解聘)及正常在保人员列表。点击「办理增员」确认参保后,员工状态从"待办理"变为"在保"。 {(() => { if (!monthlyProcessed) { @@ -482,6 +504,19 @@ export default function SocialInsurance() { icon={} summary={`${sAddCity.length + sSubCity.length + sNormalCity.length} 人 | 企业 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalOrg || 0), 0))} + 个人 ¥${fmt([...sAddCity, ...sNormalCity].reduce((s: number, i: any) => s + (i.detail?.totalEmp || 0), 0))} = ¥${fmt(sTotal)}`} defaultOpen={true} + action={sAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? ( + + ) : undefined} > {sTable} @@ -490,6 +525,19 @@ export default function SocialInsurance() { icon={} summary={`${hAddCity.length + hSubCity.length + hNormalCity.length} 人 | 企业 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.orgAmount || 0), 0))} + 个人 ¥${fmt([...hAddCity, ...hNormalCity].reduce((s: number, i: any) => s + (i.detail?.empAmount || 0), 0))} = ¥${fmt(hTotal)}`} defaultOpen={true} + action={hAddCity.filter((i: any) => i.changeType === 'PENDING').length > 0 ? ( + + ) : undefined} > {hTable} @@ -518,21 +566,22 @@ export default function SocialInsurance() { ) } -function CollapsibleSection({ title, icon, summary, defaultOpen = false, children }: { title: string; icon: React.ReactNode; summary?: string; defaultOpen?: boolean; children: React.ReactNode }) { +function CollapsibleSection({ title, icon, summary, defaultOpen = false, action, children }: { title: string; icon: React.ReactNode; summary?: string; defaultOpen?: boolean; action?: React.ReactNode; children: React.ReactNode }) { const [open, setOpen] = useState(defaultOpen) return (
-
- + + {action} + {open &&
{children}
} ) diff --git a/frontend/src/pages/money/BatchTab.tsx b/frontend/src/pages/money/BatchTab.tsx index 98f34a4..6227941 100644 --- a/frontend/src/pages/money/BatchTab.tsx +++ b/frontend/src/pages/money/BatchTab.tsx @@ -1174,6 +1174,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void 员工 + 合同类型 基本工资 加班费 津贴 @@ -1203,6 +1204,29 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void )} + + {(() => { + const ct = entry.employee.contracts?.[0]?.contractType + const cfg: Record = { + FIXED: { label: '固定期限', style: 'bg-blue-50 text-blue-700' }, + UNFIXED: { label: '无固定期', style: 'bg-purple-50 text-purple-700' }, + LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700' }, + INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700' }, + PARTTIME: { label: '兼职协议', style: 'bg-cyan-50 text-cyan-700' }, + OUTSOURCING: { label: '业务外包', style: 'bg-slate-50 text-slate-700' }, + DISPATCH: { label: '劳务派遣', style: 'bg-cyan-50 text-cyan-700' }, + UNSIGNED: { label: '未签合同', style: 'bg-red-50 text-danger' }, + } + const c = cfg[ct || ''] || { label: '未签合同', style: 'bg-red-50 text-danger' } + const noSocial = ct && ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(ct) + return ( +
+ {c.label} + {noSocial && 不缴社保} +
+ ) + })()} + {renderCell(entry, 'baseSalary')} {renderCell(entry, 'overtimePay')} {renderCell(entry, 'allowance')} diff --git a/frontend/src/pages/roster/BasicInfo.tsx b/frontend/src/pages/roster/BasicInfo.tsx index 7bba9f0..fa6d6ef 100644 --- a/frontend/src/pages/roster/BasicInfo.tsx +++ b/frontend/src/pages/roster/BasicInfo.tsx @@ -89,6 +89,12 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil } buildDeptOptions(departments, null, 0) + // 拉取岗位字典列表,用于职务/岗位下拉选择 + const { data: positions = [] } = useQuery({ + queryKey: ['positions'], + queryFn: () => api.get('/positions').then(r => r.data), + }) + const [form, setForm] = useState({ department: profile.department || '', position: profile.position || '', @@ -338,7 +344,7 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil )}
setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
-
setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
+
setForm({ ...form, hireDate: e.target.value })} />
diff --git a/frontend/src/pages/roster/modals.tsx b/frontend/src/pages/roster/modals.tsx index d723ed2..665ff72 100644 --- a/frontend/src/pages/roster/modals.tsx +++ b/frontend/src/pages/roster/modals.tsx @@ -472,6 +472,20 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { }) } buildDeptOptions(departments, null, 0) + // 拉取岗位字典列表,用于职务/岗位下拉选择 + const { data: positions = [] } = useQuery({ + queryKey: ['positions'], + queryFn: () => api.get('/positions').then(r => r.data), + }) + // 根据选中部门过滤岗位:通用岗位(departmentId 为空)+ 该部门及父部门专属岗位 + const getDeptAncestorIds = (deptLabel: string): string[] => { + const dept = departments.find((d: any) => d.name === deptLabel) + if (!dept) return [] + const ids: string[] = [] + let cur: any = dept + while (cur) { ids.push(cur.id); cur = departments.find((d: any) => d.id === cur.parentId) } + return ids + } const defaultEndDate = (() => { const d = new Date() d.setFullYear(d.getFullYear() + 3) @@ -481,7 +495,9 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { const [form, setForm] = useState({ hireDate: todayStr, department: employee.department || '', + position: employee.position || '', contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED', + signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', signDate: '', startDate: todayStr, endDate: defaultEndDate, @@ -494,6 +510,10 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { socialInsBase: '', socialInsStartMonth: '', housingFundBase: '', housingFundStartMonth: '', }) + // 根据选中部门过滤岗位 + const filteredPositions = form.department + ? positions.filter((p: any) => !p.departmentId || getDeptAncestorIds(form.department).includes(p.departmentId)) + : positions const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : '' // 计算合同月数 @@ -579,6 +599,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { const data: any = { hireDate: new Date(form.hireDate).toISOString(), department: form.department, + position: form.position || undefined, baseSalary: base, performanceSalary: perf, monthlySalary: base + perf, @@ -593,6 +614,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { startDate: new Date(form.startDate).toISOString(), endDate: form.endDate ? new Date(form.endDate).toISOString() : null, contractType: form.contractType, + signMethod: form.signMethod, contractYears: form.contractYears, probationMonths: form.probationMonths, probationSalary: form.probationSalary, @@ -620,7 +642,7 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
- setForm({ ...form, department: e.target.value, position: '' })}> {deptOptions.map(d => ( @@ -628,6 +650,15 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: {
+
+ + +
handleHireDateChange(e.target.value)} /> @@ -687,6 +718,18 @@ export function RehireModal({ employee, onClose, onSubmit, loading, error }: { {contractTypes.map(t => )}
+ {form.contractType !== 'UNSIGNED' && ( +
+ + + {form.signMethod === 'ELECTRONIC' && ( +
保存后将自动创建电子签署记录
+ )} +
+ )} {form.contractType !== 'UNSIGNED' && ( @@ -776,6 +819,18 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { }) } buildDeptOptions(departments, null, 0) + // 拉取岗位字典列表,用于职务/岗位下拉选择 + const { data: positions = [] } = useQuery({ + queryKey: ['positions'], + queryFn: () => api.get('/positions').then(r => r.data), + }) + // 根据选中部门过滤岗位:通用岗位(departmentId 为空)+ 该部门及父部门专属岗位 + const getDeptAncestorIds = (deptId: string): string[] => { + const ids: string[] = [] + let cur: any = departments.find((d: any) => d.id === deptId) + while (cur) { ids.push(cur.id); cur = departments.find((d: any) => d.id === cur.parentId) } + return ids + } const defaultEndDate = (() => { const d = new Date() d.setFullYear(d.getFullYear() + 3) @@ -793,12 +848,17 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '', city: '北京', education: '', status: 'ACTIVE', contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED', + signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', signDate: '', startDate: todayStr, endDate: defaultEndDate, contractYears: 3, probationMonths: 0, probationSalary: 0, socialInsBase: '', socialInsStartMonth: '', housingFundBase: '', housingFundStartMonth: '', } }) + // 根据选中部门过滤岗位:通用岗位(departmentId 为空)+ 该部门及父部门专属岗位 + const filteredPositions = form.departmentId + ? positions.filter((p: any) => !p.departmentId || getDeptAncestorIds(form.departmentId).includes(p.departmentId)) + : positions // 持久化草稿到 localStorage,防止录入数据丢失 useEffect(() => { @@ -1058,6 +1118,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { endDate: form.endDate ? new Date(form.endDate).toISOString() : null, contractType: form.contractType, contractYears: form.contractYears, probationMonths: form.probationMonths, probationSalary: form.probationSalary, + signMethod: form.signMethod, } } onSubmit(data) @@ -1093,7 +1154,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: { const opt = deptOptions.find(d => d.id === e.target.value) setForm({ ...form, departmentId: e.target.value, department: opt?.label || '' }) }}>{deptOptions.map(d => )} -
setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
+
handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} />
{idCardDuplicate?.exists && (
@@ -1266,6 +1327,18 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
超龄人员不可签订劳动合同,仅可选劳务协议/实习协议/未签
)}
+ {form.contractType !== 'UNSIGNED' && ( +
+ + + {form.signMethod === 'ELECTRONIC' && ( +
保存后将自动创建电子签署记录
+ )} +
+ )} {form.contractType !== 'UNSIGNED' && ( diff --git a/frontend/src/pages/social-insurance/MonthlyRows.tsx b/frontend/src/pages/social-insurance/MonthlyRows.tsx index cbb8af4..454cd5f 100644 --- a/frontend/src/pages/social-insurance/MonthlyRows.tsx +++ b/frontend/src/pages/social-insurance/MonthlyRows.tsx @@ -11,8 +11,8 @@ export function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'a const [expanded, setExpanded] = useState(false) const [editing, setEditing] = useState(false) const [editBase, setEditBase] = useState(i.base?.toString() || '') - const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' - const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' + const typeLabel = type === 'add' ? (i.changeType === 'PENDING' ? '待办理' : i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' + const typeClass = type === 'add' ? (i.changeType === 'PENDING' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-safe') : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' const d = i.detail const correctMutation = useMutation({ @@ -104,8 +104,8 @@ export function MonthlyRow({ item: i, type, onCorrected }: { item: any; type: 'a export function MonthlyHousingRow({ item: i, type, onCorrected }: { item: any; type: 'add' | 'sub' | 'normal'; onCorrected?: () => void }) { const [editing, setEditing] = useState(false) const [editBase, setEditBase] = useState(i.base?.toString() || '') - const typeLabel = type === 'add' ? (i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' - const typeClass = type === 'add' ? 'bg-green-50 text-safe' : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' + const typeLabel = type === 'add' ? (i.changeType === 'PENDING' ? '待办理' : i.changeType === 'CITY_CHANGE' ? '新增(城市变更)' : '新增') : type === 'sub' ? (i.changeType === 'CITY_CHANGE' ? '减员(城市变更)' : '减员') : '正常' + const typeClass = type === 'add' ? (i.changeType === 'PENDING' ? 'bg-amber-50 text-amber-600' : 'bg-green-50 text-safe') : type === 'sub' ? 'bg-red-50 text-danger' : 'bg-gray-100 text-gray-500' const d = i.detail const correctMutation = useMutation({