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)
}