feat: 退休提醒功能 - 身份证提取出生日期、渐进式退休年龄计算、AI流式获取政策、用户确认生效
This commit is contained in:
@@ -120,6 +120,7 @@ model Organization {
|
||||
contactName String?
|
||||
contactPhone String?
|
||||
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
|
||||
retirementReminderEnabled Boolean @default(false) // 退休提醒开关
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -151,6 +152,7 @@ model Organization {
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
retirementPolicies RetirementPolicy[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -200,6 +202,8 @@ model Employee {
|
||||
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||
city String? // 员工社保参保城市
|
||||
birthDate DateTime? // 出生日期(从身份证号提取)
|
||||
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -828,3 +832,22 @@ model RagKnowledge {
|
||||
@@index([category])
|
||||
@@map("rag_knowledge")
|
||||
}
|
||||
|
||||
// ========== 退休政策版本管理 ==========
|
||||
|
||||
model RetirementPolicy {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
version Int @default(1)
|
||||
content String // AI 返回的完整政策内容
|
||||
contentHash String // 内容 hash,用于判断内容是否变化
|
||||
status String @default("PENDING") // PENDING=待确认, CONFIRMED=已生效, SUPERSEDED=已被新版本替代
|
||||
maleRetireAge Float @default(60) // 男性退休年龄
|
||||
femaleRetireAge Float @default(55) // 女性退休年龄(干部55,工人50,由AI解析最新政策)
|
||||
femaleWorkerAge Float @default(50) // 女性工人退休年龄
|
||||
createdAt DateTime @default(now())
|
||||
confirmedAt DateTime? // 用户确认时间
|
||||
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
import { decrypt } from '../src/lib/crypto'
|
||||
|
||||
function extractBirthDate(idCard: string): Date | null {
|
||||
if (idCard.length === 18) {
|
||||
const y = parseInt(idCard.substring(6, 10))
|
||||
const m = parseInt(idCard.substring(10, 12))
|
||||
const d = parseInt(idCard.substring(12, 14))
|
||||
if (y && m && d) return new Date(y, m - 1, d)
|
||||
}
|
||||
if (idCard.length === 15) {
|
||||
const y = parseInt('19' + idCard.substring(6, 8))
|
||||
const m = parseInt(idCard.substring(8, 10))
|
||||
const d = parseInt(idCard.substring(10, 12))
|
||||
if (y && m && d) return new Date(y, m - 1, d)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const employees = await prisma.employee.findMany({ select: { id: true, idCardNumber: true, birthDate: true } })
|
||||
let updated = 0
|
||||
for (const emp of employees) {
|
||||
if (emp.birthDate) continue
|
||||
if (!emp.idCardNumber) continue
|
||||
let idCard: string
|
||||
try { idCard = decrypt(emp.idCardNumber).toString() } catch { continue }
|
||||
const birthDate = extractBirthDate(idCard)
|
||||
if (birthDate) {
|
||||
await prisma.employee.update({ where: { id: emp.id }, data: { birthDate } })
|
||||
updated++
|
||||
}
|
||||
}
|
||||
console.log(`Updated ${updated} employees with birthDate`)
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
import { decrypt } from '../src/lib/crypto'
|
||||
|
||||
async function main() {
|
||||
// 查找刘洋
|
||||
const employees = await prisma.employee.findMany({
|
||||
select: { id: true, name: true, birthDate: true, retirementDaysLeft: true, gender: true, idCardNumber: true },
|
||||
})
|
||||
for (const emp of employees) {
|
||||
let idCard = ''
|
||||
if (emp.idCardNumber) {
|
||||
try { idCard = decrypt(emp.idCardNumber).toString() } catch {}
|
||||
}
|
||||
console.log(`${emp.name} | birthDate=${emp.birthDate?.toISOString().slice(0,10) || 'NULL'} | retirementDaysLeft=${emp.retirementDaysLeft ?? 'NULL'} | gender=${emp.gender} | idCard=${idCard.slice(0,6)}...`)
|
||||
}
|
||||
|
||||
// 查找刘洋的合同
|
||||
const liu = employees.find(e => e.name === '刘洋')
|
||||
if (liu) {
|
||||
const contracts = await prisma.contract.findMany({ where: { employeeId: liu.id }, select: { contractType: true, status: true } })
|
||||
console.log('\n刘洋合同:', contracts)
|
||||
}
|
||||
|
||||
// 查找已确认政策
|
||||
const policies = await prisma.retirementPolicy.findMany({ select: { id: true, version: true, status: true, maleRetireAge: true, femaleRetireAge: true, femaleWorkerAge: true } })
|
||||
console.log('\n政策:', policies)
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { fetchRetirementPolicy } from '../src/services/ai.service'
|
||||
|
||||
async function main() {
|
||||
console.log('Calling AI...')
|
||||
const result = await fetchRetirementPolicy()
|
||||
console.log('maleRetireAge:', result.maleRetireAge)
|
||||
console.log('femaleRetireAge:', result.femaleRetireAge)
|
||||
console.log('femaleWorkerAge:', result.femaleWorkerAge)
|
||||
console.log('content length:', result.content.length)
|
||||
console.log('content preview:', result.content.slice(0, 500))
|
||||
}
|
||||
|
||||
main().catch(err => console.error('Error:', err.message))
|
||||
@@ -5,6 +5,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services/retirement.service'
|
||||
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
@@ -244,10 +245,11 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
data: {
|
||||
orgId, name, department: dept, hireDate,
|
||||
monthlySalary: encrypt(salary),
|
||||
gender: val(r['性别']) || null,
|
||||
gender: val(r['性别']) || (idCard ? extractGenderFromIdCard(idCard) : null),
|
||||
phone: val(r['手机号']) || null,
|
||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||
idCardHash: idCard ? sha256(idCard) : null,
|
||||
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
|
||||
emergencyContact: val(r['紧急联系人']) || null,
|
||||
emergencyPhone: val(r['紧急联系电话']) || null,
|
||||
address: val(r['住址']) || null,
|
||||
|
||||
@@ -4,6 +4,7 @@ import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { z } from 'zod'
|
||||
import { checkAndUpdateRetirementPolicy, confirmRetirementPolicy } from '../services/retirement.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
@@ -27,7 +28,7 @@ router.get('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.user!.orgId },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, createdAt: true },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true, createdAt: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
@@ -38,17 +39,18 @@ router.get('/org', async (req: AuthRequest, res, next) => {
|
||||
// 更新企业信息
|
||||
router.put('/org', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, payrollFrequency, city, contactName, contactPhone } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string }
|
||||
const { name, payrollFrequency, city, contactName, contactPhone, retirementReminderEnabled } = req.body as { name?: string; payrollFrequency?: number; city?: string; contactName?: string; contactPhone?: string; retirementReminderEnabled?: boolean }
|
||||
const updateData: any = {}
|
||||
if (name) updateData.name = name
|
||||
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
|
||||
if (city !== undefined) updateData.city = city
|
||||
if (contactName !== undefined) updateData.contactName = contactName
|
||||
if (contactPhone !== undefined) updateData.contactPhone = contactPhone
|
||||
if (retirementReminderEnabled !== undefined) updateData.retirementReminderEnabled = retirementReminderEnabled
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: updateData,
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, city: true, contactName: true, contactPhone: true, payrollFrequency: true, retirementReminderEnabled: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
@@ -190,3 +192,106 @@ router.get('/usage', async (req: AuthRequest, res, next) => {
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
// ========== 退休政策 ==========
|
||||
|
||||
// 获取当前生效的退休政策和待确认政策
|
||||
router.get('/retirement-policy', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { retirementReminderEnabled: true } })
|
||||
|
||||
// 如果开启了退休提醒,懒加载检查是否需要获取新政策
|
||||
if (org?.retirementReminderEnabled) {
|
||||
await checkAndUpdateRetirementPolicy(orgId).catch(() => {})
|
||||
}
|
||||
|
||||
// 查询当前生效的政策
|
||||
const confirmedPolicy = await prisma.retirementPolicy.findFirst({
|
||||
where: { orgId, status: 'CONFIRMED' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 查询待确认的政策
|
||||
const pendingPolicy = await prisma.retirementPolicy.findFirst({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
enabled: org?.retirementReminderEnabled || false,
|
||||
confirmed: confirmedPolicy,
|
||||
pending: pendingPolicy,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 确认退休政策
|
||||
router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
await confirmRetirementPolicy(req.user!.orgId, req.params.id)
|
||||
res.json({ success: true, message: '退休政策已确认生效' })
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: err.message || '确认失败' } })
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发获取最新退休政策(SSE 流式)
|
||||
router.post('/retirement-policy/refresh', requireAdmin, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
|
||||
const { fetchRetirementPolicyStream } = await import('../services/ai.service')
|
||||
const crypto = await import('crypto')
|
||||
|
||||
let fullContent = ''
|
||||
for await (const chunk of fetchRetirementPolicyStream()) {
|
||||
fullContent += chunk
|
||||
res.write(`data: ${JSON.stringify({ type: 'chunk', content: chunk })}\n\n`)
|
||||
}
|
||||
|
||||
// 解析 JSON
|
||||
let maleRetireAge = 60
|
||||
let femaleRetireAge = 55
|
||||
let femaleWorkerAge = 50
|
||||
const jsonMatch = fullContent.match(/<JSON>([\s\S]*?)<\/JSON>/)
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[1].trim())
|
||||
if (parsed.maleRetireAge) maleRetireAge = Number(parsed.maleRetireAge)
|
||||
if (parsed.femaleRetireAge) femaleRetireAge = Number(parsed.femaleRetireAge)
|
||||
if (parsed.femaleWorkerAge) femaleWorkerAge = Number(parsed.femaleWorkerAge)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const contentHash = crypto.createHash('sha256').update(fullContent).digest('hex')
|
||||
const latestPolicy = await prisma.retirementPolicy.findFirst({ where: { orgId }, orderBy: { createdAt: 'desc' } })
|
||||
|
||||
if (latestPolicy && latestPolicy.contentHash === contentHash) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', message: '政策内容未变化' })}\n\n`)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const version = latestPolicy ? latestPolicy.version + 1 : 1
|
||||
const policy = await prisma.retirementPolicy.create({
|
||||
data: {
|
||||
orgId, version, content: fullContent, contentHash, status: 'PENDING',
|
||||
maleRetireAge, femaleRetireAge, femaleWorkerAge,
|
||||
},
|
||||
})
|
||||
res.write(`data: ${JSON.stringify({ type: 'done', data: policy, message: '已获取新政策,待确认' })}\n\n`)
|
||||
res.end()
|
||||
} catch (err) {
|
||||
res.write(`data: ${JSON.stringify({ type: 'error', message: '获取失败' })}\n\n`)
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -233,3 +233,122 @@ ${orgContext}`
|
||||
if (delta) yield delta
|
||||
}
|
||||
}
|
||||
|
||||
export interface RetirementPolicyResult {
|
||||
content: string
|
||||
maleRetireAge: number
|
||||
femaleRetireAge: number
|
||||
femaleWorkerAge: number
|
||||
}
|
||||
|
||||
export async function fetchRetirementPolicy(): Promise<RetirementPolicyResult> {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const prompt = `当前日期是${today}。请提供中国最新的法定退休年龄政策。
|
||||
|
||||
已知背景信息(请以此为准):
|
||||
- 2024年9月13日,全国人大常委会通过《关于实施渐进式延迟法定退休年龄的决定》,2025年1月1日起正式实施。
|
||||
- 渐进式延迟退休改革方案(15年过渡期,2025年1月1日至2039年12月31日):
|
||||
- 男性职工:从60岁逐步延迟到63岁,每4个月延迟1个月
|
||||
- 女性干部/管理岗:从55岁逐步延迟到58岁,每4个月延迟1个月
|
||||
- 女性工人/操作岗:从50岁逐步延迟到55岁,每2个月延迟1个月
|
||||
- 改革前基准:男60岁、女干部55岁、女工人50岁(1978年国发〔1978〕104号)
|
||||
|
||||
请根据当前日期${today},计算当前渐进式延迟退休已实施的进度,给出当前实际适用的法定退休年龄(精确到月)。
|
||||
|
||||
要求:
|
||||
1. 说明当前政策状态和已实施的延迟进度
|
||||
2. 给出截至当前日期,各类人员实际适用的法定退休年龄
|
||||
3. 在末尾用以下JSON格式输出结构化数据(用<JSON>和</JSON>标记包裹):
|
||||
<JSON>
|
||||
{
|
||||
"maleRetireAge": <计算后的男性退休年龄,如60.33表示60岁4个月>,
|
||||
"femaleRetireAge": <计算后的女性干部退休年龄>,
|
||||
"femaleWorkerAge": <计算后的女性工人退休年龄>
|
||||
}
|
||||
</JSON>
|
||||
其中:
|
||||
- maleRetireAge: 当前男性法定退休年龄(含渐进延迟,必须填入计算后的值,不要填基准60)
|
||||
- femaleRetireAge: 当前女性干部/管理岗法定退休年龄(必须填入计算后的值,不要填基准55)
|
||||
- femaleWorkerAge: 当前女性工人/操作岗法定退休年龄(必须填入计算后的值,不要填基准50)
|
||||
- 年龄用小数表示,整数部分为岁,小数部分为月/12,如60岁4个月=60.33,50岁8个月=50.67
|
||||
|
||||
请确保JSON中的数值与你在正文中计算出的退休年龄完全一致,不要使用改革前的基准值。`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动政策专家,精通中国退休政策法规。请提供准确的政策信息。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
})
|
||||
|
||||
const text = response.choices[0]?.message?.content || ''
|
||||
|
||||
let maleRetireAge = 60
|
||||
let femaleRetireAge = 55
|
||||
let femaleWorkerAge = 50
|
||||
|
||||
const jsonMatch = text.match(/<JSON>([\s\S]*?)<\/JSON>/)
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[1].trim())
|
||||
if (parsed.maleRetireAge) maleRetireAge = Number(parsed.maleRetireAge)
|
||||
if (parsed.femaleRetireAge) femaleRetireAge = Number(parsed.femaleRetireAge)
|
||||
if (parsed.femaleWorkerAge) femaleWorkerAge = Number(parsed.femaleWorkerAge)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { content: text, maleRetireAge, femaleRetireAge, femaleWorkerAge }
|
||||
}
|
||||
|
||||
export async function* fetchRetirementPolicyStream(): AsyncGenerator<string> {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const prompt = `当前日期是${today}。请提供中国最新的法定退休年龄政策。
|
||||
|
||||
已知背景信息(请以此为准):
|
||||
- 2024年9月13日,全国人大常委会通过《关于实施渐进式延迟法定退休年龄的决定》,2025年1月1日起正式实施。
|
||||
- 渐进式延迟退休改革方案(15年过渡期,2025年1月1日至2039年12月31日):
|
||||
- 男性职工:从60岁逐步延迟到63岁,每4个月延迟1个月
|
||||
- 女性干部/管理岗:从55岁逐步延迟到58岁,每4个月延迟1个月
|
||||
- 女性工人/操作岗:从50岁逐步延迟到55岁,每2个月延迟1个月
|
||||
- 改革前基准:男60岁、女干部55岁、女工人50岁(1978年国发〔1978〕104号)
|
||||
|
||||
请根据当前日期${today},计算当前渐进式延迟退休已实施的进度,给出当前实际适用的法定退休年龄(精确到月)。
|
||||
|
||||
要求:
|
||||
1. 说明当前政策状态和已实施的延迟进度
|
||||
2. 给出截至当前日期,各类人员实际适用的法定退休年龄
|
||||
3. 在末尾用以下JSON格式输出结构化数据(用<JSON>和</JSON>标记包裹):
|
||||
<JSON>
|
||||
{
|
||||
"maleRetireAge": <计算后的男性退休年龄,如60.33表示60岁4个月>,
|
||||
"femaleRetireAge": <计算后的女性干部退休年龄>,
|
||||
"femaleWorkerAge": <计算后的女性工人退休年龄>
|
||||
}
|
||||
</JSON>
|
||||
其中:
|
||||
- maleRetireAge: 当前男性法定退休年龄(含渐进延迟,必须填入计算后的值,不要填基准60)
|
||||
- femaleRetireAge: 当前女性干部/管理岗法定退休年龄(必须填入计算后的值,不要填基准55)
|
||||
- femaleWorkerAge: 当前女性工人/操作岗法定退休年龄(必须填入计算后的值,不要填基准50)
|
||||
- 年龄用小数表示,整数部分为岁,小数部分为月/12,如60岁4个月=60.33,50岁8个月=50.67
|
||||
|
||||
请确保JSON中的数值与你在正文中计算出的退休年龄完全一致,不要使用改革前的基准值。`
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动政策专家,精通中国退休政策法规。请提供准确的政策信息。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content
|
||||
if (delta) yield delta
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import crypto from 'crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
import { fetchRetirementPolicy } from './ai.service'
|
||||
|
||||
// 从身份证号提取出生日期
|
||||
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
// 18位身份证:7-14位为出生日期 YYYYMMDD
|
||||
if (idCard.length === 18) {
|
||||
const year = parseInt(idCard.substring(6, 10))
|
||||
const month = parseInt(idCard.substring(10, 12))
|
||||
const day = parseInt(idCard.substring(12, 14))
|
||||
if (year && month && day) {
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
}
|
||||
// 15位老身份证:7-12位为出生日期 YYMMDD
|
||||
if (idCard.length === 15) {
|
||||
const year = parseInt('19' + idCard.substring(6, 8))
|
||||
const month = parseInt(idCard.substring(8, 10))
|
||||
const day = parseInt(idCard.substring(10, 12))
|
||||
if (year && month && day) {
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||
export function extractGenderFromIdCard(idCard: string): string | null {
|
||||
if (idCard.length === 18) {
|
||||
const genderCode = parseInt(idCard.substring(16, 17))
|
||||
return genderCode % 2 === 1 ? '男' : '女'
|
||||
}
|
||||
if (idCard.length === 15) {
|
||||
const genderCode = parseInt(idCard.substring(14, 15))
|
||||
return genderCode % 2 === 1 ? '男' : '女'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 计算距退休天数(支持小数退休年龄,如 60.33 = 60岁4个月)
|
||||
export function calcRetirementDaysLeft(birthDate: Date, gender: string, maleRetireAge: number, femaleRetireAge: number, femaleWorkerAge: number): number | null {
|
||||
const retireAge = gender === '男' ? maleRetireAge : femaleRetireAge
|
||||
if (!retireAge) return null
|
||||
|
||||
// 分离整数年和月数
|
||||
const years = Math.floor(retireAge)
|
||||
const months = Math.round((retireAge - years) * 12)
|
||||
|
||||
// 计算退休日期:出生日期 + 整数年 + 月数
|
||||
const retireDate = new Date(birthDate)
|
||||
retireDate.setFullYear(retireDate.getFullYear() + years)
|
||||
retireDate.setMonth(retireDate.getMonth() + months)
|
||||
|
||||
// 计算距今天数
|
||||
const now = new Date()
|
||||
const diffMs = retireDate.getTime() - now.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// 检查并获取最新退休政策(懒加载:每月最多获取一次,存为 PENDING 待用户确认)
|
||||
export async function checkAndUpdateRetirementPolicy(orgId: string): Promise<void> {
|
||||
const now = new Date()
|
||||
const latestPolicy = await prisma.retirementPolicy.findFirst({
|
||||
where: { orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 如果本月已获取过(无论是否确认),则跳过
|
||||
if (latestPolicy) {
|
||||
const updatedThisMonth = latestPolicy.createdAt.getMonth() === now.getMonth() &&
|
||||
latestPolicy.createdAt.getFullYear() === now.getFullYear()
|
||||
if (updatedThisMonth) return
|
||||
}
|
||||
|
||||
// 调用 AI 获取最新政策
|
||||
const result = await fetchRetirementPolicy()
|
||||
|
||||
// 计算内容 hash,判断内容是否变化
|
||||
const contentHash = crypto.createHash('sha256').update(result.content).digest('hex')
|
||||
|
||||
// 如果最新政策的 hash 相同,则不新增版本
|
||||
if (latestPolicy && latestPolicy.contentHash === contentHash) {
|
||||
return
|
||||
}
|
||||
|
||||
// 新建版本(PENDING 状态,待用户确认)
|
||||
const version = latestPolicy ? latestPolicy.version + 1 : 1
|
||||
await prisma.retirementPolicy.create({
|
||||
data: {
|
||||
orgId,
|
||||
version,
|
||||
content: result.content,
|
||||
contentHash,
|
||||
status: 'PENDING',
|
||||
maleRetireAge: result.maleRetireAge,
|
||||
femaleRetireAge: result.femaleRetireAge,
|
||||
femaleWorkerAge: result.femaleWorkerAge,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 用户确认退休政策,正式生效并更新员工退休天数
|
||||
export async function confirmRetirementPolicy(orgId: string, policyId: string): Promise<void> {
|
||||
const policy = await prisma.retirementPolicy.findFirst({
|
||||
where: { id: policyId, orgId },
|
||||
})
|
||||
if (!policy) throw new Error('政策不存在')
|
||||
if (policy.status === 'CONFIRMED') return
|
||||
|
||||
// 将旧的 CONFIRMED 政策标记为 SUPERSEDED
|
||||
await prisma.retirementPolicy.updateMany({
|
||||
where: { orgId, status: 'CONFIRMED', id: { not: policyId } },
|
||||
data: { status: 'SUPERSEDED' },
|
||||
})
|
||||
|
||||
// 确认新政策生效
|
||||
await prisma.retirementPolicy.update({
|
||||
where: { id: policyId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
})
|
||||
|
||||
// 更新所有正式合同员工的距退休天数
|
||||
await updateEmployeesRetirementDays(orgId, policy.maleRetireAge, policy.femaleRetireAge, policy.femaleWorkerAge)
|
||||
}
|
||||
|
||||
// 批量更新员工距退休天数
|
||||
export async function updateEmployeesRetirementDays(orgId: string, maleRetireAge: number, femaleRetireAge: number, femaleWorkerAge: number): Promise<void> {
|
||||
// 查询有正式劳动合同的员工
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
contracts: { some: { contractType: { in: ['FIXED', 'UNFIXED'] } } },
|
||||
},
|
||||
select: { id: true, birthDate: true, gender: true, idCardNumber: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
if (!emp.birthDate) continue
|
||||
const gender = emp.gender || '男'
|
||||
const daysLeft = calcRetirementDaysLeft(emp.birthDate, gender, maleRetireAge, femaleRetireAge, femaleWorkerAge)
|
||||
if (daysLeft !== null) {
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: { retirementDaysLeft: daysLeft },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -905,6 +905,9 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.retirementDaysLeft != null
|
||||
? [{ label: '距退休', value: profile.retirementDaysLeft > 0 ? `${profile.retirementDaysLeft}天` : '已到退休年龄' }]
|
||||
: []),
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
.map((t: any) => t.terminationDate?.toString().slice(0, 10))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -94,6 +94,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
payrollFrequency: 1,
|
||||
retirementReminderEnabled: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,6 +104,7 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
contactName: orgData.contactName || '',
|
||||
contactPhone: orgData.contactPhone || '',
|
||||
payrollFrequency: orgData.payrollFrequency || 1,
|
||||
retirementReminderEnabled: orgData.retirementReminderEnabled || false,
|
||||
})
|
||||
}
|
||||
}, [orgData])
|
||||
@@ -138,10 +140,180 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RetirementSection enabled={form.retirementReminderEnabled} onToggle={(v) => { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [streamContent, setStreamContent] = useState('')
|
||||
|
||||
const { data: policyData, isLoading } = useQuery<any>({
|
||||
queryKey: ['retirement-policy'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/settings/retirement-policy') as any
|
||||
return res.data
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/settings/retirement-policy/${id}/confirm`),
|
||||
onSuccess: () => {
|
||||
toast.success('退休政策已确认生效')
|
||||
setConfirming(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
|
||||
},
|
||||
onError: () => toast.error('确认失败'),
|
||||
})
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
setStreamContent('')
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseUrl}/settings/retirement-policy/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const msg = JSON.parse(line.slice(6))
|
||||
if (msg.type === 'chunk') {
|
||||
setStreamContent(prev => prev + msg.content)
|
||||
} else if (msg.type === 'done') {
|
||||
toast.success(msg.message || '已获取最新政策')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
|
||||
} else if (msg.type === 'error') {
|
||||
toast.error(msg.message || '获取失败')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('获取失败')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = policyData?.confirmed
|
||||
const pending = policyData?.pending
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium">退休提醒</h3>
|
||||
{enabled && (
|
||||
<div className="flex gap-2 ml-4">
|
||||
{pending && (
|
||||
<Button size="sm" onClick={() => { setConfirming(true); confirmMutation.mutate(pending.id) }} disabled={confirming}>
|
||||
{confirming ? '确认中...' : '确认生效'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{refreshing ? '获取中...' : '重新获取'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-sm text-gray-500">{enabled ? '已开启' : '未开启'}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-3">
|
||||
{/* 当前生效政策 */}
|
||||
{confirmed && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">当前生效政策(v{confirmed.version})</span>
|
||||
<span className="text-xs text-gray-500 ml-auto">
|
||||
确认于 {new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div>男性退休年龄:<b>{confirmed.maleRetireAge}岁</b> 女性干部:<b>{confirmed.femaleRetireAge}岁</b> 女性工人:<b>{confirmed.femaleWorkerAge}岁</b></div>
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整政策内容</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{confirmed.content}</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 待确认政策 */}
|
||||
{pending && (
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||||
<span className="text-sm font-medium text-orange-800">检测到新政策版本(v{pending.version}),请确认后生效</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div>男性退休年龄:<b>{pending.maleRetireAge}岁</b> 女性干部:<b>{pending.femaleRetireAge}岁</b> 女性工人:<b>{pending.femaleWorkerAge}岁</b></div>
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整政策内容</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{pending.content}</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无政策 */}
|
||||
{!confirmed && !pending && !isLoading && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-center">
|
||||
<p className="text-sm text-gray-500">暂无退休政策数据,点击上方"重新获取"按钮获取最新政策</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-gray-400 text-center">加载中...</p>}
|
||||
|
||||
{/* 流式输出 */}
|
||||
{streamContent && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw className="w-4 h-4 text-blue-600 animate-spin" />
|
||||
<span className="text-sm font-medium text-blue-800">AI 正在获取最新政策...</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-xs max-h-64 overflow-y-auto bg-white rounded p-2 border">{streamContent}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSettings({ usersData }: { usersData: any }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
Reference in New Issue
Block a user