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): Promise { 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 { 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 = { 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) const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city) 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, }, }) 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 } }