feat: 优化退休提醒功能 - 个性化退休年龄计算
- 添加 FemaleWorkerType enum (CADRE/WORKER) 和 Employee.femaleWorkerType 字段 - 新增 calcIndividualRetireAge: 按个人出生日期逐人计算退休年龄 - 重写 calcRetirementDaysLeft: 基于个人出生日期+性别+岗位类型 - updateEmployeesRetirementDays 使用 femaleWorkerType 区分女干部/女工人 - 前端新增/编辑员工表单添加女性岗位类型选择器 - 前端政策展示改为改革规则卡片(基准→目标年龄) - 前端距退休天数改为退休日期显示(XXXX年XX月XX日) - 移除AI退休政策计算相关代码和手动刷新路由 - 导入支持女性岗位类型列
This commit is contained in:
@@ -26,6 +26,11 @@ enum EmployeeStatus {
|
||||
RESIGNED
|
||||
}
|
||||
|
||||
enum FemaleWorkerType {
|
||||
CADRE // 干部/管理岗
|
||||
WORKER // 工人/操作岗
|
||||
}
|
||||
|
||||
enum ContractType {
|
||||
FIXED
|
||||
UNFIXED
|
||||
@@ -203,6 +208,7 @@ model Employee {
|
||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||
city String? // 员工社保参保城市
|
||||
birthDate DateTime? // 出生日期(从身份证号提取)
|
||||
femaleWorkerType FemaleWorkerType? // 女性岗位类型(CADRE=干部/WORKER=工人,仅女性需要区分)
|
||||
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -246,6 +246,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
orgId, name, department: dept, hireDate,
|
||||
monthlySalary: encrypt(salary),
|
||||
gender: val(r['性别']) || (idCard ? extractGenderFromIdCard(idCard) : null),
|
||||
femaleWorkerType: (val(r['女性岗位类型']) === '工人' || val(r['女性岗位类型']) === 'WORKER') ? 'WORKER'
|
||||
: (val(r['女性岗位类型']) === '干部' || val(r['女性岗位类型']) === 'CADRE') ? 'CADRE' : null,
|
||||
phone: val(r['手机号']) || null,
|
||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||
idCardHash: idCard ? sha256(idCard) : null,
|
||||
|
||||
@@ -241,57 +241,3 @@ router.post('/retirement-policy/:id/confirm', requireAdmin, async (req: AuthRequ
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发获取最新退休政策(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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export const createEmployeeSchema = z.object({
|
||||
hireDate: z.string().datetime(),
|
||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
isPregnant: z.boolean().default(false),
|
||||
isInMedicalPeriod: z.boolean().default(false),
|
||||
@@ -29,6 +30,7 @@ export const updateEmployeeSchema = z.object({
|
||||
hireDate: z.string().datetime().optional(),
|
||||
monthlySalary: z.string().min(1).optional(),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
bankName: z.string().max(50).optional(),
|
||||
bankAccount: z.string().max(30).optional(),
|
||||
|
||||
@@ -234,121 +234,3 @@ ${orgContext}`
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
hireDate,
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
gender: data.gender,
|
||||
femaleWorkerType: data.femaleWorkerType,
|
||||
phone: data.phone,
|
||||
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
|
||||
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
|
||||
@@ -505,6 +506,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
}
|
||||
}
|
||||
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.bankName !== undefined) updateData.bankName = data.bankName
|
||||
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from 'crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
import { fetchRetirementPolicy } from './ai.service'
|
||||
|
||||
// 从身份证号提取出生日期
|
||||
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
@@ -38,21 +37,132 @@ export function extractGenderFromIdCard(idCard: string): string | null {
|
||||
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
|
||||
// 渐进式延迟退休固定算法
|
||||
// 改革起始日:2025-01-01,过渡期15年至2039-12-31
|
||||
// 男性:60→63岁,每4个月延迟1个月
|
||||
// 女性干部:55→58岁,每4个月延迟1个月
|
||||
// 女性工人:50→55岁,每2个月延迟1个月
|
||||
const REFORM_START = new Date(2025, 0, 1)
|
||||
const BASE_MALE = 60
|
||||
const BASE_FEMALE_CADRE = 55
|
||||
const BASE_FEMALE_WORKER = 50
|
||||
const TARGET_MALE = 63
|
||||
const TARGET_FEMALE_CADRE = 58
|
||||
const TARGET_FEMALE_WORKER = 55
|
||||
const MALE_DELAY_INTERVAL = 4 // 每4个月延迟1个月
|
||||
const FEMALE_CADRE_DELAY_INTERVAL = 4
|
||||
const FEMALE_WORKER_DELAY_INTERVAL = 2 // 每2个月延迟1个月
|
||||
|
||||
// 分离整数年和月数
|
||||
const years = Math.floor(retireAge)
|
||||
const months = Math.round((retireAge - years) * 12)
|
||||
// 计算从改革起始日到指定日期经过的完整月数
|
||||
function calcMonthsSinceReform(date: Date = new Date()): number {
|
||||
const months = (date.getFullYear() - REFORM_START.getFullYear()) * 12 + (date.getMonth() - REFORM_START.getMonth())
|
||||
return Math.max(0, months)
|
||||
}
|
||||
|
||||
// 计算退休日期:出生日期 + 整数年 + 月数
|
||||
const retireDate = new Date(birthDate)
|
||||
retireDate.setFullYear(retireDate.getFullYear() + years)
|
||||
retireDate.setMonth(retireDate.getMonth() + months)
|
||||
// 根据出生日期和性别计算个人基准退休年龄
|
||||
function getBaseRetireAge(gender: string, femaleWorkerType: string | null): number {
|
||||
if (gender === '男') return BASE_MALE
|
||||
if (gender === '女') {
|
||||
return femaleWorkerType === 'WORKER' ? BASE_FEMALE_WORKER : BASE_FEMALE_CADRE
|
||||
}
|
||||
return BASE_MALE
|
||||
}
|
||||
|
||||
// 根据出生日期和性别计算延迟间隔
|
||||
function getDelayInterval(gender: string, femaleWorkerType: string | null): number {
|
||||
if (gender === '男') return MALE_DELAY_INTERVAL
|
||||
if (gender === '女') {
|
||||
return femaleWorkerType === 'WORKER' ? FEMALE_WORKER_DELAY_INTERVAL : FEMALE_CADRE_DELAY_INTERVAL
|
||||
}
|
||||
return MALE_DELAY_INTERVAL
|
||||
}
|
||||
|
||||
// 根据出生日期和性别计算最大延迟月数
|
||||
function getMaxDelayMonths(gender: string, femaleWorkerType: string | null): number {
|
||||
if (gender === '男') return (TARGET_MALE - BASE_MALE) * 12
|
||||
if (gender === '女') {
|
||||
return femaleWorkerType === 'WORKER'
|
||||
? (TARGET_FEMALE_WORKER - BASE_FEMALE_WORKER) * 12
|
||||
: (TARGET_FEMALE_CADRE - BASE_FEMALE_CADRE) * 12
|
||||
}
|
||||
return (TARGET_MALE - BASE_MALE) * 12
|
||||
}
|
||||
|
||||
// 计算个人的实际退休年龄(基于出生日期)
|
||||
// 原理:先算出此人达到基准退休年龄的日期,再算该日期距改革起始日过了多少个月,
|
||||
// 每N个月延迟1个月,得到个人实际退休年龄
|
||||
export function calcIndividualRetireAge(birthDate: Date, gender: string, femaleWorkerType: string | null = null): {
|
||||
retireAge: number
|
||||
retireDate: Date
|
||||
delayMonths: number
|
||||
} {
|
||||
const baseAge = getBaseRetireAge(gender, femaleWorkerType)
|
||||
const delayInterval = getDelayInterval(gender, femaleWorkerType)
|
||||
const maxDelay = getMaxDelayMonths(gender, femaleWorkerType)
|
||||
|
||||
// 达到基准退休年龄的日期
|
||||
const baseRetireDate = new Date(birthDate)
|
||||
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
|
||||
|
||||
// 从改革起始日到达到基准退休年龄日期,经过的月数
|
||||
const monthsSinceReform = calcMonthsSinceReform(baseRetireDate)
|
||||
|
||||
// 延迟月数 = floor(月数 / 间隔),不超过最大延迟
|
||||
const delayMonths = Math.min(maxDelay, Math.floor(monthsSinceReform / delayInterval))
|
||||
|
||||
// 实际退休年龄 = 基准年龄 + 延迟月数/12
|
||||
const retireAge = baseAge + delayMonths / 12
|
||||
|
||||
// 实际退休日期 = 基准退休日期 + 延迟月数
|
||||
const retireDate = new Date(baseRetireDate)
|
||||
retireDate.setMonth(retireDate.getMonth() + delayMonths)
|
||||
|
||||
return { retireAge, retireDate, delayMonths }
|
||||
}
|
||||
|
||||
// 计算当前改革进度(用于政策展示)
|
||||
export function calcCurrentRetireAges(date: Date = new Date()): {
|
||||
maleRetireAge: number
|
||||
femaleRetireAge: number
|
||||
femaleWorkerAge: number
|
||||
description: string
|
||||
} {
|
||||
const monthsElapsed = calcMonthsSinceReform(date)
|
||||
|
||||
const maleDelayMonths = Math.min(36, Math.floor(monthsElapsed / MALE_DELAY_INTERVAL))
|
||||
const femaleCadreDelayMonths = Math.min(36, Math.floor(monthsElapsed / FEMALE_CADRE_DELAY_INTERVAL))
|
||||
const femaleWorkerDelayMonths = Math.min(60, Math.floor(monthsElapsed / FEMALE_WORKER_DELAY_INTERVAL))
|
||||
|
||||
const maleRetireAge = BASE_MALE + maleDelayMonths / 12
|
||||
const femaleRetireAge = BASE_FEMALE_CADRE + femaleCadreDelayMonths / 12
|
||||
const femaleWorkerAge = BASE_FEMALE_WORKER + femaleWorkerDelayMonths / 12
|
||||
|
||||
const description = `渐进式延迟退休改革(2025年1月1日起实施)
|
||||
|
||||
【改革规则】
|
||||
- 男性职工:60岁 → 63岁,每4个月延迟1个月
|
||||
- 女性干部/管理岗:55岁 → 58岁,每4个月延迟1个月
|
||||
- 女性工人/操作岗:50岁 → 55岁,每2个月延迟1个月
|
||||
|
||||
【当前进度】截至${date.getFullYear()}年${date.getMonth() + 1}月,改革已实施${monthsElapsed}个月
|
||||
- 男性当月退休年龄:${BASE_MALE}岁${maleDelayMonths > 0 ? `+${maleDelayMonths}个月` : ''}
|
||||
- 女性干部当月退休年龄:${BASE_FEMALE_CADRE}岁${femaleCadreDelayMonths > 0 ? `+${femaleCadreDelayMonths}个月` : ''}
|
||||
- 女性工人当月退休年龄:${BASE_FEMALE_WORKER}岁${femaleWorkerDelayMonths > 0 ? `+${femaleWorkerDelayMonths}个月` : ''}
|
||||
|
||||
【个人退休年龄】根据出生年月逐人计算:
|
||||
达到基准退休年龄的时间点不同,延迟月数也不同。
|
||||
例如:1965年1月出生的男性,2025年1月满60岁,延迟0个月,60岁退休;1965年5月出生的男性,2025年5月满60岁,延迟1个月,60岁1个月退休。
|
||||
|
||||
改革依据:2024年9月13日全国人大常委会《关于实施渐进式延迟法定退休年龄的决定》
|
||||
过渡期:2025年1月1日至2039年12月31日(15年)`
|
||||
|
||||
return { maleRetireAge, femaleRetireAge, femaleWorkerAge, description }
|
||||
}
|
||||
|
||||
// 计算距退休天数(基于个人出生日期逐人计算)
|
||||
export function calcRetirementDaysLeft(birthDate: Date, gender: string, femaleWorkerType: string | null = null): number | null {
|
||||
const { retireDate } = calcIndividualRetireAge(birthDate, gender, femaleWorkerType)
|
||||
|
||||
// 计算距今天数
|
||||
const now = new Date()
|
||||
const diffMs = retireDate.getTime() - now.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
@@ -73,11 +183,11 @@ export async function checkAndUpdateRetirementPolicy(orgId: string): Promise<voi
|
||||
if (updatedThisMonth) return
|
||||
}
|
||||
|
||||
// 调用 AI 获取最新政策
|
||||
const result = await fetchRetirementPolicy()
|
||||
// 用固定算法计算当前退休年龄
|
||||
const { maleRetireAge, femaleRetireAge, femaleWorkerAge, description } = calcCurrentRetireAges(now)
|
||||
|
||||
// 计算内容 hash,判断内容是否变化
|
||||
const contentHash = crypto.createHash('sha256').update(result.content).digest('hex')
|
||||
const contentHash = crypto.createHash('sha256').update(description).digest('hex')
|
||||
|
||||
// 如果最新政策的 hash 相同,则不新增版本
|
||||
if (latestPolicy && latestPolicy.contentHash === contentHash) {
|
||||
@@ -90,12 +200,12 @@ export async function checkAndUpdateRetirementPolicy(orgId: string): Promise<voi
|
||||
data: {
|
||||
orgId,
|
||||
version,
|
||||
content: result.content,
|
||||
content: description,
|
||||
contentHash,
|
||||
status: 'PENDING',
|
||||
maleRetireAge: result.maleRetireAge,
|
||||
femaleRetireAge: result.femaleRetireAge,
|
||||
femaleWorkerAge: result.femaleWorkerAge,
|
||||
maleRetireAge,
|
||||
femaleRetireAge,
|
||||
femaleWorkerAge,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -121,24 +231,24 @@ export async function confirmRetirementPolicy(orgId: string, policyId: string):
|
||||
})
|
||||
|
||||
// 更新所有正式合同员工的距退休天数
|
||||
await updateEmployeesRetirementDays(orgId, policy.maleRetireAge, policy.femaleRetireAge, policy.femaleWorkerAge)
|
||||
await updateEmployeesRetirementDays(orgId)
|
||||
}
|
||||
|
||||
// 批量更新员工距退休天数
|
||||
export async function updateEmployeesRetirementDays(orgId: string, maleRetireAge: number, femaleRetireAge: number, femaleWorkerAge: number): Promise<void> {
|
||||
// 批量更新员工距退休天数(基于个人出生日期 individually 计算)
|
||||
export async function updateEmployeesRetirementDays(orgId: string): 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 },
|
||||
select: { id: true, birthDate: true, gender: true, femaleWorkerType: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
if (!emp.birthDate) continue
|
||||
const gender = emp.gender || '男'
|
||||
const daysLeft = calcRetirementDaysLeft(emp.birthDate, gender, maleRetireAge, femaleRetireAge, femaleWorkerAge)
|
||||
const daysLeft = calcRetirementDaysLeft(emp.birthDate, gender, emp.femaleWorkerType)
|
||||
if (daysLeft !== null) {
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
|
||||
@@ -844,6 +844,7 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
const [form, setForm] = useState({
|
||||
department: profile.department || '',
|
||||
gender: profile.gender || '男',
|
||||
femaleWorkerType: profile.femaleWorkerType || '',
|
||||
phone: profile.phone || '',
|
||||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||||
monthlySalary: profile.monthlySalary || '',
|
||||
@@ -873,6 +874,7 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
const data: any = {
|
||||
department: form.department,
|
||||
gender: form.gender,
|
||||
femaleWorkerType: form.femaleWorkerType || undefined,
|
||||
phone: form.phone || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: String(form.monthlySalary),
|
||||
@@ -895,6 +897,9 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '姓名', value: profile.name },
|
||||
{ label: '部门', value: profile.department },
|
||||
{ label: '性别', value: profile.gender || '未填写' },
|
||||
...(profile.gender === '女'
|
||||
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
|
||||
: []),
|
||||
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
|
||||
{ label: '手机号', value: profile.phone || '未填写' },
|
||||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||||
@@ -906,7 +911,26 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.retirementDaysLeft != null
|
||||
? [{ label: '距退休', value: profile.retirementDaysLeft > 0 ? `${profile.retirementDaysLeft}天` : '已到退休年龄' }]
|
||||
? (() => {
|
||||
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
|
||||
if (profile.birthDate) {
|
||||
const bd = new Date(profile.birthDate)
|
||||
const gender = profile.gender || '男'
|
||||
const fwt = profile.femaleWorkerType || null
|
||||
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
|
||||
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
|
||||
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
|
||||
const baseRetireDate = new Date(bd)
|
||||
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
|
||||
const reformStart = new Date(2025, 0, 1)
|
||||
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
|
||||
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
|
||||
const retireDate = new Date(baseRetireDate)
|
||||
retireDate.setMonth(retireDate.getMonth() + delayMonths)
|
||||
return [{ label: '退休日期', value: `${retireDate.getFullYear()}年${retireDate.getMonth() + 1}月${retireDate.getDate()}日` }]
|
||||
}
|
||||
return [{ label: '距退休', value: `${profile.retirementDaysLeft}天` }]
|
||||
})()
|
||||
: []),
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
@@ -951,6 +975,9 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
<div><Label>身份证号(不可编辑)</Label><Input value={profile.idCardNumber || ''} disabled /></div>
|
||||
<div><Label>部门</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
|
||||
<div><Label>性别</Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男">男</option><option value="女">女</option></Select></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>入职日期</Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
|
||||
<div><Label>月工资</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
|
||||
@@ -1795,7 +1822,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', phone: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||
city: '北京',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
@@ -1895,6 +1922,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
name: form.name, department: form.department,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||
idCardNumber: form.idCardNumber || undefined,
|
||||
phone: form.phone || undefined,
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
@@ -1936,6 +1964,9 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div><Label>部门 *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
|
||||
<div><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||
<div><Label>性别</Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
|
||||
@@ -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, Clock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -148,8 +148,6 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
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'],
|
||||
@@ -170,55 +168,33 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
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
|
||||
|
||||
const renderPolicyRules = () => (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">改革规则(基准退休年龄 → 目标退休年龄)</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">男性职工</div>
|
||||
<div className="text-sm font-semibold text-gray-700">60岁 → 63岁</div>
|
||||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">女性干部</div>
|
||||
<div className="text-sm font-semibold text-gray-700">55岁 → 58岁</div>
|
||||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">女性工人</div>
|
||||
<div className="text-sm font-semibold text-gray-700">50岁 → 55岁</div>
|
||||
<div className="text-xs text-gray-400">每2个月延1个月</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">个人退休年龄根据出生年月逐人计算,达到基准退休年龄的时间点不同,延迟月数也不同。</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -232,9 +208,6 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
{confirming ? '确认中...' : '确认生效'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{refreshing ? '获取中...' : '重新获取'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -262,13 +235,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
确认于 {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>
|
||||
{renderPolicyRules()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -279,35 +246,18 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
<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>
|
||||
{renderPolicyRules()}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user