diff --git a/backend/package-lock.json b/backend/package-lock.json index 1d1af59..34650eb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@prisma/client": "^5.18.0", "@types/multer": "^2.2.0", + "@types/redis": "^4.0.10", "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", @@ -22,6 +23,7 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "openai": "^6.48.0", + "redis": "^6.1.0", "uuid": "^10.0.0", "xlsx": "^0.18.5", "zod": "^3.23.0" @@ -634,6 +636,78 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@redis/bloom": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@redis/bloom/-/bloom-6.1.0.tgz", + "integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/client": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@redis/client/-/client-6.1.0.tgz", + "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@redis/json/-/json-6.1.0.tgz", + "integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/search": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@redis/search/-/search-6.1.0.tgz", + "integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/@redis/time-series/-/time-series-6.1.0.tgz", + "integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmmirror.com/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -810,6 +884,15 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/redis": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/@types/redis/-/redis-4.0.10.tgz", + "integrity": "sha512-7CLy5b5fzzEGVcOccgZjoMlNpPhX6d10jEeRy2YWbFuaMNrSPc9ExRsMYsd+0VxvEHucf4EWx24Ja7cSU1FGUA==", + "license": "MIT", + "dependencies": { + "redis": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", @@ -1336,6 +1419,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/codepage": { "version": "1.15.0", "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", @@ -2954,6 +3046,22 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/redis/-/redis-6.1.0.tgz", + "integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "6.1.0", + "@redis/client": "6.1.0", + "@redis/json": "6.1.0", + "@redis/search": "6.1.0", + "@redis/time-series": "6.1.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", diff --git a/backend/package.json b/backend/package.json index 24e392b..f7b8c8a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "dependencies": { "@prisma/client": "^5.18.0", "@types/multer": "^2.2.0", + "@types/redis": "^4.0.10", "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cors": "^2.8.5", @@ -26,6 +27,7 @@ "multer": "^2.2.0", "node-cron": "^3.0.3", "openai": "^6.48.0", + "redis": "^6.1.0", "uuid": "^10.0.0", "xlsx": "^0.18.5", "zod": "^3.23.0" diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index 1cfbcad..e45b58a 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -222,4 +222,20 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => } }) +router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const contract = await prisma.laborContract.findFirst({ + where: { id: req.params.contractId, orgId: req.user!.orgId }, + }) + if (!contract) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } }) + } + await prisma.laborContract.delete({ where: { id: req.params.contractId } }) + await auditLog(req, 'DELETE_CONTRACT', 'CONTRACT', req.params.contractId, { employeeId: contract.employeeId }) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 700d538..486c0a4 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -7,6 +7,7 @@ import { encrypt, decrypt, sha256 } from '../lib/crypto' import prisma from '../lib/prisma' import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services/retirement.service' import { calcBatchEntry } from '../services/payroll.service' +import { createEvidence } from '../services/evidence.service' const router = Router() const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }) @@ -274,9 +275,19 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } }) await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } }) await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } }) + + await createEvidence({ + orgId, category: 'ONBOARD', refId: emp.id, employeeId: emp.id, + events: [{ action: '员工入职登记(批量导入)', timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }], + createdBy: userId, + }) + result.employees++ } catch (e: any) { - result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`) + const msg = e?.message || '' + if (msg.includes('Unique constraint')) result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`) + else if (msg.includes('invalid') || msg.includes('validation')) result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`) + else result.errors.push(`员工第${i + 2}行:导入失败 — ${msg || '未知错误'}`) } } } @@ -314,8 +325,15 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async }) result.contracts++ } + await createEvidence({ + orgId, category: 'CONTRACT_SIGN', refId: undefined, employeeId: empId, + events: [{ action: '合同签订(批量导入)', timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }], + createdBy: userId, + }) } catch (e: any) { - result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`) + const msg = e?.message || '' + if (msg.includes('Unique constraint')) result.errors.push(`合同第${i + 2}行:该员工合同已存在,跳过`) + else result.errors.push(`合同第${i + 2}行:导入失败 — ${msg || '未知错误'}`) } } } @@ -340,9 +358,13 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0) const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0) const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0) - await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any }) + await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours } }) result.overtime++ - } catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) } + } catch (e: any) { + const msg = e?.message || '' + if (msg.includes('Unique constraint')) result.errors.push(`加班第${i + 2}行:该员工此月份已有加班记录,同一员工同一月只能导入一条,请合并后重试`) + else result.errors.push(`加班第${i + 2}行:导入失败 — ${msg || '未知错误'}`) + } } } @@ -364,8 +386,18 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' } const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' } await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } }) + + await createEvidence({ + orgId, category: 'DISCIPLINARY', refId: undefined, employeeId: empId, + events: [{ action: `违纪记录创建(${val(r['违纪类型']) || '其他'})`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }], + createdBy: userId, + }) + result.disciplinary++ - } catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) } + } catch (e: any) { + const msg = e?.message || '' + result.errors.push(`违纪第${i + 2}行:导入失败 — ${msg || '未知错误'}`) + } } } @@ -385,8 +417,19 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue } const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' } await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } }) + + await createEvidence({ + orgId, category: 'ATTENDANCE', refId: undefined, employeeId: empId, + events: [{ action: `考勤记录导入(${val(r['考勤状态']) || '正常'})`, timestamp: new Date().toISOString(), ip: req.ip || '', userAgent: req.get('User-Agent') || '' }], + createdBy: userId, + }) + result.attendance++ - } catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) } + } catch (e: any) { + const msg = e?.message || '' + if (msg.includes('Unique constraint')) result.errors.push(`考勤第${i + 2}行:该员工此日期已有考勤记录,跳过`) + else result.errors.push(`考勤第${i + 2}行:导入失败 — ${msg || '未知错误'}`) + } } } diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 9a35074..45b8518 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -222,11 +222,21 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body) const orgId = req.user!.orgId - // 查询当月已有批次数 - const existingBatches = await prisma.payrollBatch.count({ - where: { orgId, month }, + // 检查当月是否有未归档批次,有则拒绝创建(确保个税按批次累计计算) + const draftBatches = await prisma.payrollBatch.count({ + where: { orgId, month, status: 'DRAFT' }, }) - const batchNo = existingBatches + 1 + if (draftBatches > 0) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月存在未归档的批次,请先归档后再创建新批次' } }) + } + + // 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突 + const lastBatch = await prisma.payrollBatch.findFirst({ + where: { orgId, month }, + orderBy: { batchNo: 'desc' }, + select: { batchNo: true }, + }) + const batchNo = (lastBatch?.batchNo || 0) + 1 // 获取在职员工 + 本月离职员工 const monthStart = new Date(`${month}-01`) @@ -340,18 +350,10 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 } } - // 判断同月是否已有归档的常规批次(用于决定是否跳过社保) - const hasArchivedRegularBatch = await prisma.payrollBatch.count({ - where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } }, - }) - - // 计算社保、个税等 - // 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖 - const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0 - + // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过 let calcResult: any try { - calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial }) + calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type) } catch (calcErr: any) { // 单个员工计算失败不阻塞整个批次,记录错误并使用零值 failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' }) @@ -458,7 +460,11 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg - const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined + + // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先 + const options = Object.keys(overrideSocial).length > 0 + ? { overrideSocial } + : undefined // 重新计算 const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options) @@ -536,6 +542,7 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons }) const overtimePay = overtime?.totalPay || 0 + // calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过 const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type) const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId) @@ -549,9 +556,30 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons results.push(entry) } - // 更新批次人数 - const count = await prisma.batchEntry.count({ where: { batchId } }) - await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } }) + // 更新批次人数和汇总 + const allEntries = await prisma.batchEntry.findMany({ where: { batchId } }) + const totals = allEntries.reduce((acc, e) => ({ + totalPay: acc.totalPay + e.totalPay, + totalNetPay: acc.totalNetPay + e.netPay, + totalSocialOrg: acc.totalSocialOrg + e.socialOrg, + totalSocialEmp: acc.totalSocialEmp + e.socialEmp, + totalHousingOrg: acc.totalHousingOrg + e.housingOrg, + totalHousingEmp: acc.totalHousingEmp + e.housingEmp, + totalTax: acc.totalTax + e.tax, + }), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 }) + await prisma.payrollBatch.update({ + where: { id: batchId }, + data: { + employeeCount: allEntries.length, + totalPay: Math.round(totals.totalPay * 100) / 100, + totalNetPay: Math.round(totals.totalNetPay * 100) / 100, + totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100, + totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100, + totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100, + totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100, + totalTax: Math.round(totals.totalTax * 100) / 100, + }, + }) res.json({ success: true, data: { added: results.length } }) } catch (err) { @@ -571,8 +599,30 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } }) - const count = await prisma.batchEntry.count({ where: { batchId } }) - await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } }) + // 更新批次人数和汇总 + const allEntries = await prisma.batchEntry.findMany({ where: { batchId } }) + const totals = allEntries.reduce((acc, e) => ({ + totalPay: acc.totalPay + e.totalPay, + totalNetPay: acc.totalNetPay + e.netPay, + totalSocialOrg: acc.totalSocialOrg + e.socialOrg, + totalSocialEmp: acc.totalSocialEmp + e.socialEmp, + totalHousingOrg: acc.totalHousingOrg + e.housingOrg, + totalHousingEmp: acc.totalHousingEmp + e.housingEmp, + totalTax: acc.totalTax + e.tax, + }), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 }) + await prisma.payrollBatch.update({ + where: { id: batchId }, + data: { + employeeCount: allEntries.length, + totalPay: Math.round(totals.totalPay * 100) / 100, + totalNetPay: Math.round(totals.totalNetPay * 100) / 100, + totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100, + totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100, + totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100, + totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100, + totalTax: Math.round(totals.totalTax * 100) / 100, + }, + }) res.json({ success: true }) } catch (err) { @@ -620,6 +670,63 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, } }) +// 取消归档(只能取消最后一个归档批次,依次取消) +router.post('/batches/:batchId/unarchive', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const orgId = req.user!.orgId + + const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } }) + if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } }) + if (batch.status !== 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅已归档批次可取消归档' } }) + + // 检查是否是最后一个归档批次(按 batchNo 倒序) + const lastArchived = await prisma.payrollBatch.findFirst({ + where: { orgId, month: batch.month, status: 'ARCHIVED' }, + orderBy: { batchNo: 'desc' }, + select: { id: true }, + }) + if (lastArchived?.id !== batchId) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '只能依次取消归档,请先取消最后归档的批次' } }) + } + + // 取消归档 + await prisma.payrollBatch.update({ + where: { id: batchId }, + data: { status: 'DRAFT', archivedAt: null }, + }) + + // 如果当月已无归档批次,删除对应工资条(取消个税累计) + const remainingArchived = await prisma.payrollBatch.count({ + where: { orgId, month: batch.month, status: 'ARCHIVED' }, + }) + if (remainingArchived === 0) { + // 查找该月所有批次的员工,删除其工资条 + const entries = await prisma.batchEntry.findMany({ + where: { orgId, batch: { month: batch.month } }, + select: { employeeId: true }, + distinct: ['employeeId'], + }) + if (entries.length > 0) { + await prisma.payslip.deleteMany({ + where: { + orgId, + employeeId: { in: entries.map(e => e.employeeId) }, + month: batch.month, + }, + }) + } + } else { + // 还有归档批次,重新生成工资条(基于剩余归档批次汇总) + await generatePayslipFromBatches(orgId, batch.month) + } + + res.json({ success: true, data: { unarchived: true } }) + } catch (err) { + next(err) + } +}) + // 从已归档批次汇总生成工资条 router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index b37a456..d7ecf1a 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -3,6 +3,7 @@ import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' import { decrypt } from '../lib/crypto' import { z } from 'zod' +import OpenAI from 'openai' const router = Router() router.use(authMiddleware) @@ -50,26 +51,7 @@ router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction }) } if (!config) { - try { - config = await prisma.socialInsuranceConfig.create({ - data: { - orgId: req.user!.orgId, - effectiveFrom: new Date().toISOString().slice(0, 7), - city: city || '北京', - isCurrent: true, - createdBy: req.user!.id, - }, - }) - } catch { - // 唯一约束冲突,查询同城市任意配置 - config = await prisma.socialInsuranceConfig.findFirst({ - where: { orgId: req.user!.orgId, city: city || '北京' }, - orderBy: { effectiveFrom: 'desc' }, - }) - } - } - if (!config) { - return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } }) + return res.json({ success: true, data: null }) } res.json({ success: true, data: config }) } catch (err) { @@ -405,9 +387,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc }) } if (!config) { - config = await prisma.socialInsuranceConfig.create({ - data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id }, - }) + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的社保配置,请先在社保管理中创建` } }) } const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) @@ -496,24 +476,7 @@ router.get('/housing-config', async (req: AuthRequest, res: Response, next: Next }) } if (!config) { - try { - config = await prisma.housingFundConfig.create({ - data: { - orgId: req.user!.orgId, - effectiveFrom: new Date().toISOString().slice(0, 7), - city: city || '北京', - createdBy: req.user!.id, - }, - }) - } catch { - config = await prisma.housingFundConfig.findFirst({ - where: { orgId: req.user!.orgId, city: city || '北京' }, - orderBy: { effectiveFrom: 'desc' }, - }) - } - } - if (!config) { - return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到公积金配置' } }) + return res.json({ success: true, data: null }) } res.json({ success: true, data: config }) } catch (err) { @@ -608,9 +571,7 @@ router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: }) } if (!config) { - config = await prisma.housingFundConfig.create({ - data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id }, - }) + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的公积金配置,请先在公积金管理中创建` } }) } const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) @@ -1453,4 +1414,95 @@ router.post('/special-deduction/batch', async (req: AuthRequest, res: Response, } }) +// ========== AI 社保政策建议 ========== + +const aiSuggestSchema = z.object({ + city: z.string(), + effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/), + type: z.enum(['social', 'housing']).default('social'), +}) + +router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { city, effectiveFrom, type } = aiSuggestSchema.parse(req.body) + + const apiKey = process.env.DASHSCOPE_API_KEY || '' + if (!apiKey) { + return res.status(400).json({ success: false, error: { code: 'NO_API_KEY', message: 'AI服务未配置' } }) + } + + const client = new OpenAI({ apiKey, baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', timeout: 30 * 1000, maxRetries: 1 }) + + const year = effectiveFrom.split('-')[0] + + const prompt = type === 'social' + ? `请提供${city}在${year}年度的社保缴费政策。请严格按照以下JSON格式返回,不要包含任何其他文字: + +{ + "baseMin": 数字, + "baseMax": 数字, + "medicalBaseMin": 数字, + "medicalBaseMax": 数字, + "pensionOrg": 数字, + "pensionEmp": 数字, + "medicalOrg": 数字, + "medicalEmp": 数字, + "unemploymentOrg": 数字, + "unemploymentEmp": 数字, + "injuryOrg": 数字, + "maternityOrg": 数字, + "extraInsurances": [ + { "name": "险种名称", "baseType": "fixed或pension或medical", "fixedAmount": 数字, "empFixedAmount": 数字, "orgRate": 数字, "empRate": 数字 } + ] +} + +说明: +- baseMin/baseMax: 养老/失业/工伤保险缴费基数上下限 +- medicalBaseMin/medicalBaseMax: 医疗/生育保险缴费基数上下限(与养老相同则填相同值) +- 所有比例单位为百分比,如养老企业16%则填16 +- extraInsurances: 大病医疗、长期护理险等附加险种,fixed类型用fixedAmount/empFixedAmount(元/月),比例类型用orgRate/empRate(百分比) +- 如果没有附加险种,返回空数组 +- 请基于${year}年度${city}的最新社保政策填写` + : `请提供${city}在${year}年度的住房公积金缴存政策。请严格按照以下JSON格式返回,不要包含任何其他文字: + +{ + "baseMin": 数字, + "baseMax": 数字, + "housingOrg": 数字, + "housingEmp": 数字 +} + +说明: +- baseMin/baseMax: 公积金缴存基数上下限 +- housingOrg/housingEmp: 企业和个人缴存比例(百分比),如12%则填12 +- 请基于${year}年度${city}的最新公积金政策填写` + + const response = await client.chat.completions.create({ + model: 'qwen-plus', + messages: [ + { role: 'system', content: '你是社保政策专家,精通中国各城市的社会保险和住房公积金缴费政策。请只返回JSON格式数据,不要包含markdown代码块标记。' }, + { role: 'user', content: prompt }, + ], + temperature: 0.1, + }) + + const content = response.choices[0]?.message?.content || '' + + // 提取JSON(兼容markdown代码块) + let jsonStr = content.trim() + const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/) + if (jsonMatch) jsonStr = jsonMatch[1].trim() + // 去除可能的非JSON前后文字 + const firstBrace = jsonStr.indexOf('{') + const lastBrace = jsonStr.lastIndexOf('}') + if (firstBrace >= 0 && lastBrace >= 0) jsonStr = jsonStr.substring(firstBrace, lastBrace + 1) + + const suggested = JSON.parse(jsonStr) + + res.json({ success: true, data: suggested }) + } catch (err: any) { + next(err) + } +}) + export default router diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 9a6473a..1fb8bbf 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -61,4 +61,7 @@ export const addContractSchema = z.object({ contractYears: z.number().int().min(1).max(10).default(3), probationMonths: z.number().int().min(0).max(6).default(0), probationSalary: z.number().min(0).default(0), + attachmentUrl: z.string().nullable().optional(), + electronicContractNo: z.string().nullable().optional(), + electronicContractUrl: z.string().nullable().optional(), }) diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts index e674aa2..adea4b9 100644 --- a/backend/src/services/evidence.service.ts +++ b/backend/src/services/evidence.service.ts @@ -92,6 +92,8 @@ export async function getEvidenceDetail(orgId: string, id: string) { /** * 验证证据链完整性(重新计算哈希对比) + * 注意:PostgreSQL 的 json 类型会按 key 字母序重排属性,导致 JSON.stringify 结果与原始不一致。 + * 因此验证时需要尝试两种方式:原始序列化和排序后序列化。 */ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid: boolean; expectedHash: string; actualHash: string }> { const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } }) @@ -99,11 +101,28 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid throw { code: 'NOT_FOUND', message: '证据链不存在' } } - const eventsJson = JSON.stringify(evidence.events) + const events = evidence.events as any[] + const eventsJson = JSON.stringify(events) const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || '')) + // 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排) + if (expectedHash !== evidence.hash) { + const sortedEvents = events.map(e => { + const sorted: any = {} + Object.keys(e).sort().forEach(k => sorted[k] = e[k]) + return sorted + }) + const sortedJson = JSON.stringify(sortedEvents) + const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || '')) + return { + valid: sortedHash === evidence.hash, + expectedHash: sortedHash, + actualHash: evidence.hash, + } + } + return { - valid: expectedHash === evidence.hash, + valid: true, expectedHash, actualHash: evidence.hash, } @@ -165,9 +184,19 @@ export async function verifyAllEvidence(orgId: string) { let valid = 0 let invalid = 0 for (const r of records) { - const eventsJson = JSON.stringify(r.events) + const events = r.events as any[] + const eventsJson = JSON.stringify(events) const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || '')) - if (expectedHash === r.hash) valid++ + if (expectedHash === r.hash) { valid++; continue } + // 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排) + const sortedEvents = events.map(e => { + const sorted: any = {} + Object.keys(e).sort().forEach(k => sorted[k] = e[k]) + return sorted + }) + const sortedJson = JSON.stringify(sortedEvents) + const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || '')) + if (sortedHash === r.hash) valid++ else invalid++ } return { total: records.length, valid, invalid } diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index afc9fc3..1a86806 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -161,17 +161,40 @@ export async function calcBatchEntry( let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 // 年终奖/奖金批次、补偿金批次:不扣社保公积金 + // 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣 if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) { + // 1. 计算当月应缴社保公积金全额 + let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0 if (socialConfig) { const social = calcSocialInsurance(socialBase, socialConfig) - socialEmp = social.socialEmp - socialOrg = social.socialOrg + fullSocialEmp = social.socialEmp + fullSocialOrg = social.socialOrg } if (housingConfig) { const housing = calcHousingFund(housingBase, housingConfig) - housingEmp = housing.housingEmp - housingOrg = housing.housingOrg + fullHousingEmp = housing.housingEmp + fullHousingOrg = housing.housingOrg } + + // 2. 查询该员工当月已归档批次中已扣的社保公积金金额 + const archivedEntries = await prisma.batchEntry.findMany({ + where: { + orgId, + employeeId, + batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } }, + }, + select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true }, + }) + const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0) + const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0) + + // 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0) + socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp) + socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg) + housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp) + housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg) } // 手动覆盖社保值 @@ -192,19 +215,23 @@ export async function calcBatchEntry( } else { // 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除) const year = month.slice(0, 4) - const prevPayslips = await prisma.payslip.findMany({ + // 从已归档批次的 BatchEntry 获取当年历史数据(不依赖工资条是否已生成) + const archivedEntries = await prisma.batchEntry.findMany({ where: { orgId, employeeId, - month: { startsWith: year, lt: month }, + batch: { + month: { startsWith: year }, + status: 'ARCHIVED', + }, }, select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true }, }) - const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay - const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp - const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp + const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay + const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp + const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7)) - const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0) const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted) } diff --git a/docs/payroll-batch-algorithm.md b/docs/payroll-batch-algorithm.md new file mode 100644 index 0000000..eb05b02 --- /dev/null +++ b/docs/payroll-batch-algorithm.md @@ -0,0 +1,249 @@ +# 多批次工资发放算法 + +## 一、整体流程 + +``` +创建批次 → 拉入员工/复制数据 → 计算每个员工薪资 → 编辑调整 → 归档批次 → 生成工资条 +``` + +一个月份可以有多个批次(如第1批常规发薪、第2批离职结算、第3批奖金),每个批次独立计算,归档后汇总生成一张工资条。 + +**核心约束**:当月存在未归档(DRAFT)的批次时,不允许创建新批次,确保个税按批次累计计算。 + +## 二、批次类型 + +| 类型 | code | 社保公积金 | 个税方式 | +|------|------|-----------|---------| +| 常规发薪 | `REGULAR` | 正常扣缴 | 累计预扣法 | +| 离职结算 | `TERMINATION` | 正常扣缴 | 累计预扣法 | +| 年终奖/奖金 | `BONUS` | 不扣 | 单独计税 | +| 补偿金 | `SEVERANCE` | 不扣 | 累计预扣法 | + +## 三、创建批次(POST /batches) + +### 3.1 前置校验 + +- 当月存在 `DRAFT` 状态的批次时,拒绝创建,返回错误"当月存在未归档的批次,请先归档后再创建新批次" +- 确保新批次创建时,之前所有批次均已归档(已生成工资条),保证个税累计计算正确 + +### 3.2 确定批次号 + +取当月最大 `batchNo + 1`,避免删除批次后 `count` 不准导致唯一键冲突(`@@unique([orgId, month, batchNo])`)。 + +### 3.3 四种初始化模式 + +| 模式 | 说明 | +|------|------| +| `copy_last` | 拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动取当月加班费 | +| `blank_employees` | 拉入在职员工,所有金额为0,仅从员工档案取基本工资 | +| `blank_all` | 空批次,不拉入员工,后续手动添加 | +| `copy_batch` | 从指定已归档批次复制员工和薪资数据 | + +### 3.4 员工范围 + +- **常规发薪(REGULAR)**:`status = ACTIVE` + 当月离职员工(`status = RESIGNED` 且 `updatedAt` 在当月) +- **离职结算/补偿金(TERMINATION/SEVERANCE)**:当月有离职记录(`terminationDate` 在当月)的员工 + +### 3.5 社保公积金扣缴逻辑(按员工个人、按实际金额) + +``` +对每个员工独立判断: + 1. 计算当月应缴社保公积金全额(基于社保基数和配置) + 2. 查询该员工当月已归档的常规/离职批次中已扣的社保公积金累计金额 + 3. 本批次应扣 = max(0, 应缴全额 - 已扣金额) + - 已扣金额 = 0 → 本批次扣全额 + - 已扣金额 < 应缴全额 → 本批次补扣差额 + - 已扣金额 ≥ 应缴全额 → 本批次扣0(足额) +``` + +**规则**: +- 按员工个人维度、按实际金额计算,而非全局判断是否扣过 +- 第1批:员工A扣全额社保 → 归档后 → 第2批中员工A已扣金额=全额,应扣=0;员工B未在第1批中,已扣=0,应扣=全额 +- BONUS 和 SEVERANCE 批次本身不扣社保 +- 用户可手动编辑覆盖社保值 + +## 四、单条目计算(calcBatchEntry) + +### 4.1 应发合计 + +``` +totalPay = baseSalary + overtimePay + allowance + bonus - deduction +``` + +### 4.2 社保公积金计算 + +**基数**:优先用员工核定基数(`employee.socialInsBase` / `employee.housingFundBase`),否则用基本工资 + +**社保(calcSocialInsurance)**: +- 养老/失业/工伤:基数 × 各自比例 +- 医疗/生育:独立基数(`medicalBaseMin/Max`,fallback 到统一基数) +- 附加险种:支持固定金额或按比例 + +**公积金(calcHousingFund)**: +- 基数 × `housingEmp%`(个人)/ `housingOrg%`(单位) + +**跳过条件**: +- `batchType = BONUS 或 SEVERANCE` → 不扣 +- `skipSocial = true`(同月已有归档常规批次)→ 不扣 +- `overrideSocial` 手动覆盖 → 用用户输入值 + +### 4.3 个税计算 + +**年终奖批次(BONUS)— 单独计税**: + +``` +monthlyBonus = bonus / 12 +→ 查税率表确定 rate 和 quickDeduction +→ tax = bonus × rate - quickDeduction +``` + +| 月均奖金 | 税率 | 速算扣除数 | +|---------|------|-----------| +| ≤3000 | 3% | 0 | +| ≤12000 | 10% | 210 | +| ≤25000 | 20% | 1410 | +| ≤35000 | 25% | 2660 | +| ≤55000 | 30% | 4410 | +| ≤80000 | 35% | 7160 | +| >80000 | 45% | 15160 | + +**其他批次(REGULAR/TERMINATION/SEVERANCE)— 累计预扣法**: + +``` +1. 查询该员工当年所有已归档批次的 BatchEntry(不依赖工资条是否已生成) +2. 累计收入 = Σ 已归档批次.totalPay + 本批次.totalPay +3. 累计社保 = Σ 已归档批次.socialEmp + 本批次.socialEmp +4. 累计公积金 = Σ 已归档批次.housingEmp + 本批次.housingEmp +5. 累计专项扣除 = employee.specialDeduction × 月份序号 +6. 累计应纳税所得额 = max(0, 累计收入 - 5000×月份 - 累计社保 - 累计公积金 - 累计专项扣除) +7. 累计税额 = calcTax(累计应纳税所得额) // 查7级超额累进税率表 +8. 当月应预扣 = max(0, 累计税额 - Σ 已归档批次.tax) +``` + +**7级超额累进税率表**: + +| 累计应纳税所得额 | 税率 | 速算扣除数 | +|----------------|------|-----------| +| ≤36000 | 3% | 0 | +| ≤144000 | 10% | 2520 | +| ≤300000 | 20% | 16920 | +| ≤420000 | 25% | 31920 | +| ≤660000 | 30% | 52920 | +| ≤960000 | 35% | 85920 | +| >960000 | 45% | 181920 | + +### 4.4 实发工资 + +``` +netPay = totalPay - socialEmp - housingEmp - tax +``` + +## 五、添加/删除员工后更新汇总 + +添加或删除员工后,重新查询所有条目并汇总更新批次: + +``` +employeeCount = 条目数 +totalPay = Σ entry.totalPay +totalNetPay = Σ entry.netPay +totalTax = Σ entry.tax +totalSocialEmp = Σ entry.socialEmp +totalSocialOrg = Σ entry.socialOrg +totalHousingEmp = Σ entry.housingEmp +totalHousingOrg = Σ entry.housingOrg +``` + +## 六、编辑条目后重算 + +- 合并用户修改的输入项(baseSalary/overtimePay/allowance/deduction/bonus) +- 重新调用 `calcBatchEntry`(含 `skipSocial` 逻辑) +- 如果用户手动输入了社保值,用 `overrideSocial` 优先 +- 更新条目后重新汇总批次 + +## 七、归档批次(POST /batches/:batchId/archive) + +- 将批次状态改为 `ARCHIVED`,记录 `archivedAt` +- 归档后批次锁定不可编辑、不可删除 +- 当月存在未归档批次时,不允许创建新批次 + +## 八、取消归档(POST /batches/:batchId/unarchive) + +- 将批次状态从 `ARCHIVED` 恢复为 `DRAFT`,清除 `archivedAt` +- **只能依次取消**:只能取消最后一个归档批次(按 `batchNo` 倒序),确保个税累计链不断裂 +- 取消归档后处理工资条: + - 当月已无归档批次 → 删除该月所有相关员工的工资条(完全撤销个税累计) + - 当月仍有归档批次 → 基于剩余归档批次重新生成工资条(更新累计数据) + +## 九、生成工资条(POST /payslips/generate) + +### 8.1 汇总归档批次 + +``` +1. 获取当月所有 ARCHIVED 批次 +2. 按员工ID分组,累加所有批次的各项金额: + baseSalary, overtimePay, allowance, deduction, bonus, + socialEmp, socialOrg, housingEmp, housingOrg, tax, totalPay, netPay +``` + +### 8.2 生成/更新工资条 + +``` +upsert(employeeId + month): + - 各项金额 = 多批次汇总值 + - ytdIncome = Σ 历史工资条.totalPay + 当月汇总.totalPay + - ytdTaxDeducted = Σ 历史工资条.tax + 当月汇总.tax + - ytdSocialEmp, ytdHousingEmp 同理 + - status = PUBLISHED +``` + +### 8.3 唯一约束 + +`Payslip` 有 `@@unique([employeeId, month])`,同一员工同月只有一条工资条,多次生成会覆盖更新。 + +## 九、多批次场景示例 + +### 场景1:常规工资 + 奖金 + +| 步骤 | 批次1(REGULAR) | 批次2(BONUS) | +|------|-----------------|---------------| +| 创建 | 拉入员工,复制上月数据 | 空白批次,手动添加人员 | +| 社保 | 正常扣缴 | 不扣 | +| 个税 | 累计预扣(含历史工资条) | 单独计税 | +| 归档 | 归档 | 归档 | +| 生成工资条 | 汇总批次1+2 → 一张工资条 | + +### 场景2:常规工资 + 补发常规工资 + +| 步骤 | 批次1(REGULAR) | 批次2(REGULAR) | +|------|-----------------|-----------------| +| 创建 | 拉入员工,正常计算 | 拉入员工,skipSocial=true | +| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) | +| 个税 | 累计预扣(含历史) | 累计预扣(含历史+批次1的工资条) | +| 归档 | 归档 | 归档 | +| 生成工资条 | 汇总批次1+2 → 一张工资条,社保只算一次,个税累计 | + +### 场景3:常规工资 + 离职结算 + +| 步骤 | 批次1(REGULAR) | 批次2(TERMINATION) | +|------|-----------------|---------------------| +| 创建 | 拉入在职员工 | 拉入当月离职员工 | +| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) | +| 个税 | 累计预扣 | 累计预扣(含批次1的工资条) | +| 归档 | 归档 | 归档 | +| 生成工资条 | 汇总批次1+2 → 一张工资条 | + +## 十、关键代码位置 + +| 模块 | 文件 | 行号 | +|------|------|------| +| 创建批次 | `backend/src/routes/payroll2.routes.ts` | 220 | +| 单条目计算 | `backend/src/services/payroll.service.ts` | 126 | +| 社保计算 | `backend/src/services/payroll.service.ts` | 37 | +| 公积金计算 | `backend/src/services/payroll.service.ts` | 69 | +| 累计预扣个税 | `backend/src/services/payroll.service.ts` | 97 | +| 年终奖计税 | `backend/src/services/payroll.service.ts` | 108 | +| 编辑条目 | `backend/src/routes/payroll2.routes.ts` | 435 | +| 添加员工 | `backend/src/routes/payroll2.routes.ts` | 504 | +| 删除员工 | `backend/src/routes/payroll2.routes.ts` | 585 | +| 归档批次 | `backend/src/routes/payroll2.routes.ts` | 662 | +| 生成工资条 | `backend/src/services/payroll.service.ts` | 275 | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 462a340..b2b4090 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import MobileTabBar from './components/layout/MobileTabBar' import PortalLayout from './components/layout/PortalLayout' import PageContainer from './components/layout/PageContainer' import { SkeletonPage } from './components/ui/Skeleton' -import OnboardingGuide from './components/OnboardingGuide' const Login = lazy(() => import('./pages/auth/Login')) const Register = lazy(() => import('./pages/auth/Register')) @@ -62,7 +61,6 @@ function AdminLayout({ children }: { children: React.ReactNode }) { - ) diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index 48b8ad4..a96dff3 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Search, Paperclip, Trash2, X, FileText } from 'lucide-react' +import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react' +import { toast } from 'sonner' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -351,6 +352,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC const queryClient = useQueryClient() const fileInputRef = useRef(null) const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD') + const [previewUrl, setPreviewUrl] = useState(null) const { data: employee } = useQuery({ queryKey: ['employee-detail', employeeId], @@ -379,26 +381,42 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC }) const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - const reader = new FileReader() - reader.onload = (event) => { - const fileUrl = event.target?.result as string - addAttachmentMutation.mutate({ - employeeId, - fileName: file.name, - fileType, - fileUrl, - fileSize: file.size, - }) + const files = e.target.files + if (!files || files.length === 0) return + + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] + const maxSize = 10 * 1024 * 1024 + + for (const file of Array.from(files)) { + const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedExts.includes(ext)) { + toast.error(`不支持的文件格式: ${file.name}`) + continue + } + if (file.size > maxSize) { + toast.error(`文件过大: ${file.name}(最大 10MB)`) + continue + } + const reader = new FileReader() + reader.onload = (event) => { + const fileUrl = event.target?.result as string + addAttachmentMutation.mutate({ + employeeId, + fileName: file.name, + fileType, + fileUrl, + fileSize: file.size, + }) + } + reader.readAsDataURL(file) } - reader.readAsDataURL(file) + e.target.value = '' } const fileTypeLabels: Record = { ID_CARD: '身份证', BANK_CARD: '银行卡', - CONTRACT_SCAN: '合同扫描件', + CONTRACT_SCAN: '合同附件', EDUCATION: '学历证书', OTHER: '其他', } @@ -474,13 +492,14 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC @@ -490,9 +509,10 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} > - {addAttachmentMutation.isPending ? '上传中...' : '上传'} + {addAttachmentMutation.isPending ? '上传中...' : '上传附件'} +

