014c94e482
1. 账户管理:新建/编辑时可勾选关联 level=0 根部门(公司/分公司/子公司) 2. 后端新增 API: - PUT /social/accounts/:id/departments 批量关联根部门 - GET /social/accounts/:id/departments 查询已关联部门 - GET /social/department-account/:departmentId 按部门带出适用账户+标准 3. 部门更新 API 支持 socialAccountId/housingAccountId 字段 4. 员工新增表单:选定部门后自动带出社保公积金账户,可手动调整 5. 参保记录创建时写入 accountId Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
952 lines
35 KiB
TypeScript
952 lines
35 KiB
TypeScript
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<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')}`
|
||
}
|
||
|
||
/**
|
||
* 身份证有效性校验(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)
|
||
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 }
|
||
}
|