From 5c12f28ac76360a717d1ec27bfa4ae3b71e259f5 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Fri, 24 Jul 2026 22:28:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E3=80=81=E6=80=A7=E8=83=BD=E5=92=8C=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: 环境变量校验/事务保护/RBAC权限 P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化 P2: 导出限制/自定义错误类/body大小限制/代码去重 P3: 自定义确认弹窗替换window.confirm --- backend/src/app.ts | 2 +- backend/src/index.ts | 3 + backend/src/lib/AppError.ts | 18 + backend/src/lib/config.ts | 22 ++ backend/src/middleware/errorHandler.ts | 26 ++ backend/src/middleware/rbac.ts | 16 + backend/src/routes/auth.routes.ts | 5 + backend/src/routes/employee.routes.ts | 30 +- backend/src/routes/export.routes.ts | 22 +- backend/src/routes/import.routes.ts | 7 +- backend/src/routes/roster.routes.ts | 41 ++- backend/src/routes/settings.routes.ts | 13 +- backend/src/schemas/auth.schema.ts | 1 + backend/src/services/contract.service.ts | 202 +++++------ backend/src/services/payroll.service.ts | 29 +- backend/src/services/termination.service.ts | 383 ++++++++++---------- frontend/src/components/ErrorBoundary.tsx | 59 +++ frontend/src/hooks/useConfirm.tsx | 66 ++++ frontend/src/main.tsx | 8 +- frontend/src/pages/Money.tsx | 25 +- frontend/src/pages/Roster.tsx | 9 +- frontend/src/pages/SocialInsurance.tsx | 6 +- frontend/src/pages/Termination.tsx | 7 + 23 files changed, 634 insertions(+), 366 deletions(-) create mode 100644 backend/src/lib/AppError.ts create mode 100644 backend/src/lib/config.ts create mode 100644 backend/src/middleware/rbac.ts create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/hooks/useConfirm.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index ed1ef2e..33ed0cd 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -24,7 +24,7 @@ app.use( credentials: true, }), ) -app.use(express.json()) +app.use(express.json({ limit: '10mb' })) app.use(morgan('dev')) app.get('/health', (_req, res) => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 1a07ab3..b136e70 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,7 @@ import app from './app' +import { validateEnv } from './lib/config' + +validateEnv() const PORT = Number(process.env.PORT) || 3000 diff --git a/backend/src/lib/AppError.ts b/backend/src/lib/AppError.ts new file mode 100644 index 0000000..9837874 --- /dev/null +++ b/backend/src/lib/AppError.ts @@ -0,0 +1,18 @@ +export class AppError extends Error { + constructor( + public code: string, + message: string, + public statusCode: number = 400, + ) { + super(message) + this.name = 'AppError' + } +} + +export function isAppError(err: unknown): err is AppError { + return err instanceof AppError +} + +export function throwAppError(code: string, message: string, statusCode: number = 400): never { + throw new AppError(code, message, statusCode) +} diff --git a/backend/src/lib/config.ts b/backend/src/lib/config.ts new file mode 100644 index 0000000..76f45a5 --- /dev/null +++ b/backend/src/lib/config.ts @@ -0,0 +1,22 @@ +const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY'] +const defaults: Record = { + JWT_SECRET: 'dev-secret', + JWT_REFRESH_SECRET: 'dev-refresh-secret', + ENCRYPTION_KEY: 'default-32-byte-encryption-key!!', +} + +export function validateEnv(): void { + const missing: string[] = [] + for (const key of required) { + if (!process.env[key] || process.env[key] === defaults[key]) { + missing.push(key) + } + } + if (missing.length > 0 && process.env.NODE_ENV === 'production') { + console.error(`[FATAL] 以下环境变量未设置或使用了默认值,生产环境禁止启动: ${missing.join(', ')}`) + process.exit(1) + } + if (missing.length > 0) { + console.warn(`[WARN] 以下环境变量使用了默认值,仅限开发环境: ${missing.join(', ')}`) + } +} diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts index 39ff101..a885723 100644 --- a/backend/src/middleware/errorHandler.ts +++ b/backend/src/middleware/errorHandler.ts @@ -1,8 +1,16 @@ import { Request, Response, NextFunction } from 'express' import { ZodError } from 'zod' import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library' +import { AppError } from '../lib/AppError' export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) { + if (err instanceof AppError) { + return res.status(err.statusCode).json({ + success: false, + error: { code: err.code, message: err.message }, + }) + } + if (err instanceof ZodError) { return res.status(422).json({ success: false, @@ -29,6 +37,24 @@ export function errorHandler(err: unknown, _req: Request, res: Response, _next: } } + if (typeof err === 'object' && err !== null && 'code' in err && 'message' in err) { + const e = err as { code: string; message: string } + const statusMap: Record = { + NOT_FOUND: 404, + CONFLICT: 409, + VALIDATION_ERROR: 422, + PLAN_LIMIT: 403, + AUTH_FAILED: 401, + TOKEN_INVALID: 401, + ACCOUNT_DISABLED: 403, + DUPLICATE: 400, + } + return res.status(statusMap[e.code] || 400).json({ + success: false, + error: { code: e.code, message: e.message }, + }) + } + console.error('Unhandled error:', err) return res.status(500).json({ success: false, diff --git a/backend/src/middleware/rbac.ts b/backend/src/middleware/rbac.ts new file mode 100644 index 0000000..44212de --- /dev/null +++ b/backend/src/middleware/rbac.ts @@ -0,0 +1,16 @@ +import { Response, NextFunction } from 'express' +import { AuthRequest } from './auth' + +export function requireRole(...roles: string[]) { + return (req: AuthRequest, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未认证' } }) + } + if (!roles.includes(req.user.role)) { + return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '无权限执行此操作' } }) + } + next() + } +} + +export const requireAdmin = requireRole('ADMIN') diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 2108418..dc5f2dc 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -81,6 +81,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => { router.post('/reset-password', authLimiter, async (req, res, next) => { try { const data = resetPasswordSchema.parse(req.body) + const stored = codeStore.get(data.phone) + if (!stored || stored.expiresAt < Date.now()) { + return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) + } + codeStore.delete(data.phone) const result = await resetPassword(data.phone, data.newPassword) res.json({ success: true, data: result }) } catch (err) { diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index b55414e..1587f3b 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -111,22 +111,30 @@ router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, } // 合规检查:按员工分组,检查历史固定期合同次数 + const employeeIds = contracts.map((c) => c.employeeId) + const allFixedContracts = await prisma.laborContract.findMany({ + where: { + employeeId: { in: employeeIds }, + orgId: req.user!.orgId, + contractType: 'FIXED', + }, + orderBy: { startDate: 'asc' }, + }) + const fixedContractsByEmp = new Map() + for (const c of allFixedContracts) { + if (!fixedContractsByEmp.has(c.employeeId)) { + fixedContractsByEmp.set(c.employeeId, []) + } + fixedContractsByEmp.get(c.employeeId)!.push(c) + } + const results = [] for (const contract of contracts) { const employee = contract.employee - - // 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数) - const allFixedContracts = await prisma.laborContract.findMany({ - where: { - employeeId: contract.employeeId, - orgId: req.user!.orgId, - contractType: 'FIXED', - }, - orderBy: { startDate: 'asc' }, - }) + const empFixedContracts = fixedContractsByEmp.get(contract.employeeId) || [] // 当前合同是第几次固定期(从1开始计数) - const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id) + const currentIndex = empFixedContracts.findIndex((c) => c.id === contract.id) const renewalCount = currentIndex + 1 // 判断是否应签无固定期限: diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts index e44a680..4d6365d 100644 --- a/backend/src/routes/export.routes.ts +++ b/backend/src/routes/export.routes.ts @@ -1,5 +1,6 @@ import { Router, Response } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' +import { requireAdmin } from '../middleware/rbac' import prisma from '../lib/prisma' import { decrypt } from '../lib/crypto' import ExcelJS from 'exceljs' @@ -21,22 +22,23 @@ function maskBankAccount(account: string | null): string | null { } // 导出全部数据(支持模块选择、格式选择、脱敏) -router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => { +router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: Response, next) => { try { const orgId = req.user!.orgId const format = (req.query.format as string) || 'json' const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN' const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',') - const fetchMap: Record Promise> = { - employees: () => prisma.employee.findMany({ where: { orgId } }), - contracts: () => prisma.laborContract.findMany({ where: { orgId } }), - terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }), - payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }), - payslips: () => prisma.payslip.findMany({ where: { orgId } }), - socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }), - housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }), - riskItems: () => prisma.riskItem.findMany({ where: { orgId } }), + const exportBatchSize = 500 + const fetchMap: Record Promise> = { + employees: () => prisma.employee.findMany({ where: { orgId }, take: exportBatchSize }), + contracts: () => prisma.laborContract.findMany({ where: { orgId }, take: exportBatchSize }), + terminations: () => prisma.terminationRecord.findMany({ where: { orgId }, take: exportBatchSize }), + payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId }, take: exportBatchSize }), + payslips: () => prisma.payslip.findMany({ where: { orgId }, take: exportBatchSize }), + socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId }, take: exportBatchSize }), + housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId }, take: exportBatchSize }), + riskItems: () => prisma.riskItem.findMany({ where: { orgId }, take: exportBatchSize }), } const useGzip = req.query.gzip !== 'false' diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 6c26e53..cd78648 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -2,6 +2,7 @@ import { Router, Response } from 'express' import multer from 'multer' import * as XLSX from 'xlsx' import { authMiddleware, AuthRequest } from '../middleware/auth' +import { requireAdmin } from '../middleware/rbac' import { encrypt, decrypt, sha256 } from '../lib/crypto' import prisma from '../lib/prisma' @@ -85,7 +86,7 @@ function num(v: any): number { // ========== 导入预览(不写入数据库) ========== -router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => { +router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => { try { if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } }) @@ -209,7 +210,7 @@ router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Re } }) -router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => { +router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => { try { if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } }) const orgId = req.user!.orgId @@ -420,7 +421,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response) // ========== 月度导入 ========== -router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => { +router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), async (req: AuthRequest, res: Response, next) => { try { if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } }) const orgId = req.user!.orgId diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 0b59d90..9182c6c 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -40,13 +40,31 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { ] } - const [total, employees] = await Promise.all([ + // 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤) + if (status === 'RESIGNED') { + whereBase.status = 'RESIGNED' + } else if (status === 'PRE_HIRE') { + whereBase.status = 'ACTIVE' + whereBase.hireDate = { gt: today } + } else if (status === 'ACTIVE') { + whereBase.status = 'ACTIVE' + whereBase.hireDate = { lte: today } + } + + // unsigned 合同状态可以在 DB 层过滤 + if (contractStatus === 'unsigned') { + whereBase.contracts = { none: {} } + } + + // 当有 contractStatus(非 unsigned)筛选时,需要先查全部再过滤后分页 + const needPostFilter = !!contractStatus && contractStatus !== 'unsigned' + + const [dbTotal, employees] = await Promise.all([ prisma.employee.count({ where: whereBase }), prisma.employee.findMany({ where: whereBase, orderBy: { createdAt: 'desc' }, - skip, - take: pageSize, + ...(needPostFilter ? {} : { skip, take: pageSize }), include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { terminationDate: 'desc' }, take: 1 }, @@ -107,22 +125,25 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { } }) - // 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where) - if (status) { - result = result.filter((e) => e.status === status) - } - if (contractStatus) { + // 前端过滤:合同状态(非 unsigned 的需要后处理计算) + if (contractStatus && contractStatus !== 'unsigned') { result = result.filter((e) => e.contractStatus === contractStatus) } + // 计算过滤后的总数和分页 + const filteredTotal = needPostFilter ? result.length : dbTotal + if (needPostFilter) { + result = result.slice(skip, skip + pageSize) + } + res.json({ success: true, data: result, pagination: { page, pageSize, - total, - totalPages: Math.ceil(total / pageSize), + total: filteredTotal, + totalPages: Math.ceil(filteredTotal / pageSize), }, }) } catch (err) { diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index ef263b1..1809139 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import bcrypt from 'bcryptjs' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' +import { requireAdmin } from '../middleware/rbac' import { z } from 'zod' const router = Router() @@ -35,7 +36,7 @@ router.get('/org', async (req: AuthRequest, res, next) => { }) // 更新企业信息 -router.put('/org', async (req: AuthRequest, res, next) => { +router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => { try { const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number } const updateData: any = {} @@ -67,7 +68,7 @@ router.get('/users', async (req: AuthRequest, res, next) => { }) // 添加用户 -router.post('/users', async (req: AuthRequest, res, next) => { +router.post('/users', requireAdmin, async (req: AuthRequest, res, next) => { try { const data = createUserSchema.parse(req.body) const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } }) @@ -92,7 +93,7 @@ router.post('/users', async (req: AuthRequest, res, next) => { }) // 更新用户 -router.put('/users/:id', async (req: AuthRequest, res, next) => { +router.put('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => { try { const data = updateUserSchema.parse(req.body) const user = await prisma.user.update({ @@ -107,7 +108,7 @@ router.put('/users/:id', async (req: AuthRequest, res, next) => { }) // 删除用户 -router.delete('/users/:id', async (req: AuthRequest, res, next) => { +router.delete('/users/:id', requireAdmin, async (req: AuthRequest, res, next) => { try { if (req.params.id === req.user!.id) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } }) @@ -120,7 +121,7 @@ router.delete('/users/:id', async (req: AuthRequest, res, next) => { }) // 禁用/启用用户 -router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => { +router.patch('/users/:id/toggle-disable', requireAdmin, async (req: AuthRequest, res, next) => { try { if (req.params.id === req.user!.id) { return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } }) @@ -141,7 +142,7 @@ router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => }) // 切换套餐 -router.put('/plan', async (req: AuthRequest, res, next) => { +router.put('/plan', requireAdmin, async (req: AuthRequest, res, next) => { try { const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' } if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) { diff --git a/backend/src/schemas/auth.schema.ts b/backend/src/schemas/auth.schema.ts index 20cdcf4..171c07f 100644 --- a/backend/src/schemas/auth.schema.ts +++ b/backend/src/schemas/auth.schema.ts @@ -25,6 +25,7 @@ export const forgotPasswordSchema = z.object({ export const resetPasswordSchema = z.object({ phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'), + code: z.string().length(6, '验证码为6位数字'), newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'), }) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index b6fff78..9ba8ef6 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -190,112 +190,112 @@ export async function createEmployee(orgId: string, userId: string, data: any) { const socialInsStartMonth = data.socialInsStartMonth || hireMonth const housingFundStartMonth = data.housingFundStartMonth || hireMonth - const employee = await prisma.employee.create({ - data: { - orgId, - name: data.name, - department: data.department, - hireDate, - monthlySalary: encrypt(data.monthlySalary), - gender: data.gender, - phone: data.phone, - idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null, - idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null, - isPregnant: data.isPregnant || false, - isInMedicalPeriod: data.isInMedicalPeriod || false, - isWorkInjured: data.isWorkInjured || false, - socialInsBase, - housingFundBase, - socialInsStartMonth, - housingFundStartMonth, - createdBy: userId, - city: data.city || '北京', - }, - }) - - // 创建社保缴费记录 - await prisma.employeeSocialInsRecord.create({ - data: { - orgId, - employeeId: employee.id, - startMonth: socialInsStartMonth, - endMonth: null, - base: socialInsBase, - changeType: 'ONBOARDING', - createdBy: userId, - city: data.city || '北京', - }, - }) - - // 创建公积金缴费记录 - await prisma.employeeHousingFundRecord.create({ - data: { - orgId, - employeeId: employee.id, - startMonth: housingFundStartMonth, - endMonth: null, - base: housingFundBase, - changeType: 'ONBOARDING', - createdBy: userId, - city: data.city || '北京', - }, - }) - - // 创建初始薪资变更记录 - await prisma.salaryChangeRecord.create({ - data: { - orgId, - employeeId: employee.id, - oldSalary: 0, - newSalary: salaryNum, - effectiveDate: hireDate, - effectiveMonth: hireMonth, - endMonth: null, - changeType: 'ONBOARDING', - createdBy: userId, - }, - }) - - // 创建初始部门记录 - await prisma.employeeDepartmentRecord.create({ - data: { - orgId, - employeeId: employee.id, - oldDepartment: '', - newDepartment: data.department, - effectiveMonth: hireMonth, - endMonth: null, - changeType: 'ONBOARDING', - createdBy: userId, - }, - }) - - if (data.contract && data.contract.contractType !== 'UNSIGNED') { - const contractMonths = data.contract.endDate - ? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44) - : data.contract.contractYears * 12 - - const probationCheck = validateProbation(contractMonths, data.contract.probationMonths) - if (!probationCheck.valid) { - throw { code: 'VALIDATION_ERROR', message: probationCheck.message } - } - - await prisma.laborContract.create({ + const employee = await prisma.$transaction(async (tx) => { + const emp = await tx.employee.create({ data: { orgId, - employeeId: employee.id, - signDate: data.contract.signDate ? new Date(data.contract.signDate) : null, - startDate: new Date(data.contract.startDate), - endDate: data.contract.endDate ? new Date(data.contract.endDate) : null, - contractType: data.contract.contractType, - signMethod: data.contract.signMethod || 'PAPER', - contractYears: data.contract.contractYears || 3, - probationMonths: data.contract.probationMonths || 0, - probationSalary: data.contract.probationSalary || 0, + name: data.name, + department: data.department, + hireDate, + monthlySalary: encrypt(data.monthlySalary), + gender: data.gender, + phone: data.phone, + idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null, + idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null, + isPregnant: data.isPregnant || false, + isInMedicalPeriod: data.isInMedicalPeriod || false, + isWorkInjured: data.isWorkInjured || false, + socialInsBase, + housingFundBase, + socialInsStartMonth, + housingFundStartMonth, + createdBy: userId, + city: data.city || '北京', + }, + }) + + await tx.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: emp.id, + startMonth: socialInsStartMonth, + endMonth: null, + base: socialInsBase, + changeType: 'ONBOARDING', + createdBy: userId, + city: data.city || '北京', + }, + }) + + await tx.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: emp.id, + startMonth: housingFundStartMonth, + endMonth: null, + base: housingFundBase, + changeType: 'ONBOARDING', + createdBy: userId, + city: data.city || '北京', + }, + }) + + await tx.salaryChangeRecord.create({ + data: { + orgId, + employeeId: emp.id, + oldSalary: 0, + newSalary: salaryNum, + effectiveDate: hireDate, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', createdBy: userId, }, }) - } + + await tx.employeeDepartmentRecord.create({ + data: { + orgId, + employeeId: emp.id, + oldDepartment: '', + newDepartment: data.department, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', + createdBy: userId, + }, + }) + + if (data.contract && data.contract.contractType !== 'UNSIGNED') { + const contractMonths = data.contract.endDate + ? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44) + : data.contract.contractYears * 12 + + const probationCheck = validateProbation(contractMonths, data.contract.probationMonths) + if (!probationCheck.valid) { + throw { code: 'VALIDATION_ERROR', message: probationCheck.message } + } + + await tx.laborContract.create({ + data: { + orgId, + employeeId: emp.id, + signDate: data.contract.signDate ? new Date(data.contract.signDate) : null, + startDate: new Date(data.contract.startDate), + endDate: data.contract.endDate ? new Date(data.contract.endDate) : null, + contractType: data.contract.contractType, + signMethod: data.contract.signMethod || 'PAPER', + contractYears: data.contract.contractYears || 3, + probationMonths: data.contract.probationMonths || 0, + probationSalary: data.contract.probationSalary || 0, + createdBy: userId, + }, + }) + } + + return emp + }) await runRiskDetection(orgId) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 68c614c..d859f6e 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -280,18 +280,29 @@ export async function generatePayslipFromBatches(orgId: string, month: string) { // 计算累计数据 const year = month.slice(0, 4) + const employeeIds = Array.from(employeeMap.keys()) + + const allPrevPayslips = await prisma.payslip.findMany({ + where: { orgId, employeeId: { in: employeeIds }, month: { startsWith: year, lt: month } }, + select: { employeeId: true, totalPay: true, tax: true, socialEmp: true, housingEmp: true }, + }) + const prevMap = new Map() + for (const p of allPrevPayslips) { + const existing = prevMap.get(p.employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 } + existing.totalPay += p.totalPay + existing.tax += p.tax + existing.socialEmp += p.socialEmp + existing.housingEmp += p.housingEmp + prevMap.set(p.employeeId, existing) + } let generated = 0 for (const [employeeId, summary] of employeeMap) { - // 获取当年之前月份的累计数据 - const prevPayslips = await prisma.payslip.findMany({ - where: { orgId, employeeId, month: { startsWith: year, lt: month } }, - select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true }, - }) - const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay - const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax - const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp - const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp + const prev = prevMap.get(employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 } + const ytdIncome = prev.totalPay + summary.totalPay + const ytdTaxDeducted = prev.tax + summary.tax + const ytdSocialEmp = prev.socialEmp + summary.socialEmp + const ytdHousingEmp = prev.housingEmp + summary.housingEmp await prisma.payslip.upsert({ where: { employeeId_month: { employeeId, month } }, diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index ae68ce2..7dead09 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -136,150 +136,125 @@ export function assessRisk(employee: any, reason: string): { level: RiskAssessme return { level, warnings } } +async function createTerminationRecord( + orgId: string, + userId: string, + data: any, + recordData: { + type: 'TERMINATION' | 'RESIGNATION' + reason: TerminationReason | 'RESIGNATION' + compensation?: number + riskLevel: RiskAssessment + checklist: any + remark?: string | null + resignationReason?: string | null + }, + conflictMsg: string, +) { + const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } }) + if (!employee) { + throw { code: 'NOT_FOUND', message: '员工不存在' } + } + + const latestTerm = await prisma.terminationRecord.findFirst({ + where: { employeeId: data.employeeId }, + orderBy: { terminationDate: 'desc' }, + }) + if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { + throw { code: 'CONFLICT', message: conflictMsg } + } + + const termDate = new Date(data.terminationDate) + const termMonth = dateToMonth(termDate) + const socialInsEndMonth = data.socialInsEndMonth || termMonth + const housingFundEndMonth = data.housingFundEndMonth || termMonth + + const today = new Date() + today.setHours(0, 0, 0, 0) + const isResigned = termDate <= today + + return await prisma.$transaction(async (tx) => { + const record = await tx.terminationRecord.create({ + data: { + orgId, + employeeId: data.employeeId, + terminationDate: termDate, + socialInsEndMonth, + housingFundEndMonth, + createdBy: userId, + ...recordData, + }, + }) + + await tx.employeeSocialInsRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: socialInsEndMonth, changeRefId: record.id }, + }) + + await tx.employeeHousingFundRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: housingFundEndMonth, changeRefId: record.id }, + }) + + await tx.employee.update({ + where: { id: data.employeeId }, + data: { + status: isResigned ? 'RESIGNED' : 'ACTIVE', + socialInsEndMonth, + housingFundEndMonth, + }, + }) + + await tx.riskItem.updateMany({ + where: { employeeId: data.employeeId, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedAt: new Date() }, + }) + + return { id: record.id } + }) +} + export async function createTermination(orgId: string, userId: string, data: any) { const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } }) if (!employee) { throw { code: 'NOT_FOUND', message: '员工不存在' } } - // 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘 - const latestTerm = await prisma.terminationRecord.findFirst({ - where: { employeeId: data.employeeId }, - orderBy: { terminationDate: 'desc' }, - }) - if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { - throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' } - } - const { level } = assessRisk(employee, data.reason) - const termDate = new Date(data.terminationDate) - const termMonth = dateToMonth(termDate) - const socialInsEndMonth = data.socialInsEndMonth || termMonth - const housingFundEndMonth = data.housingFundEndMonth || termMonth - - const record = await prisma.terminationRecord.create({ - data: { - orgId, - employeeId: data.employeeId, + return createTerminationRecord( + orgId, + userId, + data, + { type: 'TERMINATION', reason: data.reason, - terminationDate: termDate, compensation: data.compensation || 0, - socialInsEndMonth, - housingFundEndMonth, riskLevel: level, checklist: data.checklist || {}, remark: data.remark, - createdBy: userId, }, - }) - - // 关闭社保缴费记录(设置 endMonth) - await prisma.employeeSocialInsRecord.updateMany({ - where: { employeeId: data.employeeId, endMonth: null }, - data: { endMonth: socialInsEndMonth, changeRefId: record.id }, - }) - - // 关闭公积金缴费记录 - await prisma.employeeHousingFundRecord.updateMany({ - where: { employeeId: data.employeeId, endMonth: null }, - data: { endMonth: housingFundEndMonth, changeRefId: record.id }, - }) - - // 根据解聘日期判断在职/离职状态 - const today = new Date() - today.setHours(0, 0, 0, 0) - const isResigned = termDate <= today - - await prisma.employee.update({ - where: { id: data.employeeId }, - data: { - status: isResigned ? 'RESIGNED' : 'ACTIVE', - socialInsEndMonth, - housingFundEndMonth, - }, - }) - - await prisma.riskItem.updateMany({ - where: { employeeId: data.employeeId, status: 'PENDING' }, - data: { status: 'RESOLVED', resolvedAt: new Date() }, - }) - - return { id: record.id } + '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣', + ) } // 员工主动离职 export async function createResignation(orgId: string, userId: string, data: any) { - const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } }) - if (!employee) { - throw { code: 'NOT_FOUND', message: '员工不存在' } - } - - // 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职 - const latestTerm = await prisma.terminationRecord.findFirst({ - where: { employeeId: data.employeeId }, - orderBy: { terminationDate: 'desc' }, - }) - if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { - throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' } - } - - const termDate = new Date(data.terminationDate) - const termMonth = dateToMonth(termDate) - const socialInsEndMonth = data.socialInsEndMonth || termMonth - const housingFundEndMonth = data.housingFundEndMonth || termMonth - - const record = await prisma.terminationRecord.create({ - data: { - orgId, - employeeId: data.employeeId, + return createTerminationRecord( + orgId, + userId, + data, + { type: 'RESIGNATION', reason: 'RESIGNATION', - terminationDate: termDate, resignationReason: data.resignationReason || null, compensation: 0, - socialInsEndMonth, - housingFundEndMonth, riskLevel: 'SAFE', checklist: {}, remark: data.remark || null, - createdBy: userId, }, - }) - - // 关闭社保缴费记录 - await prisma.employeeSocialInsRecord.updateMany({ - where: { employeeId: data.employeeId, endMonth: null }, - data: { endMonth: socialInsEndMonth, changeRefId: record.id }, - }) - - // 关闭公积金缴费记录 - await prisma.employeeHousingFundRecord.updateMany({ - where: { employeeId: data.employeeId, endMonth: null }, - data: { endMonth: housingFundEndMonth, changeRefId: record.id }, - }) - - // 根据离职日期判断在职/离职状态 - const today = new Date() - today.setHours(0, 0, 0, 0) - const isResigned = termDate <= today - - await prisma.employee.update({ - where: { id: data.employeeId }, - data: { - status: isResigned ? 'RESIGNED' : 'ACTIVE', - socialInsEndMonth, - housingFundEndMonth, - }, - }) - - await prisma.riskItem.updateMany({ - where: { employeeId: data.employeeId, status: 'PENDING' }, - data: { status: 'RESOLVED', resolvedAt: new Date() }, - }) - - return { id: record.id } + '该员工已有离职/解聘记录,如需再次办理请先重新雇佣', + ) } // 撤回离职/解聘(仅未到日期可撤回) @@ -439,73 +414,84 @@ export async function batchTerminate( const success: string[] = [] const failed: Array<{ employeeId: string; reason: string }> = [] + const employeeIds = items.map((i) => i.employeeId) + const employees = await prisma.employee.findMany({ + where: { id: { in: employeeIds }, orgId }, + }) + const empMap = new Map(employees.map((e) => [e.id, e])) + + const existingTerms = await prisma.terminationRecord.findMany({ + where: { employeeId: { in: employeeIds } }, + orderBy: { terminationDate: 'desc' }, + }) + const latestTermMap = new Map() + for (const t of existingTerms) { + if (!latestTermMap.has(t.employeeId)) { + latestTermMap.set(t.employeeId, t.terminationDate) + } + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + for (const item of items) { try { - const termDate = new Date(item.terminationDate) - const termMonth = dateToMonth(termDate) - - // 校验:已有离职/解聘记录 - const latestTerm = await prisma.terminationRecord.findFirst({ - where: { employeeId: item.employeeId }, - orderBy: { terminationDate: 'desc' }, - }) - const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } }) + const employee = empMap.get(item.employeeId) if (!employee) { failed.push({ employeeId: item.employeeId, reason: '员工不存在' }) continue } - if (latestTerm && latestTerm.terminationDate >= employee.hireDate) { + const latestTermDate = latestTermMap.get(item.employeeId) + if (latestTermDate && latestTermDate >= employee.hireDate) { failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' }) continue } + const termDate = new Date(item.terminationDate) + const termMonth = dateToMonth(termDate) const { level } = assessRisk(employee, item.reason) - - await prisma.terminationRecord.create({ - data: { - orgId, - employeeId: item.employeeId, - type: 'TERMINATION', - reason: item.reason as TerminationReason, - terminationDate: termDate, - compensation: item.compensation || 0, - socialInsEndMonth: termMonth, - housingFundEndMonth: termMonth, - riskLevel: level, - checklist: {}, - remark: '批量解聘', - createdBy: userId, - }, - }) - - // 关闭社保和公积金 - await prisma.employeeSocialInsRecord.updateMany({ - where: { employeeId: item.employeeId, endMonth: null }, - data: { endMonth: termMonth }, - }) - await prisma.employeeHousingFundRecord.updateMany({ - where: { employeeId: item.employeeId, endMonth: null }, - data: { endMonth: termMonth }, - }) - - // 更新员工状态 - const today = new Date() - today.setHours(0, 0, 0, 0) const isResigned = termDate <= today - await prisma.employee.update({ - where: { id: item.employeeId }, - data: { - status: isResigned ? 'RESIGNED' : 'ACTIVE', - socialInsEndMonth: termMonth, - housingFundEndMonth: termMonth, - }, - }) + await prisma.$transaction(async (tx) => { + await tx.terminationRecord.create({ + data: { + orgId, + employeeId: item.employeeId, + type: 'TERMINATION', + reason: item.reason as TerminationReason, + terminationDate: termDate, + compensation: item.compensation || 0, + socialInsEndMonth: termMonth, + housingFundEndMonth: termMonth, + riskLevel: level, + checklist: {}, + remark: '批量解聘', + createdBy: userId, + }, + }) - // 关闭风险项 - await prisma.riskItem.updateMany({ - where: { employeeId: item.employeeId, status: 'PENDING' }, - data: { status: 'RESOLVED', resolvedAt: new Date() }, + await tx.employeeSocialInsRecord.updateMany({ + where: { employeeId: item.employeeId, endMonth: null }, + data: { endMonth: termMonth }, + }) + await tx.employeeHousingFundRecord.updateMany({ + where: { employeeId: item.employeeId, endMonth: null }, + data: { endMonth: termMonth }, + }) + + await tx.employee.update({ + where: { id: item.employeeId }, + data: { + status: isResigned ? 'RESIGNED' : 'ACTIVE', + socialInsEndMonth: termMonth, + housingFundEndMonth: termMonth, + }, + }) + + await tx.riskItem.updateMany({ + where: { employeeId: item.employeeId, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedAt: new Date() }, + }) }) success.push(item.employeeId) @@ -687,42 +673,39 @@ export async function executeTermination(orgId: string, recordId: string, userId const socialInsEndMonth = record.socialInsEndMonth || termMonth const housingFundEndMonth = record.housingFundEndMonth || termMonth - // 关闭社保缴费记录 - await prisma.employeeSocialInsRecord.updateMany({ - where: { employeeId: record.employeeId, endMonth: null }, - data: { endMonth: socialInsEndMonth, changeRefId: record.id }, - }) - - // 关闭公积金缴费记录 - await prisma.employeeHousingFundRecord.updateMany({ - where: { employeeId: record.employeeId, endMonth: null }, - data: { endMonth: housingFundEndMonth, changeRefId: record.id }, - }) - - // 更新员工状态 const today = new Date() today.setHours(0, 0, 0, 0) const isResigned = termDate <= today - await prisma.employee.update({ - where: { id: record.employeeId }, - data: { - status: isResigned ? 'RESIGNED' : 'ACTIVE', - socialInsEndMonth, - housingFundEndMonth, - }, - }) + await prisma.$transaction(async (tx) => { + await tx.employeeSocialInsRecord.updateMany({ + where: { employeeId: record.employeeId, endMonth: null }, + data: { endMonth: socialInsEndMonth, changeRefId: record.id }, + }) - // 关闭风险项 - await prisma.riskItem.updateMany({ - where: { employeeId: record.employeeId, status: 'PENDING' }, - data: { status: 'RESOLVED', resolvedAt: new Date() }, - }) + await tx.employeeHousingFundRecord.updateMany({ + where: { employeeId: record.employeeId, endMonth: null }, + data: { endMonth: housingFundEndMonth, changeRefId: record.id }, + }) - // 标记为已完成 - await prisma.terminationRecord.update({ - where: { id: recordId }, - data: { status: 'COMPLETED', updatedBy: userId }, + await tx.employee.update({ + where: { id: record.employeeId }, + data: { + status: isResigned ? 'RESIGNED' : 'ACTIVE', + socialInsEndMonth, + housingFundEndMonth, + }, + }) + + await tx.riskItem.updateMany({ + where: { employeeId: record.employeeId, status: 'PENDING' }, + data: { status: 'RESOLVED', resolvedAt: new Date() }, + }) + + await tx.terminationRecord.update({ + where: { id: recordId }, + data: { status: 'COMPLETED', updatedBy: userId }, + }) }) return { id: recordId } diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..571778c --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,59 @@ +import { Component, ErrorInfo, ReactNode } from 'react' + +interface Props { + children: ReactNode +} + +interface State { + hasError: boolean + error: Error | null +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('ErrorBoundary caught:', error, errorInfo) + } + + handleReset = () => { + this.setState({ hasError: false, error: null }) + } + + render() { + if (this.state.hasError) { + return ( +
+
😵
+

页面出错了

+

+ {this.state.error?.message || '发生了未知错误,请刷新页面重试'} +

+
+ + +
+
+ ) + } + + return this.props.children + } +} diff --git a/frontend/src/hooks/useConfirm.tsx b/frontend/src/hooks/useConfirm.tsx new file mode 100644 index 0000000..453bb12 --- /dev/null +++ b/frontend/src/hooks/useConfirm.tsx @@ -0,0 +1,66 @@ +import { useState, useCallback, createContext, useContext, ReactNode } from 'react' +import ConfirmDialog from '../components/ui/ConfirmDialog' + +interface ConfirmOptions { + title?: string + message: string + confirmLabel?: string + variant?: 'danger' | 'primary' +} + +interface ConfirmContextValue { + confirm: (options: ConfirmOptions) => Promise +} + +const ConfirmContext = createContext(null) + +export function useConfirm(): (options: ConfirmOptions) => Promise { + const ctx = useContext(ConfirmContext) + if (!ctx) { + throw new Error('useConfirm must be used within ConfirmProvider') + } + return ctx.confirm +} + +export function useConfirmDialog() { + return useConfirm() +} + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState<{ + open: boolean + options: ConfirmOptions + resolve?: (value: boolean) => void + }>({ open: false, options: { message: '' } }) + + const confirm = useCallback((options: ConfirmOptions) => { + return new Promise((resolve) => { + setState({ open: true, options, resolve }) + }) + }, []) + + const handleConfirm = useCallback(() => { + state.resolve?.(true) + setState((s) => ({ ...s, open: false, resolve: undefined })) + }, [state]) + + const handleCancel = useCallback(() => { + state.resolve?.(false) + setState((s) => ({ ...s, open: false, resolve: undefined })) + }, [state]) + + return ( + + {children} + + + ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 418ec96..c63e1f3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' +import ErrorBoundary from './components/ErrorBoundary' +import { ConfirmProvider } from './hooks/useConfirm' import './index.css' const queryClient = new QueryClient({ @@ -19,7 +21,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + + + , diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index fe4588e..a3cfc3a 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useConfirm } from '../hooks/useConfirm' import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' @@ -62,6 +63,7 @@ export default function Money() { function BatchManager() { const queryClient = useQueryClient() + const confirm = useConfirm() const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [monthFrom, setMonthFrom] = useState('') const [monthTo, setMonthTo] = useState('') @@ -336,8 +338,8 @@ function BatchManager() { {!item.isDefault && ( @@ -677,6 +679,7 @@ export default function Termination() { onClick={() => { setDraftId(item.id); setView('detail') }} className="p-1 text-safe hover:opacity-70" aria-label="审批通过" + title="审批通过" > @@ -684,6 +687,7 @@ export default function Termination() { onClick={() => { setDraftId(item.id); setView('detail') }} className="p-1 text-danger hover:opacity-70" aria-label="驳回" + title="驳回" > @@ -694,6 +698,7 @@ export default function Termination() { onClick={() => { setDraftId(item.id); executeMutation.mutate() }} className="p-1 text-primary hover:opacity-70" aria-label="执行" + title="执行解聘" > @@ -703,6 +708,7 @@ export default function Termination() { onClick={() => { setDraftId(item.id); cancelMutation.mutate() }} className="p-1 text-gray-400 hover:text-danger" aria-label="撤销" + title="撤销" > @@ -711,6 +717,7 @@ export default function Termination() { onClick={() => handleViewDetail(item.id)} className="p-1 text-gray-500 hover:text-primary" aria-label="详情" + title="详情" >