feat: 离职/解聘完整功能

1. 已有离职/解聘记录的员工不能再次解聘(后端校验+前端提示)
2. 员工主动离职功能:花名册列表增加「离职」操作按钮和弹窗
3. 离职也按离职日期动态判断在职/离职状态,支持提前办理
4. 员工档案「解聘记录」tab改为「离职/解聘记录」,区分类型显示
5. Schema: TerminationRecord 增加 type、resignationReason 字段
6. 后端新增 POST /termination/resignation 接口
7. 花名册列表返回 hasTermination 标记
This commit is contained in:
freedakgmail
2026-07-23 17:31:27 +08:00
parent e6b5ac5989
commit 29ba3a98bc
6 changed files with 234 additions and 19 deletions
+3
View File
@@ -83,6 +83,7 @@ enum TerminationReason {
NONFAULT
LAYOFF
EXPIRED
RESIGNATION
}
enum RiskAssessment {
@@ -249,8 +250,10 @@ model TerminationRecord {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
type String @default("TERMINATION") // TERMINATION=公司解聘, RESIGNATION=员工主动离职
reason TerminationReason
terminationDate DateTime
resignationReason String? // 主动离职原因(type=RESIGNATION时使用)
compensation Float @default(0)
riskLevel RiskAssessment @default(SAFE)
checklist Json
+2
View File
@@ -63,6 +63,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
name: e.name,
department: e.department,
status: e.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE',
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
+18 -1
View File
@@ -2,7 +2,7 @@ import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
import { createTermination, createResignation, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
@@ -70,4 +70,21 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId, terminationDate, resignationReason, remark } = req.body
if (!employeeId || !terminationDate) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
}
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
@@ -141,12 +141,22 @@ export async function createTermination(orgId: string, userId: string, data: any
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' }
}
const { level } = assessRisk(employee, data.reason)
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'TERMINATION',
reason: data.reason,
terminationDate: new Date(data.terminationDate),
compensation: data.compensation || 0,
@@ -176,6 +186,57 @@ export async function createTermination(orgId: string, userId: string, data: any
return { id: record.id }
}
// 员工主动离职
export async function createResignation(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
}
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: new Date(data.terminationDate),
resignationReason: data.resignationReason || null,
compensation: 0,
riskLevel: 'SAFE',
checklist: {},
remark: data.remark || null,
createdBy: userId,
},
})
// 根据离职日期判断在职/离职状态
const termDate = new Date(data.terminationDate)
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' },
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
}
export async function getTerminations(orgId: string, page: number, pageSize: number) {
const skip = (page - 1) * pageSize
@@ -195,10 +256,13 @@ export async function getTerminations(orgId: string, page: number, pageSize: num
id: r.id,
employeeName: r.employee.name,
department: r.employee.department,
type: r.type,
reason: r.reason,
resignationReason: r.resignationReason,
terminationDate: r.terminationDate.toISOString().slice(0, 10),
compensation: r.compensation,
riskLevel: r.riskLevel,
remark: r.remark,
createdAt: r.createdAt.toISOString().slice(0, 10),
})),
total,