feat: 完成全部11项优化需求 + 模板必填项标注 + 归档重算个税
- 高优先级: 花名册导入模板必填项标注、性别自动识别、导入结果反馈、证据链Excel导出、身份证搜索修复、试用期区分与转正提醒、薪税批次流程优化 - 中优先级: 专项附加扣除批量导入、文本模板库完善(Word下载/复制/使用说明)、用工体检评分标准说明、考勤页面导入入口 - 所有导入模板表头标注必填项(*后缀)并含示例行 - 导入逻辑统一改用getField兼容*后缀列名 - 批次归档时强制重算所有条目个税和社保,解决多未归档批次并存时累计计算不准问题 - 更新需求梳理文档
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Router } from 'express'
|
||||
import { Router, Response } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, encrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
import ExcelJS from 'exceljs'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -54,7 +55,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
todayEnd.setDate(todayEnd.getDate() + 1)
|
||||
|
||||
// 先查询满足 orgId 和搜索条件的员工
|
||||
const isIdCardSearch = search && /^\d{4}$/.test(search)
|
||||
const isIdCardSearch = search && /^\d{2,}$/.test(search)
|
||||
const whereBase: any = { orgId: req.user!.orgId }
|
||||
if (department) {
|
||||
whereBase.department = department
|
||||
@@ -156,6 +157,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
idCardMasked,
|
||||
idCardNumber: e.idCardNumber,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
isPregnant: e.isPregnant,
|
||||
isInMedicalPeriod: e.isInMedicalPeriod,
|
||||
@@ -164,6 +166,19 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
probationInfo: (() => {
|
||||
if (!latestContract || latestContract.probationMonths === 0) return null
|
||||
const probEnd = new Date(e.hireDate)
|
||||
probEnd.setMonth(probEnd.getMonth() + latestContract.probationMonths)
|
||||
const daysToConfirm = Math.ceil((probEnd.getTime() - today.getTime()) / 86400000)
|
||||
return {
|
||||
months: latestContract.probationMonths,
|
||||
endDate: probEnd.toISOString().slice(0, 10),
|
||||
daysToConfirm,
|
||||
isProbation: daysToConfirm > 0 && dynamicStatus === 'ACTIVE',
|
||||
isExpiring: daysToConfirm <= 7 && daysToConfirm > 0,
|
||||
}
|
||||
})(),
|
||||
counts: e._count,
|
||||
}
|
||||
})
|
||||
@@ -173,11 +188,16 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
// 身份证号后4位搜索:在内存中过滤
|
||||
// 身份证号后N位搜索:在内存中过滤(解密完整身份证号后匹配)
|
||||
if (isIdCardSearch) {
|
||||
result = result.filter((e: any) => {
|
||||
if (!e.idCardMasked) return false
|
||||
return e.idCardMasked.endsWith(search!)
|
||||
if (!e.idCardNumber) return false
|
||||
try {
|
||||
const fullIdCard = decrypt(e.idCardNumber)
|
||||
return fullIdCard.endsWith(search!)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -535,6 +555,174 @@ router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res,
|
||||
}
|
||||
})
|
||||
|
||||
// 仲裁证据链 Excel 导出
|
||||
router.get('/:id/evidence-chain/export', authMiddleware, async (req: AuthRequest, res: Response, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' } },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { where: { status: { not: 'CANCELLED' } }, orderBy: { createdAt: 'desc' } },
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const empName = employee.name
|
||||
const empDept = employee.department
|
||||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
|
||||
// Sheet 1: 员工信息
|
||||
const wsInfo = workbook.addWorksheet('员工信息')
|
||||
wsInfo.columns = [
|
||||
{ header: '项目', key: 'label', width: 16 },
|
||||
{ header: '内容', key: 'value', width: 40 },
|
||||
]
|
||||
wsInfo.getRow(1).font = { bold: true }
|
||||
const empStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? '离职' : '在职'
|
||||
wsInfo.addRows([
|
||||
{ label: '姓名', value: empName },
|
||||
{ label: '部门', value: empDept },
|
||||
{ label: '入职日期', value: hireDate },
|
||||
{ label: '状态', value: empStatus },
|
||||
{ label: '导出时间', value: new Date().toLocaleString('zh-CN') },
|
||||
])
|
||||
|
||||
// Sheet 2: 证据清单
|
||||
const wsEv = workbook.addWorksheet('证据清单')
|
||||
wsEv.columns = [
|
||||
{ header: '序号', key: 'no', width: 6 },
|
||||
{ header: '类别', key: 'category', width: 12 },
|
||||
{ header: '标题', key: 'title', width: 28 },
|
||||
{ header: '日期', key: 'date', width: 12 },
|
||||
{ header: '描述', key: 'description', width: 60 },
|
||||
{ header: '签字状态', key: 'ack', width: 10 },
|
||||
]
|
||||
wsEv.getRow(1).font = { bold: true }
|
||||
|
||||
let evNo = 0
|
||||
// 劳动关系
|
||||
evNo++; wsEv.addRow({ no: evNo, category: '劳动关系', title: '入职登记', date: hireDate, description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`, ack: '' })
|
||||
employee.contracts.forEach((c) => {
|
||||
const typeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record<string, string>)[c.contractType] || '未签订'
|
||||
evNo++
|
||||
wsEv.addRow({
|
||||
no: evNo, category: '劳动关系', title: `合同(${typeText})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。${c.signDate ? '' : '⚠ 该合同尚未签订。'}`,
|
||||
ack: c.signDate ? '已签字' : '未签字',
|
||||
})
|
||||
})
|
||||
|
||||
// 薪酬发放
|
||||
employee.payslips.forEach((p) => {
|
||||
evNo++
|
||||
wsEv.addRow({
|
||||
no: evNo, category: '薪酬发放', title: `${p.month}月工资条`, date: p.month,
|
||||
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
|
||||
ack: p.confirmedAt ? '已签字' : '未签字',
|
||||
})
|
||||
})
|
||||
employee.overtimeRecords.forEach((o) => {
|
||||
if (o.totalPay > 0) {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '薪酬发放', title: `${o.month}月加班费记录`, date: o.month, description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`, ack: '' })
|
||||
}
|
||||
})
|
||||
|
||||
// 考勤记录
|
||||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
employee.attendanceRecords.filter((a) => a.status !== 'NORMAL').forEach((a) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '考勤记录', title: `${a.date.toISOString().slice(0, 10)} 考勤异常`, date: a.date.toISOString().slice(0, 10), description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`, ack: '' })
|
||||
})
|
||||
|
||||
// 违纪处理
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
employee.disciplinaryRecords.forEach((d) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '违纪处理', title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`, date: d.violationDate.toISOString().slice(0, 10), description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`, ack: d.employeeAck ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 培训签收
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
employee.trainingRecords.forEach((t) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '培训签收', title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`, date: t.trainingDate.toISOString().slice(0, 10), description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`, ack: t.ackStatus === 'SIGNED' ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 绩效考核
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
employee.performanceRecords.forEach((p) => {
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '绩效考核', title: `${p.period} 绩效考核`, date: p.period, description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`, ack: p.employeeAck ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// 解聘记录
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' }
|
||||
const termStatusMap: Record<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||||
employee.terminations.forEach((t) => {
|
||||
const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED' ? '协商一致离职' : t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘'
|
||||
evNo++
|
||||
wsEv.addRow({ no: evNo, category: '解聘记录', title: `${t.terminationDate.toISOString().slice(0, 10)} ${typeLabel}记录`, date: t.terminationDate.toISOString().slice(0, 10), description: `类型:${typeLabel}。原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。流程状态:${termStatusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`, ack: t.status === 'COMPLETED' ? '已签字' : '未签字' })
|
||||
})
|
||||
|
||||
// Sheet 3: 风险提醒
|
||||
const risks: any[] = []
|
||||
if (employee.contracts.length === 0) {
|
||||
const days = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000)
|
||||
if (days > 30) risks.push({ level: days > 365 ? '危险' : '高', category: '劳动关系', title: '未签订书面劳动合同', description: `入职已${days}天仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险。${days > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}` })
|
||||
}
|
||||
employee.contracts.forEach((c) => {
|
||||
if (!c.signDate) risks.push({ level: '高', category: '劳动关系', title: '合同未签字', description: `合同期限${c.startDate.toISOString().slice(0, 10)}至${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},尚未签订。` })
|
||||
if (c.endDate && c.endDate < today) {
|
||||
const expiredDays = Math.floor((today.getTime() - c.endDate.getTime()) / 86400000)
|
||||
risks.push({ level: expiredDays > 30 ? '高' : '中', category: '劳动关系', title: '合同已过期', description: `合同已于${c.endDate.toISOString().slice(0, 10)}过期,过期${expiredDays}天。` })
|
||||
} else if (c.endDate) {
|
||||
const daysToExpire = Math.floor((c.endDate.getTime() - today.getTime()) / 86400000)
|
||||
if (daysToExpire <= 30 && daysToExpire >= 0) risks.push({ level: '中', category: '劳动关系', title: '合同即将到期', description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。` })
|
||||
}
|
||||
if (c.probationMonths > 0 && c.probationSalary === 0) risks.push({ level: '中', category: '劳动关系', title: '试用期工资为0', description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条。` })
|
||||
})
|
||||
const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000)
|
||||
if (daysSinceHire > 30 && employee.payslips.length === 0) risks.push({ level: '中', category: '薪酬发放', title: '无工资条记录', description: `入职已${daysSinceHire}天但无任何工资条记录。` })
|
||||
employee.terminations.forEach((t) => {
|
||||
if (t.status !== 'COMPLETED') risks.push({ level: '高', category: '解聘记录', title: '离职流程未完成', description: `流程当前状态为「${termStatusMap[t.status] || t.status}」,尚未完成闭环。` })
|
||||
if (t.reason === 'NEGOTIATED' && t.compensation === 0) risks.push({ level: '中', category: '解聘记录', title: '协商解除但补偿金为0', description: '解聘原因为协商解除但经济补偿金为0,面临2N赔偿风险。' })
|
||||
})
|
||||
|
||||
const wsRisk = workbook.addWorksheet('风险提醒')
|
||||
wsRisk.columns = [
|
||||
{ header: '序号', key: 'no', width: 6 },
|
||||
{ header: '风险等级', key: 'level', width: 10 },
|
||||
{ header: '类别', key: 'category', width: 12 },
|
||||
{ header: '标题', key: 'title', width: 24 },
|
||||
{ header: '描述', key: 'description', width: 60 },
|
||||
]
|
||||
wsRisk.getRow(1).font = { bold: true }
|
||||
risks.forEach((r, i) => wsRisk.addRow({ no: i + 1, ...r }))
|
||||
|
||||
const encodedName = encodeURIComponent(empName)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
|
||||
Reference in New Issue
Block a user