Files
TurboHR/backend/src/routes/roster.routes.ts
T
freedakgmail 152b0b5024 fix: 后端对非劳动合同类型 socialInsBase/housingFundBase 返回 null
不再在数据源头返回社保基数和公积金基数,而非前端隐藏
2026-08-17 20:27:19 +08:00

1627 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { calcSocialInsurance, calcHousingFund } from '../services/payroll.service'
import ExcelJS from 'exceljs'
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
}
}
function safeDecryptStr(encrypted: string | null): string | null {
if (!encrypted) return null
try {
if (!encrypted.includes(':')) return encrypted
return decrypt(encrypted)
} catch {
return encrypted
}
}
// ========== 花名册聚合 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('/positions', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
select: { position: true },
distinct: 'position',
})
const positions = employees.map((e) => e.position).filter(Boolean).sort()
res.json({ success: true, data: positions })
} 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 position = req.query.position 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{2,}$/.test(search)
const whereBase: any = { orgId: req.user!.orgId }
if (department) {
whereBase.department = department
}
if (position) {
whereBase.position = position
}
if (search && !isIdCardSearch) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
if (status === 'RESIGNED') {
whereBase.status = 'RESIGNED'
} else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
// 预入职:ACTIVE 状态但入职日期在未来(按入职日期自动判定)
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 },
socialInsRecords: { orderBy: { startMonth: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
}),
])
// 获取社保和公积金配置(按城市缓存)
const currentMonth = new Date().toISOString().slice(0, 7)
const configCache = new Map<string, { social?: any; housing?: any }>()
const getConfigsForCity = async (city?: string) => {
const key = city || '_default'
if (configCache.has(key)) return configCache.get(key)!
const cityWhere = city ? { orgId: req.user!.orgId, city } : { orgId: req.user!.orgId }
const [socialCfg, housingCfg] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: currentMonth }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: currentMonth } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const result = { social: socialCfg, housing: housingCfg }
configCache.set(key, result)
return result
}
// 预加载所有涉及城市的配置
const cities = [...new Set(employees.map((e) => e.city).filter(Boolean))] as string[]
await Promise.all(cities.map((c) => getConfigsForCity(c)))
// 计算动态状态和合同状态
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,
hasRecord: true,
})
: getContractStatus({
signDate: null,
startDate: e.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: e.hireDate,
hasRecord: false,
})
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,
position: e.position,
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,
idCardNumber: safeDecryptStr(e.idCardNumber),
monthlySalary: safeDecrypt(e.monthlySalary),
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
socialInsBase: (() => {
const ct = latestContract?.contractType
if (!ct || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(ct)) return null
return e.socialInsBase
})(),
housingFundBase: (() => {
const ct = latestContract?.contractType
if (!ct || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(ct)) return null
return e.housingFundBase
})(),
socialInsCalc: (() => {
const contractType = latestContract?.contractType
// 劳务协议、实习协议、兼职协议、业务外包等非劳动合同不缴纳社保
if (!contractType || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(contractType)) return null
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.social || !e.socialInsBase) return null
const r = calcSocialInsurance(e.socialInsBase, cfgs.social)
return { socialEmp: r.socialEmp, socialOrg: r.socialOrg }
})(),
housingFundCalc: (() => {
const contractType = latestContract?.contractType
if (!contractType || ['LABOR', 'INTERNSHIP', 'PARTTIME', 'OUTSOURCING', 'UNSIGNED'].includes(contractType)) return null
const cfgs = configCache.get(e.city || '_default')
if (!cfgs?.housing || !e.housingFundBase) return null
const r = calcHousingFund(e.housingFundBase, cfgs.housing)
return { housingEmp: r.housingEmp, housingOrg: r.housingOrg }
})(),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
isWorkInjured: e.isWorkInjured,
latestContract,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
socialInsuranceStatus: (() => {
const sr = (e as any).socialInsRecords?.[0]
// 劳务协议/实习协议:不缴纳社保,返回 null(前端显示"—"
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
if (isNoSocialContract) return null
if (!sr) {
// 在职且应缴社保但无社保记录 → 待办理
return 'PENDING'
}
// endMonth 为 null 表示在保,否则已停保
if (sr.endMonth) return 'SUSPENDED'
return 'ACTIVE'
})(),
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,
}
})
// 前端过滤:合同状态(非 unsigned 的需要后处理计算)
if (contractStatus && contractStatus !== 'unsigned') {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardNumber) return false
return String(e.idCardNumber).endsWith(search!)
})
}
// 计算过滤后的总数和分页
const needMemoryPaging = needPostFilter || isIdCardSearch
const filteredTotal = needMemoryPaging ? result.length : dbTotal
if (needMemoryPaging) {
result = result.slice(skip, skip + pageSize)
}
// 全局合同风险统计(基于 RiskItem,与风险中心同口径)
const [globalExpiring, globalExpired, globalUnsigned] = await Promise.all([
prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'CONTRACT', level: 'MEDIUM' } }),
prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'CONTRACT', level: 'HIGH' } }),
prisma.riskItem.count({ where: { orgId: req.user!.orgId, status: 'PENDING', type: 'ONBOARDING' } }),
])
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total: filteredTotal,
totalPages: Math.ceil(filteredTotal / pageSize),
},
globalRiskStats: {
expiring: globalExpiring,
expired: globalExpired,
unsigned: globalUnsigned,
},
})
} 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, baseSalary, performanceSalary, 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),
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: safeDecryptStr(idCardNumber),
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: `入职已${Math.round(daysSinceHire / 30)}个月仍未签订书面劳动合同,超过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: `入职已${Math.round(daysSinceHire / 30)}个月但无任何工资条记录,在仲裁中难以证明已按时足额支付工资。建议尽快创建发薪批次并归档。`,
})
}
// 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)
}
})
// 仲裁证据链 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: `入职已${Math.round(days / 30)}个月仍未签订书面劳动合同,超过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: `入职已${Math.round(daysSinceHire / 30)}个月但无任何工资条记录。` })
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 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="${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()
}
}
})
// ========== 组织级列表查询 ==========
// 培训记录列表(全员)
router.get('/training/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const ackStatus = (req.query.ackStatus as string) || ''
const where: any = { orgId }
if (ackStatus) {
where.ackStatus = ackStatus
}
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.trainingRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { trainingDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.trainingRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 培训记录催办(发送通知给未签收员工)
router.post('/training/remind/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
})
if (!record) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '培训记录不存在' } })
}
if (record.ackStatus !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅待签收记录可催办' } })
}
// 记录催办通知日志
await prisma.notificationLog.create({
data: {
orgId,
type: 'TRAINING_REMIND',
title: `培训签收催办:${record.topic}`,
content: `员工 ${record.employee.name}${record.employee.department})的培训记录「${record.topic}」尚未签收,请尽快完成签收。`,
channel: 'SYSTEM',
status: 'SENT',
},
})
res.json({ success: true, data: { message: `已催办 ${record.employee.name} 签收「${record.topic}` } })
} catch (err) { next(err) }
})
// 绩效记录列表(全员)
router.get('/performance/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.performanceRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { period: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.performanceRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} catch (err) { next(err) }
})
// 违纪记录列表(全员)
router.get('/disciplinary/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const keyword = (req.query.keyword as string) || ''
const where: any = { orgId }
if (keyword) {
const employees = await prisma.employee.findMany({
where: { orgId, name: { contains: keyword } },
select: { id: true },
})
where.employeeId = { in: employees.map(e => e.id) }
}
const [records, total] = await Promise.all([
prisma.disciplinaryRecord.findMany({
where,
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { violationDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.disciplinaryRecord.count({ where }),
])
res.json({ success: true, data: { records, total, page, pageSize } })
} 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, deductionAmount, 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,
deductionAmount: Number(deductionAmount) || 0,
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, deductionAmount, 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,
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
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) }
})
// 违纪确认证明导出
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) => {
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.post('/training/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeIds, trainingDate, topic, content, trainer, duration, remark } = req.body
if (!employeeIds || !Array.isArray(employeeIds) || employeeIds.length === 0) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '请至少选择一名员工' } })
}
const results = await Promise.all(employeeIds.map((empId: string) =>
prisma.trainingRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: empId,
trainingDate: new Date(trainingDate),
topic,
content,
trainer,
duration: duration || 0,
ackStatus: 'PENDING',
remark,
createdBy: req.user!.id,
},
})
))
res.json({ success: true, data: { count: results.length } })
} 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, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = 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,
periodType: periodType || 'MONTHLY',
score: score || 0,
grade: grade || 'B',
result: result || 'QUALIFIED',
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
createdBy: req.user!.id,
},
update: {
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
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, periodType, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer, templateId, dimensionScores } = 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,
periodType,
score,
grade,
result,
summary,
improvementPlan,
templateId: templateId || null,
dimensionScores: dimensionScores || undefined,
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) }
})
// ========== 绩效模板 CRUD ==========
// 获取模板列表
router.get('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const templates = await prisma.performanceTemplate.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: templates })
} catch (err) { next(err) }
})
// 创建模板
router.post('/performance/templates', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
if (!name || !dimensions || !Array.isArray(dimensions)) {
return res.json({ success: false, error: { code: 'VALIDATION_ERROR', message: '模板名称和考核维度为必填' } })
}
// 如果设为默认,先取消其他默认
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true }, data: { isDefault: false } })
}
const template = await prisma.performanceTemplate.create({
data: {
orgId: req.user!.orgId,
name,
description,
dimensions,
gradeRules: gradeRules || undefined,
isDefault: isDefault || false,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: template })
} catch (err) { next(err) }
})
// 更新模板
router.put('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { name, description, dimensions, gradeRules, isDefault } = req.body
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
if (isDefault) {
await prisma.performanceTemplate.updateMany({ where: { orgId: req.user!.orgId, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const updated = await prisma.performanceTemplate.update({
where: { id: req.params.id },
data: { name, description, dimensions, gradeRules: gradeRules || undefined, isDefault },
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 删除模板
router.delete('/performance/templates/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const existing = await prisma.performanceTemplate.findFirst({ where: { id: req.params.id, orgId: req.user!.orgId } })
if (!existing) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
await prisma.performanceTemplate.delete({ where: { id: req.params.id } })
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, baseSalary, performanceSalary, 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 便捷字段(含工资结构)
const updateData: any = { monthlySalary: encrypt(String(newSalary)) }
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
await prisma.employee.update({
where: { id: req.params.id },
data: updateData,
})
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, newPosition, departmentId, 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 oldPosition = employee.position || null
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 校验 departmentId 是否属于当前组织
let deptName = newDepartment
if (departmentId) {
const dept = await prisma.department.findFirst({ where: { id: departmentId, orgId: req.user!.orgId } })
if (!dept) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '目标部门不存在' } })
}
deptName = dept.name
}
// 关闭之前有效记录
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: deptName,
oldPosition,
newPosition: newPosition || null,
effectiveMonth: effMonth,
endMonth: null,
changeType: 'TRANSFER',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 字段(department 文本 + departmentId 关联 + position 职务)
await prisma.employee.update({
where: { id: req.params.id },
data: {
department: deptName,
departmentId: departmentId || null,
position: newPosition || employee.position,
},
})
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition })
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: true },
{ value: 'INTERNSHIP', label: '实习协议', hasEndDate: true },
{ value: 'DISPATCH', label: '劳务派遣', hasEndDate: true },
{ value: 'OUTSOURCING', label: '业务外包', hasEndDate: true },
{ value: 'PARTTIME', label: '兼职协议', hasEndDate: true },
{ value: 'UNSIGNED', label: '未签合同', hasEndDate: false },
]
res.json({ success: true, data: types })
})
export default router