From b892df1d5239460396981f894130c358c74ff5ed Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 25 Jul 2026 01:21:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=80=E4=BC=91=E6=8F=90=E9=86=92?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20-=20=E8=BA=AB=E4=BB=BD=E8=AF=81=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=87=BA=E7=94=9F=E6=97=A5=E6=9C=9F=E3=80=81=E6=B8=90?= =?UTF-8?q?=E8=BF=9B=E5=BC=8F=E9=80=80=E4=BC=91=E5=B9=B4=E9=BE=84=E8=AE=A1?= =?UTF-8?q?=E7=AE=97=E3=80=81AI=E6=B5=81=E5=BC=8F=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E6=94=BF=E7=AD=96=E3=80=81=E7=94=A8=E6=88=B7=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/prisma/schema.prisma | 23 +++ backend/scripts/backfill-birthdate.ts | 38 +++++ backend/scripts/check-retirement.ts | 30 ++++ backend/scripts/test-retirement.ts | 13 ++ backend/src/routes/import.routes.ts | 4 +- backend/src/routes/settings.routes.ts | 111 ++++++++++++- backend/src/services/ai.service.ts | 119 ++++++++++++++ backend/src/services/retirement.service.ts | 149 ++++++++++++++++++ frontend/src/pages/Roster.tsx | 3 + frontend/src/pages/Settings.tsx | 174 ++++++++++++++++++++- 10 files changed, 659 insertions(+), 5 deletions(-) create mode 100644 backend/scripts/backfill-birthdate.ts create mode 100644 backend/scripts/check-retirement.ts create mode 100644 backend/scripts/test-retirement.ts create mode 100644 backend/src/services/retirement.service.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e1d3d53..b455137 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) +} diff --git a/backend/scripts/backfill-birthdate.ts b/backend/scripts/backfill-birthdate.ts new file mode 100644 index 0000000..d785bfb --- /dev/null +++ b/backend/scripts/backfill-birthdate.ts @@ -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() diff --git a/backend/scripts/check-retirement.ts b/backend/scripts/check-retirement.ts new file mode 100644 index 0000000..138651f --- /dev/null +++ b/backend/scripts/check-retirement.ts @@ -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() diff --git a/backend/scripts/test-retirement.ts b/backend/scripts/test-retirement.ts new file mode 100644 index 0000000..7c7f397 --- /dev/null +++ b/backend/scripts/test-retirement.ts @@ -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)) diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index cd78648..6f6000b 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -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, diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 88dee0e..adb5bc1 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -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(/([\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() + } +}) diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index c0b9fe2..898a954 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -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 { + 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格式输出结构化数据(用标记包裹): + +{ + "maleRetireAge": <计算后的男性退休年龄,如60.33表示60岁4个月>, + "femaleRetireAge": <计算后的女性干部退休年龄>, + "femaleWorkerAge": <计算后的女性工人退休年龄> +} + +其中: +- 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(/([\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 { + 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格式输出结构化数据(用标记包裹): + +{ + "maleRetireAge": <计算后的男性退休年龄,如60.33表示60岁4个月>, + "femaleRetireAge": <计算后的女性干部退休年龄>, + "femaleWorkerAge": <计算后的女性工人退休年龄> +} + +其中: +- 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 + } +} diff --git a/backend/src/services/retirement.service.ts b/backend/src/services/retirement.service.ts new file mode 100644 index 0000000..1aa3817 --- /dev/null +++ b/backend/src/services/retirement.service.ts @@ -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 { + 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 { + 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 { + // 查询有正式劳动合同的员工 + 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 }, + }) + } + } +} diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 7b68daa..8aa4d03 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -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)) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 244135d..7e945b0 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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: + { setForm({ ...form, retirementReminderEnabled: v }); onSave({ ...form, retirementReminderEnabled: v }) }} /> ) } +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({ + 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 ( +
+
+
+ +

退休提醒

+ {enabled && ( +
+ {pending && ( + + )} + +
+ )} +
+ +
+ + {enabled && ( +
+ {/* 当前生效政策 */} + {confirmed && ( +
+
+ + 当前生效政策(v{confirmed.version}) + + 确认于 {new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')} + +
+
+
男性退休年龄:{confirmed.maleRetireAge}岁 女性干部:{confirmed.femaleRetireAge}岁 女性工人:{confirmed.femaleWorkerAge}岁
+
+ 查看完整政策内容 +
{confirmed.content}
+
+
+
+ )} + + {/* 待确认政策 */} + {pending && ( +
+
+ + 检测到新政策版本(v{pending.version}),请确认后生效 +
+
+
男性退休年龄:{pending.maleRetireAge}岁 女性干部:{pending.femaleRetireAge}岁 女性工人:{pending.femaleWorkerAge}岁
+
+ 查看完整政策内容 +
{pending.content}
+
+
+
+ )} + + {/* 无政策 */} + {!confirmed && !pending && !isLoading && ( +
+

暂无退休政策数据,点击上方"重新获取"按钮获取最新政策

+
+ )} + + {isLoading &&

加载中...

} + + {/* 流式输出 */} + {streamContent && ( +
+
+ + AI 正在获取最新政策... +
+
{streamContent}
+
+ )} +
+ )} +
+ ) +} + function UserSettings({ usersData }: { usersData: any }) { const queryClient = useQueryClient() const [showAddModal, setShowAddModal] = useState(false)