Files
TurboHR/backend/src/routes/roster.routes.ts
T
freedakgmail fb36b10402 feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
2026-07-29 08:35:29 +08:00

1022 lines
40 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 } 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