fb36b10402
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
1022 lines
40 KiB
TypeScript
1022 lines
40 KiB
TypeScript
import { Router } 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'
|
||
|
||
const router = Router()
|
||
|
||
function safeDecrypt(encrypted: string): number {
|
||
try {
|
||
if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0
|
||
return Number(decrypt(encrypted))
|
||
} catch {
|
||
return Number(encrypted) || 0
|
||
}
|
||
}
|
||
|
||
// ========== 花名册聚合 API ==========
|
||
|
||
// 获取部门列表(去重)
|
||
router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const employees = await prisma.employee.findMany({
|
||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||
select: { department: true },
|
||
distinct: 'department',
|
||
})
|
||
const departments = employees.map((e) => e.department).filter(Boolean).sort()
|
||
res.json({ success: true, data: departments })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 花名册列表(含汇总信息,支持分页和过滤)
|
||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const page = parseInt(req.query.page as string) || 1
|
||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
|
||
const search = req.query.search as string
|
||
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
|
||
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
||
const department = req.query.department as string
|
||
const skip = (page - 1) * pageSize
|
||
|
||
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
// hireDate 可能以 UTC 午夜存储(如 new Date('2026-07-24')),在 UTC+8 下为 08:00
|
||
// 将 today 前移一天作为比较基准,确保当天入职的员工不被误判为预入职
|
||
const todayEnd = new Date(today)
|
||
todayEnd.setDate(todayEnd.getDate() + 1)
|
||
|
||
// 先查询满足 orgId 和搜索条件的员工
|
||
const isIdCardSearch = search && /^\d{4}$/.test(search)
|
||
const whereBase: any = { orgId: req.user!.orgId }
|
||
if (department) {
|
||
whereBase.department = department
|
||
}
|
||
if (search && !isIdCardSearch) {
|
||
whereBase.OR = [
|
||
{ name: { contains: search } },
|
||
{ department: { contains: search } },
|
||
]
|
||
}
|
||
|
||
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
||
if (status === 'RESIGNED') {
|
||
whereBase.status = 'RESIGNED'
|
||
} else if (status === 'PRE_HIRE') {
|
||
whereBase.status = 'ACTIVE'
|
||
whereBase.hireDate = { gt: todayEnd }
|
||
} else if (status === 'ACTIVE') {
|
||
whereBase.status = 'ACTIVE'
|
||
whereBase.hireDate = { lte: todayEnd }
|
||
}
|
||
|
||
// unsigned 合同状态可以在 DB 层过滤
|
||
if (contractStatus === 'unsigned') {
|
||
whereBase.contracts = { none: {} }
|
||
}
|
||
|
||
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
|
||
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
|
||
|
||
const [dbTotal, employees] = await Promise.all([
|
||
prisma.employee.count({ where: whereBase }),
|
||
prisma.employee.findMany({
|
||
where: whereBase,
|
||
orderBy: { createdAt: 'desc' },
|
||
...(needPostFilter ? {} : { skip, take: pageSize }),
|
||
include: {
|
||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
|
||
_count: {
|
||
select: {
|
||
disciplinaryRecords: true,
|
||
attendanceRecords: true,
|
||
trainingRecords: true,
|
||
performanceRecords: true,
|
||
payslips: true,
|
||
overtimeRecords: true,
|
||
},
|
||
},
|
||
},
|
||
}),
|
||
])
|
||
|
||
// 计算动态状态和合同状态
|
||
let result = employees.map((e) => {
|
||
const latestContract = e.contracts[0] || null
|
||
const contractInfo = latestContract
|
||
? getContractStatus({
|
||
signDate: latestContract.signDate,
|
||
startDate: latestContract.startDate,
|
||
endDate: latestContract.endDate,
|
||
contractType: latestContract.contractType,
|
||
hireDate: e.hireDate,
|
||
})
|
||
: getContractStatus({
|
||
signDate: null,
|
||
startDate: e.hireDate,
|
||
endDate: null,
|
||
contractType: 'UNSIGNED',
|
||
hireDate: e.hireDate,
|
||
})
|
||
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
|
||
const isPreHire = !isResigned && e.hireDate > todayEnd
|
||
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
||
// 身份证号脱敏显示
|
||
let idCardMasked: string | null = null
|
||
if (e.idCardNumber) {
|
||
try {
|
||
const idCard = decrypt(e.idCardNumber)
|
||
if (idCard.length >= 11) {
|
||
idCardMasked = idCard.slice(0, 3) + '****' + idCard.slice(-4)
|
||
} else {
|
||
idCardMasked = '****'
|
||
}
|
||
} catch {}
|
||
}
|
||
return {
|
||
id: e.id,
|
||
name: e.name,
|
||
department: e.department,
|
||
city: e.city,
|
||
status: dynamicStatus,
|
||
hasTermination: e.terminations.length > 0,
|
||
latestTerminationDate: e.terminations[0]?.terminationDate || null,
|
||
latestTerminationType: e.terminations[0]?.type || null,
|
||
latestTerminationId: e.terminations[0]?.id || null,
|
||
latestTerminationStatus: e.terminations[0]?.status || null,
|
||
hireDate: e.hireDate,
|
||
gender: e.gender,
|
||
phone: e.phone,
|
||
idCardMasked,
|
||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||
isPregnant: e.isPregnant,
|
||
isInMedicalPeriod: e.isInMedicalPeriod,
|
||
isWorkInjured: e.isWorkInjured,
|
||
latestContract,
|
||
contractStatus: contractInfo.status,
|
||
contractStatusText: contractInfo.statusText,
|
||
riskLevel: contractInfo.riskLevel,
|
||
counts: e._count,
|
||
}
|
||
})
|
||
|
||
// 前端过滤:合同状态(非 unsigned 的需要后处理计算)
|
||
if (contractStatus && contractStatus !== 'unsigned') {
|
||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||
}
|
||
|
||
// 身份证号后4位搜索:在内存中过滤
|
||
if (isIdCardSearch) {
|
||
result = result.filter((e: any) => {
|
||
if (!e.idCardMasked) return false
|
||
return e.idCardMasked.endsWith(search!)
|
||
})
|
||
}
|
||
|
||
// 计算过滤后的总数和分页
|
||
const needMemoryPaging = needPostFilter || isIdCardSearch
|
||
const filteredTotal = needMemoryPaging ? result.length : dbTotal
|
||
if (needMemoryPaging) {
|
||
result = result.slice(skip, skip + pageSize)
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: result,
|
||
pagination: {
|
||
page,
|
||
pageSize,
|
||
total: filteredTotal,
|
||
totalPages: Math.ceil(filteredTotal / pageSize),
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 员工完整档案(花名册详情)
|
||
router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, 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' }, take: 90 },
|
||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||
performanceRecords: { orderBy: { period: 'desc' } },
|
||
terminations: { orderBy: { createdAt: 'desc' } },
|
||
attachments: true,
|
||
socialInsRecords: { orderBy: { startMonth: 'desc' } },
|
||
housingFundRecords: { orderBy: { startMonth: 'desc' } },
|
||
salaryChanges: { orderBy: { effectiveDate: 'desc' } },
|
||
departmentRecords: { orderBy: { effectiveMonth: 'desc' } },
|
||
},
|
||
})
|
||
if (!employee) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
}
|
||
|
||
// 查询该员工相关的月度办理记录(从快照中筛选该员工)
|
||
const allProcesses = await prisma.socialMonthlyProcess.findMany({
|
||
where: { orgId: req.user!.orgId },
|
||
orderBy: { month: 'desc' },
|
||
})
|
||
const employeeId = req.params.id
|
||
const monthlyProcessRecords: any[] = []
|
||
for (const p of allProcesses) {
|
||
const snap = p.snapshot as any
|
||
// 从增减员快照中筛选
|
||
const changes = snap.changes
|
||
const active = snap.active
|
||
const type = p.type
|
||
let found = false
|
||
let recordData: any = { month: p.month, type, processedAt: p.processedAt, status: p.status }
|
||
if (changes?.additions) {
|
||
const item = changes.additions.find((a: any) => a.employeeId === employeeId)
|
||
if (item) { recordData.changeType = '新增'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||
}
|
||
if (!found && changes?.reductions) {
|
||
const item = changes.reductions.find((a: any) => a.employeeId === employeeId)
|
||
if (item) { recordData.changeType = '减少'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||
}
|
||
if (!found && active?.items) {
|
||
const item = active.items.find((a: any) => a.employeeId === employeeId)
|
||
if (item) { recordData.changeType = '正常在保'; recordData.detail = item.detail; recordData.base = item.base; recordData.city = item.city; found = true }
|
||
}
|
||
if (found) monthlyProcessRecords.push(recordData)
|
||
}
|
||
|
||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
...rest,
|
||
status: dynamicStatus,
|
||
monthlySalary: safeDecrypt(monthlySalary),
|
||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
|
||
monthlyProcessRecords,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 仲裁证据链导出
|
||
router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, 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: true,
|
||
},
|
||
})
|
||
if (!employee) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
}
|
||
|
||
const evidence: any[] = []
|
||
const empName = employee.name
|
||
const empDept = employee.department
|
||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||
|
||
// 风险检测
|
||
const risks: any[] = []
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
// 1. 劳动关系证据
|
||
evidence.push({
|
||
category: '劳动关系',
|
||
title: '入职登记',
|
||
date: hireDate,
|
||
description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`,
|
||
evidenceType: 'EMPLOYMENT',
|
||
})
|
||
|
||
// 无合同风险检测
|
||
if (employee.contracts.length === 0) {
|
||
const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / (86400000))
|
||
if (daysSinceHire > 30) {
|
||
risks.push({
|
||
level: daysSinceHire > 365 ? 'DANGER' : 'HIGH',
|
||
category: '劳动关系',
|
||
title: '未签订书面劳动合同',
|
||
description: `入职已${daysSinceHire}天仍未签订书面劳动合同,超过30天未签合同将面临双倍工资赔偿风险(《劳动合同法》第82条)。${daysSinceHire > 365 ? '已满一年未签合同,视为已订立无固定期限劳动合同。' : ''}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
employee.contracts.forEach((c) => {
|
||
const contractTypeText = ({ FIXED: '固定期限', UNFIXED: '无固定期限', LABOR: '劳务协议', INTERNSHIP: '实习协议', UNSIGNED: '未签订' } as Record<string, string>)[c.contractType] || '未签订'
|
||
const isSigned = !!c.signDate
|
||
evidence.push({
|
||
category: '劳动关系',
|
||
title: `合同(${contractTypeText})`,
|
||
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}。${isSigned ? '' : '⚠ 该合同尚未签订。'}`,
|
||
evidenceType: 'CONTRACT',
|
||
signed: isSigned,
|
||
riskLevel: !isSigned ? 'HIGH' : undefined,
|
||
})
|
||
|
||
// 合同未签字风险
|
||
if (!isSigned) {
|
||
risks.push({
|
||
level: 'HIGH',
|
||
category: '劳动关系',
|
||
title: '合同未签字',
|
||
description: `合同(${contractTypeText})期限${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 ? 'HIGH' : 'MEDIUM',
|
||
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: 'MEDIUM',
|
||
category: '劳动关系',
|
||
title: '合同即将到期',
|
||
description: `合同将于${c.endDate.toISOString().slice(0, 10)}到期,剩余${daysToExpire}天。请及时办理续签或终止手续。`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// 试用期工资为0风险
|
||
if (c.probationMonths > 0 && c.probationSalary === 0) {
|
||
risks.push({
|
||
level: 'MEDIUM',
|
||
category: '劳动关系',
|
||
title: '试用期工资为0',
|
||
description: `合同约定试用期${c.probationMonths}个月但试用期工资为0,违反《劳动合同法》第20条(试用期工资不得低于本单位相同岗位最低档工资的80%或劳动合同约定工资的80%)。`,
|
||
})
|
||
}
|
||
})
|
||
|
||
// 无工资条风险检测(入职超过1个月但无工资条)
|
||
const daysSinceHire = Math.floor((today.getTime() - employee.hireDate.getTime()) / 86400000)
|
||
if (daysSinceHire > 30 && employee.payslips.length === 0) {
|
||
risks.push({
|
||
level: 'MEDIUM',
|
||
category: '薪酬发放',
|
||
title: '无工资条记录',
|
||
description: `入职已${daysSinceHire}天但无任何工资条记录,在仲裁中难以证明已按时足额支付工资。建议尽快创建发薪批次并归档。`,
|
||
})
|
||
}
|
||
|
||
// 2. 薪酬证据
|
||
employee.payslips.forEach((p) => {
|
||
evidence.push({
|
||
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 ? '员工已确认。' : '员工未确认。'}`,
|
||
evidenceType: 'PAYSLIP',
|
||
confirmed: !!p.confirmedAt,
|
||
})
|
||
})
|
||
employee.overtimeRecords.forEach((o) => {
|
||
if (o.totalPay > 0) {
|
||
evidence.push({
|
||
category: '薪酬发放',
|
||
title: `${o.month}月加班费记录`,
|
||
date: o.month,
|
||
description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`,
|
||
evidenceType: 'OVERTIME',
|
||
})
|
||
}
|
||
})
|
||
|
||
// 3. 考勤证据
|
||
const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL')
|
||
abnormalAttendance.forEach((a) => {
|
||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||
evidence.push({
|
||
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 || ''}`,
|
||
evidenceType: 'ATTENDANCE',
|
||
})
|
||
})
|
||
|
||
// 4. 违纪证据
|
||
employee.disciplinaryRecords.forEach((d) => {
|
||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||
evidence.push({
|
||
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.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`,
|
||
evidenceType: 'DISCIPLINARY',
|
||
acknowledged: d.employeeAck,
|
||
})
|
||
})
|
||
|
||
// 5. 培训签收证据
|
||
employee.trainingRecords.forEach((t) => {
|
||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||
evidence.push({
|
||
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}。`,
|
||
evidenceType: 'TRAINING',
|
||
acknowledged: t.ackStatus === 'SIGNED',
|
||
})
|
||
})
|
||
|
||
// 6. 绩效证据
|
||
employee.performanceRecords.forEach((p) => {
|
||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||
evidence.push({
|
||
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 ? '员工已签字确认。' : '员工未签字。'}`,
|
||
evidenceType: 'PERFORMANCE',
|
||
acknowledged: p.employeeAck,
|
||
})
|
||
})
|
||
|
||
// 7. 解聘证据(排除已撤销的记录)
|
||
employee.terminations.filter((t) => t.status !== 'CANCELLED').forEach((t) => {
|
||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商一致', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期', RESIGNATION: '员工主动离职' }
|
||
const typeLabel = t.type === 'RESIGNATION' && t.reason === 'NEGOTIATED'
|
||
? '协商一致离职'
|
||
: t.type === 'RESIGNATION' ? '员工主动离职' : '公司解聘'
|
||
const statusMap: Record<string, string> = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTING: '执行中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已撤销' }
|
||
evidence.push({
|
||
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)}。流程状态:${statusMap[t.status] || t.status}。${t.resignationReason ? `离职原因:${t.resignationReason}。` : ''}${t.remark || ''}`,
|
||
evidenceType: 'TERMINATION',
|
||
acknowledged: t.status === 'COMPLETED',
|
||
})
|
||
|
||
// 离职流程未完成风险
|
||
if (t.status !== 'COMPLETED') {
|
||
risks.push({
|
||
level: 'HIGH',
|
||
category: '解聘记录',
|
||
title: '离职流程未完成',
|
||
description: `${typeLabel}流程当前状态为「${statusMap[t.status] || t.status}」,尚未完成闭环。离职日期${t.terminationDate.toISOString().slice(0, 10)},若未签署解除协议/离职确认书、未完成工作交接、未结清工资、未办理社保减员,在仲裁中无法证明离职的合法性与完整性,面临继续履行合同或违法解除赔偿(2N)风险。`,
|
||
})
|
||
}
|
||
|
||
// 协商解除但补偿金为0
|
||
if (t.reason === 'NEGOTIATED' && t.compensation === 0) {
|
||
risks.push({
|
||
level: 'MEDIUM',
|
||
category: '解聘记录',
|
||
title: '协商解除但补偿金为0',
|
||
description: `解聘原因为协商解除但经济补偿金为0,若员工事后主张非自愿离职,企业需举证协商一致且员工自愿放弃补偿,否则可能被认定为单方违法解除,面临2N赔偿风险。`,
|
||
})
|
||
}
|
||
})
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
employee: {
|
||
name: empName,
|
||
department: empDept,
|
||
hireDate,
|
||
status: employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE',
|
||
gender: employee.gender,
|
||
phone: employee.phone,
|
||
},
|
||
evidence,
|
||
risks,
|
||
summary: {
|
||
total: evidence.length,
|
||
signed: evidence.filter((e) => e.acknowledged === true).length,
|
||
unsigned: evidence.filter((e) => e.acknowledged === false).length,
|
||
riskCount: risks.length,
|
||
},
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// ========== 违纪记录 CRUD ==========
|
||
|
||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.disciplinaryRecord.findMany({
|
||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||
orderBy: { violationDate: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||
const record = await prisma.disciplinaryRecord.create({
|
||
data: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.employeeId,
|
||
violationDate: new Date(violationDate),
|
||
violationType,
|
||
description,
|
||
severity: severity || 'WARNING',
|
||
action: action || 'ORAL_WARNING',
|
||
actionDetail,
|
||
employeeAck: employeeAck || false,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
ackMethod,
|
||
witness,
|
||
attachmentUrl,
|
||
createdBy: req.user!.id,
|
||
},
|
||
})
|
||
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId, violationType, severity, action })
|
||
await createEvidence({
|
||
orgId: req.user!.orgId,
|
||
category: 'DISCIPLINARY',
|
||
refId: record.id,
|
||
employeeId: req.params.employeeId,
|
||
events: [{ action: '违纪记录创建', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||
createdBy: req.user!.id,
|
||
}).catch(() => {})
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||
const record = await prisma.disciplinaryRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
const updated = await prisma.disciplinaryRecord.update({
|
||
where: { id: req.params.recordId },
|
||
data: {
|
||
violationDate: violationDate ? new Date(violationDate) : undefined,
|
||
violationType,
|
||
description,
|
||
severity,
|
||
action,
|
||
actionDetail,
|
||
employeeAck,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
ackMethod,
|
||
witness,
|
||
attachmentUrl,
|
||
},
|
||
})
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const record = await prisma.disciplinaryRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } })
|
||
res.json({ success: true })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 考勤记录 CRUD ==========
|
||
|
||
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.attendanceRecord.findMany({
|
||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||
orderBy: { date: 'desc' },
|
||
take: 90,
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body
|
||
const record = await prisma.attendanceRecord.upsert({
|
||
where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } },
|
||
create: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.employeeId,
|
||
date: new Date(date),
|
||
checkInTime,
|
||
checkOutTime,
|
||
status: status || 'NORMAL',
|
||
lateMinutes: lateMinutes || 0,
|
||
earlyMinutes: earlyMinutes || 0,
|
||
workHours: workHours || 0,
|
||
overtimeHours: overtimeHours || 0,
|
||
remark,
|
||
createdBy: req.user!.id,
|
||
},
|
||
update: {
|
||
checkInTime,
|
||
checkOutTime,
|
||
status,
|
||
lateMinutes,
|
||
earlyMinutes,
|
||
workHours,
|
||
overtimeHours,
|
||
remark,
|
||
},
|
||
})
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const record = await prisma.attendanceRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } })
|
||
res.json({ success: true })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 培训签收记录 CRUD ==========
|
||
|
||
router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.trainingRecord.findMany({
|
||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||
orderBy: { trainingDate: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||
const record = await prisma.trainingRecord.create({
|
||
data: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.employeeId,
|
||
trainingDate: new Date(trainingDate),
|
||
topic,
|
||
content,
|
||
trainer,
|
||
duration: duration || 0,
|
||
ackStatus: ackStatus || 'PENDING',
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
attachmentUrl,
|
||
remark,
|
||
createdBy: req.user!.id,
|
||
},
|
||
})
|
||
await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId, topic, trainer, duration })
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||
const record = await prisma.trainingRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
const updated = await prisma.trainingRecord.update({
|
||
where: { id: req.params.recordId },
|
||
data: {
|
||
trainingDate: trainingDate ? new Date(trainingDate) : undefined,
|
||
topic,
|
||
content,
|
||
trainer,
|
||
duration,
|
||
ackStatus,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
attachmentUrl,
|
||
remark,
|
||
},
|
||
})
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const record = await prisma.trainingRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
await prisma.trainingRecord.delete({ where: { id: req.params.recordId } })
|
||
res.json({ success: true })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 绩效记录 CRUD ==========
|
||
|
||
router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.performanceRecord.findMany({
|
||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||
orderBy: { period: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||
const record = await prisma.performanceRecord.upsert({
|
||
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
|
||
create: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.employeeId,
|
||
period,
|
||
score: score || 0,
|
||
grade: grade || 'B',
|
||
result: result || 'QUALIFIED',
|
||
summary,
|
||
improvementPlan,
|
||
employeeAck: employeeAck || false,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
reviewer,
|
||
createdBy: req.user!.id,
|
||
},
|
||
update: {
|
||
score,
|
||
grade,
|
||
result,
|
||
summary,
|
||
improvementPlan,
|
||
employeeAck,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
reviewer,
|
||
},
|
||
})
|
||
await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId, period, score, grade, reviewer })
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||
const record = await prisma.performanceRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
const updated = await prisma.performanceRecord.update({
|
||
where: { id: req.params.recordId },
|
||
data: {
|
||
period,
|
||
score,
|
||
grade,
|
||
result,
|
||
summary,
|
||
improvementPlan,
|
||
employeeAck,
|
||
ackDate: ackDate ? new Date(ackDate) : null,
|
||
reviewer,
|
||
},
|
||
})
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const record = await prisma.performanceRecord.findFirst({
|
||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||
})
|
||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||
await prisma.performanceRecord.delete({ where: { id: req.params.recordId } })
|
||
res.json({ success: true })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// ========== 调薪/调部门 API ==========
|
||
|
||
function dateToMonth(date: Date): string {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
return `${y}-${m}`
|
||
}
|
||
|
||
function prevMonth(month: string): string {
|
||
const [y, m] = month.split('-').map(Number)
|
||
const d = new Date(y, m - 2, 1)
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||
}
|
||
|
||
// 调薪
|
||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { newSalary, effectiveMonth, reason } = req.body
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!employee) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
}
|
||
|
||
const oldSalary = safeDecrypt(employee.monthlySalary)
|
||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||
const prevEffMonth = prevMonth(effMonth)
|
||
|
||
// 关闭之前有效记录
|
||
await prisma.salaryChangeRecord.updateMany({
|
||
where: { employeeId: req.params.id, endMonth: null },
|
||
data: { endMonth: prevEffMonth },
|
||
})
|
||
|
||
// 创建新薪资记录
|
||
const record = await prisma.salaryChangeRecord.create({
|
||
data: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.id,
|
||
oldSalary,
|
||
newSalary: Number(newSalary),
|
||
effectiveDate: new Date(`${effMonth}-01`),
|
||
effectiveMonth: effMonth,
|
||
endMonth: null,
|
||
changeType: 'SALARY_CHANGE',
|
||
reason: reason || null,
|
||
createdBy: req.user!.id,
|
||
},
|
||
})
|
||
|
||
// 同步 Employee 便捷字段
|
||
await prisma.employee.update({
|
||
where: { id: req.params.id },
|
||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||
})
|
||
|
||
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 调薪历史
|
||
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.salaryChangeRecord.findMany({
|
||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||
orderBy: { effectiveDate: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 调部门
|
||
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const { newDepartment, effectiveMonth, reason } = req.body
|
||
const employee = await prisma.employee.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!employee) {
|
||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||
}
|
||
|
||
const oldDepartment = employee.department
|
||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||
const prevEffMonth = prevMonth(effMonth)
|
||
|
||
// 关闭之前有效记录
|
||
await prisma.employeeDepartmentRecord.updateMany({
|
||
where: { employeeId: req.params.id, endMonth: null },
|
||
data: { endMonth: prevEffMonth },
|
||
})
|
||
|
||
// 创建新部门记录
|
||
const record = await prisma.employeeDepartmentRecord.create({
|
||
data: {
|
||
orgId: req.user!.orgId,
|
||
employeeId: req.params.id,
|
||
oldDepartment,
|
||
newDepartment,
|
||
effectiveMonth: effMonth,
|
||
endMonth: null,
|
||
changeType: 'TRANSFER',
|
||
reason: reason || null,
|
||
createdBy: req.user!.id,
|
||
},
|
||
})
|
||
|
||
// 同步 Employee 便捷字段
|
||
await prisma.employee.update({
|
||
where: { id: req.params.id },
|
||
data: { department: newDepartment },
|
||
})
|
||
|
||
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
|
||
res.json({ success: true, data: record })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 调部门历史
|
||
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const records = await prisma.employeeDepartmentRecord.findMany({
|
||
where: { employeeId: req.params.id, orgId: req.user!.orgId },
|
||
orderBy: { effectiveMonth: 'desc' },
|
||
})
|
||
res.json({ success: true, data: records })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 30天内合同到期列表
|
||
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
|
||
try {
|
||
const days = parseInt(req.query.days as string) || 30
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
const future = new Date(today)
|
||
future.setDate(future.getDate() + days)
|
||
|
||
const employees = await prisma.employee.findMany({
|
||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||
include: {
|
||
contracts: {
|
||
where: {
|
||
endDate: { gte: today, lte: future },
|
||
contractType: 'FIXED',
|
||
},
|
||
orderBy: { endDate: 'asc' },
|
||
take: 1,
|
||
},
|
||
},
|
||
})
|
||
|
||
const result = employees
|
||
.filter(e => e.contracts.length > 0)
|
||
.map(e => {
|
||
const contract = e.contracts[0]
|
||
const endDate = new Date(contract.endDate!)
|
||
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||
return {
|
||
employeeId: e.id,
|
||
employeeName: e.name,
|
||
department: e.department,
|
||
contractEndDate: contract.endDate,
|
||
daysLeft,
|
||
}
|
||
})
|
||
.sort((a, b) => a.daysLeft - b.daysLeft)
|
||
|
||
res.json({ success: true, data: result })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 合同类型列表(供前端动态获取)
|
||
router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => {
|
||
const types = [
|
||
{ value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true },
|
||
{ value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false },
|
||
{ value: 'LABOR', label: '劳务协议', hasEndDate: false },
|
||
{ value: 'INTERNSHIP', label: '实习协议', hasEndDate: false },
|
||
{ value: 'UNSIGNED', label: '未签合同', hasEndDate: false },
|
||
]
|
||
res.json({ success: true, data: types })
|
||
})
|
||
|
||
export default router
|