Files
TurboHR/backend/src/services/contract.service.ts
T
selfrelease c3ca2c9f50 fix: 公积金基数上下限独立于社保
问题:SocialYearStandard 只有一套 baseMin/baseMax,社保和公积金
共用,但公积金下限通常是最低工资标准(如北京2420),远低于社保
下限(如北京6326)。

修复:
- SocialYearStandard 新增 housingBaseMin/housingBaseMax 字段
- clampHousingFundBase 优先用公积金专用上下限,为0时回退到社保
- calcHousingFund 同样优先用公积金专用上下限
- 前端公积金年度标准表单加公积金基数上下限输入
- 前端公积金当前标准展示用公积金专用上下限

同时修复 blank_employees/blank_all 模式跳过试用期工资覆盖

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

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

976 lines
36 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'
import bcrypt from 'bcryptjs'
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, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) return Math.min(Math.max(base, standard.baseMin), standard.baseMax)
}
// 回退到旧配置表
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, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) {
// 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
const min = standard.housingBaseMin > 0 ? standard.housingBaseMin : standard.baseMin
const max = standard.housingBaseMax > 0 ? standard.housingBaseMax : standard.baseMax
return Math.min(Math.max(base, min), max)
}
}
// 回退到旧配置表
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')}`
}
/**
* 身份证有效性校验(GB 11643-1999):格式 + 校验位 + 出生日期合法性
* @returns null=有效,string=错误原因
*/
export function validateIdCard(idCard: string): string | null {
if (!idCard) return null
if (idCard.length !== 18) return '证件号码必须为18位'
if (!/^\d{17}[\dXx]$/.test(idCard)) return '证件号码格式错误:前17位必须为数字,第18位为数字或X'
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = idCard.substring(0, 17).split('').reduce((s, c, i) => s + parseInt(c) * WEIGHTS[i], 0)
if (idCard[17].toUpperCase() !== CHECK_CODES[sum % 11]) return '证件号码校验位错误'
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
if (birthMonth < 1 || birthMonth > 12 || birthDay < 1 || birthDay > 31) return '证件号码出生日期非法'
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
if (isNaN(birthDate.getTime()) || birthDate.getFullYear() !== birthYear || birthDate.getMonth() !== birthMonth - 1 || birthDate.getDate() !== birthDay) return '证件号码出生日期不存在'
if (birthDate > new Date()) return '证件号码出生日期晚于今天'
return null
}
/**
* 从身份证计算年龄(周岁)
*/
export function getAgeFromIdCard(idCard: string, referenceDate: Date = new Date()): number | null {
if (!idCard || idCard.length !== 18) return null
const birthYear = parseInt(idCard.substring(6, 10))
const birthMonth = parseInt(idCard.substring(10, 12))
const birthDay = parseInt(idCard.substring(12, 14))
if (isNaN(birthYear) || isNaN(birthMonth) || isNaN(birthDay)) return null
let age = referenceDate.getFullYear() - birthYear
const monthDiff = referenceDate.getMonth() - (birthMonth - 1)
if (monthDiff < 0 || (monthDiff === 0 && referenceDate.getDate() < birthDay)) age--
return age
}
/**
* 判定是否超龄(达法定退休年龄):男60,女干部55,女工人50(未选类型按50)
*/
export function isOverageEmployee(idCard: string, gender: string, femaleWorkerType?: string | null): boolean {
const age = getAgeFromIdCard(idCard)
if (age === null) return false
if (gender === '男') return age >= 60
return age >= (femaleWorkerType === 'CADRE' ? 55 : 50)
}
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,故 signDate 为空即视为未签署
if (!contract.signDate) {
if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) {
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(已到期)`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(${daysToExpire}天到期)`, riskLevel: 'medium' }
}
}
return { status: 'pending_sign', statusText: `${typeLabel}·待签署`, riskLevel: 'medium' }
}
// 已签署合同:按到期日判定
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 }
}
/**
* 判定员工在指定参考日期是否仍处于试用期。
* 试用期结束日 = 合同开始日期 + 试用期月数;若结束日 > 参考日期,则仍在试用期。
*
* @param contract 合同记录(需含 startDate、probationMonths、probationSalary
* @param referenceDate 参考日期,默认当前日期;批次场景应传批次月份的月末
* @returns true=仍在试用期,false=已转正或无试用期
*
* 注意:判定依据是"试用期是否结束",而非"入职是否满 1 年"。
* 旧逻辑 `startDate > now - 365d` 是错误的,转正后仍会用 probationSalary。
*/
export function isInProbation(
contract: { startDate: Date | string | null; probationMonths: number | null } | null | undefined,
referenceDate: Date = new Date(),
): boolean {
if (!contract || !contract.startDate || !contract.probationMonths || contract.probationMonths <= 0) {
return false
}
const start = new Date(contract.startDate)
if (isNaN(start.getTime())) return false
const probationEnd = new Date(start)
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
return probationEnd > referenceDate
}
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 idCardError = validateIdCard(data.idCardNumber)
if (idCardError) {
throw { code: 'VALIDATION_ERROR', message: idCardError }
}
// 童工阻断
const age = getAgeFromIdCard(data.idCardNumber)
if (age !== null && age < 16) {
throw { code: 'VALIDATION_ERROR', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` }
}
// 证件号码查重
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' ? '在职' : '离职'}),请确认是否重复录入` }
}
}
// 超龄人员不得签订劳动合同(FIXED/UNFIXED
if (data.idCardNumber && data.contract && data.contract.contractType) {
const overage = isOverageEmployee(data.idCardNumber, data.gender, data.femaleWorkerType)
if (overage && ['FIXED', 'UNFIXED'].includes(data.contract.contractType)) {
throw { code: 'VALIDATION_ERROR', message: '超龄人员(达法定退休年龄)不可签订劳动合同,请选择劳务协议或实习协议' }
}
}
// 手机号查重(同组织内不允许重复,影响员工端登录)
if (data.phone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: data.phone },
select: { id: true, name: true, department: true, status: true },
})
if (phoneExists) {
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}${phoneExists.department}${phoneExists.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 || '北京'
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
const rawSocialInsBase = isNoSocialContract ? 0 : (data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum)
const rawHousingFundBase = isNoSocialContract ? 0 : (data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum)
const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city, data.socialAccountId)
const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city, data.housingAccountId)
const socialInsStartMonth = isNoSocialContract ? '' : (data.socialInsStartMonth || hireMonth)
const housingFundStartMonth = isNoSocialContract ? '' : (data.housingFundStartMonth || hireMonth)
const employee = await prisma.$transaction(async (tx) => {
// 默认密码:手机号后6位(员工可在员工端自行修改)
const defaultPassword = data.phone ? data.phone.slice(-6) : '123456'
const passwordHash = await bcrypt.hash(defaultPassword, 10)
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,
passwordHash,
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,
status: data.status || 'ACTIVE',
},
})
await tx.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
accountId: data.socialAccountId || null,
},
})
await tx.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: emp.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
accountId: data.housingAccountId || null,
},
})
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: '员工不存在' }
}
// 手机号查重(同组织内排除自身,phone 唯一影响员工端登录)
if (data.phone !== undefined && data.phone) {
const phoneExists = await prisma.employee.findFirst({
where: { orgId, phone: data.phone, NOT: { id } },
select: { id: true, name: true, department: true, status: true },
})
if (phoneExists) {
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}${phoneExists.department}),同企业内手机号不可重复` }
}
}
// 身份证查重(同组织内排除自身)
if (data.idCardNumber !== undefined && data.idCardNumber) {
const idCardExists = await prisma.employee.findFirst({
where: { orgId, idCardHash: sha256(data.idCardNumber), NOT: { id } },
select: { id: true, name: true, department: true, status: true },
})
if (idCardExists) {
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${idCardExists.name}${idCardExists.department}),同企业内身份证号不可重复` }
}
}
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 }
}