feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式

This commit is contained in:
freedakgmail
2026-07-27 23:45:22 +08:00
parent 71d7ab2de5
commit 5e22a82163
21 changed files with 1246 additions and 178 deletions
+108
View File
@@ -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",
+2
View File
@@ -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"
+16
View File
@@ -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
+49 -6
View File
@@ -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 || '未知错误'}`)
}
}
}
+127 -20
View File
@@ -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 {
+96 -44
View File
@@ -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
+3
View File
@@ -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(),
})
+33 -4
View File
@@ -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 }
+37 -10
View File
@@ -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)
}
+249
View File
@@ -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:常规工资 + 奖金
| 步骤 | 批次1REGULAR | 批次2BONUS |
|------|-----------------|---------------|
| 创建 | 拉入员工,复制上月数据 | 空白批次,手动添加人员 |
| 社保 | 正常扣缴 | 不扣 |
| 个税 | 累计预扣(含历史工资条) | 单独计税 |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条 |
### 场景2:常规工资 + 补发常规工资
| 步骤 | 批次1REGULAR | 批次2REGULAR |
|------|-----------------|-----------------|
| 创建 | 拉入员工,正常计算 | 拉入员工,skipSocial=true |
| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) |
| 个税 | 累计预扣(含历史) | 累计预扣(含历史+批次1的工资条) |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条,社保只算一次,个税累计 |
### 场景3:常规工资 + 离职结算
| 步骤 | 批次1REGULAR | 批次2TERMINATION |
|------|-----------------|---------------------|
| 创建 | 拉入在职员工 | 拉入当月离职员工 |
| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) |
| 个税 | 累计预扣 | 累计预扣(含批次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 |
-2
View File
@@ -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 }) {
</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
</div>
</div>
)
+103 -24
View File
@@ -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<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const { data: employee } = useQuery<any>({
queryKey: ['employee-detail', employeeId],
@@ -379,26 +381,42 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<string, string> = {
ID_CARD: '身份证',
BANK_CARD: '银行卡',
CONTRACT_SCAN: '合同扫描件',
CONTRACT_SCAN: '合同件',
EDUCATION: '学历证书',
OTHER: '其他',
}
@@ -474,13 +492,14 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="OTHER"></option>
</Select>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileUpload}
/>
@@ -490,9 +509,10 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
onClick={() => fileInputRef.current?.click()}
disabled={addAttachmentMutation.isPending}
>
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
</Button>
</div>
<p className="text-xs text-gray-400 mb-2"> PDFWordExcel 10MB</p>
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
@@ -501,18 +521,26 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<button onClick={() => setPreviewUrl(att.fileUrl)} className="text-primary hover:underline truncate text-left">
{att.fileName}
</button>
<div className="text-xs text-gray-400">
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger shrink-0 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1 shrink-0 ml-2">
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-primary p-1" title="下载">
<Download className="w-4 h-4" />
</a>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger p-1"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
@@ -522,6 +550,57 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC
</div>
</div>
</div>
{/* 附件预览弹窗 */}
{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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}
+78 -8
View File
@@ -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<string | null>(null)
const [renameValue, setRenameValue] = useState('')
if (selectedBatchId) {
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
return <BatchDetail batchId={selectedBatchId} onBack={() => {
queryClient.invalidateQueries({ queryKey: ['batches'] })
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
setSelectedBatchId(null)
}} />
}
return (
@@ -190,7 +205,14 @@ function BatchManager() {
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">{batches.length} </span>
)}
<div className="flex-1" />
<Button onClick={() => { setCreateType('REGULAR'); setShowCreateModal(true) }} className="shrink-0">
<Button onClick={() => {
const hasDraft = batches?.some((b: any) => b.status === 'DRAFT' && b.month === month)
if (hasDraft) {
toast.error('当月存在未归档的批次,请先归档后再创建新批次')
return
}
setCreateType('REGULAR'); setShowCreateModal(true)
}} className="shrink-0">
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
@@ -353,6 +375,21 @@ function BatchManager() {
</button>
</>
)}
{batch.status === 'ARCHIVED' && (
<button
onClick={async () => {
if (await confirm({ title: '取消归档', message: `确认取消归档「${batch.name}」?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。`, variant: 'primary' })) {
unarchiveBatchMutation.mutate(batch.id)
}
}}
disabled={unarchiveBatchMutation.isPending}
className="p-1 rounded hover:bg-amber-50 text-gray-500 hover:text-warning transition-colors"
aria-label="取消归档"
title="取消归档"
>
<Archive className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
@@ -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
</div>
)}
{isArchived && (
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
<div className="flex gap-2">
<a href={`/api/v1/payroll2/batches/${batchId}/export?format=csv`} download>
<Button variant="secondary" size="sm">
<Download className="w-4 h-4 mr-1" />
</Button>
</a>
<Button
variant="secondary"
size="sm"
onClick={async () => {
if (await confirm({ title: '取消归档', message: '确认取消归档?取消后批次恢复为草稿状态,对应的工资条和个税累计将被撤销。', variant: 'primary' })) {
unarchiveMutation.mutate()
}
}}
disabled={unarchiveMutation.isPending}
>
{unarchiveMutation.isPending ? '取消中...' : '取消归档'}
</Button>
</a>
</div>
)}
</div>
@@ -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()
},
})
+9 -1
View File
@@ -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<any>(null)
const [showRehireModal, setShowRehireModal] = useState(false)
@@ -263,7 +265,7 @@ export default function Roster() {
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
<Plus className="mr-1.5 h-4 w-4" />
</Button>
<Button variant="secondary" onClick={() => window.location.hash = '#/settings'} className="h-9 shrink-0">
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
<Upload className="mr-1.5 h-4 w-4" />
</Button>
</div>
@@ -536,6 +538,12 @@ export default function Roster() {
/>
)}
{showImportModal && (
<Modal open={showImportModal} onClose={() => setShowImportModal(false)} title="批量导入" size="lg">
<ImportSettings />
</Modal>
)}
{showResignModal && resignEmployee && (
<ResignModal
employee={resignEmployee}
+1 -1
View File
@@ -775,7 +775,7 @@ function NotificationSettings() {
)
}
function ImportSettings() {
export function ImportSettings() {
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
return (
+115 -14
View File
@@ -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<string>('北京')
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<any>({
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<any>({
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<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
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') && (
<div className="flex items-center gap-2 ml-auto">
<label className="text-sm text-gray-500">:</label>
<input
list="social-cities"
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="输入或选择城市"
/>
<datalist id="social-cities">
{cities.map((c) => <option key={c} value={c} />)}
</datalist>
{showAddCity ? (
<div className="flex items-center gap-1">
<input
className="h-9 w-24 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newCityName}
onChange={(e) => 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('') }
}}
/>
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
if (newCityName.trim()) {
setCity(newCityName.trim())
setNewCityName('')
setShowAddCity(false)
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
}
}}></button>
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}></button>
</div>
) : (
<div className="flex items-center gap-1">
<select
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
<button
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
title="添加新城市"
onClick={() => setShowAddCity(true)}
>
<MapPin className="w-4 h-4" />
</button>
</div>
)}
</div>
)}
</div>
@@ -415,6 +492,17 @@ export default function SocialInsurance() {
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
<div key={idx} className="flex justify-between border-b pb-1.5">
<span className="text-gray-500">{ins.name}(/)</span>
<span className="font-medium">
{ins.baseType === 'fixed'
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
: `${ins.orgRate}% / ${ins.empRate}%`
}
</span>
</div>
))}
</div>
)}
</Card>
@@ -573,9 +661,22 @@ export default function SocialInsurance() {
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>7</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
disabled={aiSuggestMut.isPending}
>
<Sparkles className="w-4 h-4" />
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
</button>
<div className="grid md:grid-cols-3 gap-3">
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label>
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
+3 -4
View File
@@ -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
}
+3 -4
View File
@@ -30,11 +30,10 @@ export default function BasicInfo({ profile, employeeId, attachments }: { profil
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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) {
+142 -36
View File
@@ -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<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(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<HTMLInputElement>) => {
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<string, string> = { 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
</div>
{form.signMethod === 'PAPER' && (
<div className="md:col-span-2">
<Label> *</Label>
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
<Label> *</Label>
<input ref={contractFileRef} type="file" multiple className="hidden" onChange={handleContractFileUpload} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
<Paperclip className="w-4 h-4 mr-1" />
<Paperclip className="w-4 h-4 mr-1" />
</Button>
{form.attachmentUrl && <span className="text-xs text-safe"> </span>}
{form.attachments.length > 0 && <span className="text-xs text-gray-500">{form.attachments.length} </span>}
</div>
{form.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{form.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button type="button" onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<button type="button" onClick={() => setForm(prev => ({ ...prev, attachments: prev.attachments.filter((_, i) => i !== idx) }))} className="text-danger hover:underline ml-2 shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-1"> PDFJPGPNGHEICGIFBMPWebPTIFFWordExcel 10MB</p>
</div>
)}
{form.signMethod === 'ELECTRONIC' && (
@@ -178,6 +202,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<Button onClick={() => {
const payload = {
...form,
attachmentUrl: form.attachments.length > 0 ? JSON.stringify(form.attachments) : '',
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
@@ -185,7 +210,7 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
addContractMutation.mutate(payload)
}} disabled={
addContractMutation.isPending || !form.startDate ||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
(form.signMethod === 'PAPER' && form.attachments.length === 0) ||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
}>
{addContractMutation.isPending ? '保存中...' : '保存'}
@@ -211,15 +236,36 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
{c.attachmentUrl ? (
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
<Paperclip className="w-3 h-3" />
</a>
) : (
<span className="text-gray-400"></span>
)}
<div className="md:col-span-3">
<span className="text-gray-500"></span>
{(() => {
let atts: { name: string; url: string }[] = []
try {
const parsed = JSON.parse(c.attachmentUrl)
atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }]
} catch {
if (c.attachmentUrl) {
const mime = c.attachmentUrl.match(/data:(.*?);/)?.[1] || ''
const ext = mime.split('/')[1] || '文件'
atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<Download className="w-3 h-3" />
</a>
</div>
))}
</div>
)
})()}
</div>
)}
{c.signMethod === 'ELECTRONIC' && (
@@ -236,9 +282,69 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
</>
)}
</div>
<button
onClick={() => { if (confirm('确定删除此合同记录?')) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
{/* 附件预览弹窗 */}
{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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}
+72
View File
@@ -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)}')
Binary file not shown.