支持 PDF、图片、Word、Excel 等格式,每个文件最大 10MB

{attachments && attachments.length > 0 ? (
@@ -501,18 +521,26 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
-
{att.fileName}
+
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
- +
+ + + + +
))} @@ -522,6 +550,57 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC + + {/* 附件预览弹窗 */} + {previewUrl && (() => { + const dataToBlobUrl = (dataUrl: string) => { + try { + const arr = dataUrl.split(',') + const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream' + const bstr = atob(arr[1]) + const u8 = new Uint8Array(bstr.length) + for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i) + return URL.createObjectURL(new Blob([u8], { type: mime })) + } catch { return dataUrl } + } + const blobUrl = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl + const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : '' + const isImage = mime.startsWith('image/') + const isPdf = mime === 'application/pdf' + + return ( +
{ if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}> +
e.stopPropagation()}> +
+ 附件预览 +
+ + 下载 + + +
+
+
+ {isImage ? ( + 附件预览 + ) : isPdf ? ( + + ) : ( +
+ +

此文件格式不支持在线预览

+ + 点击下载查看 + +
+ )} +
+
+
+ ) + })()} ) } diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index be3e6b0..a4df7c9 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -117,7 +117,7 @@ function BatchManager() { setShowCreateModal(false) toast.success('发薪批次已创建') }, - onError: () => toast.error('创建失败'), + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'), }) const deleteBatchMutation = useMutation({ @@ -140,11 +140,26 @@ function BatchManager() { onError: () => toast.error('重命名失败'), }) + const unarchiveBatchMutation = useMutation({ + mutationFn: (batchId: string) => api.post(`/payroll2/batches/${batchId}/unarchive`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batches'] }) + queryClient.invalidateQueries({ queryKey: ['batch-check'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + toast.success('已取消归档,批次恢复为草稿状态') + }, + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'), + }) + const [renamingId, setRenamingId] = useState(null) const [renameValue, setRenameValue] = useState('') if (selectedBatchId) { - return setSelectedBatchId(null)} /> + return { + queryClient.invalidateQueries({ queryKey: ['batches'] }) + queryClient.invalidateQueries({ queryKey: ['batch-check'] }) + setSelectedBatchId(null) + }} /> } return ( @@ -190,7 +205,14 @@ function BatchManager() { {batches.length} 个批次 )}
-
@@ -353,6 +375,21 @@ function BatchManager() { )} + {batch.status === 'ARCHIVED' && ( + + )} @@ -391,12 +428,16 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void api.put(`/payroll2/batches/${batchId}/entries/${employeeId}`, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) }, }) const removeEmployeeMutation = useMutation({ mutationFn: (employeeId: string) => api.delete(`/payroll2/batches/${batchId}/employees/${employeeId}`), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['batch-detail'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + }, }) const archiveMutation = useMutation({ @@ -410,10 +451,23 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }, }) + const unarchiveMutation = useMutation({ + mutationFn: () => api.post(`/payroll2/batches/${batchId}/unarchive`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) + queryClient.invalidateQueries({ queryKey: ['batch-check'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + toast.success('已取消归档,批次恢复为草稿状态') + }, + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '取消归档失败'), + }) + const importOvertimeMutation = useMutation({ mutationFn: () => api.post(`/payroll/overtime/import-to-batch/${batchId}`), onSuccess: (res: any) => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) queryClient.invalidateQueries({ queryKey: ['overtime-records'] }) if (res.data?.imported > 0) { toast.success(`成功导入 ${res.data.imported} 条加班费记录`) @@ -443,6 +497,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void }, onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) setPayrollImportResult(data) if (data.updated > 0) { toast.success(`成功更新 ${data.updated} 条工资记录`) @@ -640,11 +695,25 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void )} {isArchived && ( - - + + - + )} @@ -816,6 +885,7 @@ function AddEmployeeToBatch({ batchId, onClose }: { batchId: string; onClose: () mutationFn: (employeeIds: string[]) => api.post(`/payroll2/batches/${batchId}/employees`, { employeeIds }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batch-detail'] }) + queryClient.invalidateQueries({ queryKey: ['batches'] }) onClose() }, }) diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 139d25b..965aeb1 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -13,6 +13,7 @@ import Pagination from '../components/ui/Pagination' import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared' import EmployeeProfile from './roster/EmployeeProfile' import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals' +import { ImportSettings } from './Settings' export default function Roster() { const queryClient = useQueryClient() @@ -21,6 +22,7 @@ export default function Roster() { const [search, setSearch] = useState('') const debouncedSearch = useDebouncedValue(search, 300) const [showAddModal, setShowAddModal] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) const [showResignModal, setShowResignModal] = useState(false) const [resignEmployee, setResignEmployee] = useState(null) const [showRehireModal, setShowRehireModal] = useState(false) @@ -263,7 +265,7 @@ export default function Roster() { - @@ -536,6 +538,12 @@ export default function Roster() { /> )} + {showImportModal && ( + setShowImportModal(false)} title="批量导入" size="lg"> + + + )} + {showResignModal && resignEmployee && ( ('init') return ( diff --git a/frontend/src/pages/SocialInsurance.tsx b/frontend/src/pages/SocialInsurance.tsx index 57ddc10..ecd3838 100644 --- a/frontend/src/pages/SocialInsurance.tsx +++ b/frontend/src/pages/SocialInsurance.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } 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, AlertCircle, Clock } from 'lucide-react' +import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -16,6 +16,8 @@ export default function SocialInsurance() { const confirm = useConfirm() const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly') const [city, setCity] = useState('北京') + const [showAddCity, setShowAddCity] = useState(false) + const [newCityName, setNewCityName] = useState('') const [base, setBase] = useState(8000) const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7)) const [showNewVersion, setShowNewVersion] = useState(false) @@ -161,14 +163,14 @@ export default function SocialInsurance() { const { data: result, mutate: calcMutate, isPending } = useMutation({ mutationFn: async () => { - const res = await api.post('/social/calculate', { base }) as any + const res = await api.post('/social/calculate', { base, city }) as any return res.data }, }) const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation({ mutationFn: async () => { - const res = await api.post('/social/housing-calculate', { base }) as any + const res = await api.post('/social/housing-calculate', { base, city }) as any return res.data }, }) @@ -193,6 +195,45 @@ export default function SocialInsurance() { }, }) + const aiSuggestMut = useMutation({ + mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => { + const res = await api.post('/social/ai-suggest', vars) as any + return res.data + }, + onSuccess: (data) => { + if (isHousing) { + activeSetNewVersion({ + ...activeNewVersion, + baseMin: data.baseMin ?? activeNewVersion.baseMin, + baseMax: data.baseMax ?? activeNewVersion.baseMax, + housingOrg: data.housingOrg ?? activeNewVersion.housingOrg, + housingEmp: data.housingEmp ?? activeNewVersion.housingEmp, + }) + } else { + activeSetNewVersion({ + ...activeNewVersion, + baseMin: data.baseMin ?? activeNewVersion.baseMin, + baseMax: data.baseMax ?? activeNewVersion.baseMax, + medicalBaseMin: data.medicalBaseMin ?? 0, + medicalBaseMax: data.medicalBaseMax ?? 0, + pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg, + pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp, + medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg, + medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp, + unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg, + unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp, + injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg, + maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg, + extraInsurances: data.extraInsurances ?? [], + }) + } + toast.success('AI建议已填入,请核对后保存') + }, + onError: () => { + toast.error('AI建议获取失败,请手动填写') + }, + }) + const previewAdjustMutation = useMutation({ mutationFn: async () => { const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any @@ -337,16 +378,52 @@ export default function SocialInsurance() { {(tab === 'social' || tab === 'housing') && (
- setCity(e.target.value)} - placeholder="输入或选择城市" - /> - - {cities.map((c) => + {showAddCity ? ( +
+ setNewCityName(e.target.value)} + placeholder="城市名" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && newCityName.trim()) { + setCity(newCityName.trim()) + setNewCityName('') + setShowAddCity(false) + queryClient.invalidateQueries({ queryKey: ['social-config-cities'] }) + } + if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') } + }} + /> + + +
+ ) : ( +
+ + +
+ )}
)} @@ -415,6 +492,17 @@ export default function SocialInsurance() {
失业(企业/个人){activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%
工伤(企业){activeConfig.injuryOrg}%
生育(企业){activeConfig.maternityOrg}%
+ {Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => ( +
+ {ins.name}(企业/个人) + + {ins.baseType === 'fixed' + ? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}` + : `${ins.orgRate}% / ${ins.empRate}%` + } + +
+ ))} )} @@ -573,9 +661,22 @@ export default function SocialInsurance() {
新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。
+
activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} />
-
activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} />
+
+ +
activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} />
activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} />
diff --git a/frontend/src/pages/roster/AttachmentInfo.tsx b/frontend/src/pages/roster/AttachmentInfo.tsx index dcea307..d4e3c92 100644 --- a/frontend/src/pages/roster/AttachmentInfo.tsx +++ b/frontend/src/pages/roster/AttachmentInfo.tsx @@ -29,11 +29,10 @@ export default function AttachmentInfo({ employeeId, attachments }: { employeeId if (!file) return // 文件类型校验 - const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic'] - const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic'] + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) - if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) { - toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式') + if (!allowedExts.includes(ext)) { + toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式') return } diff --git a/frontend/src/pages/roster/BasicInfo.tsx b/frontend/src/pages/roster/BasicInfo.tsx index 8cc6a9e..ac2275f 100644 --- a/frontend/src/pages/roster/BasicInfo.tsx +++ b/frontend/src/pages/roster/BasicInfo.tsx @@ -30,11 +30,10 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return - const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic'] - const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic'] + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) - if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) { - toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式') + if (!allowedExts.includes(ext)) { + toast.error('不支持的文件格式,支持 PDF、图片、Word、Excel 等常见格式') return } if (file.size > 10 * 1024 * 1024) { diff --git a/frontend/src/pages/roster/ContractInfo.tsx b/frontend/src/pages/roster/ContractInfo.tsx index 1d325d5..1389d67 100644 --- a/frontend/src/pages/roster/ContractInfo.tsx +++ b/frontend/src/pages/roster/ContractInfo.tsx @@ -12,40 +12,48 @@ import { fmt } from "./shared" export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) { const queryClient = useQueryClient() const [showForm, setShowForm] = useState(false) - const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' }) + const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' }) const contractFileRef = useRef(null) + const [previewUrl, setPreviewUrl] = useState(null) const addContractMutation = useMutation({ mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) }, }) + const deleteContractMutation = useMutation({ + mutationFn: (contractId: string) => api.delete(`/employees/contracts/${contractId}`), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') }, + }) + const handleContractFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return + const files = e.target.files + if (!files || files.length === 0) return - // 文件类型校验 - const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/heic'] - const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic'] - const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) - if (!allowedTypes.includes(file.type) && !allowedExts.includes(ext)) { - toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式') - return - } - - // 文件大小校验(10MB) + const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] const maxSize = 10 * 1024 * 1024 - if (file.size > maxSize) { - const formatSize = (bytes: number) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)}KB` : `${(bytes / 1024 / 1024).toFixed(1)}MB` - toast.error(`文件过大,请上传小于 10MB 的文件(当前: ${formatSize(file.size)})`) - return - } - const reader = new FileReader() - reader.onload = (event) => { - setForm({ ...form, attachmentUrl: event.target?.result as string }) + for (const file of Array.from(files)) { + const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + if (!allowedExts.includes(ext)) { + toast.error(`不支持的文件格式: ${file.name}`) + continue + } + if (file.size > maxSize) { + toast.error(`文件过大: ${file.name}(最大 10MB)`) + continue + } + const reader = new FileReader() + reader.onload = (event) => { + setForm(prev => ({ + ...prev, + attachments: [...prev.attachments, { name: file.name, url: event.target?.result as string }], + })) + } + reader.readAsDataURL(file) } - reader.readAsDataURL(file) + // 清空 input 以便重复选择同一文件 + e.target.value = '' } const typeMap: Record = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' } @@ -102,6 +110,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl probationSalary: 0, signMethod: 'PAPER', attachmentUrl: '', + attachments: [], electronicContractNo: '', electronicContractUrl: '', }) @@ -158,14 +167,29 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl {form.signMethod === 'PAPER' && (
- - + +
- {form.attachmentUrl && ✓ 已上传} + {form.attachments.length > 0 && {form.attachments.length} 个文件}
+ {form.attachments.length > 0 && ( +
+ {form.attachments.map((att, idx) => ( +
+ + +
+ ))} +
+ )} +

支持 PDF、JPG、PNG、HEIC、GIF、BMP、WebP、TIFF、Word、Excel 等格式,每个文件最大 10MB

)} {form.signMethod === 'ELECTRONIC' && ( @@ -178,6 +202,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl + + + + + ))} + + ) + })()} )} {c.signMethod === 'ELECTRONIC' && ( @@ -236,9 +282,69 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl )} + ))} + + {/* 附件预览弹窗 */} + {previewUrl && (() => { + // 将 data URL 转为 blob URL,解决浏览器阻止 iframe 加载 data URL 的问题 + const dataToBlobUrl = (dataUrl: string) => { + try { + const arr = dataUrl.split(',') + const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream' + const bstr = atob(arr[1]) + const u8 = new Uint8Array(bstr.length) + for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i) + return URL.createObjectURL(new Blob([u8], { type: mime })) + } catch { return dataUrl } + } + const blobUrl = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl + const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : '' + const isImage = mime.startsWith('image/') + const isPdf = mime === 'application/pdf' + const previewable = isImage || isPdf + + return ( +
{ if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}> +
e.stopPropagation()}> +
+ 附件预览 +
+ + 下载 + + +
+
+
+ {isImage ? ( + 附件预览 + ) : isPdf ? ( + + ) : ( +
+ +

此文件格式不支持在线预览

+ + 点击下载查看 + +
+ )} +
+
+
+ ) + })()} ) } diff --git a/gen_import_sample.py b/gen_import_sample.py new file mode 100644 index 0000000..d66aa4f --- /dev/null +++ b/gen_import_sample.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""生成2名示例人员的全量数据Excel,用于测试批量导入功能""" +from openpyxl import Workbook + +wb = Workbook() + +# ========== Sheet 1: 员工信息 ========== +ws1 = wb.active +ws1.title = '员工信息' +emp_headers = ['姓名','部门','性别','手机号','身份证号','入职日期','月工资','社保基数','公积金基数','专项附加扣除','紧急联系人','紧急联系电话','住址','开户行','银行账号','孕期','医疗期','工伤'] +ws1.append(emp_headers) +emp_data = [ + ['林晓东','技术部','男','13912345678','110101199003072332','2023-03-01',18000,18000,18000,2000,'林父','13800001111','北京市海淀区中关村大街1号','招商银行','6225880123456789','否','否','否'], + ['苏雨晴','销售部','女','13987654321','310101199505121226','2023-06-15',12000,12000,12000,1500,'苏母','13800002222','上海市浦东新区张江路100号','建设银行','6217001234567890','否','否','否'], +] +for row in emp_data: + ws1.append(row) + +# ========== Sheet 2: 劳动合同 ========== +ws2 = wb.create_sheet('劳动合同') +ws2.append(['姓名','身份证号','合同类型','签订日期','合同开始日期','合同结束日期','合同年限','签订方式','试用期月数','试用期工资']) +contract_data = [ + ['林晓东','110101199003072332','固定期限','2023-03-01','2023-03-01','2026-03-01',3,'纸质',3,14400], + ['苏雨晴','310101199505121226','固定期限','2023-06-15','2023-06-15','2026-06-14',3,'电子',2,9600], +] +for row in contract_data: + ws2.append(row) + +# ========== Sheet 3: 加班记录 ========== +ws3 = wb.create_sheet('加班记录') +ws3.append(['姓名','身份证号','日期','工作日加班时长','休息日加班时长','法定节假日加班时长','加班类型','加班时长','倍率','是否审批']) +ot_data = [ + ['林晓东','110101199003072332','2024-01-15',2,4,0,'工作日加班',6,1.5,'是'], + ['苏雨晴','310101199505121226','2024-01-16',1.5,0,0,'工作日加班',1.5,1.5,'是'], + ['苏雨晴','310101199505121226','2024-02-10',0,0,8,'法定节假日加班',8,3.0,'是'], +] +for row in ot_data: + ws3.append(row) + +# ========== Sheet 4: 违纪记录 ========== +ws4 = wb.create_sheet('违纪记录') +ws4.append(['姓名','身份证号','日期','违纪类型','描述','严重程度','处罚']) +disc_data = [ + ['林晓东','110101199003072332','2024-01-10','迟到','上午迟到15分钟','警告','口头警告'], + ['苏雨晴','310101199505121226','2024-02-05','旷工','未请假缺勤半天','严重','书面警告'], +] +for row in disc_data: + ws4.append(row) + +# ========== Sheet 5: 考勤记录 ========== +ws5 = wb.create_sheet('考勤记录') +ws5.append(['姓名','身份证号','日期','考勤状态','上班时间','下班时间','备注']) +att_data = [ + ['林晓东','110101199003072332','2024-01-15','正常','09:00','18:30',''], + ['林晓东','110101199003072332','2024-01-16','迟到','09:20','18:00','迟到20分钟'], + ['林晓东','110101199003072332','2024-01-17','正常','08:55','18:00',''], + ['苏雨晴','310101199505121226','2024-01-15','正常','09:00','18:00',''], + ['苏雨晴','310101199505121226','2024-01-16','请假','','','年假'], + ['苏雨晴','310101199505121226','2024-01-17','正常','09:00','18:30',''], +] +for row in att_data: + ws5.append(row) + +# 保存 +output = '/Users/freedak/Documents/AIDashboard/UTMS/TurboHR/import_sample.xlsx' +wb.save(output) +print(f'已生成: {output}') +print(f'员工信息: {len(emp_data)} 条') +print(f'劳动合同: {len(contract_data)} 条') +print(f'加班记录: {len(ot_data)} 条') +print(f'违纪记录: {len(disc_data)} 条') +print(f'考勤记录: {len(att_data)} 条') diff --git a/import_sample.xlsx b/import_sample.xlsx new file mode 100644 index 0000000..4b3dbfe Binary files /dev/null and b/import_sample.xlsx differ