Files
TurboHR/backend/src/services/contract.service.ts
T
selfrelease e1b5ae9aab feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 12:37:27 +08:00

809 lines
27 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 prisma from '../lib/prisma'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import { runRiskDetection } from './risk.service'
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
}
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
})
if (config) return Math.min(Math.max(base, config.baseMin), config.baseMax)
return base
}
export 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')}`
}
export function getContractStatus(contract: {
signDate: Date | null
startDate: Date
endDate: Date | null
contractType: string
hireDate: Date
/** hasRecord: 是否存在合同记录(区分"有合同但未填签订日期"和"完全无合同" */
hasRecord?: boolean
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
const today = new Date()
const typeLabelMap: Record<string, string> = {
FIXED: '固定期限',
UNFIXED: '无固定期限',
LABOR: '劳务协议',
INTERNSHIP: '实习协议',
UNSIGNED: '',
}
const typeLabel = typeLabelMap[contract.contractType] || ''
// 只有真正没有合同记录(hasRecord=false)或类型为 UNSIGNED 时,才判定为"未签合同"
// 有合同记录但 signDate 为 null 时,不再判定为"未签合同"
const isUnsigned = contract.contractType === 'UNSIGNED' || (contract.hasRecord === false && !contract.signDate)
if (isUnsigned) {
const days = daysBetween(today, contract.hireDate)
if (days > 365) {
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
} else if (days > 30) {
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
}
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
}
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理
if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) {
return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
}
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
}
// 无固定期限或有合同但无结束日期
if (contract.contractType === 'UNFIXED') {
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
}
// 有合同记录但未填结束日期(如 FIXED 但 endDate 为 null),视为正常
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
}
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
let max = 0
if (contractMonths >= 36) max = 6
else if (contractMonths >= 12) max = 2
else if (contractMonths >= 3) max = 1
if (probationMonths > max) {
return {
valid: false,
max,
message: `${contractMonths}个月合同试用期最多${max}个月,当前${probationMonths}个月不合法`,
}
}
return { valid: true, max }
}
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
const page = params.page || 1
const pageSize = params.pageSize || 20
const skip = (page - 1) * pageSize
const where: any = { orgId, status: 'ACTIVE' }
if (params.search) {
where.OR = [
{ name: { contains: params.search } },
{ phone: { contains: params.search } },
]
}
if (params.department) {
where.department = params.department
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where }),
prisma.employee.findMany({
where,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
])
const items = employees.map((emp) => {
const latestContract = emp.contracts[0]
const contractInfo = latestContract
? getContractStatus({
signDate: latestContract.signDate,
startDate: latestContract.startDate,
endDate: latestContract.endDate,
contractType: latestContract.contractType,
hireDate: emp.hireDate,
hasRecord: true,
})
: getContractStatus({
signDate: null,
startDate: emp.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: emp.hireDate,
hasRecord: false,
})
let decryptedSalary = 0
try {
decryptedSalary = Number(decrypt(emp.monthlySalary)) || 0
} catch {
decryptedSalary = Number(emp.monthlySalary) || 0
}
return {
id: emp.id,
name: emp.name,
department: emp.department,
hireDate: emp.hireDate.toISOString().slice(0, 10),
status: emp.status,
monthlySalary: decryptedSalary,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
isPregnant: emp.isPregnant,
isInMedicalPeriod: emp.isInMedicalPeriod,
isWorkInjured: emp.isWorkInjured,
city: emp.city,
}
})
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
export async function getEmployeeDetail(orgId: string, id: string) {
const employee = await prisma.employee.findFirst({
where: { id, orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' } },
riskItems: { where: { status: 'PENDING' }, orderBy: { level: 'asc' } },
},
})
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
let decryptedSalary = 0
try {
decryptedSalary = Number(decrypt(employee.monthlySalary)) || 0
} catch {
decryptedSalary = Number(employee.monthlySalary) || 0
}
return {
...employee,
monthlySalary: decryptedSalary,
}
}
export async function createEmployee(orgId: string, userId: string, data: any) {
// 证件号码查重
if (data.idCardNumber) {
const existing = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber) },
select: { id: true, name: true, department: true, status: true },
})
if (existing) {
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${existing.name}${existing.department}${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
if (activeCount >= org.maxEmployees) {
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
}
}
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const city = data.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
const employee = await prisma.$transaction(async (tx) => {
const emp = await tx.employee.create({
data: {
orgId,
name: data.name,
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
femaleWorkerType: data.femaleWorkerType,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
education: data.education || null,
position: data.position || null,
},
})
await tx.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
await tx.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
await tx.salaryChangeRecord.create({
data: {
orgId,
employeeId: emp.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
await tx.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: emp.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await tx.laborContract.create({
data: {
orgId,
employeeId: emp.id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
createdBy: userId,
},
})
}
return emp
})
await runRiskDetection(orgId)
return { id: employee.id }
}
// 重新入职:复用已有员工基本信息,更新入职日期和状态,可选创建新合同
export async function rehireEmployee(orgId: string, userId: string, id: string, data: any) {
const employee = await prisma.employee.findFirst({
where: { id, orgId },
include: { terminations: { orderBy: { terminationDate: 'desc' }, take: 1 } },
})
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
if (!isResigned) {
throw { code: 'CONFLICT', message: '该员工当前在职,无需重新入职' }
}
const newHireDate = new Date(data.hireDate)
const latestTerm = employee.terminations[0]
if (latestTerm && newHireDate <= latestTerm.terminationDate) {
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
}
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const city = data.city || employee.city || '北京'
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
// 关闭旧社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧薪资记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧部门记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
await prisma.employee.update({
where: { id },
data: {
hireDate: newHireDate,
status: 'ACTIVE',
department: data.department || employee.department,
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
socialInsEndMonth: null,
housingFundStartMonth,
housingFundEndMonth: null,
city: data.city || employee.city || '北京',
},
})
// 创建新社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新薪资记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary: salaryNum,
newSalary: salaryNum,
effectiveDate: newHireDate,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: id,
oldDepartment: employee.department,
newDepartment: data.department || employee.department,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await prisma.laborContract.create({
data: {
orgId,
employeeId: id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
createdBy: userId,
},
})
}
await runRiskDetection(orgId)
return { id }
}
export async function updateEmployee(orgId: string, id: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const updateData: any = {}
if (data.name !== undefined) updateData.name = data.name
if (data.department !== undefined) updateData.department = data.department
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
if (data.monthlySalary !== undefined) {
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
const newSalary = Number(data.monthlySalary) || 0
updateData.monthlySalary = encrypt(data.monthlySalary)
// 记录薪资变更
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = dateToMonth(now)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: data.salaryChangeReason || '手动调整',
createdBy: '',
},
})
}
}
if (data.gender !== undefined) updateData.gender = data.gender
if (data.femaleWorkerType !== undefined) updateData.femaleWorkerType = data.femaleWorkerType
if (data.phone !== undefined) updateData.phone = data.phone
if (data.idCardNumber !== undefined) {
updateData.idCardNumber = encrypt(data.idCardNumber)
updateData.idCardHash = sha256(data.idCardNumber)
updateData.birthDate = extractBirthDateFromIdCard(data.idCardNumber)
if (!updateData.gender) {
const gender = extractGenderFromIdCard(data.idCardNumber)
if (gender) updateData.gender = gender
}
}
if (data.bankName !== undefined) updateData.bankName = data.bankName
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
if (data.address !== undefined) updateData.address = data.address
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.socialInsBase = await clampSocialInsBase(orgId, Number(data.socialInsBase), city)
}
if (data.housingFundBase !== undefined) {
const city = data.city || employee.city || '北京'
updateData.housingFundBase = await clampHousingFundBase(orgId, Number(data.housingFundBase), city)
}
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
if (data.education !== undefined) updateData.education = data.education
if (data.position !== undefined) updateData.position = data.position
if (data.status !== undefined) updateData.status = data.status
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
if (data.city !== undefined && data.city !== employee.city) {
const nowMonth = new Date().toISOString().slice(0, 7)
const cityChangeReason = data.cityChangeReason || '未填写原因'
const changeRemark = `城市变更:${employee.city || '未设置'}${data.city}${cityChangeReason}`
// 社保:关闭旧在保记录,创建新城市记录
const activeSocial = await prisma.employeeSocialInsRecord.findFirst({
where: { employeeId: id, endMonth: null },
})
if (activeSocial) {
await prisma.employeeSocialInsRecord.update({
where: { id: activeSocial.id },
data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark },
})
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
city: data.city,
startMonth: nowMonth,
endMonth: null,
base: activeSocial.base,
changeType: 'CITY_CHANGE',
remark: changeRemark,
createdBy: '',
},
})
} else {
// 兜底:没有在保记录也创建一条,保留变更历史
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
city: data.city,
startMonth: nowMonth,
endMonth: null,
base: employee.socialInsBase || 0,
changeType: 'CITY_CHANGE',
remark: changeRemark,
createdBy: '',
},
})
}
// 公积金:同上
const activeHousing = await prisma.employeeHousingFundRecord.findFirst({
where: { employeeId: id, endMonth: null },
})
if (activeHousing) {
await prisma.employeeHousingFundRecord.update({
where: { id: activeHousing.id },
data: { endMonth: nowMonth, changeType: 'CITY_CHANGE', remark: changeRemark },
})
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
city: data.city,
startMonth: nowMonth,
endMonth: null,
base: activeHousing.base,
changeType: 'CITY_CHANGE',
remark: changeRemark,
createdBy: '',
},
})
} else {
// 兜底:没有在保记录也创建一条
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
city: data.city,
startMonth: nowMonth,
endMonth: null,
base: employee.housingFundBase || 0,
changeType: 'CITY_CHANGE',
remark: changeRemark,
createdBy: '',
},
})
}
// 写审计日志
await prisma.auditLog.create({
data: {
orgId,
userId: '',
action: 'CITY_CHANGE',
entity: 'Employee',
entityId: id,
detail: { oldCity: employee.city, newCity: data.city, reason: cityChangeReason, remark: changeRemark },
},
})
}
await prisma.employee.update({ where: { id }, data: updateData })
await runRiskDetection(orgId)
return { id }
}
export async function deleteEmployee(orgId: string, id: string) {
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
await prisma.employee.update({ where: { id }, data: { status: 'RESIGNED' } })
await prisma.riskItem.updateMany({
where: { employeeId: id, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id }
}
export async function batchRenew(orgId: string, userId: string, contractIds: string[], years: number) {
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId },
})
if (contracts.length === 0) {
throw { code: 'NOT_FOUND', message: '未找到符合条件的合同' }
}
for (const contract of contracts) {
const newStartDate = contract.endDate || new Date()
const newEndDate = new Date(newStartDate)
newEndDate.setFullYear(newEndDate.getFullYear() + years)
// 将旧合同 endDate 截止到续签开始日前一天,避免重叠
const oldEndDate = new Date(newStartDate)
oldEndDate.setDate(oldEndDate.getDate() - 1)
await prisma.laborContract.update({
where: { id: contract.id },
data: { endDate: oldEndDate },
})
await prisma.laborContract.create({
data: {
orgId,
employeeId: contract.employeeId,
signDate: new Date(),
startDate: newStartDate,
endDate: newEndDate,
contractType: contract.contractType,
signMethod: contract.signMethod,
contractYears: years,
probationMonths: 0,
probationSalary: 0,
renewalCount: contract.renewalCount + 1,
createdBy: userId,
},
})
}
await runRiskDetection(orgId)
return { renewed: contracts.length }
}
export async function addContract(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 duplicate = await prisma.laborContract.findFirst({
where: {
employeeId: data.employeeId,
orgId,
startDate: new Date(data.startDate),
...(data.endDate ? { endDate: new Date(data.endDate) } : { endDate: null }),
},
})
if (duplicate) {
throw { code: 'DUPLICATE', message: '该员工已存在相同日期的合同,请勿重复添加' }
}
const contractMonths = data.endDate
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
: data.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
const contract = await prisma.laborContract.create({
data: {
orgId,
employeeId: data.employeeId,
signDate: data.signDate ? new Date(data.signDate) : null,
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
contractType: data.contractType,
signMethod: data.signMethod || 'PAPER',
contractYears: data.contractYears || 3,
probationMonths: data.probationMonths || 0,
probationSalary: data.probationSalary || 0,
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null,
electronicContractUrl: data.electronicContractUrl || null,
createdBy: userId,
},
})
await runRiskDetection(orgId)
return { id: contract.id }
}