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 { 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 } 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 = { 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.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 } }