feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式
This commit is contained in:
Generated
+108
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '未知错误'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">支持 PDF、图片、Word、Excel 等格式,每个文件最大 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -775,7 +775,7 @@ function NotificationSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
function ImportSettings() {
|
||||
export function ImportSettings() {
|
||||
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">支持 PDF、JPG、PNG、HEIC、GIF、BMP、WebP、TIFF、Word、Excel 等格式,每个文件最大 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user