fix: 全面优化安全、性能和代码质量
P0: 环境变量校验/事务保护/RBAC权限 P1: 花名册分页/密码重置验证码/ErrorBoundary/N+1查询优化 P2: 导出限制/自定义错误类/body大小限制/代码去重 P3: 自定义确认弹窗替换window.confirm
This commit is contained in:
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import app from './app'
|
||||
import { validateEnv } from './lib/config'
|
||||
|
||||
validateEnv()
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const required = ['JWT_SECRET', 'JWT_REFRESH_SECRET', 'ENCRYPTION_KEY']
|
||||
const defaults: Record<string, string> = {
|
||||
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(', ')}`)
|
||||
}
|
||||
}
|
||||
@@ -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<string, number> = {
|
||||
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,
|
||||
|
||||
@@ -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')
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, typeof allFixedContracts>()
|
||||
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
|
||||
|
||||
// 判断是否应签无固定期限:
|
||||
|
||||
@@ -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<string, () => Promise<any>> = {
|
||||
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<string, () => Promise<any[]>> = {
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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位'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<string, { totalPay: number; tax: number; socialEmp: number; housingEmp: number }>()
|
||||
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 } },
|
||||
|
||||
@@ -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<string, Date>()
|
||||
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 }
|
||||
|
||||
@@ -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<Props, State> {
|
||||
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 (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4 px-4">
|
||||
<div className="text-6xl">😵</div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">页面出错了</h2>
|
||||
<p className="text-sm text-gray-500 text-center max-w-md">
|
||||
{this.state.error?.message || '发生了未知错误,请刷新页面重试'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
|
||||
|
||||
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
|
||||
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<boolean>((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 (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
<ConfirmDialog
|
||||
open={state.open}
|
||||
title={state.options.title || '确认操作'}
|
||||
message={state.options.message}
|
||||
confirmLabel={state.options.confirmLabel || '确认'}
|
||||
variant={state.options.variant || 'danger'}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</ConfirmContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<App />
|
||||
</ConfirmProvider>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -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() {
|
||||
<SettingsIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||||
deleteBatchMutation.mutate(batch.id)
|
||||
}
|
||||
}}
|
||||
@@ -366,6 +368,7 @@ function BatchManager() {
|
||||
|
||||
function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [editCell, setEditCell] = useState<{ employeeId: string; field: string } | null>(null)
|
||||
const [editValue, setEditValue] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -554,8 +557,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm('确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。')) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '归档确认', message: '确认归档?归档后批次将锁定不可编辑。工资条需在「工资条管理」中单独生成。', variant: 'primary' })) {
|
||||
archiveMutation.mutate()
|
||||
}
|
||||
}}
|
||||
@@ -567,8 +570,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除批次「${batch.name}」?此操作不可撤销。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除批次', message: `确认删除批次「${batch.name}」?此操作不可撤销。` })) {
|
||||
deleteBatchMutation.mutate()
|
||||
}
|
||||
}}
|
||||
@@ -772,6 +775,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: ()
|
||||
|
||||
function TemplateManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<any>(null)
|
||||
const [form, setForm] = useState({
|
||||
@@ -917,8 +921,8 @@ function TemplateManager() {
|
||||
</button>
|
||||
{!item.isDefault && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认删除薪酬项「${item.name}」?`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除薪酬项', message: `确认删除薪酬项「${item.name}」?` })) {
|
||||
deleteMutation.mutate(item.id)
|
||||
}
|
||||
}}
|
||||
@@ -1447,6 +1451,7 @@ function OvertimeCalculator() {
|
||||
|
||||
function PayslipManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
@@ -1517,8 +1522,8 @@ function PayslipManager() {
|
||||
税率试算
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (window.confirm(`确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '生成工资条', message: `确认从 ${month} 已归档批次汇总生成工资条?这将覆盖已有的工资条数据。`, variant: 'primary' })) {
|
||||
generateFromBatchMutation.mutate({ month })
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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 { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, Briefcase, FileSignature, DollarSign, Building2, RotateCcw } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import api from '../lib/api'
|
||||
@@ -27,6 +28,7 @@ type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary'
|
||||
|
||||
export default function Roster() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebouncedValue(search, 300)
|
||||
@@ -476,9 +478,12 @@ export default function Roster() {
|
||||
title="撤回"
|
||||
aria-label={`撤回${e.name}的${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录`}
|
||||
className="rounded-md p-1.5 text-gray-400 transition hover:bg-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
onClick={async (ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.latestTerminationId && window.confirm(`确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`)) {
|
||||
if (e.latestTerminationId && await confirm({
|
||||
title: '撤回确认',
|
||||
message: `确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`,
|
||||
})) {
|
||||
revokeMutation.mutate(e.latestTerminationId)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -12,6 +13,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
@@ -303,8 +305,8 @@ export default function SocialInsurance() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -19,6 +19,7 @@ const REASONS = [
|
||||
{ value: 'LAYOFF', label: '公司裁员(经营困难/技术调整等)', legalBasis: '《劳动合同法》第41条' },
|
||||
{ value: 'EXPIRED', label: '合同到期不续签', legalBasis: '《劳动合同法》第44条、第46条' },
|
||||
{ value: 'ILLEGAL', label: '违法解除(赔偿金×2)', legalBasis: '《劳动合同法》第87条' },
|
||||
{ value: 'RESIGNATION', label: '员工主动离职', legalBasis: '《劳动合同法》第37条' },
|
||||
]
|
||||
|
||||
const STEPS = ['选择员工', '解聘方式', '合规检查', '费用结算', '工作交接', '确认提交']
|
||||
@@ -667,6 +668,7 @@ export default function Termination() {
|
||||
onClick={() => handleEditDraft(item)}
|
||||
className="p-1 text-gray-500 hover:text-primary"
|
||||
aria-label="编辑"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -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="审批通过"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -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="驳回"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -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="执行解聘"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -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="撤销"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -711,6 +717,7 @@ export default function Termination() {
|
||||
onClick={() => handleViewDetail(item.id)}
|
||||
className="p-1 text-gray-500 hover:text-primary"
|
||||
aria-label="详情"
|
||||
title="详情"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user