feat: 待签合同支持登记线下签署日期
- 线下手签合同:展开后可点击「登记签署日期」选择日期并保存 保存后合同从待签列表移除(signDate 已填写) - 电子签合同:签署日期由电签系统自动回写,不可手动修改 - 后端新增 POST /esign/sign-date 接口 电子签合同拒绝手动修改签署日期 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -191,6 +191,42 @@ router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunctio
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 登记线下合同签署日期
|
||||
* POST /esign/sign-date body: { contractId, signDate }
|
||||
*
|
||||
* 适用于纸质合同(signMethod=PAPER)未登记签署日期的情况。
|
||||
* 电子签合同的签署日期由电签系统回写,不通过此接口修改。
|
||||
*/
|
||||
router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { contractId, signDate } = req.body
|
||||
if (!contractId || !signDate) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractId 或 signDate' } })
|
||||
}
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { id: contractId, orgId: req.user!.orgId },
|
||||
select: { id: true, signMethod: true, employee: { select: { name: true } } },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
|
||||
}
|
||||
if (contract.signMethod === 'ELECTRONIC') {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
|
||||
}
|
||||
const parsedDate = new Date(signDate)
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
||||
}
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { signDate: parsedDate },
|
||||
})
|
||||
await auditLog(req, 'SIGN_DATE', 'CONTRACT', contractId, { employeeName: contract.employee.name, signDate: parsedDate.toISOString() })
|
||||
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 催办 — 生成员工一次性自动登录链接(指向员工端签署页)
|
||||
* POST /esign/remind body: { employeeId }
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
import { isInProbation } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
@@ -377,8 +378,11 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
|
||||
const deduction = deductions[emp.id] || 0
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按月份判定是否仍在试用期
|
||||
const monthEnd = new Date(`${month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts[0]
|
||||
if (isInProbation(latestContract, monthEnd) && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try {
|
||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
generatePayslipFromBatches,
|
||||
prePayrollCheck,
|
||||
} from '../services/payroll.service'
|
||||
import { isInProbation } from '../services/contract.service'
|
||||
|
||||
// RFC 5987 编码中文文件名
|
||||
function contentDisposition(filename: string): string {
|
||||
@@ -352,8 +353,12 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按批次月份判定是否仍在试用期(试用期结束日 = 合同开始日 + 试用期月数)
|
||||
// 试用期且 probationSalary > 0 → 用试用期工资;否则用转正工资
|
||||
const batchMonthEnd = new Date(`${month}-28T23:59:59`) // 月末近似
|
||||
const latestContract = emp.contracts?.[0]
|
||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
@@ -607,8 +612,11 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
if (!emp) continue
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按批次月份判定是否仍在试用期
|
||||
const batchMonthEnd = new Date(`${batch.month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts?.[0]
|
||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
|
||||
@@ -38,6 +38,53 @@ export function prevMonth(month: string): string {
|
||||
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
|
||||
@@ -71,7 +118,21 @@ export function getContractStatus(contract: {
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理
|
||||
// 有合同记录(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) {
|
||||
@@ -107,6 +168,31 @@ export function validateProbation(contractMonths: number, probationMonths: numbe
|
||||
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
|
||||
@@ -210,8 +296,18 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -220,6 +316,13 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
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({
|
||||
@@ -243,12 +346,14 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
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
|
||||
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 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位(员工可在员工端自行修改)
|
||||
|
||||
@@ -312,8 +312,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=PREGNANCY`,
|
||||
})
|
||||
}
|
||||
if (emp.isInMedicalPeriod) {
|
||||
@@ -322,8 +322,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=MEDICAL_PERIOD`,
|
||||
})
|
||||
}
|
||||
if (emp.isWorkInjured) {
|
||||
@@ -332,8 +332,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}工伤期间,解聘受限`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=WORK_INJURY`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,6 +650,9 @@ export const esignApi = {
|
||||
/** 催办(生成自动登录链接) */
|
||||
remind: (employeeId: string) =>
|
||||
post('/esign/remind', { employeeId }).then(unwrap<any>()),
|
||||
/** 登记线下合同签署日期 */
|
||||
signDate: (contractId: string, signDate: string) =>
|
||||
post('/esign/sign-date', { contractId, signDate }).then(unwrap<any>()),
|
||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
|
||||
post('/esign/create', data),
|
||||
detail: (id: string) =>
|
||||
|
||||
@@ -788,9 +788,30 @@ function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData,
|
||||
remindData: any
|
||||
onDetail: (id: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
|
||||
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [signDateEditing, setSignDateEditing] = useState<string | null>(null)
|
||||
const [signDateValue, setSignDateValue] = useState('')
|
||||
const [signDateSaving, setSignDateSaving] = useState(false)
|
||||
|
||||
/** 保存签署日期 */
|
||||
const handleSaveSignDate = async (contractId: string) => {
|
||||
if (!signDateValue) { toast.error('请选择签署日期'); return }
|
||||
setSignDateSaving(true)
|
||||
try {
|
||||
await esignApi.signDate(contractId, signDateValue)
|
||||
toast.success('签署日期已登记')
|
||||
setSignDateEditing(null)
|
||||
setSignDateValue('')
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error?.message || '登记失败')
|
||||
} finally {
|
||||
setSignDateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (url: string) => {
|
||||
navigator.clipboard?.writeText(url)
|
||||
@@ -908,7 +929,44 @@ function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData,
|
||||
</button>
|
||||
)}
|
||||
{item.type === 'contract' && (
|
||||
<span className="text-gray-400 ml-auto">纸质合同未登记签署日期</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{signDateEditing === item.contractId ? (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
value={signDateValue}
|
||||
onChange={(e) => setSignDateValue(e.target.value)}
|
||||
className="h-7 px-2 text-xs border rounded"
|
||||
/>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
disabled={signDateSaving}
|
||||
onClick={() => handleSaveSignDate(item.contractId)}
|
||||
>
|
||||
{signDateSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
onClick={() => { setSignDateEditing(null); setSignDateValue('') }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">未登记签署日期</span>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => {
|
||||
setSignDateEditing(item.contractId)
|
||||
setSignDateValue(new Date().toISOString().slice(0, 10))
|
||||
}}
|
||||
>
|
||||
登记签署日期
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -748,10 +748,15 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
}
|
||||
}
|
||||
|
||||
// 根据证件号码自动计算性别(第17位:奇数=男,偶数=女)+ 查重 + 年龄合规筛查
|
||||
// 根据证件号码自动计算性别(第17位:奇数=男,偶数=女)+ 查重 + 年龄合规筛查 + 有效性校验
|
||||
const [idCardDuplicate, setIdCardDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
const [ageWarning, setAgeWarning] = useState<{ type: 'BLOCK' | 'WARN'; message: string } | null>(null)
|
||||
const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
|
||||
// 身份证校验位算法(GB 11643-1999)
|
||||
const ID_WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const ID_CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
|
||||
const handleIdCardChange = (idCard: string) => {
|
||||
let gender = form.gender
|
||||
if (idCard.length >= 17) {
|
||||
@@ -762,33 +767,84 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
setIdCardDuplicate(null)
|
||||
setAgeWarning(null)
|
||||
if (idCard.length === 18) {
|
||||
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
|
||||
setIdCardDuplicate(data)
|
||||
}).catch(() => {})
|
||||
// 年龄合规筛查:从证件号码提取出生日期计算年龄
|
||||
// 1. 基本格式校验:前17位必须为数字
|
||||
if (!/^\d{17}[\dXx]$/.test(idCard)) {
|
||||
setAgeWarning({ type: 'BLOCK', message: '证件号码格式错误:前17位必须为数字,第18位为数字或X' })
|
||||
return
|
||||
}
|
||||
// 2. 校验位验证
|
||||
const sum = idCard.substring(0, 17).split('').reduce((s, c, i) => s + parseInt(c) * ID_WEIGHTS[i], 0)
|
||||
const expectedCheck = ID_CHECK_CODES[sum % 11]
|
||||
if (idCard[17].toUpperCase() !== expectedCheck) {
|
||||
setAgeWarning({ type: 'BLOCK', message: '证件号码校验位错误,请检查输入是否正确' })
|
||||
return
|
||||
}
|
||||
// 3. 出生日期合法性验证
|
||||
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)) {
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
const monthDiff = today.getMonth() - (birthMonth - 1)
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
if (age < 16) {
|
||||
setAgeWarning({ type: 'BLOCK', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` })
|
||||
} else if (age < 18) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,未满18周岁,属于未成年工,需遵守特殊保护规定(《劳动法》第58条)` })
|
||||
} else if (gender === '男' && age >= 60) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达法定退休年龄(男60岁),建议确认是否按退休处理` })
|
||||
} else if (gender === '女' && age >= 50) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达或接近法定退休年龄(女工人50岁/干部55岁),建议确认是否按退休处理` })
|
||||
}
|
||||
if (birthMonth < 1 || birthMonth > 12 || birthDay < 1 || birthDay > 31) {
|
||||
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期非法(月份或日期超出范围)' })
|
||||
return
|
||||
}
|
||||
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
|
||||
if (isNaN(birthDate.getTime()) || birthDate.getFullYear() !== birthYear || birthDate.getMonth() !== birthMonth - 1 || birthDate.getDate() !== birthDay) {
|
||||
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期不存在(如2月30日)' })
|
||||
return
|
||||
}
|
||||
if (birthDate > new Date()) {
|
||||
setAgeWarning({ type: 'BLOCK', message: '证件号码出生日期晚于今天,不可录入' })
|
||||
return
|
||||
}
|
||||
|
||||
employeeApi.checkIdCard(idCard).then((data: { exists: boolean; employee?: any }) => {
|
||||
setIdCardDuplicate(data)
|
||||
}).catch(() => {})
|
||||
// 4. 年龄合规筛查
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
const monthDiff = today.getMonth() - (birthMonth - 1)
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
if (age < 16) {
|
||||
setAgeWarning({ type: 'BLOCK', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` })
|
||||
} else if (age < 18) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,未满18周岁,属于未成年工,需遵守特殊保护规定(《劳动法》第58条)` })
|
||||
} else if (gender === '男' && age >= 60) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达法定退休年龄(男60岁),不可签订劳动合同,请选择劳务协议` })
|
||||
} else if (gender === '女' && age >= 50) {
|
||||
setAgeWarning({ type: 'WARN', message: `该员工年龄 ${age} 岁,已达或接近法定退休年龄(女工人50岁/干部55岁),不可签订劳动合同,请选择劳务协议` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 派生:是否超龄(用于限制合同类型)
|
||||
const isOverage = (() => {
|
||||
if (form.idCardNumber.length !== 18) return false
|
||||
const birthYear = parseInt(form.idCardNumber.substring(6, 10))
|
||||
const birthMonth = parseInt(form.idCardNumber.substring(10, 12))
|
||||
const birthDay = parseInt(form.idCardNumber.substring(12, 14))
|
||||
if (isNaN(birthYear) || isNaN(birthMonth) || isNaN(birthDay)) return false
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
const monthDiff = today.getMonth() - (birthMonth - 1)
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDay)) age--
|
||||
if (form.gender === '男') return age >= 60
|
||||
// 女:干部55,工人50,未选类型按50
|
||||
return age >= (form.femaleWorkerType === 'CADRE' ? 55 : 50)
|
||||
})()
|
||||
|
||||
// 劳务协议/实习协议:不缴纳社保公积金
|
||||
const isNoSocialContract = form.contractType === 'LABOR' || form.contractType === 'INTERNSHIP'
|
||||
|
||||
// 超龄时若当前合同类型非法(草稿恢复场景),自动切到 UNSIGNED
|
||||
useEffect(() => {
|
||||
if (isOverage && !['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType)) {
|
||||
setForm((prev: any) => ({ ...prev, contractType: 'UNSIGNED' }))
|
||||
}
|
||||
}, [isOverage, form.contractType])
|
||||
|
||||
// 计算合同月数
|
||||
const contractMonths = (() => {
|
||||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||||
@@ -884,8 +940,11 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
|
||||
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
|
||||
&& form.idCardNumber.length >= 18
|
||||
&& ageWarning?.type !== 'BLOCK'
|
||||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||||
&& !probationError && !probationSalaryError
|
||||
// 超龄人员不得签订劳动合同
|
||||
&& (!isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(form.contractType))
|
||||
|
||||
const isDirty = !!(form.name || form.department || form.idCardNumber || form.monthlySalary || form.phone)
|
||||
useUnsavedChanges(isDirty)
|
||||
@@ -975,24 +1034,28 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Briefcase className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">社保公积金</span>
|
||||
<span className="text-xs text-gray-500">默认与月工资一致,可手动修改</span>
|
||||
{isNoSocialContract ? (
|
||||
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-500">默认与月工资一致,可手动修改</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className={`grid grid-cols-4 gap-4 ${isNoSocialContract ? 'opacity-50' : ''}`}>
|
||||
<div>
|
||||
<Label>社保缴费基数</Label>
|
||||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
|
||||
<Input type="number" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} disabled={isNoSocialContract} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>社保开始年月</Label>
|
||||
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} />
|
||||
<Input type="month" value={form.socialInsStartMonth || hireMonth} onChange={(e) => setForm({ ...form, socialInsStartMonth: e.target.value })} disabled={isNoSocialContract} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金缴费基数</Label>
|
||||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} />
|
||||
<Input type="number" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} onFocus={(e) => e.target.select()} placeholder={form.monthlySalary || '默认为月工资'} disabled={isNoSocialContract} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>公积金开始年月</Label>
|
||||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} />
|
||||
<Input type="month" value={form.housingFundStartMonth || hireMonth} onChange={(e) => setForm({ ...form, housingFundStartMonth: e.target.value })} disabled={isNoSocialContract} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1007,10 +1070,27 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<Label>合同类型</Label>
|
||||
<Select value={form.contractType} onChange={(e) => {
|
||||
const ct = contractTypes.find(t => t.value === e.target.value)
|
||||
setForm({ ...form, contractType: e.target.value as any, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
|
||||
const newType = e.target.value as any
|
||||
// 选劳务协议/实习协议 → 社保公积金置 0 并清空起始月
|
||||
if (newType === 'LABOR' || newType === 'INTERNSHIP') {
|
||||
setForm({
|
||||
...form,
|
||||
contractType: newType,
|
||||
endDate: ct && !ct.hasEndDate ? '' : form.endDate,
|
||||
socialInsBase: '0', socialInsStartMonth: '',
|
||||
housingFundBase: '0', housingFundStartMonth: '',
|
||||
})
|
||||
} else {
|
||||
setForm({ ...form, contractType: newType, endDate: ct && !ct.hasEndDate ? '' : form.endDate })
|
||||
}
|
||||
}}>
|
||||
{contractTypes.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
{contractTypes
|
||||
.filter(t => !isOverage || ['LABOR', 'INTERNSHIP', 'UNSIGNED'].includes(t.value))
|
||||
.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</Select>
|
||||
{isOverage && (
|
||||
<div className="text-xs text-amber-600 mt-1">超龄人员不可签订劳动合同,仅可选劳务协议/实习协议/未签</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user