feat: 退休提醒功能 - 身份证提取出生日期、渐进式退休年龄计算、AI流式获取政策、用户确认生效

This commit is contained in:
freedakgmail
2026-07-25 01:21:07 +08:00
parent 169a0e1117
commit b892df1d52
10 changed files with 659 additions and 5 deletions
+3 -1
View File
@@ -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,
+108 -3
View File
@@ -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()
}
})
+119
View File
@@ -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年国发〔1978104号)
请根据当前日期${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.3350岁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年国发〔1978104号)
请根据当前日期${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.3350岁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
}
}
+149
View File
@@ -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 },
})
}
}
}