feat: 20260805 系统优化 - 身份证复制fallback/薪税日期筛选/社保版本修复/证据链导出/违纪证明/医疗期政策/绩效类型评级/合同作废/帮助更新

This commit is contained in:
freedakgmail
2026-08-05 20:26:16 +08:00
parent a5901d648e
commit c355a7d208
24 changed files with 1185 additions and 257 deletions
+16
View File
@@ -195,6 +195,7 @@ model Organization {
benefitPlans EmployeeBenefitPlan[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
medicalPeriodPolicies MedicalPeriodPolicy[]
}
model User {
@@ -500,6 +501,21 @@ model OvertimeConfig {
updatedAt DateTime @updatedAt
}
model MedicalPeriodPolicy {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
region String // 地区名称,如"全国"、"上海"、"广东"
legalBasis String // 法律依据
rules Json // 分档规则: [{ maxYears: 5, months: 3, cycleMonths: 6 }, ...]
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, region])
@@index([orgId])
}
model NotificationLog {
id String @id @default(cuid())
orgId String
+26 -2
View File
@@ -284,9 +284,33 @@ router.delete('/contracts/:contractId', authMiddleware, async (req: AuthRequest,
if (!contract) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
}
await prisma.laborContract.delete({ where: { id: req.params.contractId } })
// 作废处理:设置结束日期为当前时间,保留记录但不物理删除
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { endDate: new Date() },
})
const emp = await prisma.employee.findFirst({ where: { id: contract.employeeId }, select: { name: true } })
await auditLog(req, 'DELETE_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
await auditLog(req, 'VOID_CONTRACT', 'CONTRACT', req.params.contractId, { employeeName: emp?.name || '', employeeId: contract.employeeId, contractType: contract.contractType, startDate: contract.startDate, endDate: contract.endDate })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 补充上传合同附件
router.patch('/contracts/:contractId/attachment', 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: '合同不存在' } })
}
const { attachmentUrl } = req.body as { attachmentUrl: string }
await prisma.laborContract.update({
where: { id: req.params.contractId },
data: { attachmentUrl: attachmentUrl || null },
})
res.json({ success: true })
} catch (err) {
next(err)
+4 -2
View File
@@ -150,7 +150,7 @@ router.get('/batches/archived/list', async (req: AuthRequest, res: Response, nex
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, monthFrom, monthTo, status, type } = req.query
const { month, monthFrom, monthTo, status, type, dateFrom, dateTo } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
@@ -159,8 +159,10 @@ router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunctio
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
...(dateFrom ? { createdAt: { gte: new Date(String(dateFrom)) } } : {}),
...(dateTo ? { createdAt: { lte: new Date(String(dateTo) + 'T23:59:59') } } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
orderBy: [{ createdAt: 'desc' }, { month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
+76 -15
View File
@@ -18,6 +18,16 @@ function safeDecrypt(encrypted: string): number {
}
}
function safeDecryptStr(encrypted: string | null): string | null {
if (!encrypted) return null
try {
if (!encrypted.includes(':')) return encrypted
return decrypt(encrypted)
} catch {
return encrypted
}
}
// ========== 花名册聚合 API ==========
// 获取部门列表(去重)
@@ -161,7 +171,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
gender: e.gender,
phone: e.phone,
idCardMasked,
idCardNumber: e.idCardNumber,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
@@ -199,16 +209,11 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardNumber) return false
try {
const fullIdCard = decrypt(e.idCardNumber)
return fullIdCard.endsWith(search!)
} catch {
return false
}
return String(e.idCardNumber).endsWith(search!)
})
}
@@ -312,7 +317,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber),
monthlyProcessRecords,
},
})
@@ -736,13 +741,20 @@ router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest
wsRisk.getRow(1).font = { bold: true }
risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r }))
const encodedName = encodeURIComponent(empName)
const fullFileName = `${empName}_证据链.xlsx`
const encodedName = encodeURIComponent(fullFileName)
const asciiFallback = `evidence_chain_${employee.id.slice(-8)}.xlsx`
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="${encodedName}_证据链.xlsx"; filename*=UTF-8''${encodedName}_证据链.xlsx`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
res.setHeader('Content-Disposition', `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedName}`)
const buffer = await workbook.xlsx.writeBuffer()
res.send(Buffer.from(buffer))
} catch (err: any) {
console.error('证据链导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
} else {
res.end()
}
}
})
@@ -919,6 +931,55 @@ router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req:
} catch (err) { next(err) }
})
// 违纪确认证明导出
router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
include: { employee: true },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
}
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } })
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
let idCard = ''
try { if (record.employee.idCardNumber) idCard = decrypt(record.employee.idCardNumber) } catch { idCard = record.employee.idCardNumber || '' }
const content = `违纪确认证明
兹证明 ${record.employee.name}(身份证号:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
违纪类型:${typeMap[record.violationType] || record.violationType}
严重程度:${severityMap[record.severity] || record.severity}
违纪事实:${record.description}
处理结果:${actionMap[record.action] || record.action}${record.actionDetail ? `${record.actionDetail}` : ''}
${record.employeeAck ? `该员工已于 ${record.ackDate ? new Date(record.ackDate).toISOString().slice(0, 10) : '___'} 签字确认上述违纪事实及处理结果。${record.witness ? `见证人:${record.witness}` : ''}` : '该员工尚未签字确认。'}
特此证明。
${org?.name || ''}
${new Date().toLocaleDateString('zh-CN')}`
const blob = Buffer.from('\ufeff' + content, 'utf8')
const certFileName = `${record.employee.name}_违纪确认证明.doc`
const encodedCertName = encodeURIComponent(certFileName)
const asciiCertFallback = `disciplinary_cert_${record.id.slice(-8)}.doc`
res.setHeader('Content-Type', 'application/msword;charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="${asciiCertFallback}"; filename*=UTF-8''${encodedCertName}`)
res.send(blob)
} catch (err: any) {
console.error('违纪确认证明导出失败:', err?.message || err)
if (!res.headersSent) {
res.status(500).json({ success: false, error: { code: 'EXPORT_FAILED', message: `导出失败:${err?.message || '服务器错误'}` } })
}
}
})
// ========== 考勤记录 CRUD ==========
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
+98
View File
@@ -245,3 +245,101 @@ router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequ
}
})
// ========== 医疗期政策配置 ==========
const DEFAULT_POLICIES = [
{
region: '全国',
legalBasis: '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)',
rules: [
{ maxYears: 5, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 15, months: 9, cycleMonths: 15 },
{ maxYears: 20, months: 12, cycleMonths: 18 },
{ maxYears: 999, months: 24, cycleMonths: 30 },
],
isDefault: true,
},
{
region: '上海',
legalBasis: '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》',
rules: [
{ maxYears: 1, months: 3, cycleMonths: 6 },
{ maxYears: 4, months: 3, cycleMonths: 6 },
{ maxYears: 10, months: 6, cycleMonths: 12 },
{ maxYears: 999, months: 9, cycleMonths: 18 },
],
isDefault: false,
},
]
// 获取医疗期政策列表
router.get('/medical-period/policies', async (req: AuthRequest, res, next) => {
try {
let policies = await prisma.medicalPeriodPolicy.findMany({
where: { orgId: req.user!.orgId },
orderBy: [{ isDefault: 'desc' }, { region: 'asc' }],
})
if (policies.length === 0) {
policies = await prisma.$transaction(
DEFAULT_POLICIES.map(p =>
prisma.medicalPeriodPolicy.create({
data: { orgId: req.user!.orgId, ...p },
})
)
)
}
res.json({ success: true, data: policies })
} catch (err) {
next(err)
}
})
// 新增/编辑医疗期政策
const medicalPolicySchema = z.object({
region: z.string().min(1, '地区名称不能为空'),
legalBasis: z.string().min(1, '法律依据不能为空'),
rules: z.array(z.object({
maxYears: z.number().min(0),
months: z.number().min(1),
cycleMonths: z.number().min(1),
})).min(1, '至少需要一条分档规则'),
isDefault: z.boolean().default(false),
})
router.post('/medical-period/policies', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const data = medicalPolicySchema.parse(req.body)
if (data.isDefault) {
await prisma.medicalPeriodPolicy.updateMany({
where: { orgId: req.user!.orgId },
data: { isDefault: false },
})
}
const policy = await prisma.medicalPeriodPolicy.upsert({
where: { orgId_region: { orgId: req.user!.orgId, region: data.region } },
update: { legalBasis: data.legalBasis, rules: data.rules, isDefault: data.isDefault },
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: policy })
} catch (err: any) {
if (err.issues) return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: err.issues[0]?.message } })
next(err)
}
})
// 删除医疗期政策
router.delete('/medical-period/policies/:id', requireAdmin, async (req: AuthRequest, res, next) => {
try {
const policy = await prisma.medicalPeriodPolicy.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!policy) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '政策不存在' } })
if (policy.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除默认政策' } })
await prisma.medicalPeriodPolicy.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
+4 -4
View File
@@ -9,7 +9,7 @@ const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
@@ -26,7 +26,7 @@ const socialConfigFields = {
}
const housingConfigFields = {
city: z.string().optional(),
city: z.string().min(1),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
@@ -136,9 +136,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
// 将之前当前版本标记为失效(按城市过滤)
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
where: { orgId, city: data.city, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
+18
View File
@@ -185,6 +185,24 @@ router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Respon
}
}
// 离职证明/收入证明审批通过后,如果有关联员工,创建电子签记录
if (execResult.employeeId && (process.type === 'LEAVING_CERT' || process.type === 'INCOME_CERT')) {
const docTitle = process.type === 'LEAVING_CERT' ? '离职证明签署' : '收入证明签署'
await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: execResult.employeeId,
scene: process.type === 'LEAVING_CERT' ? 'RESIGNATION' : 'OTHER',
documentTitle: docTitle,
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: '文书审批通过后自动发起',
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
}).catch(() => {})
}
res.json({ success: true, data: updated })
} catch (err) {
next(err)