feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.env
*.local
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
"**/flutter/ephemeral": true,
"**/Flutter/ephemeral": true,
"**/.symlinks": true,
"**/.plugin_symlinks": true
},
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore",
"less.lint.unknownAtRules": "ignore"
}
+1387
View File
File diff suppressed because it is too large Load Diff
+1107
View File
File diff suppressed because it is too large Load Diff
+762
View File
@@ -0,0 +1,762 @@
# 劳动用工合规助手 — 开发任务清单
> **文档编号**: 2-task.md
> **版本**: v1.0
> **日期**: 2026-07-23
> **状态**: 开发中
> **依据**: 0-req.md v3.0 需求规格说明书 / 1-prd.md v1.0 产品需求文档
---
## 任务总览
| 阶段 | 内容 | 预估工期 | 任务数 |
|------|------|---------|--------|
| P0 | 项目搭建 + 路由骨架 + Prisma Schema | 2天 | 8 | ✅ 已完成 |
| P1 | 认证体系(注册/登录/JWT/路由守卫) | 2天 | 7 | ✅ 已完成 |
| P2 | 首页风险总览 + 风险检测引擎 | 2天 | 6 | ✅ 已完成 |
| P3 | 合同管理(列表/添加/续签/纸质电子) | 3天 | 10 | ✅ 已完成 |
| P4 | 钱的计算(3 Tab 计算器 + 加班费保存 + 工资条管理) | 2天 | 5 | ✅ 已完成 |
| P5 | 解聘助手(5步向导 + 禁止检查) | 2天 | 6 | ✅ 已完成 |
| P6 | AI 合规顾问(问答/预测/审查/案例 + RAG) | 4天 | 9 | ✅ 已完成 |
| P7 | 员工端(密码/验证码登录 + 工资条 + 合同 + 入职填报 + 合同确认) | 3天 | 10 | ✅ 已完成 |
| P8 | 系统设置 + 新手引导 + 空状态 | 1天 | 5 | ✅ 已完成 |
| P9 | 移动端适配 + 联调 | 2天 | 4 | ✅ 已完成 |
| P10 | 部署上线 + 验证 | 1天 | 4 | ⏳ 进行中 |
| P11 | 功能补齐(社保公积金 + 到期提醒 + 批量工资条 + Excel导入 + 员工档案附件) | 3天 | 10 | ✅ 已完成 |
| **合计** | | **~27天** | **84** | |
---
## P0 — 项目搭建 + 路由骨架 + Prisma Schema2天)
### 前端
- [x] **T-P0-01** 初始化前端项目
- Vite + React 18 + TypeScript
- 安装 TailwindCSS + PostCSS
- 配置路径别名 `@/``src/`
- 安装核心依赖:react-router-dom, axios, zustand, @tanstack/react-query, react-hook-form, zod, lucide-react, qrcode.react
- **产出**: `package.json`, `vite.config.ts`, `tailwind.config.ts`, `tsconfig.json`
- [x] **T-P0-02** 前端项目结构搭建
- 创建目录结构:`components/`, `pages/`, `hooks/`, `lib/`, `store/`, `types/`
- 创建 `App.tsx` 路由骨架(含管理端 + 员工端路由定义)
- 创建 `main.tsx` 入口
- 创建 `lib/api.ts`(Axios 实例 + 请求/响应拦截器)
- 创建 `types/index.ts`TypeScript 类型定义)
- **产出**: 项目目录结构 + 路由配置
- [x] **T-P0-03** 前端布局组件
- `TopNav.tsx`:顶部导航栏(Logo + 5 Tab + 风险角标 + 用户头像下拉)
- `MobileTabBar.tsx`:移动端底部导航
- `PageContainer.tsx`:主内容区容器(max-width 960px 居中)
- `ui/Button.tsx`, `ui/Card.tsx`, `ui/Input.tsx`, `ui/Select.tsx`, `ui/Modal.tsx`, `ui/Signal.tsx`, `ui/EmptyState.tsx`
- **产出**: 通用组件库
### 后端
- [x] **T-P0-04** 初始化后端项目
- Node.js + Express + TypeScript
- 安装核心依赖:prisma, @prisma/client, zod, jsonwebtoken, bcryptjs, cors, helmet, morgan, express-rate-limit
- 配置 ts-node-dev 热重载
- **产出**: `package.json`, `tsconfig.json`, `.env.example`
- [x] **T-P0-05** 后端项目结构搭建
- 创建目录结构:`routes/`, `middleware/`, `services/`, `lib/`, `validators/`, `jobs/`
- `app.ts`Express 应用(CORS + helmet + JSON 解析 + 路由挂载)
- `index.ts`:服务入口
- **产出**: 后端骨架 + 健康检查接口 `/health`
- [x] **T-P0-06** Prisma Schema 编写
- 编写完整 `schema.prisma`Organization, User, Employee, LaborContract, OvertimeRecord, TerminationRecord, RiskItem, AuditLog, Payslip, OnboardingLink, ContractConfirmLink
- 定义所有枚举:Plan, Role, EmployeeStatus, ContractType, SignMethod, RiskType, RiskLevel, RiskStatus, TerminationReason, RiskAssessment, OnboardingStatus, ContractConfirmStatus
- 配置 PostgreSQL 数据源
- **产出**: `prisma/schema.prisma`
- [x] **T-P0-07** 数据库迁移 + 种子数据
- 运行 `prisma migrate dev` 生成初始迁移
- 编写 `prisma/seed.ts` 种子数据(测试企业 + 员工 + 合同)
- 配置 `prisma.ts` 客户端单例
- **产出**: 数据库表结构 + 测试数据
- [x] **T-P0-08** 中间件骨架
- `auth.ts`JWT 校验中间件(从 Header 提取 Token → 验证 → 注入 req.user
- `orgFilter.ts`:多租户中间件(从 req.user 提取 orgId → 注入 req.orgId
- `errorHandler.ts`:统一错误处理(Zod 错误 → 422,Prisma 错误 → 400,其他 → 500
- `rateLimit.ts`:限流中间件(基于 express-rate-limit
- `auditLog.ts`:审计日志中间件(记录关键操作)
- **产出**: 5 个中间件文件
---
## P1 — 认证体系(2天)
- [x] **T-P1-01** 后端:注册接口
- `POST /api/v1/auth/register`
- 输入校验(Zod):企业名称、手机号、密码(8位+)
- 逻辑:创建 Organizationplan=free, maxEmployees=20+ Userrole=admin, bcrypt 加密)
- 返回:Access Token2h+ Refresh Token7d
- 限流:同一 IP 每小时 5 次
- **产出**: `auth.routes.ts` + `auth.service.ts` + `auth.validator.ts`
- [x] **T-P1-02** 后端:登录接口
- `POST /api/v1/auth/login`
- 输入校验:手机号、密码
- 逻辑:查询 User → bcrypt 比对 → 签发 Token
- 限流:同一 IP 每分钟 5 次
- **产出**: 登录逻辑
- [x] **T-P1-03** 后端:Token 刷新 + 当前用户
- `POST /api/v1/auth/refresh`:校验 Refresh Token → 签发新 Access Token
- `GET /api/v1/auth/me`:返回当前用户信息 + 组织信息
- **产出**: Token 刷新逻辑
- [x] **T-P1-04** 后端:JWT 工具
- `lib/jwt.ts`:签发/验证 Access Token + Refresh Token
- 密钥从环境变量读取
- **产出**: `jwt.ts`
- [x] **T-P1-05** 前端:注册页面
- `/register` 页面
- 表单:企业名称、手机号、密码、确认密码
- React Hook Form + Zod 校验
- 注册成功 → 存储 Token → 跳转首页
- **产出**: `Register.tsx`
- [x] **T-P1-06** 前端:登录页面 + 路由守卫
- `/login` 页面
- 表单:手机号、密码
- `useAuth` HookZustand storeuser, token, isAuthenticated
- `ProtectedRoute`:未登录 → 跳转 `/login`
- `PublicRoute`:已登录 → 跳转 `/`
- Axios 拦截器:401 → 自动刷新 Token / 跳转登录
- **产出**: `Login.tsx` + `useAuth.ts` + `authStore.ts` + 路由守卫
- [x] **T-P1-07** 前端:忘记密码页面
- `/forgot-password` 页面
- 手机号 + 验证码 + 新密码
- **产出**: `ForgotPassword.tsx`
---
## P2 — 首页风险总览 + 风险检测引擎(2天)
- [x] **T-P2-01** 后端:Dashboard 数据聚合接口
- `GET /api/v1/dashboard`
- 聚合:员工数、高风险数、待办数、月加班费
- 生成待办列表(从 RiskItem 查询 pending 状态)
- 风险分布统计(按 type 分组)
- AI 预测数据(从缓存读取,P6 实现)
- **产出**: `dashboard.routes.ts` + `dashboard.service.ts`
- [x] **T-P2-02** 后端:风险检测引擎
- `lib/riskEngine.ts`
- 合同风险检测:未签合同(>30天 🔴 / >365天 视为无固定期限 🔴)、即将到期(≤30天 🟡)、已到期 🔴
- 试用期风险检测:试用期超法定上限
- 加班风险检测:月加班 > 36h
- 解聘风险检测:禁止解聘情形(孕期/工伤/医疗期)
- 触发时机:数据变更时实时检测 + 定时全量扫描
- **产出**: `riskEngine.ts`
- [x] **T-P2-03** 后端:风险 CRUD 接口
- `GET /api/v1/risks`:风险列表(分页 + 类型筛选 + 状态筛选)
- `PUT /api/v1/risks/:id`:更新风险状态(resolved / ignored + 备注)
- **产出**: `risk.routes.ts` + `risk.service.ts`
- [x] **T-P2-04** 后端:定时风险扫描任务
- `jobs/riskScan.ts`:每日凌晨 2:00 全量扫描
- 使用 node-cron 调度
- 扫描所有企业的员工/合同 → 生成/更新 RiskItem
- **产出**: `riskScan.ts`
- [x] **T-P2-05** 前端:首页风险总览页面
- `/` Dashboard 页面
- 一句话状态("早上好!今天有 N 件事需要处理")
- 数字卡片:员工数 / 高风险数 / 待办数 / 月加班费
- 待办列表:每条含风险等级颜色 + 标题 + 「去处理」按钮
- 风险分布进度条(合同/工资/解聘)
- AI 风险预测卡片(P6 实现后接入)
- **产出**: `Dashboard.tsx` + `TodoList.tsx` + `ProgressBar.tsx`
- [x] **T-P2-06** 前端:风险角标组件
- 顶部导航栏红色角标,显示待处理风险总数
- 点击跳转首页
- 数据来源:Dashboard 接口或独立计数接口
- **产出**: `TopNav.tsx` 集成角标
---
## P3 — 合同管理(3天)
- [x] **T-P3-01** 后端:员工 CRUD 接口
- `GET /api/v1/employees`:列表(分页 + 搜索 + 部门筛选)
- `POST /api/v1/employees`:添加员工(含合同信息 + AES-256 加密工资)
- `GET /api/v1/employees/:id`:详情(含合同 + 风险)
- `PUT /api/v1/employees/:id`:编辑
- `DELETE /api/v1/employees/:id`:软删除(status=resigned
- **产出**: `employee.routes.ts` + `employee.service.ts` + `employee.validator.ts`
- [x] **T-P3-02** 后端:AES-256 加密工具
- `lib/crypto.ts`:加密/解密工资字段
- 密钥从环境变量 `ENCRYPTION_KEY` 读取
- **产出**: `crypto.ts`
- [x] **T-P3-03** 后端:合同状态计算
- `lib/contractStatus.ts`
- 输入:signDate, startDate, endDate, contractType, renewalCount, hireDate
- 输出:status + statusText + riskLevel
- 逻辑:未签/即将到期/已到期/正常/无固定期限
- **产出**: `contractStatus.ts`
- [x] **T-P3-04** 后端:试用期合法性校验
- 合同期 < 3月 → 不能约定试用期
- 合同期 3月~1年 → 试用期 ≤ 1月
- 合同期 1~3年 → 试用期 ≤ 2月
- 合同期 ≥ 3年 → 试用期 ≤ 6月
- **产出**: 集成到 `employee.validator.ts`
- [x] **T-P3-05** 后端:批量续签接口
- `POST /api/v1/contracts/batch-renew`
- 输入:合同 ID 列表 + 新期限
- 逻辑:更新 endDate + renewalCount++ + 重新检测风险
- **产出**: `contract.service.ts` 续签逻辑
- [x] **T-P3-06** 后端:合同附件上传
- `POST /api/v1/contracts/:id/attachment`
- 接收 multipart 文件(纸质合同扫描件)
- 存储到 Supabase Storage / 本地临时目录
- 更新合同记录 attachmentName + attachmentUrl
- **产出**: 文件上传逻辑
- [x] **T-P3-07** 前端:合同管理列表页
- `/contracts` 页面
- 员工合同列表:姓名 / 部门 / 合同状态信号灯 / 到期日 / 操作
- 搜索框 + 部门筛选
- 信号灯组件(🔴🟡🟢)
- **产出**: `Contracts.tsx`
- [x] **T-P3-08** 前端:添加/编辑员工表单
- 模态框表单
- Step 1 基本信息:姓名*、部门*、手机号、入职日期*、月工资*、性别
- Step 2 合同信息:合同类型*、签订方式(纸质/电子)、签订日期、起止日期、试用期月数、试用期工资
- 纸质合同:显示文件上传按钮
- 电子合同:显示合同编号 + 链接输入
- 试用期实时校验(红色提示)
- 特殊标记:孕期/工伤/医疗期 复选框
- **产出**: `EmployeeForm.tsx`
- [x] **T-P3-09** 前端:一键续签弹窗
- 选中即将到期的合同 → 点击「续签」
- 弹窗:显示当前合同信息 + 选择新期限
- 确认 → 调用批量续签接口 → 刷新列表
- **产出**: `RenewModal.tsx`
- [x] **T-P3-10** 前端:合同详情页/弹窗
- 显示员工信息 + 合同完整信息 + 风险卡片
- 纸质合同:查看扫描件
- 电子合同:查看合同链接
- 操作按钮:编辑 / 续签 / 发送确认二维码(P7 实现)
- **产出**: `ContractDetail.tsx`
---
## P4 — 钱的计算(2天)
- [x] **T-P4-01** 前端:加班费计算器(含员工关联 + 月份选择 + 保存记录)
- `/money` Tab 1
- 输入:月工资、工作日加班小时、休息日加班小时、节假日加班小时
- 公式:hourlyWage = monthlyWage / 21.75 / 8
- weekdayPay = hourlyWage × 1.5 × weekdayHours
- weekendPay = hourlyWage × 2.0 × weekendHours
- holidayPay = hourlyWage × 3.0 × holidayHours
- 实时计算,右侧显示结果
- 总加班 > 36h → 黄色警告
- **产出**: `OvertimeCalculator.tsx` + `lib/calculator.ts`
- [x] **T-P4-02** 前端:双倍工资计算器
- `/money` Tab 2
- 输入:月工资、入职日期、合同签订日期(可选)
- 公式:未签或超 30 天签订 → 起算入职+1月 → 截止入职+1年或签订日 → 双倍工资差额
- 实时计算
- **产出**: `DoublePayCalculator.tsx`
- [x] **T-P4-03** 前端:经济补偿金计算器
- `/money` Tab 3
- 输入:入职日期、离职日期、月平均工资、离职原因、社平工资(选填)
- 公式:工作年限 → 补偿月数 → 封顶限制 → 经济补偿金 / 违法解除赔偿金(×2)
- 实时计算
- **产出**: `CompensationCalculator.tsx`
- [x] **T-P4-04** 后端:加班记录 CRUD + 工资条管理(`payroll.routes.ts`
- `GET /api/v1/overtime`:加班记录列表
- `POST /api/v1/overtime`:添加加班记录
- **产出**: `overtime.routes.ts` + `overtime.service.ts`
- [x] **T-P4-05** 前端:计算器工具函数 + 工资条管理 Tab
- `lib/calculator.ts`:纯函数,输入输出明确
- 编写单元测试验证计算公式正确性
- 边界用例:0 加班、36h 临界值、社平工资 3 倍封顶
- **产出**: `calculator.ts` + `calculator.test.ts`
---
## P5 — 解聘助手(2天)
- [x] **T-P5-01** 后端:解聘记录 CRUD
- `GET /api/v1/termination`:解聘记录列表
- `POST /api/v1/termination`:创建解聘记录
- 逻辑:保存向导数据 + 更新员工状态为 resigned + 记录审计日志
- **产出**: `termination.routes.ts` + `termination.service.ts` + `termination.validator.ts`
- [x] **T-P5-02** 前端:解聘向导 Step 1 — 选择解聘原因
- `/termination` 页面
- 5 个选项卡片:协商解除 / 员工犯错 / 员工没犯错但干不了 / 公司裁员 / 合同到期不续签
- 每个选项含简短说明
- **产出**: `Termination.tsx` Step 1
- [x] **T-P5-03** 前端:解聘向导 Step 2 — 选择员工 + 禁止情形检查
- 员工选择下拉框(仅在职员工)
- 选择后自动检查:isPregnant / isWorkInjured / isInMedicalPeriod
- 命中禁止情形 → 红色警告弹窗 + 「我已了解风险,继续操作」
- **产出**: Step 2 + 禁止情形检查逻辑
- [x] **T-P5-04** 前端:解聘向导 Step 3 — 合规检查清单
- 根据解聘原因动态生成检查项
- 协商解除:是否支付补偿金 / 是否签署协议
- 员工犯错:是否有规章制度 / 是否有证据 / 是否通知工会
- 员工没犯错:是否提前30天通知 / 是否经过培训调岗
- 公司裁员:是否提前30天向工会说明 / 是否听取意见 / 是否报劳动部门
- 合同到期:是否提前通知 / 是否支付补偿金
- 每项 ✅/❌ 选择
- **产出**: Step 3 + 动态检查项规则
- [x] **T-P5-05** 前端:解聘向导 Step 4 — 补偿金计算 + Step 5 — 确认提交
- Step 4:自动填充员工工资 + 入职日期 → 计算补偿金(复用 P4 计算逻辑)
- Step 5:汇总信息确认 → 提交保存
- 进度条显示 1/5 ~ 5/5
- **产出**: Step 4 + Step 5
- [x] **T-P5-06** 前端:解聘历史记录
- `/termination` 页面底部
- 历史记录列表:员工名 / 解聘日期 / 原因 / 补偿金 / 风险等级
- 点击查看详情
- **产出**: `TerminationHistory.tsx`
---
## P6 — AI 合规顾问(4天)
- [x] **T-P6-01** 后端:DashScope SDK 封装
- `lib/dashscope.ts`
- 封装通义千问 API 调用(兼容 OpenAI 格式)
- 支持 qwen-plus(日常问答)和 qwen-max(复杂任务)
- 支持 SSE 流式输出
- API Key 从环境变量 `DASHSCOPE_API_KEY` 读取
- **产出**: `dashscope.ts`
- [x] **T-P6-02** 后端:RAG 知识库 — 法律条文向量化
- `services/rag.service.ts`
- 收集劳动法/劳动合同法/司法解释/地方条例文本
- 使用 DashScope text-embedding-v2 生成向量
- 存储到 Supabase pgvector
- 提供语义搜索接口(输入问题 → 检索相关法条)
- **产出**: `rag.service.ts` + 知识库数据
- [x] **T-P6-03** 后端:智能问答接口(SSE
- `POST /api/v1/ai/chat`
- 逻辑:
1. 构建系统 Prompt(劳动法专家 + 人话风格)
2. RAG 检索相关法条
3. 注入企业数据上下文(员工数/风险项/合同状态)
4. 调用 qwen-plus SSE 流式返回
- SSE 事件格式:`data: {"type":"chunk","content":"xxx"}`
- 结束事件:`data: {"type":"done","legalBasis":"..."}`
- **产出**: `ai.routes.ts` + `ai.service.ts`
- [x] **T-P6-04** 后端:风险预测接口
- `GET /api/v1/ai/prediction`
- 逻辑:
1. 查询未来 30 天到期合同
2. 查询入职满 1 年未签合同员工
3. 分析上月加班趋势
4. 调用 LLM 生成优先级建议
- 缓存 24h
- 定时任务:每日凌晨生成
- **产出**: 预测逻辑 + `jobs/aiPrediction.ts`
- [x] **T-P6-05** 后端:合同审查接口
- `POST /api/v1/ai/contract-review`
- 输入:合同文本(粘贴或文件解析)
- 逻辑:调用 qwen-max 逐条分析 → 标注红/黄/绿 + 修改建议 → 合规评分
- **产出**: 合同审查逻辑
- [x] **T-P6-06** 后端:案例匹配接口
- `POST /api/v1/ai/case-match`
- 输入:争议情况描述
- 逻辑:text-embedding-v2 向量化 → pgvector 检索 top 5 → qwen-max 分析败诉概率
- **产出**: 案例匹配逻辑
- [x] **T-P6-07** 后端:AI 使用次数限制
- 中间件:每次 AI 请求前检查当月已用次数
-`orgId + 月份 + 类型` 统计
- free: 10 问答 / 3 审查 / 3 案例
- pro: 100 / 20 / 20
- enterprise: 无限
- 超限 → 429 + 提示升级
- **产出**: AI 限流中间件
- [x] **T-P6-08** 前端:AI 顾问页面 — 智能问答
- `/ai-assistant` 页面
- 聊天界面:消息列表 + 输入框
- 预设问题快捷按钮("试用期最长多久?" "未签合同怎么办?"
- SSE 流式接收:逐字显示打字机效果
- 法律依据折叠展示
- 多轮对话(保留上下文 messages)
- **产出**: `AIAssistant.tsx` 聊天 Tab
- [x] **T-P6-09** 前端:AI 顾问页面 — 合同审查 + 案例匹配
- 合同审查 Tab:文本框粘贴合同 / 文件上传 → 提交 → 逐条标注展示 + 合规评分
- 案例匹配 Tab:描述争议情况 → 提交 → 相似案例卡片列表 + 败诉概率 + 赔偿预估
- **产出**: 合同审查 Tab + 案例匹配 Tab
---
## P7 — 员工端(3天)
- [x] **T-P7-01** 后端:员工端认证(密码登录 + 验证码登录)
- `POST /api/v1/portal/auth/login`:手机号 + 密码(bcrypt 校验员工密码)
- `POST /api/v1/portal/auth/send-code`:发送验证码(v1.0 页面内显示,存 Redis/内存)
- `POST /api/v1/portal/auth/verify`:验证码登录
- `POST /api/v1/portal/auth/change-password`:修改密码
- 员工 Token 与管理端 Token 区分(role=employee
- **产出**: `portal.routes.ts` 认证部分 + `portal.service.ts`
- [x] **T-P7-02** 后端:员工端工资条接口
- `GET /api/v1/portal/payslip`:工资条列表(按月)
- `GET /api/v1/portal/payslip/:month`:指定月工资明细
- `POST /api/v1/portal/payslip/:month/confirm`:确认已阅(记录时间 + IP
- 数据隔离:只能查看自己的工资条
- **产出**: 工资条接口
- [x] **T-P7-03** 后端:员工端合同查看接口
- `GET /api/v1/portal/contract`:当前员工的合同信息(只读)
- 包含:合同类型、期限、试用期、工资、扫描件/电子链接、签署确认记录
- **产出**: 合同查看接口
- [x] **T-P7-04** 后端:入职填报接口
- `GET /api/v1/portal/onboarding/:token`:根据 token 获取填报信息(企业名等)
- `POST /api/v1/portal/onboarding/:token`:提交填报数据
- Token 校验:有效性 + 过期检查(24h)
- 提交后状态 → PENDINGHR 审核后 → APPROVED(创建 Employee
- `POST /api/v1/employees/:id/generate-onboarding-qr`:管理端生成填报 token
- **产出**: 入职填报接口 + OnboardingLink 表操作
- [x] **T-P7-05** 后端:合同确认接口
- `GET /api/v1/portal/contract-confirm/:token`:根据 token 获取合同信息
- `POST /api/v1/portal/contract-confirm/:token`:确认签署(记录时间 + IP + 设备)
- Token 校验:有效性 + 过期检查(7天)
- 确认后更新合同状态 + ContractConfirmLink 状态
- `POST /api/v1/contracts/:id/generate-confirm-qr`:管理端生成确认 token
- **产出**: 合同确认接口 + ContractConfirmLink 表操作
- [x] **T-P7-06** 后端:二维码生成服务
- `services/qrcode.service.ts`
- 生成 token + 构建完整 URL(如 `https://xxx/portal/onboarding?token=xxx`
- 返回 URL 供前端生成二维码图片
- **产出**: `qrcode.service.ts`
- [x] **T-P7-07** 前端:员工端登录页面
- `/portal/login` 页面
- 双 Tab 切换:[密码登录] [验证码登录]
- 密码登录:手机号 + 密码
- 验证码登录:手机号 → 获取验证码 → 输入验证码
- v1.0 验证码页面内弹窗显示
- **产出**: `PortalLogin.tsx`
- [x] **T-P7-08** 前端:员工端工资条页面
- `/portal/payslip` 页面
- 月份选择器
- 工资明细卡片:基本工资 + 加班费拆分(工作日/休息日/节假日)+ 应发合计
- 「确认已阅」按钮
- 空状态:暂无工资记录
- **产出**: `Payslip.tsx`
- [x] **T-P7-09** 前端:员工端合同查看 + 入职填报 + 合同确认页面
- `/portal/contract`:合同信息只读展示 + 扫描件查看 + 签署记录 + 到期提示
- `/portal/onboarding`:入职填报表单(姓名/手机号/身份证/银行卡等)+ 提交
- `/portal/contract-confirm`:合同信息展示 + 查看合同文件 + 勾选确认 + 签署
- Token 失效页面:链接已过期提示
- **产出**: `MyContract.tsx` + `Onboarding.tsx` + `ContractConfirm.tsx`
- [x] **T-P7-10** 前端:管理端二维码生成弹窗
- 合同管理页:「生成填报二维码」按钮 → 弹窗显示二维码图片 + 可复制链接
- 合同详情页:「生成确认二维码」按钮 → 弹窗显示二维码图片 + 可复制链接
- 使用 qrcode.react 生成二维码
- 保存二维码图片功能
- **产出**: `QRCodeModal.tsx`
---
## P8 — 系统设置 + 新手引导 + 空状态(1天)
- [x] **T-P8-01** 后端:系统设置接口
- `GET /api/v1/settings/org`:企业信息
- `PUT /api/v1/settings/org`:更新企业信息(名称、城市)
- `GET /api/v1/settings/users`:用户列表
- `POST /api/v1/settings/users`:添加用户
- `PUT /api/v1/settings/users/:id`:编辑用户
- `DELETE /api/v1/settings/users/:id`:移除用户
- **产出**: `settings.routes.ts` + `settings.service.ts`
- [x] **T-P8-02** 前端:系统设置页面
- `/settings` 页面,3 个子 Tab
- 企业信息:名称、城市选择(联动最低工资/社平工资默认值)
- 用户管理:用户列表 + 添加/编辑/移除 + 角色分配(admin/hr/viewer
- 套餐信息:当前套餐 + 已用人数 + 上限 + 升级按钮
- **产出**: `Settings.tsx`
- [x] **T-P8-03** 前端:新手引导弹窗
- 首次登录显示 3 步引导
- Step 1"这里看风险"(指向首页 Tab)
- Step 2"这里管合同"(指向合同 Tab)
- Step 3"这里算钱"(指向算钱 Tab
- localStorage 记录已看过
- **产出**: `OnboardingGuide.tsx`
- [x] **T-P8-04** 前端:空状态组件
- 首页无员工:插图 + 「添加第一个员工」按钮
- 合同列表无数据:插图 + 「还没有员工,点这里添加」
- 无风险:绿色大勾 + 「✅ 暂无风险,继续保持!」
- AI 顾问无对话:欢迎语 + 预设问题
- 解聘无历史:插图 + 文字
- 员工端无工资条/合同:插图 + 文字
- 链接失效:过期提示
- **产出**: `EmptyState.tsx` 各场景
- [x] **T-P8-05** 前端:全局配色 + 样式规范
- TailwindCSS 配色:主色 #2563EB、危险 #DC2626、警告 #F59E0B、安全 #16A34A、背景 #F8FAFC
- 字体:系统字体栈
- 圆角:rounded-lg(卡片)/ rounded-md(按钮)
- 阴影:shadow-sm(卡片)
- **产出**: `tailwind.config.ts` 完整配置
---
## P9 — 移动端适配 + 联调(2天)
- [x] **T-P9-01** 前端:响应式适配
- 桌面 ≥1280px:顶部导航 + 960px 居中
- 平板 768-1279px:顶部导航 + 全宽
- 手机 375-767px:底部 Tab Bar + 全宽
- 合同列表 → 移动端卡片式
- 计算器 → 移动端上下排列
- AI 聊天 → 移动端全屏
- 员工端 → 移动端优先(员工主要用手机)
- **产出**: 响应式样式
- [x] **T-P9-02** 前端:员工端移动端优化
- 员工端以移动端为主场景
- 大按钮、大字体、简洁布局
- 扫码后自动适配手机屏幕
- 工资条卡片式展示
- 合同信息折叠展开
- **产出**: 员工端移动端样式
- [x] **T-P9-03** 全栈:端到端联调
- 注册 → 登录 → 添加员工 → 查看首页 → 合同管理 → 计算 → 解聘 → AI 问答 → 员工端登录 → 工资条 → 入职填报 → 合同确认
- 多租户隔离测试:A 企业无法访问 B 企业数据
- 员工端隔离测试:员工只能查看自己的数据
- Token 过期自动刷新测试
- **产出**: 联调问题清单 + 修复
- [x] **T-P9-04** 全栈:性能优化
- 前端:路由懒加载(React.lazy + Suspense
- 前端:API 请求缓存(React Query staleTime 配置)
- 后端:数据库索引(orgId + 常用查询字段)
- 后端:API 响应压缩(compression 中间件)
- **产出**: 性能优化
---
## P10 — 部署上线 + 验证(1天)
- [x] **T-P10-01** 后端:部署配置(`netlify.toml` + `.env.example`
- 配置 Railway 项目
- 环境变量配置:DATABASE_URL, JWT_SECRET, DASHSCOPE_API_KEY, ENCRYPTION_KEY, SUPABASE_URL, CORS_ORIGIN
- 运行 Prisma migrate deploy
- 健康检查验证
- **产出**: 后端线上地址
- [x] **T-P10-02** 前端:部署到 Netlify`netlify.toml` 已配置)
- 配置 Vercel 项目
- 环境变量配置:VITE_API_URL
- 构建配置:`npm run build`
- 路由重写配置(SPA fallback
- **产出**: 前端线上地址
- [ ] **T-P10-03** 数据库:Neon/Supabase 配置
- 创建 PostgreSQL 数据库
- 启用 pgvector 扩展(AI 模块用)
- 配置连接池
- 运行迁移
- 导入 RAG 知识库数据
- **产出**: 数据库线上环境
- [ ] **T-P10-04** 验收测试
- 按 1-prd.md 第 10 章验收标准逐项验证
- 功能验收:注册/登录/首页/合同/计算/解聘/AI/员工端/设置
- 非功能验收:性能/安全/响应式/兼容/数据隔离
- 修复发现的问题
- **产出**: 验收报告
---
## 依赖关系
```
P0 ──→ P1 ──→ P2 ──→ P3 ──→ P4(纯前端,可与 P3 并行)
├──→ P5(依赖 P3 员工数据 + P4 补偿金计算)
├──→ P6(依赖 P0 数据库 + P2 风险数据)
├──→ P7(依赖 P3 合同数据 + P0 数据库)
└──→ P8(依赖 P1 认证)
P9(依赖 P2~P8 全部完成)
P10(依赖 P9 完成)
```
**可并行任务**
- P4(钱的计算)纯前端计算,可在 P3 完成后与 P5/P6 并行
- P8(系统设置)可在 P6/P7 期间并行
---
## 技术栈速查
| 层 | 技术 |
|-----|------|
| 前端框架 | React 18 + Vite + TypeScript |
| 前端样式 | TailwindCSS |
| 前端路由 | React Router v6 |
| 状态管理 | Zustand(认证)+ TanStack Query(服务端数据)|
| 表单 | React Hook Form + Zod |
| 二维码 | qrcode.react |
| 图标 | lucide-react |
| 后端框架 | Express + TypeScript |
| ORM | Prisma |
| 数据库 | PostgreSQLNeon/Supabase+ pgvector |
| 认证 | JWTAccess + Refresh|
| 加密 | bcrypt(密码)+ AES-256(工资)|
| AI | 通义千问 QwenDashScope API|
| Embedding | DashScope text-embedding-v2 |
| 部署 | Vercel(前端)+ Railway(后端)|
---
## 环境变量清单
```env
# 数据库
DATABASE_URL=postgresql://...
# JWT
JWT_SECRET=...
JWT_REFRESH_SECRET=...
# DashScope (通义千问)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/api/v1
# 加密
ENCRYPTION_KEY=...
# 存储
SUPABASE_URL=...
SUPABASE_KEY=...
# 部署
PORT=3000
CORS_ORIGIN=https://your-app.vercel.app
# 前端
VITE_API_URL=https://your-backend.railway.app
```
---
## P11 — 功能补齐(3天)
> **目标**: 补齐中小企业 HR 实际使用中的关键缺失功能
### 后端
- [x] **T-P11-01** 后端:社保公积金计算器
- Prisma 模型 `SocialInsuranceConfig`(养老/医疗/失业/工伤/生育/公积金 比例 + 基数上下限)
- `social.routes.ts`GET/PUT 配置 + POST 计算
- 支持基数封顶/保底逻辑
- **产出**: `social.routes.ts` + `SocialInsuranceConfig` 模型
- [x] **T-P11-02** 后端:到期提醒通知服务
- Prisma 模型 `NotificationSetting`(通知开关 + 提前天数 + 微信Webhook + 邮箱)
- Prisma 模型 `NotificationLog`(通知记录)
- `notification.routes.ts`GET/PUT 设置 + GET 日志 + POST 手动检查
- 支持企业微信 Webhook 推送
- **产出**: `notification.routes.ts` + `NotificationSetting` + `NotificationLog` 模型
- [x] **T-P11-03** 后端:批量生成工资条
- `POST /api/v1/payroll/payslip/batch-generate`
- 自动遍历所有在职员工,关联加班记录,一键生成全员工资条
- 支持传入津贴/扣款映射
- **产出**: `payroll.routes.ts` 新增接口
- [x] **T-P11-04** 后端:Excel/CSV 批量导入加班数据
- `POST /api/v1/payroll/overtime/batch`
- 接收数组格式加班数据,批量 upsert
- 前端解析 CSV 按员工姓名匹配
- **产出**: `payroll.routes.ts` 新增接口
- [x] **T-P11-05** 后端:员工档案附件管理
- Prisma 模型 `EmployeeAttachment`(文件名/类型/URL/大小)
- `attachment.routes.ts`GET 列表 + POST 添加 + DELETE 删除
- 支持身份证/银行卡/合同扫描件/学历证书/其他分类
- **产出**: `attachment.routes.ts` + `EmployeeAttachment` 模型
### 前端
- [x] **T-P11-06** 前端:社保公积金计算器 Tab
- Money 页面新增「社保公积金」Tab
- `SocialInsuranceCalculator` 组件:输入缴费基数 → 计算五险一金明细
- 支持企业/个人比例配置(可展开配置面板)
- 表格展示各险种比例、企业缴纳、个人缴纳
- **产出**: `Money.tsx` 新增 `SocialInsuranceCalculator` 组件
- [x] **T-P11-07** 前端:批量生成工资条 UI
- PayslipManager 新增「一键全员生成」按钮
- 调用 `batch-generate` 接口,自动关联加班费
- **产出**: `Money.tsx` PayslipManager 增强
- [x] **T-P11-08** 前端:CSV 批量导入加班数据
- OvertimeCalculator 新增「批量导入加班数据(CSV)」按钮
- 前端解析 CSV(姓名,工作日加班,休息日加班,节假日加班,月份)
- 按员工姓名自动匹配 employeeId
- **产出**: `Money.tsx` OvertimeCalculator 增强
- [x] **T-P11-09** 前端:员工档案附件管理 UI
- Contracts 页面点击员工行打开右侧抽屉
- `EmployeeDetailDrawer` 组件:展示员工基本信息 + 合同信息 + 附件管理
- 支持文件上传(FileReader → base64)和删除
- 附件分类:身份证/银行卡/合同扫描件/学历证书/其他
- **产出**: `Contracts.tsx` 新增 `EmployeeDetailDrawer` 组件
- [x] **T-P11-10** 前端:通知设置页面
- Settings 页面新增「通知设置」Tab
- `NotificationSettings` 组件:合同到期提醒/未签提醒/加班超时/工资条通知开关
- 提前提醒天数配置
- 企业微信 Webhook 配置
- 邮件通知配置
- 手动触发合同到期检查 + 通知日志展示
- **产出**: `Settings.tsx` 新增 `NotificationSettings` 组件
+377
View File
@@ -0,0 +1,377 @@
# 社保公积金优化方案
## 核心原则
- 社保和公积金**完全分离**:独立配置、独立调基、独立增减员、独立申报
- 社保公积金开始/截止年月**必填**,增减变以此为准
- 基数缺省等于工资,可修改
- 调薪后社保公积金基数**不自动调整**(社保基数通常每年7月统一调基,调薪仅影响发薪基数)
- 发薪列表和社保/公积金申报列表中,入离职日期与社保公积金年月不一致时**提醒**
- 所有变更(入职/重新入职/调基/调薪/调部门/离职/解聘)都按**版本记录**保存,算薪和月度处理时按月份获取当前有效版本
---
## 一、Schema 改动
### 1.1 拆分配置模型
现有 `SocialInsuranceConfig`(含社保+公积金比例)拆为:
- **`SocialInsuranceConfig`**(保留,移除公积金字段):养老/医疗/失业/工伤/生育比例 + 社保基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记
- **`HousingFundConfig`**(新增):公积金企业/个人比例 + 公积金基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记(字段结构同社保配置)
> Organization 和 Employee 需增加反向关联字段:
> - Organization: `housingFundConfigs HousingFundConfig[]`、`socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]``salaryChangeRecords` 已存在)
> - Employee: `socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]``salaryChanges` 已存在)
### 1.2 新增模型:社保/公积金缴费记录(按版本保存)
社保和公积金的基数、起止年月不是 Employee 上的简单字段,而是按**版本记录**保存。每次入职/重新入职/调基/离职/解聘都生成新版本,形成完整变更历史。
#### EmployeeSocialInsRecord(社保缴费记录)
```prisma
model EmployeeSocialInsRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
// 变更来源
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本
}
```
#### EmployeeHousingFundRecord(公积金缴费记录)
```prisma
model EmployeeHousingFundRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
// 变更来源
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本
}
```
#### Employee 保留便捷字段(当前生效值,由后端同步维护)
```
socialInsStartMonth String? // 当前社保开始年月(=最新记录的startMonth
socialInsBase Float? // 当前社保基数(=最新记录的base)
socialInsEndMonth String? // 当前社保截止年月(=最新记录的endMonth,null=在保)
housingFundStartMonth String? // 当前公积金开始年月
housingFundBase Float? // 当前公积金基数
housingFundEndMonth String? // 当前公积金截止年月
```
> 这些字段是冗余的便捷查询字段,由后端在创建/更新缴费记录时自动同步。增减员和在职申报查询主要使用 Record 表,发薪计算使用 Employee 便捷字段。
### 1.3 TerminationRecord 增加字段
```
socialInsEndMonth String // 社保截止缴费年月 YYYY-MM(必填)
housingFundEndMonth String // 公积金截止缴费年月 YYYY-MM(必填)
```
> TerminationRecord 保存截止年月的同时,后端自动创建一条 EmployeeSocialInsRecord / EmployeeHousingFundRecord,将上一条有效记录的 endMonth 设为此值,并同步 Employee 便捷字段。
### 1.4 扩展模型:调薪/调部门按版本保存
调薪和调部门也按**版本记录**保存,与社保公积金缴费记录同理。每次变更生成新版本,算薪和社保公积金月度处理时获取当前最新版。
#### 扩展现有 SalaryChangeRecord(增加版本字段)
现有 `SalaryChangeRecord` 已有 `oldSalary`/`newSalary`/`effectiveDate`/`reason`,与其新建模型,直接扩展:
```prisma
// 在现有 SalaryChangeRecord 增加字段:
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
@@index([employeeId, effectiveMonth, endMonth]) // 复合索引
```
> 不新建 `EmployeeSalaryRecord`,直接复用 `SalaryChangeRecord`,避免数据分散。入职时也创建一条(oldSalary=0, newSalary=月薪, changeType=ONBOARDING)。
#### EmployeeDepartmentRecord(部门变更记录,新增模型)
```prisma
model EmployeeDepartmentRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth]) // 复合索引
}
```
> Employee 上的 `monthlySalary` 和 `department` 作为便捷字段由后端同步维护。
### 1.5 PayrollBatchType 增加枚举
```
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
```
### 1.6 数据迁移策略
Schema 改动后,需要为现有员工创建初始 Record:
- **EmployeeSocialInsRecord**:为每个现有员工创建一条,`startMonth` = 入职日期年月,`endMonth` = 已离职员工的离职日期年月(如有),`base` = 现有 `socialInsBase` 或月薪,`changeType` = 'ONBOARDING'
- **EmployeeHousingFundRecord**:同上,`base` = 现有 `housingFundBase` 或月薪
- **SalaryChangeRecord**:为每个现有员工创建一条初始记录,`oldSalary` = 0, `newSalary` = 当前月薪, `effectiveMonth` = 入职日期年月, `endMonth` = null
- **EmployeeDepartmentRecord**:为每个现有员工创建一条,`oldDepartment` = '', `newDepartment` = 当前部门, `effectiveMonth` = 入职日期年月, `endMonth` = null
- **迁移脚本**`npx prisma db push` 后执行一次性迁移脚本 `scripts/migrate-records.ts`
---
## 二、需求1:新增/重新入职填写社保公积金开始年月+基数
### 前端 AddEmployeeModal
- 新增4个必填字段(2列布局):
- 社保开始年月(type=month,缺省=入职日期年月,可修改)
- 社保基数(type=number,缺省=月薪,可修改)
- 公积金开始年月(type=month,缺省=入职日期年月,可修改)
- 公积金基数(type=number,缺省=月薪,可修改)
- 当入职日期变更时(`handleHireDateChange`),自动同步4个缺省值
- `canSubmit` 增加这4个字段的必填校验
### 前端 RehireModal
- 同 AddEmployeeModal,缺省=新入职日期年月
### 后端
- `createEmployeeSchema` 增加 `socialInsStartMonth``socialInsBase``housingFundStartMonth``housingFundBase`(必填)
- `createEmployee` 存储这些字段到 Employee 便捷字段,**同时创建一条 `EmployeeSocialInsRecord`changeType=ONBOARDING)和一条 `EmployeeHousingFundRecord`changeType=ONBOARDING**
- `rehireEmployee` 接收并更新这些字段,**同时创建新版本缴费记录(changeType=REHIRE**,并将之前有效记录的 endMonth 设为重新入职前一个月
---
## 二.5 需求补充:花名册增加调薪/调部门操作
### 前端花名册列表
- 每行操作区增加「调薪」「调部门」按钮(与「离职」并列)
### 前端调薪弹窗(SalaryChangeModal
- 显示:员工姓名、当前月薪、当前部门
- 输入:
- 新月薪(必填,缺省=当前月薪)
- 生效年月(type=month,必填,缺省=当月)
- 调薪原因(选填)
- 提交后:
- 后端创建 `SalaryChangeRecord`oldSalary=当前月薪,newSalary=新月薪,effectiveMonth=生效年月, changeType=SALARY_CHANGE
- 将之前有效记录的 `endMonth` 设为生效月前一个月
- 同步 `Employee.monthlySalary` = 新月薪
### 前端调部门弹窗(DepartmentChangeModal
- 显示:员工姓名、当前部门
- 输入:
- 新部门(必填,缺省=当前部门)
- 生效年月(type=month,必填,缺省=当月)
- 调部门原因(选填)
- 提交后:
- 后端创建 `EmployeeDepartmentRecord`oldDepartment=当前部门,newDepartment=新部门,effectiveMonth=生效年月)
- 将之前有效记录的 `endMonth` 设为生效月前一个月
- 同步 `Employee.department` = 新部门
### 后端
- `POST /roster/:id/salary-change` — 调薪,创建版本记录 + 同步 Employee
- `POST /roster/:id/department-change` — 调部门,创建版本记录 + 同步 Employee
- `GET /roster/:id/salary-records` — 调薪历史
- `GET /roster/:id/department-records` — 调部门历史
### 算薪和社保公积金月度处理
- 算薪时:根据发薪月份获取该月有效的 `SalaryChangeRecord``effectiveMonth <= month``endMonth == null 或 >= month`),使用该记录的 `newSalary` 作为发薪基数
- 社保公积金月度处理时:根据月份获取该月有效的 `EmployeeSocialInsRecord` / `EmployeeHousingFundRecord`,使用该记录的 `base` 作为缴费基数
- 部门信息:根据月份获取该月有效的 `EmployeeDepartmentRecord`,用于月度报表中的部门归属
- **调薪与社保基数关系**:调薪仅影响发薪基数,**不自动调整**社保公积金基数。社保公积金基数仅在每年7月统一调基时调整
---
## 三、需求2:离职/解聘填写社保公积金截止年月
### 前端 ResignModalRoster.tsx
- 新增2个必填字段:
- 社保截止年月(type=month,缺省=离职日期年月,可修改)
- 公积金截止年月(type=month,缺省=离职日期年月,可修改)
- 当离职日期变更时,自动同步缺省值
- `canSubmit` 增加必填校验
### 前端 Termination.tsx(解聘向导 Step 1
- 在解聘日期下方增加社保截止年月、公积金截止年月输入
- 缺省=解聘日期年月,可修改
### 后端
- `terminationChecklistSchema` 增加 `socialInsEndMonth``housingFundEndMonth`(必填)
- `createTermination``createResignation` 存储这些字段到 TerminationRecord
- **同时更新 Employee 便捷字段**`socialInsEndMonth``housingFundEndMonth`
- **同时创建/更新缴费记录**:将当前有效记录的 `endMonth` 设为截止年月,同步 Employee 便捷字段
---
## 四、需求3:社保公积金Tab增加月度增减员+在职申报+导出
### 4.1 前端 SocialInsurance.tsx 改造
增加顶层 Tab 切换:
- **「社保」Tab**:社保配置管理 + 社保调基 + 社保月度增减员 + 社保在职申报
- **「公积金」Tab**:公积金配置管理 + 公积金调基 + 公积金月度增减员 + 公积金在职申报
每个 Tab 内再分子 Tab
- 配置管理(现有功能,社保/公积金各自独立)
- 月度增减员
- 在职申报
### 4.2 月度增减员
**后端 API**
- `GET /social/monthly-changes?month=YYYY-MM` — 社保增减员
- `GET /housing/monthly-changes?month=YYYY-MM` — 公积金增减员
**逻辑**(统一使用 Record 表查询,确保历史月份也能查到已离职员工):
- **增员**:查 `EmployeeSocialInsRecord.startMonth == month`(姓名、部门、基数、开始年月、changeType)
- **减员**:查 `EmployeeSocialInsRecord.endMonth == month``changeType == 'TERMINATION'`(姓名、部门、基数、截止年月、离职类型)
- 支持导出 CSV(前端生成,无需后端依赖)
**前端**:选择月份 → 显示增员表和减员表(两个表格或折叠分区)→ 导出按钮
### 4.3 在职申报
**后端 API**
- `GET /social/active-declaration?month=YYYY-MM` — 社保在保人员
- `GET /housing/active-declaration?month=YYYY-MM` — 公积金在保人员
**逻辑**(使用 Record 表查询):
- 筛选条件:`EmployeeSocialInsRecord.startMonth <= month``endMonth == null 或 >= month`
- 返回:姓名、身份证号、部门、社保基数、开始年月、截止年月
- 支持导出 CSV(前端生成,无需后端依赖)
**前端**:选择月份 → 显示在保人员表格 → 导出按钮
### 4.4 调基拆分
现有调基操作同时调整社保和公积金基数。改为:
- 社保调基:只调整社保基数,使用 `SocialInsuranceConfig` 的上下限
- 将当前有效记录的 `endMonth` 设为调基月前一个月
- 创建新 `EmployeeSocialInsRecord`changeType=ADJUST),startMonth=调基月,base=新基数
- 同步 Employee.socialInsBase / socialInsStartMonth
- 公积金调基:只调整公积金基数,使用 `HousingFundConfig` 的上下限
- 将当前有效记录的 `endMonth` 设为调基月前一个月
- 创建新 `EmployeeHousingFundRecord`changeType=ADJUST),startMonth=调基月,base=新基数
- 同步 Employee.housingFundBase / housingFundStartMonth
- 两个调基操作独立执行,各自有 `adjustmentDone` 标记
---
## 五、需求4:已离职员工按月发放补偿金
### 后端
- `PayrollBatchType` 增加 `SEVERANCE`
- `calcBatchEntry`:当 `batchType === 'SEVERANCE'` 时:
- `socialEmp=0``housingEmp=0``socialOrg=0``housingOrg=0`(无社保公积金)
- `tax`:经济补偿金在当地社平工资3倍以内免征个税,超过部分按单独税率计税。简化处理:`tax=0`,备注注明「补偿金免征个税(社平3倍以内)」,如超过3倍需手动计算
- 允许 `status === 'RESIGNED'` 的员工加入 `SEVERANCE` 批次
- 补偿金发放可设置**发放月数**(如约定发放6个月),到期后自动标记为已完成
- 也可手动停止发放
### 前端 Money.tsx
- 批次类型下拉增加「补偿金发放」选项
- `SEVERANCE` 批次:员工选择列表包含已离职员工
- 输入项简化:只有补偿金金额(baseSalary),无加班/津贴/扣款
- 可设置发放月数
- 工资条显示:社保=0、公积金=0、个税=0(备注:补偿金免征)
---
## 六、需求5:日期不一致提醒
### 发薪列表提醒
在发薪批次详情中,对每个员工检查发薪月份与入离职日期的一致性:
- 发薪月份 < 入职日期年月 → ⚠️ "该员工2025-07入职,当前发薪月份2025-06尚未入职"
- 发薪月份 > 离职日期年月 → ⚠️ "该员工已于2025-06离职,当前发薪月份2025-07已离职"
- 同时也检查社保公积金年月范围,如有不一致也提醒
### 社保/公积金申报列表提醒
- **增员**`socialInsStartMonth``hireDate` 年月不一致 → ⚠️ "社保开始年月与入职日期不一致"
- **减员**`socialInsEndMonth``terminationDate` 年月不一致 → ⚠️ "社保截止年月与离职日期不一致"
- **在职申报**`hireDate` 年月与 `socialInsStartMonth` 不一致、`terminationDate` 年月与 `socialInsEndMonth` 不一致 → ⚠️ 提醒
---
## 七、实施顺序
| 步骤 | 内容 | 涉及 |
|------|------|------|
| 1 | Schema 改动(拆分配置、新增缴费/部门记录模型、扩展SalaryChangeRecord、增加字段、增加枚举)+ `prisma db push` | 后端 |
| 1.5 | 数据迁移脚本:为现有员工创建初始 Record | 后端 |
| 2 | 后端:`createEmployee`/`rehireEmployee` 接收社保公积金字段 + 创建缴费记录版本 | 后端 |
| 3 | 后端:`createTermination`/`createResignation` 接收截止年月 + 更新缴费记录版本 | 后端 |
| 3.5 | 后端:调薪/调部门 API + 创建/扩展版本记录 + 同步 Employee | 后端 |
| 4 | 前端:AddEmployeeModal 增加社保公积金输入 | 前端 |
| 5 | 前端:RehireModal 同步 | 前端 |
| 6 | 前端:ResignModal 增加截止年月 | 前端 |
| 6.5 | 前端:花名册增加调薪/调部门弹窗 | 前端 |
| 7 | 前端:Termination.tsx 解聘向导增加截止年月 | 前端 |
| 8 | 后端:月度增减员 + 在职申报 API | 后端 |
| 9 | 后端:`SEVERANCE` 批次类型 + `calcBatchEntry` 修改 | 后端 |
| 10 | 前端:SocialInsurance.tsx 改造(Tab拆分+增减员+申报+导出) | 前端 |
| 11 | 前端:Money.tsx 增加补偿金批次 | 前端 |
| 12 | 前端:日期不一致提醒 | 前端 |
| 13 | 编译验证 + git 推送 | 全部 |
+111
View File
@@ -0,0 +1,111 @@
# 劳动用工合规助手 — 待实现功能清单
> **文档编号**: 20260723-优化-2.md
> **日期**: 2026-07-23
> **来源**: 对照 `0-req.md` 需求规格说明书完整扫描后得出
---
## 一、部分实现(需完善)
### 1. AI 流式输出
- **现状**: `ai.service.ts` 使用同步 `chat.completions.create`,一次性返回完整回复
- **需求**: DashScope SSE 流式返回,前端打字机效果
- **涉及文件**: `backend/src/services/ai.service.ts``backend/src/routes/ai.routes.ts``frontend/src/pages/AIAssistant.tsx`
- **方案**: 后端改用 `stream: true` + SSE 响应;前端用 `EventSource``fetch + ReadableStream` 逐字渲染
### 2. RAG 知识库
- **现状**: 未实现向量数据库集成
- **需求**: 劳动法/劳动合同法/司法解释/地方条例向量化存储,Supabase pgvector + DashScope text-embedding-v2
- **涉及文件**: 新建 `backend/src/services/rag.service.ts`、schema 新增向量表
- **方案**: 文档分块 → DashScope embedding → 存入 pgvector → 问答时向量检索 → 注入 context
### 3. AI 使用限制
- **现状**: 未实现套餐次数限制
- **需求**: free 10次问答/3次审查/3次案例;pro 100/20/20enterprise 无限
- **涉及文件**: `backend/src/routes/ai.routes.ts``backend/src/services/ai.service.ts`
- **方案**: 每次调用前查询当月已用次数(按 orgId + 类型),超限返回 403
### 4. 顶部导航风险角标
- **现状**: `TopNav.tsx:44` 有角标代码但 `hidden` 固定不显示
- **需求**: 红色角标显示待处理风险总数,点击跳转首页
- **涉及文件**: `frontend/src/components/layout/TopNav.tsx`
- **方案**: 查询 pending 风险数量,动态显示角标数字
### 5. 审计日志写入
- **现状**: `AuditLog` 模型存在于 schema,但无实际写入代码
- **需求**: 关键操作(解聘/合同变更/工资调整)记录审计日志
- **涉及文件**: `backend/src/services/contract.service.ts``termination.service.ts``roster.routes.ts`
- **方案**: 在关键操作后 `prisma.auditLog.create({ orgId, userId, action, target, detail, ipAddress })`
### 6. 数据导出
- **现状**: 仅社保月度有 CSV 导出
- **需求**: 支持导出全部数据为 JSON/Excel
- **涉及文件**: 新建 `backend/src/routes/export.routes.ts`、前端设置页增加导出按钮
- **方案**: 后端打包全量数据为 Excel(exceljs),前端下载
### 7. 二维码生成
- **现状**: 入职填报/合同确认有 token 链接,但无前端二维码图片
- **需求**: HR 端生成二维码图片,可保存通过微信发给员工
- **涉及文件**: `frontend/src/pages/Contracts.tsx``Roster.tsx`
- **方案**: 前端引入 `qrcode.react`,生成二维码图片,支持下载
### 8. 批量续签
- **现状**: 需确认花名册列表是否有全选→批量续签功能
- **需求**: 合同列表支持全选 → 批量续签
- **涉及文件**: `frontend/src/pages/Roster.tsx`
- **方案**: 列表增加 checkbox 全选,批量调用续签 API
---
## 二、未实现(需新建)
### 9. 忘记密码 — 手机验证码重置
- **现状**: `/forgot-password` 路由存在,但功能不完整
- **需求**: 手机号 + 验证码 → 设置新密码
- **涉及文件**: `frontend/src/pages/auth/ForgotPassword.tsx``backend/src/routes/auth.routes.ts`
- **方案**: 复用 portal 的验证码逻辑,验证后允许重置密码
### 10. AI 顾问语音输入(移动端)
- **现状**: 未实现
- **需求**: 移动端支持语音输入问题
- **涉及文件**: `frontend/src/pages/AIAssistant.tsx`
- **方案**: 使用 Web Speech API `SpeechRecognition`,语音转文字后发送
### 11. 解聘记录 PDF 导出
- **现状**: 未实现
- **需求**: 支持导出单条解聘记录为 PDF
- **涉及文件**: `frontend/src/pages/Termination.tsx`
- **方案**: 前端使用 `jspdf` + `html2canvas` 生成 PDF,或后端用 `puppeteer` 生成
### 12. 登录接口速率限制
- **现状**: 未实现
- **需求**: 登录接口限流 5次/分钟,防止暴力破解;密码错误5次锁定30分钟
- **涉及文件**: `backend/src/routes/auth.routes.ts``backend/src/routes/portal.routes.ts`
- **方案**: 使用 `express-rate-limit` 中间件,或基于 Map 的简易限流
### 13. 套餐人数上限校验
- **现状**: 未实现
- **需求**: free 限20人,pro 限200人,enterprise 无限制;添加员工时校验
- **涉及文件**: `backend/src/services/contract.service.ts`createEmployee
- **方案**: 创建员工前查询当前员工数 + 套餐上限,超限返回 403
---
## 三、优先级排序
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 4 | 顶部导航风险角标 | 小 |
| P0 | 12 | 登录接口速率限制 | 小 |
| P0 | 13 | 套餐人数上限校验 | 小 |
| P1 | 5 | 审计日志写入 | 中 |
| P1 | 1 | AI 流式输出 | 中 |
| P1 | 9 | 忘记密码重置 | 中 |
| P1 | 7 | 二维码生成 | 小 |
| P2 | 3 | AI 使用限制 | 中 |
| P2 | 8 | 批量续签 | 中 |
| P2 | 6 | 数据导出 | 中 |
| P3 | 2 | RAG 知识库 | 大 |
| P3 | 11 | 解聘记录 PDF 导出 | 中 |
| P3 | 10 | AI 语音输入 | 小 |
+210
View File
@@ -0,0 +1,210 @@
# 劳动用工合规 SaaS — 功能层面优化清单
> **文档编号**: 20260723-优化-3.md
> **日期**: 2026-07-23
> **来源**: 对 Money.tsx、Termination.tsx、SocialInsurance.tsx、Roster.tsx 四个核心业务页面深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Money — 发薪批次创建后无法重命名
**现状**: 批次列表只显示自动生成的名称(如"2026-01 第1批 发薪"),创建后名称固定不可修改。当企业有多个批次(按部门/按职级分批发薪)时,列表难以区分。
**建议**:
- 后端:PUT `/payroll2/batches/:id` 支持更新 `name` 字段
- 前端:在 `BatchDetail` 右上角增加「重命名」按钮,弹出编辑框修改批次名称
**涉及文件**: `backend/src/routes/payroll2.routes.ts``frontend/src/pages/Money.tsx`
---
### 2. Termination — 费用计算与表单完全割裂
**现状**: `costResult` 是纯前端 `useMemo` 计算,但编辑表单字段(解聘日期、解聘原因)时不会实时触发重算。用户必须切到 Step 4 才能看到费用变化,导致操作反馈链路过长。
**建议**:
-`costResult` 的依赖项(`terminationDate``socialAvgWage``reason`)用 `useEffect` 驱动,每次表单变更实时展示费用预览
- 在 Step 1(选择员工)和 Step 2(解聘方式)之间增加一个「实时费用预览区」,显示经济补偿金、赔偿金、代通知金的大致金额,降低误操作风险
**涉及文件**: `frontend/src/pages/Termination.tsx`
---
### 3. Termination — 模拟计算结果被静默覆盖
**现状**: `handleSimulate` 只将数据存入本地 state `savedItems``costResult` 依赖的是表单实时值。当用户修改参数后,之前的模拟结果会被静默覆盖,无法对比不同参数下的补偿金额。
**建议**:
- `savedItems` 每条记录增加 `version` 字段和 `isSimulated: boolean` 标记
- 每次模拟生成新版本而非覆盖,用户可在右侧列表查看多个版本的对比
- 模拟结果与实际保存结果分开展示,避免混淆
**涉及文件**: `frontend/src/pages/Termination.tsx`
---
### 4. Roster — 批量续签无合规预检
**现状**: 批量续签直接提交 `contractIds`,无任何预览或合规检查。用户可能对已连续签订两次固定期限合同的员工续签固定期(法律上应签无固定期限)。
**建议**:
- 选择员工后,先调用后端接口 `GET /employees/contracts/preview-renew` 返回每个员工的合规提示
- 展示预览列表:每个员工一行,显示「可续签固定期」或「应签无固定期限(已连续签订X次)」等提示
- 用户确认后再提交,避免法律风险
**涉及文件**: `frontend/src/pages/Roster.tsx``backend/src/routes/employee.routes.ts`
---
### 5. SocialInsurance — 社保基数调整只能一次性操作
**现状**: `adjustmentDone` 标志为 true 后无法再次调整基数。但实践中基数可能需要多次修正(员工投诉、基数算错、重新申报)。
**建议**:
- 增加「重置调整」接口 `POST /social/config/:id/reset-adjustment`,允许管理员撤销本次调整重新来过
- 或改为记录每次调整的版本历史,支持查看历史调整记录
**涉及文件**: `backend/src/routes/social.routes.ts``frontend/src/pages/SocialInsurance.tsx`
---
## 二、中优先级(高频操作体验)
### 6. Roster — 员工搜索无分页、无法多选过滤
**现状**: 花名册仅支持姓名/部门 substring 搜索,无分页和高级过滤。添加人员到批次时取 `pageSize: 100`,超过 100 人就覆盖不全。
**建议**:
- 花名册搜索增加状态过滤(在职/预入职/离职)、合同状态过滤(正常/即将到期/已过期/未签合同)、合同到期时间范围过滤
- 添加人员到批次改为服务端搜索,支持分页 + 关键词搜索 + 多选,超 100 人场景也能覆盖
**涉及文件**: `frontend/src/pages/Roster.tsx``backend/src/routes/roster.routes.ts``backend/src/routes/payroll2.routes.ts`
---
### 7. Money — 批次列表无月份范围筛选
**现状**: 只有单月筛选,企业要查看历史所有批次只能逐月切换,且无状态(草稿/归档)过滤。
**建议**:
- 批次列表增加月份范围选择器(开始月份 ~ 结束月份)
- 增加状态过滤(全部/草稿/已归档)
- 增加批次类型过滤(全部/常规发薪/离职结算/年终奖/补偿金)
**涉及文件**: `frontend/src/pages/Money.tsx``backend/src/routes/payroll2.routes.ts`
---
### 8. Termination — 无批量解聘能力
**现状**: 只能逐个处理。当企业裁员时(如一次性解除 20 人),需重复操作 20 次,体验极差。
**建议**:
- 在「解聘补偿」页面增加「批量解聘」入口
- 选择员工后批量填写共性参数(解聘日期、解聘原因、社保截止月份),差异项(补偿金金额)可逐个补充或批量默认
- 批量提交后统一生成解聘记录和调薪批次
**涉及文件**: `frontend/src/pages/Termination.tsx``backend/src/services/termination.service.ts`
---
### 9. Roster — 合同到期预警机制缺失
**现状**: 花名册表头显示合同状态标签(`expiring``expired`),但系统无主动预警。用户需主动逐个查看。
**建议**:
- Dashboard 增加合同到期预警卡片,显示 30 天内到期、60 天内到期、90 天内到期的员工数量
- 点击卡片跳转花名册,预设筛选条件为「合同到期时间 ≤ N 天」
- Roster 列表页增加「合同到期时间」列,支持按到期时间排序
**涉及文件**: `frontend/src/pages/Dashboard.tsx``frontend/src/pages/Roster.tsx``backend/src/routes/roster.routes.ts`
---
### 10. Money — 无工资条税率试算预览
**现状**: `PayslipManager` 只能从批次汇总生成工资条,无法单独查看某员工的个税明细和实发金额分解。
**建议**:
- 在批次详情页或员工 profile 的 payslip tab 中,增加「税率试算」功能
- 展示个税计算过程:应发金额 → 社保公积金扣除 → 个税起征点扣除 → 应纳税所得额 → 税率/速算扣除数 → 个税 → 实发金额
- 支持单员工试算,不依赖批次
**涉及文件**: `frontend/src/pages/Money.tsx``frontend/src/pages/Roster.tsx``backend/src/services/payroll.service.ts`
---
## 三、低优先级(功能补全)
### 11. SocialInsurance — 仅支持北京配置,无多城市扩展
**现状**: `newVersion` 硬编码北京配置,版本历史中城市字段存在但无人使用。
**建议**:
- 后续扩展多城市时,社保配置表增加 `cityCode` 字段
- 版本历史按城市分组展示
- 城市列表可配置(新增城市配置时自动出现在下拉)
**涉及文件**: `backend/prisma/schema.prisma``frontend/src/pages/SocialInsurance.tsx`
---
### 12. Termination — 离职与解聘入口分离不清晰
**现状**: `ResignModal`(员工主动离职)和解聘向导(公司主导)是两套流程,但在同一个「解聘补偿」模块中容易让用户困惑。
**建议**:
- 在 Step 1 员工选择后,优先展示「员工主动离职」vs「公司解聘」两个入口
- 选择「主动离职」则弹出简化版离职表单(仅需离职日期和原因)
- 选择「公司解聘」则进入完整解聘向导
**涉及文件**: `frontend/src/pages/Termination.tsx``frontend/src/pages/Roster.tsx`
---
### 13. Roster — 员工附件上传无预览
**现状**: 合同扫描件以 DataURL 形式存储,无文件大小校验,无 PDF/Word 在线预览。
**建议**:
- 附件上传增加文件类型限制(仅 PDF/图片)和大小限制(最大 10MB)
- 员工 profile 附件 tab 增加文件预览功能(图片直接显示,PDF 用 iframe 或第三方预览组件)
- 上传前显示文件大小提示
**涉及文件**: `frontend/src/pages/Roster.tsx`
---
### 14. Money — 加班费 CSV 导入无批量编辑
**现状**: CSV 导入后只能整体确认,无法逐条修改导入数据中的工时数值。
**建议**:
- 导入预览阶段支持逐行编辑工时数据(工作日/休息日/节假日小时数)
- 增加「校验」按钮,对齐员工姓名未匹配的记录高亮提示
- 支持从预览中删除不需要的记录
**涉及文件**: `frontend/src/pages/Money.tsx`OvertimeCalculator 组件)
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 发薪批次重命名 | 小 |
| P0 | 2 | 费用计算实时预览 | 中 |
| P0 | 4 | 批量续签合规预检 | 中 |
| P0 | 5 | 社保基数调整可重复操作 | 小 |
| P1 | 3 | 模拟计算版本管理 | 小 |
| P1 | 6 | 员工搜索分页+多选过滤 | 中 |
| P1 | 7 | 批次列表范围筛选 | 小 |
| P1 | 8 | 批量解聘 | 大 |
| P1 | 9 | 合同到期预警 | 中 |
| P1 | 10 | 工资条税率试算 | 中 |
| P2 | 12 | 离职/解聘入口分离 | 小 |
| P2 | 13 | 附件上传预览 | 中 |
| P2 | 14 | 加班费导入批量编辑 | 中 |
| P3 | 11 | 多城市社保配置 | 大 |
+75
View File
@@ -0,0 +1,75 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续)
> **文档编号**: 20260723-优化-4.md
> **日期**: 2026-07-23
> **来源**: 对 Dashboard.tsx、AIAssistant.tsx 及相关后端服务深入研究后得出
> **注意**: Contracts.tsx 和 Compensation.tsx 已无路由引用(功能已整合到 Roster 和 Termination),涉及这两个页面的条目已移除
---
## 一、高优先级(核心业务缺陷)
### 1. ✅ AIAssistant — 会话历史保存(已完成)
**状态**: 已实现会话历史保存功能。后端新增 `AIConversation` 表,前端 ChatTab 支持「新建对话」「历史会话」列表加载/切换/删除,消息自动 debounce 保存。
---
### 2. ✅ Dashboard — 待办事项批量操作(已完成)
**状态**: 已实现批量操作功能。后端新增 `PATCH /dashboard/todos/batch-resolve``batch-ignore` 端点,前端待办列表增加全选复选框和批量操作按钮。
---
## 二、中优先级(高频操作体验)
### 3. ✅ AIAssistant — 分析结果关联员工档案(已完成)
**状态**: 已实现审查/分析结果保存到员工档案功能。后端新增 `AIReviewRecord` 表和 `/ai/review/save``/ai/review/employee/:employeeId` 端点,前端 ReviewTab 和 CaseTab 增加「保存到员工档案」按钮和员工选择弹窗。
---
### 4. ✅ Dashboard — 风险分布可下钻(已完成)
**状态**: 已实现风险分布下钻功能。后端 `getDashboardData` 返回 `topRisks` 字段(最近5条高风险项摘要),前端风险分布卡片改为可点击,点击后展开该类型风险明细列表并支持跳转。
---
### 5. ✅ AIAssistant — 风险预测上下文查询(已完成)
**状态**: 已实现风险预测上下文查询功能。后端 `/ai/predict` 支持 `scope`all/department/employee)、`riskType`all/contract/salary/termination)参数,前端 PredictTab 增加预测范围、风险类型、部门/员工筛选条件。
---
## 三、低优先级(功能补全)
### 6. ✅ Roster — 附件上传类型校验(已完成)
**状态**: 已在 `Roster.tsx``handleFileUpload` 中实现文件类型校验(PDF/JPG/PNG/HEIC)和大小限制(10MB)。
---
### 7. ✅ Dashboard — 刷新按钮 Tab 级联(已完成)
**状态**: 已实现刷新按钮 Tab 级联。刷新按钮在 `risk``task` tab 下半透明且禁用(这两个 tab 数据来自 dashboard 查询的子集),在 `overview``payroll` tab 下正常显示,按钮文案根据 tab 变化(「刷新概览」/「刷新薪税」)。
---
### 8. ✅ Dashboard — 薪税 tab 导出功能(已完成)
**状态**: 已实现薪税导出功能。后端新增 `GET /export/payroll` 端点,使用 `exceljs` 导出本月已归档批次的薪税明细为 Excel(含工资构成、扣减项、企业成本、合计行),前端薪税 tab 右上角增加「导出」按钮。
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 | 状态 |
|--------|------|------|--------|------|
| P0 | 1 | AI 会话历史保存 | 中 | ✅ 已完成 |
| P0 | 2 | 待办批量操作 | 小 | ✅ 已完成 |
| P1 | 3 | AI 结果关联员工档案 | 中 | ✅ 已完成 |
| P1 | 4 | 风险分布可下钻 | 中 | ✅ 已完成 |
| P1 | 5 | 风险预测上下文查询 | 中 | ✅ 已完成 |
| P2 | 6 | 附件上传类型校验 | 小 | ✅ 已完成 |
| P2 | 7 | 刷新按钮 Tab 级联 | 小 | ✅ 已完成 |
| P2 | 8 | 薪税数据导出 | 中 | ✅ 已完成 |
+219
View File
@@ -0,0 +1,219 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续二)
> **文档编号**: 20260723-优化-5.md
> **日期**: 2026-07-23
> **来源**: 对 Settings.tsx、export.routes.ts、import.routes.ts 及相关 Portal 页面深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Settings — 企业信息表单无初始化数据回填
**现状**: `OrgSettings` 组件的 `form` state 用 `useState` 初始化,但初始化值依赖 `orgData?.data?.name`,而 `useState` 的初始值只在组件首次挂载时读取一次。当 `orgData` 异步加载完成后,state 不会自动更新,导致表单始终为空。
**建议**:
- 使用 `useEffect` 监听 `orgData` 变化,异步回填表单数据
- 或将 `form` 改为受控组件:`value={orgData?.data?.name || ''}`
**涉及文件**: `frontend/src/pages/Settings.tsx`
---
### 2. Settings — 用户管理无编辑和禁用能力
**现状**: `UserSettings` 只展示用户列表和添加用户功能,没有编辑已有用户、禁用用户、修改角色的能力。当员工离职时,管理员无法停用其账号,存在安全风险。
**建议**:
- 用户列表增加「编辑」「禁用」操作按钮
- 编辑 Modal 支持修改用户姓名、手机号、角色
- 禁用后用户无法登录,但保留历史操作记录
- 增加「最近登录」列,显示用户活跃状态
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/settings.routes.ts`
---
### 3. Import — 导入预览缺失,无法逐条确认
**现状**: Excel 导入直接上传后端解析,用户无法在提交前预览数据。错误只能在导入完成后看到,且只能看到前 10 条。用户可能上传了错误的 Excel 模板,导致大量数据导入失败后才知晓。
**建议**:
- 改为两阶段导入:上传文件 → 后端解析但不写入 → 前端展示预览列表 → 用户确认后才写入
- 预览阶段支持逐行修改(如修正姓名、部门、工资等)
- 增加「模板校验」接口,上传前先检查 Sheet 结构是否符合预期,不符合给出明确提示
- 预览界面区分「正常数据」「警告数据」「错误数据」,用户可选择只导入正常数据
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
### 4. Export — 导出格式单一,无选择性导出
**现状**: `export/all` 导出全部数据的 JSON 文件,既没有 Excel 格式选择,也没有按模块选择性导出(只导出员工、只导出社保等)。对于企业财务或法务,只需要部分数据时,导出一个大 JSON 不够实用。
**建议**:
- 增加导出格式选择(JSON / Excel
- 增加按模块选择性导出(员工信息、合同信息、薪税记录、社保记录、离职记录)
- Excel 格式应包含表头和格式化,便于直接查看
- 增加导出时间范围过滤(本月/本季度/本年/自定义)
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/export.routes.ts`
---
## 二、中优先级(高频操作体验)
### 5. Import — 身份证号哈希校验缺失
**现状**: `import.routes.ts` 中多处使用 `sha256(idCard)` 匹配员工,但身份证号可能存在格式错误(如 15 位、假号、校验位错误)。脏数据进入数据库后无法关联,且没有前置校验。
**建议**:
- 增加身份证号格式校验函数(18 位正则 + 校验位算法)
- 校验不通过的行在预览阶段标红并给出提示,不写入数据库
- 15 位身份证号自动升级为 18 位(基于出生日期补全)
- 导入完成后给出数据质量报告(格式错误数、重名数等)
**涉及文件**: `backend/src/routes/import.routes.ts`
---
### 6. Settings — 通知设置无测试功能
**现状**: 用户配置了企业微信 Webhook 或邮件通知后,没有「发送测试消息」按钮验证配置是否正确。通知发不出去时用户无法定位问题。
**建议**:
- Webhook 配置行增加「测试」按钮,点击后发送测试消息到配置的地址
- 测试结果(成功/失败/错误信息)实时显示在界面上
- 邮件通知增加同样的测试功能
- 配置页面增加连接状态指示器(已连接/未配置/配置错误)
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/notification.routes.ts`
---
### 7. Import — 月度导入覆盖逻辑不清晰
**现状**: 月度导入中「考勤记录」用 `upsert` 覆盖同日记录,「加班记录」用 `increment` 累加。这些行为没有在界面上说明,用户可能误以为所有数据都是覆盖,导致数据异常。
**建议**:
- 导入界面的 Sheet 说明中明确标注每种记录的处理策略(覆盖 / 累加 / 跳过)
- 月度导入前增加「本次导入模式」选择:覆盖 / 累加 / 仅新增
- 导入完成后显示各类型记录的处理方式摘要
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
### 8. Settings — 套餐升级无实际功能
**现状**: `PlanSettings` 展示三个套餐,但「升级」按钮只有 UI 没有实际逻辑。免费版和专业版的功能差异(如 AI 问答次数限制、合同审查)也未在系统中实际执行。
**建议**:
- 实现套餐切换逻辑(可对接 Stripe/微信支付等)
- 在系统各模块中实际执行用量限制(如 AI 问答次数扣减)
- 免费版用户在试用受限功能时提示升级
- 增加用量统计面板,显示本月已用 AI 次数 / 已用存储空间等
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/settings.routes.ts``backend/src/middleware/rateLimit.ts`
---
### 9. Import — 错误日志无导出
**现状**: 导入完成后如果有很多错误,只能看到前 10 条提示。用户需要截取或手动记录错误信息来修正 Excel 后重新导入。
**建议**:
- 导入完成后增加「导出错误日志」按钮,生成 CSV/Excel 文件,列出所有错误行及原因
- 错误日志包含:行号、员工姓名/身份证、错误类型、具体原因
- 错误日志文件名包含导入时间戳,便于管理
**涉及文件**: `frontend/src/pages/Settings.tsx``backend/src/routes/import.routes.ts`
---
## 三、低优先级(功能补全)
### 10. Settings — 数据导出缺少敏感字段脱敏
**现状**: `export.routes.ts` 对工资和身份证号做了解密导出,但没有脱敏处理。导出的 JSON 包含完整的身份证号、银行账号、工资数据,存在数据泄露风险。
**建议**:
- 增加「脱敏导出」模式:身份证号显示前 3 后 4 位(如 `110***********1234`),银行账号显示后 4 位
- 敏感字段脱敏后用 `(hidden)` 占位,便于识别
- 仅管理员可导出完整数据,普通 HR 角色只能导出脱敏版本
- 导出日志记录每次导出的操作人、时间、范围
**涉及文件**: `backend/src/routes/export.routes.ts`
---
### 11. Import — 加班类型字段未使用
**现状**: Excel 模板中加班类型是文本字段("工作日加班/休息日加班/法定节假日加班"),但解析时用 `includes()` 字符串匹配判断类型,这种方式无法准确区分多类型混合的加班记录。
**建议**:
- 改为三列独立填写:工作日加班时长、休息日加班时长、法定节假日加班时长
- 每列只填数值,减少歧义
- 或在解析时按分隔符拆分为数组,逐个判断类型
**涉及文件**: `backend/src/routes/import.routes.ts`
---
### 12. Settings — 通知设置 useMemo 错误使用
**现状**: `NotificationSettings``useMemo` 用于副作用(设置 form state),这违反了 React Hooks 的规则。`useMemo` 不应该在副作用中调用,应该用 `useEffect` 替代。
**建议**:
-`useMemo` 替换为 `useEffect`,正确处理数据加载后的表单回填
**涉及文件**: `frontend/src/pages/Settings.tsx`
---
### 13. Import — 社保/公积金增减员未校验基数范围
**现状**: 社保和公积金变动导入时,只记录用户填写的基数,没有校验基数是否在政策允许的上下限范围内(北京 2024 年社保基数下限 6326、上限 33891)。
**建议**:
- 增加基数上下限校验逻辑(可配置城市参数)
- 超出范围的记录在预览阶段标红提示
- 提供默认值建议(低于下限用下限,高于上限用上限)
**涉及文件**: `backend/src/routes/import.routes.ts``backend/src/routes/social.routes.ts`
---
### 14. Export — 导出无压缩,大数据集超时
**现状**: 全量导出 JSON 时,如果员工数量很多(如 1000+ 人),文件可能很大,导出接口响应时间过长甚至超时。没有分页或流式导出机制。
**建议**:
- 增加分页导出:按员工分批导出,每次最多 500 条
- 大数据集使用 Stream API 流式响应,避免内存溢出
- JSON 导出支持压缩(gzip
- 增加导出进度条,前端可实时看到导出进度
**涉及文件**: `backend/src/routes/export.routes.ts`
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 企业信息表单数据回填 | 小 |
| P0 | 2 | 用户管理编辑/禁用 | 中 |
| P0 | 3 | 导入预览+逐行编辑 | 大 |
| P0 | 4 | 选择性导出+格式选择 | 中 |
| P1 | 5 | 身份证号格式校验 | 小 |
| P1 | 6 | 通知渠道测试功能 | 中 |
| P1 | 7 | 导入覆盖逻辑说明 | 小 |
| P1 | 8 | 套餐升级+用量限制 | 大 |
| P1 | 9 | 错误日志导出 | 小 |
| P2 | 10 | 导出敏感字段脱敏 | 小 |
| P2 | 11 | 加班类型字段改进 | 小 |
| P2 | 12 | useMemo 替换为 useEffect | 小 |
| P2 | 13 | 社保基数范围校验 | 小 |
| P2 | 14 | 大数据集分页/流式导出 | 中 |
+219
View File
@@ -0,0 +1,219 @@
# 劳动用工合规 SaaS — 功能层面优化清单(续三)
> **文档编号**: 20260723-优化-6.md
> **日期**: 2026-07-23
> **来源**: 对 Portal 相关页面、AI 服务、RAG 服务深入研究后得出
---
## 一、高优先级(核心业务缺陷)
### 1. Portal — 工资条确认后无反馈机制
**现状**: 员工点击「确认已阅」后只更新 `confirmedAt`,没有通知 HR 已确认。如果 HR 期望所有员工都确认后才能完成工资条审核流程,当前系统无法感知确认状态。
**建议**:
- 工资条确认后通过 WebSocket 或轮询通知 HR
- 在 Money 页面展示各员工的工资条确认状态(已确认 / 未确认)
- 未确认员工超过 N 人时,HR 收到系统通知
- 员工确认后记录 IP 地址(已有),用于审计
**涉及文件**: `frontend/src/pages/Money.tsx``backend/src/routes/portal.routes.ts``frontend/src/pages/portal/Payslip.tsx`
---
### 2. AI — 会话上下文无企业数据关联
**现状**: `buildOrgContext` 只返回员工姓名、部门、入职日期和合同类型的摘要,过于粗略。HR 在问「我们公司有几个试用期还没签合同的员工」时,AI 无法基于这些数据准确回答。
**建议**:
- 增强 `buildOrgContext` 的数据粒度:增加合同状态、即将到期天数、特殊状态(孕期/工伤)等
-`riskItem` 的详细描述也传入,而非只传标题
- 考虑将员工数据以结构化 JSON 传入,而非纯文本,便于 AI 理解
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
### 3. Portal — 合同签署确认无电子签名
**现状**: 员工点击「确认签署」后只更新 `status = 'CONFIRMED'`,没有电子签名或意愿确认机制。法律上电子合同需要可靠的电子签名(CA 证书或人脸识别),当前实现不具备法律效力。
**建议**:
- 增加短信验证码二次确认:员工点击确认后,发送验证码到手机,输入后完成签署
- 或对接第三方电子签名服务(如 e签宝、法大大)
- 签署完成后生成带有时间戳的签署记录 PDF
- 签署记录存储签名证据(IP、设备信息、地理位置),用于后续举证
**涉及文件**: `backend/src/routes/portal.routes.ts``frontend/src/pages/portal/ContractConfirm.tsx`
---
### 4. AI — 用量限制校验逻辑有误
**现状**: `checkUsageLimit` 函数用 `prisma.auditLog``detail` 字段(JSON 序列化后的字符串)做 `count`,但 `JSON.stringify({ month })` 的结果与数据库中 `recordUsage` 时写入的 `detail` 字段格式可能不匹配(后者是对象直接存储)。查询条件无法正确匹配,导致限制失效。
**建议**:
- 统一 `auditLog.detail` 字段的存储格式,要么都用 JSON 字符串,要么都用对象
- 或者用独立的 `aiUsage` 表记录 AI 使用次数,按月统计更准确
- `checkUsageLimit` 应在请求前调用,而非请求后(避免超限后才报错)
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
## 二、中优先级(高频操作体验)
### 5. Portal — 入职填报无文件上传
**现状**: `onboardingSchema` 定义了身份证照片、银行流水等字段,但实际表单只提交文本数据,没有文件上传功能。员工入职时仍需线下提交证件复印件。
**建议**:
- 增加文件上传功能(身份证正反面、学历证明、体检报告等)
- 文件上传到 OSS/S3,返回 URL 后存入 `formData`
- 支持员工端在「我的合同」页面查看已上传的入职材料
- HR 在 Roster 页面可查看员工上传的入职材料
**涉及文件**: `backend/src/routes/portal.routes.ts``frontend/src/pages/portal/Onboarding.tsx`
---
### 6. AI — RAG 知识库无增量更新机制
**现状**: `seedKnowledgeBase` 初始化知识库后,没有提供增量更新接口。劳动法律法规更新后,系统无法自动同步新法规。`addKnowledge` 接口存在但没有在前端暴露入口。
**建议**:
- 增加「知识库管理」页面,HR 可手动添加/编辑法规条文
- 增加法规有效期字段,过期法规自动失效
- 对接权威劳动法数据库(如北大法宝)的增量更新接口(可选)
- 知识库更新后触发向量重索引
**涉及文件**: `backend/src/routes/ai.routes.ts``backend/src/services/rag.service.ts`
---
### 7. Portal — 工资条只能看当前月
**现状**: 员工只能通过月份选择器切换查看历史月份,但无法快速看到工资历史趋势。当员工想对比近半年收入变化时,只能逐月切换。
**建议**:
- 在工资条页面增加「工资趋势」图表(近 6 个月应发金额折线图)
- 增加「收入明细导出」功能,员工可下载自己的历史工资条
- 增加「电子工资条存档」功能,每年自动生成 PDF 年度收入证明(用于贷款、签证等场景)
**涉及文件**: `frontend/src/pages/portal/Payslip.tsx``backend/src/routes/portal.routes.ts`
---
### 8. AI — 对话流异常时 token 不回收
**现状**: `/chat-stream` 在流式响应中途发生错误时,`recordUsage` 可能不会被调用(因为它在 `res.end()` 之后才调用),导致用户使用了 AI 但次数未记录。
**建议**:
-`recordUsage` 移到请求处理开始前,用 `try/finally` 确保无论成功失败都记录
- 或者使用中间件在响应完成后统一记录
- 增加 `aiUsage` 独立表,用事务保证计数准确性
**涉及文件**: `backend/src/routes/ai.routes.ts`
---
## 三、低优先级(功能补全)
### 9. Portal — 验证码登录安全性不足
**现状**: `codeStore` 使用内存 Map 存储验证码,重启服务器后失效,且在多实例部署时无法共享。5 分钟过期时间也较长,存在被暴力破解风险。
**建议**:
- 生产环境使用 Redis 存储验证码,支持多实例共享和自动过期
- 增加验证码错误次数限制(5 次错误后锁定 15 分钟)
- 验证码增加图形验证码或行为验证码(如滑动拼图)防止机器攻击
- 增加登录失败日志记录
**涉及文件**: `backend/src/routes/portal.routes.ts`
---
### 10. AI — 合同审查结果无结构化存储
**现状**: `reviewContract` 返回纯文本审查结果,用户无法按风险类型检索,也无法统计一段时间内的合同合规趋势。
**建议**:
- 将审查结果结构化存储(风险项、条款位置、严重程度、建议)
- 增加 `contractReviewHistory` 表,记录每次审查的时间、内容摘要
- 前端展示审查结果时,按风险等级分类展示,支持按条款搜索
**涉及文件**: `backend/src/routes/ai.routes.ts``backend/prisma/schema.prisma`
---
### 11. Portal — 入职链接无撤回机制
**现状**: HR 生成入职填报链接后无法撤回。如果员工已经收到链接但临时不入职,链接过期前仍然有效,可能被误用。
**建议**:
- 增加「撤销链接」功能,HR 可将已发送的链接置为无效
- 链接撤销后员工访问时提示「该链接已失效,请联系 HR」
- 链接状态增加「已发送」「已使用」「已过期」「已撤销」四种状态
**涉及文件**: `backend/src/routes/employee.routes.ts``backend/prisma/schema.prisma`
---
### 12. AI — 对话未设置超时机制
**现状**: AI 服务调用(特别是 `qwen-max` 模型)可能响应很慢,前端没有超时处理。当 AI 服务不可用时,用户只能等待 30 秒才看到错误。
**建议**:
- 后端设置请求超时(如 30 秒),超时时返回友好的错误提示
- 前端增加加载状态超时提示(如 15 秒无响应时显示「AI 服务响应较慢」)
- 增加 AI 服务健康检查接口,前端可在发送请求前检查服务状态
**涉及文件**: `backend/src/services/ai.service.ts``frontend/src/pages/AIAssistant.tsx`
---
### 13. Portal — 合同确认链接无重发功能
**现状**: 员工收到合同确认邮件/短信后,如果链接过期或未收到,只能让 HR 重新生成一次。员工端没有「重新发送确认链接」的功能。
**建议**:
- 在员工登录 Portal 后,如果存在待确认合同,显示「合同待确认」提示
- 增加「重新发送确认链接」按钮,员工可自行触发重发
- 链接重发记录需要 HR 审批或系统自动发送(根据企业配置)
**涉及文件**: `frontend/src/pages/portal/MyContract.tsx``backend/src/routes/employee.routes.ts`
---
### 14. AI — 案例匹配结果无后续操作
**现状**: `matchCase` 返回的案例分析和建议是纯文本展示,用户无法基于建议快速创建相应的待办事项或调整员工状态。
**建议**:
- 解析案例匹配结果中的「建议」部分,生成可执行的待办事项列表
- 支持用户点击「采纳建议」后,系统自动创建对应操作(如「与员工协商续签」待办)
- 案例匹配结果存入 `AICaseMatch` 表,便于后续审计和分析
**涉及文件**: `backend/src/routes/ai.routes.ts``frontend/src/pages/AIAssistant.tsx``backend/prisma/schema.prisma`
---
## 四、优先级总览
| 优先级 | 编号 | 功能 | 工作量 |
|--------|------|------|--------|
| P0 | 1 | 工资条确认通知 HR | 中 |
| P0 | 2 | AI 会话上下文数据增强 | 小 |
| P0 | 3 | 合同签署电子签名 | 大 |
| P0 | 4 | AI 用量限制校验修复 | 小 |
| P1 | 5 | 入职材料文件上传 | 中 |
| P1 | 6 | RAG 知识库管理界面 | 中 |
| P1 | 7 | 工资趋势图表+导出 | 中 |
| P1 | 8 | AI 用量记录事务保证 | 小 |
| P2 | 9 | 验证码登录安全加固 | 中 |
| P2 | 10 | 合同审查结构化存储 | 中 |
| P2 | 11 | 入职链接撤回功能 | 小 |
| P2 | 12 | AI 服务超时机制 | 小 |
| P2 | 13 | 合同确认链接重发 | 小 |
| P2 | 14 | 案例匹配结果转待办 | 中 |
+21
View File
@@ -0,0 +1,21 @@
# 数据库
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hr_compliance?schema=public
# JWT
JWT_SECRET=your-jwt-secret-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
# DashScope (通义千问)
DASHSCOPE_API_KEY=sk-xxx
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# 加密
ENCRYPTION_KEY=your-32-byte-encryption-key-here
# 存储
SUPABASE_URL=
SUPABASE_KEY=
# 部署
PORT=3000
CORS_ORIGIN=http://localhost:5173
+3696
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "hr-compliance-backend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^5.18.0",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"exceljs": "^4.4.0",
"express": "^4.19.0",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.14.0",
"@types/node-cron": "^3.0.11",
"@types/uuid": "^10.0.0",
"prisma": "^5.18.0",
"ts-node-dev": "^2.0.0",
"tsx": "^4.23.1",
"typescript": "^5.5.0"
}
}
@@ -0,0 +1,16 @@
-- 解聘流程状态机:仅新增列,不删除/修改现有列
-- PostgreSQL 语法,安全执行不会丢失数据
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'DRAFT';
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "currentStep" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "compensationBreakdown" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "checklistOverrides" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "handoverItems" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvedBy" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvedAt" TIMESTAMP(3);
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvalComment" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "updatedBy" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP;
-- 创建索引
CREATE INDEX IF NOT EXISTS "TerminationRecord_orgId_status_idx" ON "TerminationRecord"("orgId", "status");
@@ -0,0 +1,931 @@
-- 启用 pgvector 扩展(RAG 知识库需要 vector 类型)
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'ENTERPRISE');
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'HR', 'VIEWER');
-- CreateEnum
CREATE TYPE "EmployeeStatus" AS ENUM ('ACTIVE', 'RESIGNED');
-- CreateEnum
CREATE TYPE "ContractType" AS ENUM ('FIXED', 'UNFIXED', 'UNSIGNED');
-- CreateEnum
CREATE TYPE "SignMethod" AS ENUM ('PAPER', 'ELECTRONIC');
-- CreateEnum
CREATE TYPE "RiskType" AS ENUM ('CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING');
-- CreateEnum
CREATE TYPE "RiskLevel" AS ENUM ('HIGH', 'MEDIUM', 'LOW');
-- CreateEnum
CREATE TYPE "PayrollBatchType" AS ENUM ('REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE');
-- CreateEnum
CREATE TYPE "PayrollBatchStatus" AS ENUM ('DRAFT', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "PayslipItemType" AS ENUM ('INPUT', 'CALCULATED');
-- CreateEnum
CREATE TYPE "PayslipStatus" AS ENUM ('PENDING', 'PUBLISHED');
-- CreateEnum
CREATE TYPE "RiskStatus" AS ENUM ('PENDING', 'RESOLVED', 'IGNORED');
-- CreateEnum
CREATE TYPE "TerminationReason" AS ENUM ('NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF', 'EXPIRED', 'RESIGNATION');
-- CreateEnum
CREATE TYPE "RiskAssessment" AS ENUM ('SAFE', 'WARNING', 'DANGER');
-- CreateEnum
CREATE TYPE "OnboardingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ContractConfirmStatus" AS ENUM ('UNCONFIRMED', 'CONFIRMED', 'EXPIRED');
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"plan" "Plan" NOT NULL DEFAULT 'FREE',
"maxEmployees" INTEGER NOT NULL DEFAULT 20,
"city" TEXT,
"payrollFrequency" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'ADMIN',
"disabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastLoginAt" TIMESTAMP(3),
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Employee" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department" TEXT NOT NULL,
"hireDate" TIMESTAMP(3) NOT NULL,
"monthlySalary" TEXT NOT NULL,
"status" "EmployeeStatus" NOT NULL DEFAULT 'ACTIVE',
"gender" TEXT,
"phone" TEXT,
"idCardNumber" TEXT,
"idCardHash" TEXT,
"emergencyContact" TEXT,
"emergencyPhone" TEXT,
"address" TEXT,
"bankAccount" TEXT,
"bankName" TEXT,
"passwordHash" TEXT,
"isPregnant" BOOLEAN NOT NULL DEFAULT false,
"isInMedicalPeriod" BOOLEAN NOT NULL DEFAULT false,
"isWorkInjured" BOOLEAN NOT NULL DEFAULT false,
"socialInsBase" DOUBLE PRECISION,
"housingFundBase" DOUBLE PRECISION,
"socialInsStartMonth" TEXT,
"socialInsEndMonth" TEXT,
"housingFundStartMonth" TEXT,
"housingFundEndMonth" TEXT,
"specialDeduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"city" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Employee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LaborContract" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"signDate" TIMESTAMP(3),
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"contractType" "ContractType" NOT NULL,
"signMethod" "SignMethod" NOT NULL DEFAULT 'PAPER',
"contractYears" INTEGER NOT NULL DEFAULT 3,
"probationMonths" INTEGER NOT NULL DEFAULT 0,
"probationSalary" INTEGER NOT NULL DEFAULT 0,
"renewalCount" INTEGER NOT NULL DEFAULT 0,
"attachmentName" TEXT,
"attachmentUrl" TEXT,
"electronicContractNo" TEXT,
"electronicContractUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LaborContract_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OvertimeRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"weekdayHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekdayPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"batchId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OvertimeRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TerminationRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'TERMINATION',
"reason" "TerminationReason" NOT NULL,
"terminationDate" TIMESTAMP(3) NOT NULL,
"resignationReason" TEXT,
"compensation" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialInsEndMonth" TEXT,
"housingFundEndMonth" TEXT,
"riskLevel" "RiskAssessment" NOT NULL DEFAULT 'SAFE',
"checklist" JSONB NOT NULL,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TerminationRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RiskItem" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT,
"type" "RiskType" NOT NULL,
"level" "RiskLevel" NOT NULL,
"status" "RiskStatus" NOT NULL DEFAULT 'PENDING',
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"actionUrl" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolvedBy" TEXT,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RiskItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"detail" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialInsuranceConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"pensionOrg" DOUBLE PRECISION NOT NULL DEFAULT 16,
"pensionEmp" DOUBLE PRECISION NOT NULL DEFAULT 8,
"medicalOrg" DOUBLE PRECISION NOT NULL DEFAULT 9.8,
"medicalEmp" DOUBLE PRECISION NOT NULL DEFAULT 2,
"unemploymentOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"unemploymentEmp" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"injuryOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.2,
"maternityOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.8,
"baseMin" DOUBLE PRECISION NOT NULL DEFAULT 6326,
"baseMax" DOUBLE PRECISION NOT NULL DEFAULT 33891,
"effectiveFrom" TEXT NOT NULL,
"effectiveTo" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"adjustmentDone" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialInsuranceConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HousingFundConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"housingOrg" DOUBLE PRECISION NOT NULL DEFAULT 12,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 12,
"baseMin" DOUBLE PRECISION NOT NULL DEFAULT 6326,
"baseMax" DOUBLE PRECISION NOT NULL DEFAULT 33891,
"effectiveFrom" TEXT NOT NULL,
"effectiveTo" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"adjustmentDone" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HousingFundConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationSetting" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"contractExpiry" BOOLEAN NOT NULL DEFAULT true,
"expiryDays" INTEGER NOT NULL DEFAULT 30,
"contractUnsigned" BOOLEAN NOT NULL DEFAULT true,
"overtimeAlert" BOOLEAN NOT NULL DEFAULT true,
"payslipReady" BOOLEAN NOT NULL DEFAULT true,
"payrollDay" INTEGER NOT NULL DEFAULT 10,
"socialInsDay" INTEGER NOT NULL DEFAULT 15,
"housingFundDay" INTEGER NOT NULL DEFAULT 15,
"taxDay" INTEGER NOT NULL DEFAULT 15,
"wechatWebhook" TEXT,
"emailNotify" BOOLEAN NOT NULL DEFAULT false,
"email" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OvertimeConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"weekdayRate" DOUBLE PRECISION NOT NULL DEFAULT 1.5,
"weekendRate" DOUBLE PRECISION NOT NULL DEFAULT 2.0,
"holidayRate" DOUBLE PRECISION NOT NULL DEFAULT 3.0,
"monthlyDays" DOUBLE PRECISION NOT NULL DEFAULT 21.75,
"dailyHours" DOUBLE PRECISION NOT NULL DEFAULT 8,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OvertimeConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationLog" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'SENT',
"employeeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeAttachment" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"fileType" TEXT NOT NULL,
"fileUrl" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL DEFAULT 0,
"uploadedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeAttachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DisciplinaryRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"violationDate" TIMESTAMP(3) NOT NULL,
"violationType" TEXT NOT NULL,
"description" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'WARNING',
"action" TEXT NOT NULL DEFAULT 'ORAL_WARNING',
"actionDetail" TEXT,
"employeeAck" BOOLEAN NOT NULL DEFAULT false,
"ackDate" TIMESTAMP(3),
"ackMethod" TEXT,
"witness" TEXT,
"attachmentUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DisciplinaryRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AttendanceRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"checkInTime" TEXT,
"checkOutTime" TEXT,
"status" TEXT NOT NULL DEFAULT 'NORMAL',
"lateMinutes" INTEGER NOT NULL DEFAULT 0,
"earlyMinutes" INTEGER NOT NULL DEFAULT 0,
"workHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimeHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendanceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrainingRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"trainingDate" TIMESTAMP(3) NOT NULL,
"topic" TEXT NOT NULL,
"content" TEXT,
"trainer" TEXT,
"duration" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ackStatus" TEXT NOT NULL DEFAULT 'PENDING',
"ackDate" TIMESTAMP(3),
"attachmentUrl" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TrainingRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PerformanceRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"grade" TEXT NOT NULL DEFAULT 'B',
"result" TEXT NOT NULL DEFAULT 'QUALIFIED',
"summary" TEXT,
"improvementPlan" TEXT,
"employeeAck" BOOLEAN NOT NULL DEFAULT false,
"ackDate" TIMESTAMP(3),
"reviewer" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PerformanceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payslip" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"baseSalary" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekdayOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"allowance" DOUBLE PRECISION NOT NULL DEFAULT 0,
"deduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"bonus" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"tax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"netPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdIncome" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdTaxDeducted" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdSocialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdHousingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"status" "PayslipStatus" NOT NULL DEFAULT 'PENDING',
"confirmedAt" TIMESTAMP(3),
"confirmedIp" TEXT,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payslip_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayrollBatch" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"batchNo" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"type" "PayrollBatchType" NOT NULL DEFAULT 'REGULAR',
"status" "PayrollBatchStatus" NOT NULL DEFAULT 'DRAFT',
"employeeCount" INTEGER NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalNetPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalSocialOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalSocialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalHousingOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalHousingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalTax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"archivedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayrollBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchEntry" (
"id" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"baseSalary" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"allowance" DOUBLE PRECISION NOT NULL DEFAULT 0,
"deduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"bonus" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"tax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"netPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"riskWarnings" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BatchEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayslipItem" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"type" "PayslipItemType" NOT NULL DEFAULT 'INPUT',
"formula" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isDefault" BOOLEAN NOT NULL DEFAULT true,
"isEditable" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayslipItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalaryChangeRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"oldSalary" DOUBLE PRECISION NOT NULL,
"newSalary" DOUBLE PRECISION NOT NULL,
"effectiveDate" TIMESTAMP(3) NOT NULL,
"effectiveMonth" TEXT NOT NULL,
"endMonth" TEXT,
"changeType" TEXT NOT NULL DEFAULT 'SALARY_CHANGE',
"reason" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalaryChangeRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeSocialInsRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"startMonth" TEXT NOT NULL,
"endMonth" TEXT,
"base" DOUBLE PRECISION NOT NULL,
"changeType" TEXT NOT NULL,
"changeRefId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeSocialInsRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeHousingFundRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"startMonth" TEXT NOT NULL,
"endMonth" TEXT,
"base" DOUBLE PRECISION NOT NULL,
"changeType" TEXT NOT NULL,
"changeRefId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeHousingFundRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeDepartmentRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"oldDepartment" TEXT NOT NULL,
"newDepartment" TEXT NOT NULL,
"effectiveMonth" TEXT NOT NULL,
"endMonth" TEXT,
"reason" TEXT,
"changeType" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeDepartmentRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OnboardingLink" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"employeeName" TEXT,
"phone" TEXT,
"status" "OnboardingStatus" NOT NULL DEFAULT 'PENDING',
"formData" JSONB,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OnboardingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractConfirmLink" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"status" "ContractConfirmStatus" NOT NULL DEFAULT 'UNCONFIRMED',
"confirmedAt" TIMESTAMP(3),
"confirmedIp" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContractConfirmLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AIConversation" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '新对话',
"messages" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AIConversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AIReviewRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT,
"type" TEXT NOT NULL,
"input" TEXT NOT NULL,
"result" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AIReviewRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rag_knowledge" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"source" TEXT NOT NULL,
"category" TEXT NOT NULL,
"embedding" vector(1536),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rag_knowledge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Employee_orgId_idCardHash_key" ON "Employee"("orgId", "idCardHash");
-- CreateIndex
CREATE UNIQUE INDEX "OvertimeRecord_employeeId_month_key" ON "OvertimeRecord"("employeeId", "month");
-- CreateIndex
CREATE INDEX "RiskItem_orgId_status_idx" ON "RiskItem"("orgId", "status");
-- CreateIndex
CREATE INDEX "RiskItem_orgId_type_idx" ON "RiskItem"("orgId", "type");
-- CreateIndex
CREATE INDEX "AuditLog_orgId_createdAt_idx" ON "AuditLog"("orgId", "createdAt");
-- CreateIndex
CREATE INDEX "SocialInsuranceConfig_orgId_isCurrent_idx" ON "SocialInsuranceConfig"("orgId", "isCurrent");
-- CreateIndex
CREATE UNIQUE INDEX "SocialInsuranceConfig_orgId_city_effectiveFrom_key" ON "SocialInsuranceConfig"("orgId", "city", "effectiveFrom");
-- CreateIndex
CREATE INDEX "HousingFundConfig_orgId_isCurrent_idx" ON "HousingFundConfig"("orgId", "isCurrent");
-- CreateIndex
CREATE UNIQUE INDEX "HousingFundConfig_orgId_city_effectiveFrom_key" ON "HousingFundConfig"("orgId", "city", "effectiveFrom");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationSetting_orgId_key" ON "NotificationSetting"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "OvertimeConfig_orgId_key" ON "OvertimeConfig"("orgId");
-- CreateIndex
CREATE INDEX "NotificationLog_orgId_createdAt_idx" ON "NotificationLog"("orgId", "createdAt");
-- CreateIndex
CREATE INDEX "EmployeeAttachment_orgId_employeeId_idx" ON "EmployeeAttachment"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "DisciplinaryRecord_orgId_employeeId_idx" ON "DisciplinaryRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "AttendanceRecord_orgId_employeeId_idx" ON "AttendanceRecord"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "AttendanceRecord_employeeId_date_key" ON "AttendanceRecord"("employeeId", "date");
-- CreateIndex
CREATE INDEX "TrainingRecord_orgId_employeeId_idx" ON "TrainingRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "PerformanceRecord_orgId_employeeId_idx" ON "PerformanceRecord"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "PerformanceRecord_employeeId_period_key" ON "PerformanceRecord"("employeeId", "period");
-- CreateIndex
CREATE INDEX "Payslip_orgId_month_idx" ON "Payslip"("orgId", "month");
-- CreateIndex
CREATE INDEX "Payslip_orgId_status_idx" ON "Payslip"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "Payslip_employeeId_month_key" ON "Payslip"("employeeId", "month");
-- CreateIndex
CREATE INDEX "PayrollBatch_orgId_month_idx" ON "PayrollBatch"("orgId", "month");
-- CreateIndex
CREATE INDEX "PayrollBatch_orgId_status_idx" ON "PayrollBatch"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "PayrollBatch_orgId_month_batchNo_key" ON "PayrollBatch"("orgId", "month", "batchNo");
-- CreateIndex
CREATE INDEX "BatchEntry_orgId_employeeId_idx" ON "BatchEntry"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "BatchEntry_batchId_employeeId_key" ON "BatchEntry"("batchId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "PayslipItem_orgId_code_key" ON "PayslipItem"("orgId", "code");
-- CreateIndex
CREATE INDEX "SalaryChangeRecord_orgId_employeeId_idx" ON "SalaryChangeRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "SalaryChangeRecord_employeeId_effectiveMonth_endMonth_idx" ON "SalaryChangeRecord"("employeeId", "effectiveMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_orgId_employeeId_idx" ON "EmployeeSocialInsRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_employeeId_startMonth_endMonth_idx" ON "EmployeeSocialInsRecord"("employeeId", "startMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_orgId_city_idx" ON "EmployeeSocialInsRecord"("orgId", "city");
-- CreateIndex
CREATE INDEX "EmployeeHousingFundRecord_orgId_employeeId_idx" ON "EmployeeHousingFundRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeHousingFundRecord_employeeId_startMonth_endMonth_idx" ON "EmployeeHousingFundRecord"("employeeId", "startMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeDepartmentRecord_orgId_employeeId_idx" ON "EmployeeDepartmentRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeDepartmentRecord_employeeId_effectiveMonth_endMonth_idx" ON "EmployeeDepartmentRecord"("employeeId", "effectiveMonth", "endMonth");
-- CreateIndex
CREATE UNIQUE INDEX "OnboardingLink_token_key" ON "OnboardingLink"("token");
-- CreateIndex
CREATE INDEX "OnboardingLink_orgId_status_idx" ON "OnboardingLink"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "ContractConfirmLink_token_key" ON "ContractConfirmLink"("token");
-- CreateIndex
CREATE INDEX "ContractConfirmLink_orgId_status_idx" ON "ContractConfirmLink"("orgId", "status");
-- CreateIndex
CREATE INDEX "AIConversation_orgId_userId_idx" ON "AIConversation"("orgId", "userId");
-- CreateIndex
CREATE INDEX "AIReviewRecord_orgId_employeeId_idx" ON "AIReviewRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "rag_knowledge_category_idx" ON "rag_knowledge"("category");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeRecord" ADD CONSTRAINT "OvertimeRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeRecord" ADD CONSTRAINT "OvertimeRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TerminationRecord" ADD CONSTRAINT "TerminationRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TerminationRecord" ADD CONSTRAINT "TerminationRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RiskItem" ADD CONSTRAINT "RiskItem_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RiskItem" ADD CONSTRAINT "RiskItem_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SocialInsuranceConfig" ADD CONSTRAINT "SocialInsuranceConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HousingFundConfig" ADD CONSTRAINT "HousingFundConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationSetting" ADD CONSTRAINT "NotificationSetting_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeConfig" ADD CONSTRAINT "OvertimeConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeAttachment" ADD CONSTRAINT "EmployeeAttachment_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeAttachment" ADD CONSTRAINT "EmployeeAttachment_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DisciplinaryRecord" ADD CONSTRAINT "DisciplinaryRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DisciplinaryRecord" ADD CONSTRAINT "DisciplinaryRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendanceRecord" ADD CONSTRAINT "AttendanceRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendanceRecord" ADD CONSTRAINT "AttendanceRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainingRecord" ADD CONSTRAINT "TrainingRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainingRecord" ADD CONSTRAINT "TrainingRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PerformanceRecord" ADD CONSTRAINT "PerformanceRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PerformanceRecord" ADD CONSTRAINT "PerformanceRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payslip" ADD CONSTRAINT "Payslip_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payslip" ADD CONSTRAINT "Payslip_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PayrollBatch" ADD CONSTRAINT "PayrollBatch_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchEntry" ADD CONSTRAINT "BatchEntry_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "PayrollBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchEntry" ADD CONSTRAINT "BatchEntry_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PayslipItem" ADD CONSTRAINT "PayslipItem_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryChangeRecord" ADD CONSTRAINT "SalaryChangeRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryChangeRecord" ADD CONSTRAINT "SalaryChangeRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeSocialInsRecord" ADD CONSTRAINT "EmployeeSocialInsRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeSocialInsRecord" ADD CONSTRAINT "EmployeeSocialInsRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeHousingFundRecord" ADD CONSTRAINT "EmployeeHousingFundRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeHousingFundRecord" ADD CONSTRAINT "EmployeeHousingFundRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeDepartmentRecord" ADD CONSTRAINT "EmployeeDepartmentRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeDepartmentRecord" ADD CONSTRAINT "EmployeeDepartmentRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OnboardingLink" ADD CONSTRAINT "OnboardingLink_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractConfirmLink" ADD CONSTRAINT "ContractConfirmLink_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractConfirmLink" ADD CONSTRAINT "ContractConfirmLink_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "LaborContract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIConversation" ADD CONSTRAINT "AIConversation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIReviewRecord" ADD CONSTRAINT "AIReviewRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIReviewRecord" ADD CONSTRAINT "AIReviewRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+826
View File
@@ -0,0 +1,826 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ========== 枚举 ==========
enum Plan {
FREE
PRO
ENTERPRISE
}
enum Role {
ADMIN
HR
VIEWER
}
enum EmployeeStatus {
ACTIVE
RESIGNED
}
enum ContractType {
FIXED
UNFIXED
UNSIGNED
}
enum SignMethod {
PAPER
ELECTRONIC
}
enum RiskType {
CONTRACT
SALARY
TERMINATION
MONTHLY
ONBOARDING
}
enum RiskLevel {
HIGH
MEDIUM
LOW
}
enum PayrollBatchType {
REGULAR // 常规发薪
TERMINATION // 离职结算
BONUS // 年终奖/奖金
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
}
enum PayrollBatchStatus {
DRAFT // 草稿(可编辑)
ARCHIVED // 归档(已发薪,锁定)
}
enum PayslipItemType {
INPUT // 手工输入项(计算依据)
CALCULATED // 计算项(公式自动计算)
}
enum PayslipStatus {
PENDING // 待发布
PUBLISHED // 已发布到员工端
}
enum RiskStatus {
PENDING
RESOLVED
IGNORED
}
enum TerminationReason {
NEGOTIATED
FAULT
NONFAULT
LAYOFF
EXPIRED
RESIGNATION
}
enum RiskAssessment {
SAFE
WARNING
DANGER
}
enum OnboardingStatus {
PENDING
APPROVED
REJECTED
CANCELLED
}
enum ContractConfirmStatus {
UNCONFIRMED
CONFIRMED
EXPIRED
}
// ========== 核心表 ==========
model Organization {
id String @id @default(cuid())
name String
plan Plan @default(FREE)
maxEmployees Int @default(20)
city String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
employees Employee[]
contracts LaborContract[]
overtimeRecords OvertimeRecord[]
terminations TerminationRecord[]
riskItems RiskItem[]
auditLogs AuditLog[]
payslips Payslip[]
payrollBatches PayrollBatch[]
payslipItems PayslipItem[]
salaryChangeRecords SalaryChangeRecord[]
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
aiConversations AIConversation[]
aiReviewRecords AIReviewRecord[]
socialInsuranceConfig SocialInsuranceConfig[]
housingFundConfigs HousingFundConfig[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
notificationSetting NotificationSetting?
overtimeConfig OvertimeConfig?
notificationLogs NotificationLog[]
employeeAttachments EmployeeAttachment[]
disciplinaryRecords DisciplinaryRecord[]
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
}
model User {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
phone String @unique
email String?
passwordHash String
name String
role Role @default(ADMIN)
disabled Boolean @default(false)
createdAt DateTime @default(now())
lastLoginAt DateTime?
}
// ========== 业务表 ==========
model Employee {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
department String
hireDate DateTime
monthlySalary String // AES-256 加密存储
status EmployeeStatus @default(ACTIVE)
gender String?
phone String?
idCardNumber String? // AES-256 加密存储
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
emergencyContact String?
emergencyPhone String?
address String?
bankAccount String? // AES-256 加密存储
bankName String?
passwordHash String? // 员工端登录密码
isPregnant Boolean @default(false)
isInMedicalPeriod Boolean @default(false)
isWorkInjured Boolean @default(false)
// 薪税扩展
socialInsBase Float? // 社保缴费基数(便捷字段,由Record同步)
housingFundBase Float? // 公积金缴费基数(便捷字段,由Record同步)
socialInsStartMonth String? // 当前社保开始年月(便捷字段)
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contracts LaborContract[]
overtimeRecords OvertimeRecord[]
terminations TerminationRecord[]
riskItems RiskItem[]
payslips Payslip[]
salaryChanges SalaryChangeRecord[]
batchEntries BatchEntry[]
attachments EmployeeAttachment[]
disciplinaryRecords DisciplinaryRecord[]
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
aiReviewRecords AIReviewRecord[]
@@unique([orgId, idCardHash])
}
model LaborContract {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
signDate DateTime?
startDate DateTime
endDate DateTime?
contractType ContractType
signMethod SignMethod @default(PAPER)
contractYears Int @default(3)
probationMonths Int @default(0)
probationSalary Int @default(0)
renewalCount Int @default(0)
attachmentName String?
attachmentUrl String?
electronicContractNo String?
electronicContractUrl String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
confirmLinks ContractConfirmLink[]
}
model OvertimeRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
month String // YYYY-MM
weekdayHours Float @default(0)
weekendHours Float @default(0)
holidayHours Float @default(0)
weekdayPay Float @default(0)
weekendPay Float @default(0)
holidayPay Float @default(0)
totalPay Float @default(0)
batchId String? // 关联的发薪批次(加入后锁定,不可重复加入)
createdAt DateTime @default(now())
@@unique([employeeId, month])
}
model TerminationRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
type String @default("TERMINATION") // TERMINATION=公司解聘, RESIGNATION=员工主动离职
reason TerminationReason
terminationDate DateTime
resignationReason String? // 主动离职原因(type=RESIGNATION时使用)
compensation Float @default(0)
socialInsEndMonth String? // 社保截止缴费年月 YYYY-MM
housingFundEndMonth String? // 公积金截止缴费年月 YYYY-MM
riskLevel RiskAssessment @default(SAFE)
checklist Json
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 流程状态机
status String @default("DRAFT") // DRAFT|PENDING_APPROVAL|APPROVED|EXECUTING|COMPLETED|REJECTED|CANCELLED
currentStep Int @default(0) // 当前完成到第几步
// 补偿金分项明细 + 调整记录
compensationBreakdown Json? // { severance, noticePay, doublePay, other, adjustments: [{field, from, to, reason}] }
// 合规检查覆盖记录
checklistOverrides Json? // { key: { checked: bool, overrideReason: string } }
// 工作交接清单
handoverItems Json? // [{ key, label, done, remark }]
// 审批信息
approvedBy String?
approvedAt DateTime?
approvalComment String?
updatedBy String?
@@index([orgId, status])
}
model RiskItem {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
type RiskType
level RiskLevel
status RiskStatus @default(PENDING)
title String
description String
actionUrl String?
resolvedAt DateTime?
resolvedBy String?
remark String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
model AuditLog {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String
action String
entity String
entityId String?
detail Json?
ip String?
createdAt DateTime @default(now())
@@index([orgId, createdAt])
}
// ========== 社保 & 通知 & 附件 ==========
model SocialInsuranceConfig {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
pensionOrg Float @default(16) // 养老保险 企业比例 %
pensionEmp Float @default(8) // 养老保险 个人比例 %
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
medicalEmp Float @default(2) // 医疗保险 个人比例 %
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
baseMin Float @default(6326) // 社保缴费基数下限
baseMax Float @default(33891) // 社保缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过社保基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
model HousingFundConfig {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
housingOrg Float @default(12) // 公积金 企业比例 %
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 公积金缴费基数下限
baseMax Float @default(33891) // 公积金缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过公积金基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
model NotificationSetting {
id String @id @default(cuid())
orgId String @unique
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
contractExpiry Boolean @default(true)
expiryDays Int @default(30)
contractUnsigned Boolean @default(true)
overtimeAlert Boolean @default(true)
payslipReady Boolean @default(true)
// 月度事务提醒日(每月几号)
payrollDay Int @default(10) // 发薪日
socialInsDay Int @default(15) // 社保缴纳日
housingFundDay Int @default(15) // 公积金缴纳日
taxDay Int @default(15) // 个税申报日
wechatWebhook String?
emailNotify Boolean @default(false)
email String?
updatedAt DateTime @updatedAt
}
model OvertimeConfig {
id String @id @default(cuid())
orgId String @unique
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
weekdayRate Float @default(1.5) // 工作日加班倍率
weekendRate Float @default(2.0) // 休息日加班倍率
holidayRate Float @default(3.0) // 法定节假日加班倍率
monthlyDays Float @default(21.75) // 月计薪天数
dailyHours Float @default(8) // 每日工时
updatedAt DateTime @updatedAt
}
model NotificationLog {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // CONTRACT_EXPIRY / CONTRACT_UNSIGNED / OVERTIME / PAYSLIP
title String
content String
channel String // WECHAT / EMAIL / IN_APP
status String @default("SENT") // SENT / FAILED
employeeId String?
createdAt DateTime @default(now())
@@index([orgId, createdAt])
}
model EmployeeAttachment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
fileName String
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER
fileUrl String
fileSize Int @default(0)
uploadedBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
// ========== 仲裁证据链 ==========
model DisciplinaryRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
violationDate DateTime
violationType String // LATE/ABSENT/INSUBORDINATION/MISCONDUCT/VIOLATE_POLICY/OTHER
description String
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
actionDetail String?
employeeAck Boolean @default(false) // 员工是否签字确认
ackDate DateTime?
ackMethod String? // SIGN/ELECTRONIC/REFUSED
witness String? // 见证人
attachmentUrl String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, employeeId])
}
model AttendanceRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
date DateTime
checkInTime String? // HH:mm
checkOutTime String? // HH:mm
status String @default("NORMAL") // NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP
lateMinutes Int @default(0)
earlyMinutes Int @default(0)
workHours Float @default(0)
overtimeHours Float @default(0)
remark String?
createdBy String
createdAt DateTime @default(now())
@@unique([employeeId, date])
@@index([orgId, employeeId])
}
model TrainingRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
trainingDate DateTime
topic String // 培训主题/制度名称
content String? // 培训内容摘要
trainer String?
duration Float @default(0) // 培训时长(小时)
ackStatus String @default("PENDING") // PENDING/SIGNED/REFUSED
ackDate DateTime?
attachmentUrl String? // 签收单扫描件
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
model PerformanceRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
period String // 考核周期 YYYY-MM 或 YYYY-Q1
score Float @default(0) // 考核得分
grade String @default("B") // A/B/C/D
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
summary String? // 考核评语
improvementPlan String? // 改进计划(不胜任时)
employeeAck Boolean @default(false)
ackDate DateTime?
reviewer String?
createdBy String
createdAt DateTime @default(now())
@@unique([employeeId, period])
@@index([orgId, employeeId])
}
// ========== 员工端表 ==========
model Payslip {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
month String // YYYY-MM
// 薪酬构成
baseSalary Float @default(0)
overtimePay Float @default(0)
weekdayOvertimePay Float @default(0)
weekendOvertimePay Float @default(0)
holidayOvertimePay Float @default(0)
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0) // 奖金/年终奖
totalPay Float @default(0) // 应发合计
// 扣除项
socialEmp Float @default(0) // 个人社保
housingEmp Float @default(0) // 个人公积金
tax Float @default(0) // 个人所得税
netPay Float @default(0) // 实发工资 = totalPay - socialEmp - housingEmp - tax
// 累计预扣法
ytdIncome Float @default(0) // 当年累计收入
ytdTaxDeducted Float @default(0) // 当年累计已扣税
ytdSocialEmp Float @default(0) // 当年累计个人社保
ytdHousingEmp Float @default(0) // 当年累计个人公积金
// 状态
status PayslipStatus @default(PENDING) // PENDING → PUBLISHED
confirmedAt DateTime?
confirmedIp String?
publishedAt DateTime? // 工资条发布到员工端的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, month])
@@index([orgId, month])
@@index([orgId, status])
}
model PayrollBatch {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
month String // YYYY-MM
batchNo Int // 批次序号(1, 2, 3...
name String // 批次名称
type PayrollBatchType @default(REGULAR)
status PayrollBatchStatus @default(DRAFT)
employeeCount Int @default(0)
totalPay Float @default(0)
totalNetPay Float @default(0)
totalSocialOrg Float @default(0)
totalSocialEmp Float @default(0)
totalHousingOrg Float @default(0)
totalHousingEmp Float @default(0)
totalTax Float @default(0)
remark String?
createdBy String
createdAt DateTime @default(now())
archivedAt DateTime?
updatedAt DateTime @updatedAt
entries BatchEntry[]
@@unique([orgId, month, batchNo])
@@index([orgId, month])
@@index([orgId, status])
}
model BatchEntry {
id String @id @default(cuid())
batchId String
batch PayrollBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
orgId String
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
// 薪酬项(可编辑的输入项)
baseSalary Float @default(0)
overtimePay Float @default(0)
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0)
// 自动计算项
socialEmp Float @default(0)
socialOrg Float @default(0)
housingEmp Float @default(0)
housingOrg Float @default(0)
tax Float @default(0)
totalPay Float @default(0) // 应发合计
netPay Float @default(0) // 实发工资
// 风险提示
riskWarnings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([batchId, employeeId])
@@index([orgId, employeeId])
}
model PayslipItem {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 显示名称
code String // 字段代码
type PayslipItemType @default(INPUT)
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"
order Int @default(0)
isDefault Boolean @default(true) // 系统预置项不可删除
isEditable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, code])
}
model SalaryChangeRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldSalary Float
newSalary Float
effectiveDate DateTime // 生效日期
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
reason String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
model EmployeeSocialInsRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
@@index([orgId, city])
}
model EmployeeHousingFundRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
}
model EmployeeDepartmentRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
model OnboardingLink {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
token String @unique
employeeName String?
phone String?
status OnboardingStatus @default(PENDING)
formData Json?
expiresAt DateTime
usedAt DateTime?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
}
model ContractConfirmLink {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
contractId String
contract LaborContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
token String @unique
status ContractConfirmStatus @default(UNCONFIRMED)
confirmedAt DateTime?
confirmedIp String?
expiresAt DateTime
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
}
// ========== AI 会话 & 审查记录 ==========
model AIConversation {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String
title String @default("新对话")
messages Json // [{ role, content }]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, userId])
}
model AIReviewRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
type String // REVIEW=合同审查, CASE=案例匹配
input String // 用户输入的合同文本或争议情形
result String // AI 返回的审查/分析结果
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
// ========== RAG 知识库 ==========
model RagKnowledge {
id String @id
title String
content String
source String
category String
embedding Unsupported("vector(1536)")?
createdAt DateTime @default(now()) @map("created_at")
@@index([category])
@@map("rag_knowledge")
}
+120
View File
@@ -0,0 +1,120 @@
import prisma from '../src/lib/prisma'
const EID = 'cmrx61v6d001oqqcwb2pu2tih'
const ORGID = 'cmrx61v3l0000qqcwo3dr3h95'
const UID = 'cmrx61v5u0002qqcwqf4vlyth'
async function main() {
// 加班记录
const otMonths = [
{ month: '2025-03', wh: 8, weh: 4, hh: 0, wp: 600, wep: 600, hp: 0, pay: 1200 },
{ month: '2025-06', wh: 12, weh: 8, hh: 0, wp: 1200, wep: 1200, hp: 0, pay: 2400 },
{ month: '2025-09', wh: 6, weh: 0, hh: 8, wp: 600, wep: 0, hp: 1200, pay: 1800 },
]
for (const o of otMonths) {
const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } })
if (!existing) {
await prisma.overtimeRecord.create({ data: { orgId: ORGID, employeeId: EID, month: o.month, weekdayHours: o.wh, weekendHours: o.weh, holidayHours: o.hh, weekdayPay: o.wp, weekendPay: o.wep, holidayPay: o.hp, totalPay: o.pay } })
}
}
console.log('加班记录: 完成')
// 违纪记录
const discRecords = [
{ violationDate: new Date('2025-05-12'), violationType: 'LATE', description: '月度迟到超过5次,影响团队考勤', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '口头警告并谈话', employeeAck: true, ackDate: new Date('2025-05-13'), ackMethod: 'SIGN', witness: '王强' },
{ violationDate: new Date('2025-09-20'), violationType: 'ABSENT', description: '未经请假擅自旷工1天', severity: 'SERIOUS', action: 'DEDUCTION', actionDetail: '扣款200元', employeeAck: true, ackDate: new Date('2025-09-21'), ackMethod: 'SIGN', witness: '王强' },
]
for (const d of discRecords) {
const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } })
if (!existing) {
await prisma.disciplinaryRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...d } })
}
}
console.log('违纪记录: 完成')
// 考勤记录 - 最近10个工作日
const attendance = [
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-11', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0 },
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-18', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30 },
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0 },
]
for (const a of attendance) {
const existing = await prisma.attendanceRecord.findUnique({ where: { employeeId_date: { employeeId: EID, date: new Date(a.date) } } })
if (!existing) {
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: '09:00', checkOutTime: '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: 8, overtimeHours: 0 } })
}
}
console.log('考勤记录: 完成')
// 培训签收记录
const trainings = [
{ trainingDate: new Date('2025-03-15'), topic: '《员工手册》培训', content: '公司规章制度、考勤制度、奖惩条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-03-15'), remark: '新员工入职培训' },
{ trainingDate: new Date('2025-06-20'), topic: '销售技巧与合规培训', content: '销售话术规范、客户信息保护、合同签订注意事项', trainer: '王强', duration: 4, ackStatus: 'SIGNED', ackDate: new Date('2025-06-20') },
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
]
for (const t of trainings) {
const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } })
if (!existing) {
await prisma.trainingRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...t } })
}
}
console.log('培训记录: 完成')
// 绩效记录
const performances = [
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
{ period: '2025-Q2', score: 75, grade: 'B', result: 'QUALIFIED', summary: '业绩略有下滑,新客户开发不足,团队协作有待加强', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-07-08'), reviewer: '王强' },
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升', improvementPlan: '', employeeAck: false, reviewer: '王强' },
]
for (const p of performances) {
const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } })
if (!existing) {
await prisma.performanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...p } })
}
}
console.log('绩效记录: 完成')
// 附件
const attachments = [
{ fileName: '吴芳身份证扫描件.pdf', fileType: 'ID_CARD', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 102400 },
{ fileName: '吴芳银行卡复印件.jpg', fileType: 'BANK_CARD', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 51200 },
{ fileName: '吴芳劳动合同扫描件.pdf', fileType: 'CONTRACT_SCAN', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 204800 },
{ fileName: '吴芳学历证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 81920 },
]
for (const a of attachments) {
const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } })
if (!existing) {
await prisma.employeeAttachment.create({ data: { ...a, orgId: ORGID, employeeId: EID, uploadedBy: UID } })
}
}
console.log('附件: 完成')
// 验证
const emp = await prisma.employee.findFirst({
where: { id: EID },
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true }
})
if (emp) {
console.log('--- 吴芳完整档案数据统计 ---')
console.log('contracts:', emp.contracts.length)
console.log('payslips:', emp.payslips.length)
console.log('overtimeRecords:', emp.overtimeRecords.length)
console.log('disciplinaryRecords:', emp.disciplinaryRecords.length)
console.log('attendanceRecords:', emp.attendanceRecords.length)
console.log('trainingRecords:', emp.trainingRecords.length)
console.log('performanceRecords:', emp.performanceRecords.length)
console.log('terminations:', emp.terminations.length)
console.log('attachments:', emp.attachments.length)
}
await prisma.$disconnect()
}
main().catch(console.error)
+298
View File
@@ -0,0 +1,298 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import { encrypt } from '../src/lib/crypto'
const prisma = new PrismaClient()
// 社保计算(与 payroll.service.ts 一致)
function calcSocial(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
return { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100 }
}
function calcHousing(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingEmp = actualBase * config.housingEmp / 100
const housingOrg = actualBase * config.housingOrg / 100
return { housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100 }
}
function calcTax(taxableIncome: number): number {
if (taxableIncome <= 0) return 0
let tax = 0
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
else tax = taxableIncome * 0.45 - 181920
return Math.max(0, Math.round(tax * 100) / 100)
}
// 9名员工完整数据
const EMPLOYEES = [
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', idCard: '310101199001011234', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' },
{ name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', idCard: '310102199203052345', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true },
{ name: '王强', gender: '男', dept: '销售部', phone: '13900000003', idCard: '310103198812103456', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' },
{ name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', idCard: '310104199506154567', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' },
{ name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', idCard: '310105199907205678', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' },
{ name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', idCard: '310106198504016789', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' },
{ name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', idCard: '310107199311157890', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' },
{ name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', idCard: '310108199008018901', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' },
{ name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', idCard: '310109199702159012', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' },
]
async function main() {
// 1. 清空所有数据(按依赖顺序删除)
console.log('清空现有数据...')
await prisma.notificationLog.deleteMany()
await prisma.auditLog.deleteMany()
await prisma.batchEntry.deleteMany()
await prisma.payrollBatch.deleteMany()
await prisma.payslipItem.deleteMany()
await prisma.salaryChangeRecord.deleteMany()
await prisma.payslip.deleteMany()
await prisma.overtimeRecord.deleteMany()
await prisma.terminationRecord.deleteMany()
await prisma.riskItem.deleteMany()
await prisma.employeeAttachment.deleteMany()
await prisma.disciplinaryRecord.deleteMany()
await prisma.attendanceRecord.deleteMany()
await prisma.trainingRecord.deleteMany()
await prisma.performanceRecord.deleteMany()
await prisma.laborContract.deleteMany()
await prisma.contractConfirmLink.deleteMany()
await prisma.onboardingLink.deleteMany()
await prisma.employee.deleteMany()
await prisma.socialInsuranceConfig.deleteMany()
await prisma.notificationSetting.deleteMany()
await prisma.user.deleteMany()
await prisma.organization.deleteMany()
console.log('数据已清空')
// 2. 创建企业
const org = await prisma.organization.create({
data: {
name: '智云科技有限公司',
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
},
})
console.log('企业已创建:', org.name)
// 3. 创建管理员
const passwordHash = await bcrypt.hash('12345678', 10)
const admin = await prisma.user.create({
data: {
orgId: org.id,
phone: '13800000001',
name: '管理员',
passwordHash,
role: 'ADMIN',
},
})
console.log('管理员已创建:', admin.phone)
// 4. 创建社保配置(上海标准)
await prisma.socialInsuranceConfig.create({
data: {
orgId: org.id,
city: '上海',
pensionOrg: 16,
pensionEmp: 8,
medicalOrg: 9.8,
medicalEmp: 2,
unemploymentOrg: 0.5,
unemploymentEmp: 0.5,
injuryOrg: 0.2,
maternityOrg: 0.8,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
createdBy: admin.id,
},
})
console.log('社保配置已创建')
// 4.5 创建公积金配置(上海标准)
await prisma.housingFundConfig.create({
data: {
orgId: org.id,
city: '上海',
housingOrg: 7,
housingEmp: 7,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
createdBy: admin.id,
},
})
console.log('公积金配置已创建')
// 5. 创建通知设置
await prisma.notificationSetting.create({
data: {
orgId: org.id,
contractExpiry: true,
expiryDays: 30,
contractUnsigned: true,
overtimeAlert: true,
payslipReady: true,
payrollDay: 10,
socialInsDay: 15,
housingFundDay: 15,
taxDay: 15,
},
})
// 6. 创建薪酬模版(预置项)
const defaultItems: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
]
for (const item of defaultItems) {
await prisma.payslipItem.create({
data: { orgId: org.id, ...item },
})
}
console.log('薪酬模版已创建')
// 7. 创建9名员工 + 合同
for (let i = 0; i < EMPLOYEES.length; i++) {
const e = EMPLOYEES[i]
const emp = await prisma.employee.create({
data: {
orgId: org.id,
name: e.name,
department: e.dept,
hireDate: new Date(e.hireDate),
monthlySalary: encrypt(String(e.salary)),
phone: e.phone,
idCardNumber: encrypt(e.idCard),
gender: e.gender,
socialInsBase: e.socialBase,
housingFundBase: e.housingBase,
specialDeduction: e.specialDeduction,
bankName: e.bank,
bankAccount: encrypt(e.account),
emergencyContact: e.emergency,
emergencyPhone: e.emergencyPhone,
address: e.address,
isPregnant: e.pregnant || false,
createdBy: admin.id,
},
})
// 创建合同
const startDate = new Date(e.hireDate)
const endDate = e.contractType === 'FIXED'
? new Date(startDate.getFullYear() + e.years, startDate.getMonth(), startDate.getDate() - 1)
: null
await prisma.laborContract.create({
data: {
orgId: org.id,
employeeId: emp.id,
signDate: new Date(e.hireDate),
startDate,
endDate,
contractType: e.contractType as any,
signMethod: 'PAPER',
contractYears: e.years,
probationMonths: e.probation,
probationSalary: e.probationSalary,
createdBy: admin.id,
},
})
console.log(`员工 ${i + 1}/9 已创建: ${e.name} - ${e.dept} - ¥${e.salary}/月`)
}
// 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据
console.log('\n生成 1-6 月历史工资条...')
const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
const housingConfig = await prisma.housingFundConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } })
for (const emp of allEmployees) {
// 跳过 2026 年之后入职的员工
const hireYear = emp.hireDate.getFullYear()
if (hireYear > 2026) continue
const hireMonth = hireYear === 2026 ? emp.hireDate.getMonth() + 1 : 1
let ytdIncome = 0, ytdSocialEmp = 0, ytdHousingEmp = 0, ytdTaxDeducted = 0
for (let m = 1; m <= 6; m++) {
if (m < hireMonth) continue
const monthStr = `2026-${String(m).padStart(2, '0')}`
const baseSalary = emp.socialInsBase || 0 // 用社保基数作为基本工资(简化)
const social = calcSocial(emp.socialInsBase || baseSalary, socialConfig)
const housing = calcHousing(emp.housingFundBase || baseSalary, housingConfig || socialConfig)
const totalPay = baseSalary
const specialDeduction = emp.specialDeduction * m
ytdIncome += totalPay
ytdSocialEmp += social.socialEmp
ytdHousingEmp += housing.housingEmp
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * m - ytdSocialEmp - ytdHousingEmp - specialDeduction)
const ytdTax = calcTax(ytdTaxableIncome)
const monthTax = Math.max(0, Math.round((ytdTax - ytdTaxDeducted) * 100) / 100)
ytdTaxDeducted += monthTax
const netPay = Math.round((totalPay - social.socialEmp - housing.housingEmp - monthTax) * 100) / 100
await prisma.payslip.create({
data: {
org: { connect: { id: org.id } },
employee: { connect: { id: emp.id } },
month: monthStr,
baseSalary,
overtimePay: 0,
allowance: 0,
deduction: 0,
bonus: 0,
totalPay,
socialEmp: social.socialEmp,
housingEmp: housing.housingEmp,
tax: monthTax,
netPay,
ytdIncome,
ytdTaxDeducted,
ytdSocialEmp,
ytdHousingEmp,
status: 'PUBLISHED',
publishedAt: new Date(`${monthStr}-10T10:00:00Z`),
confirmedAt: new Date(`${monthStr}-12T10:00:00Z`),
},
})
}
console.log(` ${emp.name}: 1-6月工资条已生成`)
}
console.log('\n===== 示例数据创建完成 =====')
console.log(`企业: ${org.name}`)
console.log(`管理员: 13800000001 / 密码: 12345678`)
console.log(`员工: ${EMPLOYEES.length}`)
console.log('社保配置: 上海标准')
console.log('薪酬模版: 10项预置')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+150
View File
@@ -0,0 +1,150 @@
/**
* 一次性迁移脚本:为现有员工创建初始版本记录
* 运行方式:npx tsx scripts/migrate-records.ts
*/
import prisma from '../src/lib/prisma.js'
import { decrypt } from '../src/lib/crypto.js'
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
async function main() {
const employees = await prisma.employee.findMany({
include: {
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
salaryChanges: { orderBy: { createdAt: 'desc' }, take: 1 },
socialInsRecords: { take: 1 },
housingFundRecords: { take: 1 },
departmentRecords: { take: 1 },
},
})
console.log(`Found ${employees.length} employees to migrate`)
for (const emp of employees) {
const hireMonth = dateToMonth(emp.hireDate)
const termination = emp.terminations[0]
const endMonth = termination ? dateToMonth(termination.terminationDate) : null
// 解密月薪获取数值
let salaryNum = 0
try {
salaryNum = parseFloat(decrypt(emp.monthlySalary)) || 0
} catch {
salaryNum = parseFloat(emp.monthlySalary) || 0
}
const socialInsBase = emp.socialInsBase ?? salaryNum
const housingFundBase = emp.housingFundBase ?? salaryNum
// 1. 社保缴费记录(仅当尚无记录时创建)
if (emp.socialInsRecords.length === 0) {
await prisma.employeeSocialInsRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.socialInsStartMonth || hireMonth,
endMonth: endMonth || emp.socialInsEndMonth || null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 2. 公积金缴费记录
if (emp.housingFundRecords.length === 0) {
await prisma.employeeHousingFundRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.housingFundStartMonth || hireMonth,
endMonth: endMonth || emp.housingFundEndMonth || null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 3. 薪资变更记录(仅当尚无记录时创建)
if (emp.salaryChanges.length === 0) {
await prisma.salaryChangeRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: emp.hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
} else {
// 已有记录但缺少 effectiveMonth/endMonth/changeType,补充
const latest = emp.salaryChanges[0]
if (!latest.effectiveMonth || !latest.changeType) {
await prisma.salaryChangeRecord.update({
where: { id: latest.id },
data: {
effectiveMonth: dateToMonth(latest.effectiveDate),
changeType: latest.changeType || 'SALARY_CHANGE',
},
})
}
}
// 4. 部门变更记录
if (emp.departmentRecords.length === 0) {
await prisma.employeeDepartmentRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldDepartment: '',
newDepartment: emp.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 5. 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsStartMonth: emp.socialInsStartMonth || hireMonth,
socialInsEndMonth: endMonth || emp.socialInsEndMonth || null,
socialInsBase,
housingFundStartMonth: emp.housingFundStartMonth || hireMonth,
housingFundEndMonth: endMonth || emp.housingFundEndMonth || null,
housingFundBase,
},
})
console.log(`${emp.name} (${emp.department}) — records created/synced`)
}
console.log('\nMigration complete!')
}
main()
.catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+68
View File
@@ -0,0 +1,68 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import morgan from 'morgan'
import compression from 'compression'
import { errorHandler } from './middleware/errorHandler'
import { apiLimiter } from './middleware/rateLimit'
const app = express()
app.use(helmet())
app.use(compression())
app.use(
cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
}),
)
app.use(express.json())
app.use(morgan('dev'))
app.get('/health', (_req, res) => {
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
})
app.use('/api/v1', apiLimiter)
// 路由挂载
import authRoutes from './routes/auth.routes'
import dashboardRoutes from './routes/dashboard.routes'
import employeeRoutes from './routes/employee.routes'
import terminationRoutes from './routes/termination.routes'
import aiRoutes from './routes/ai.routes'
import portalRoutes from './routes/portal.routes'
import settingsRoutes from './routes/settings.routes'
import payrollRoutes from './routes/payroll.routes'
import payroll2Routes from './routes/payroll2.routes'
import socialRoutes from './routes/social.routes'
import notificationRoutes from './routes/notification.routes'
import attachmentRoutes from './routes/attachment.routes'
import rosterRoutes from './routes/roster.routes'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
app.use('/api/v1/termination', terminationRoutes)
app.use('/api/v1/ai', aiRoutes)
app.use('/api/v1/portal', portalRoutes)
app.use('/api/v1/settings', settingsRoutes)
app.use('/api/v1/payroll', payrollRoutes)
app.use('/api/v1/payroll2', payroll2Routes)
app.use('/api/v1/social', socialRoutes)
app.use('/api/v1/notifications', notificationRoutes)
app.use('/api/v1/attachments', attachmentRoutes)
app.use('/api/v1/roster', rosterRoutes)
app.use('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动)
import { seedKnowledgeBase } from './services/rag.service'
seedKnowledgeBase().catch((err) => {
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
})
export default app
+7
View File
@@ -0,0 +1,7 @@
import app from './app'
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
+26
View File
@@ -0,0 +1,26 @@
import crypto from 'crypto'
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-32-byte-encryption-key!!'
const ALGORITHM = 'aes-256-cbc'
const KEY = Buffer.from(ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32), 'utf8')
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return iv.toString('hex') + ':' + encrypted
}
export function decrypt(encryptedText: string): string {
const [ivHex, encrypted] = encryptedText.split(':')
const iv = Buffer.from(ivHex, 'hex')
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
export function sha256(text: string): string {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
+28
View File
@@ -0,0 +1,28 @@
import jwt from 'jsonwebtoken'
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'
export function signAccessToken(payload: { id: string; orgId: string; role: string }): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '2h' })
}
export function signRefreshToken(payload: { id: string; orgId: string; role: string }): string {
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' })
}
export function verifyAccessToken(token: string): { id: string; orgId: string; role: string } | null {
try {
return jwt.verify(token, JWT_SECRET) as { id: string; orgId: string; role: string }
} catch {
return null
}
}
export function verifyRefreshToken(token: string): { id: string; orgId: string; role: string } | null {
try {
return jwt.verify(token, JWT_REFRESH_SECRET) as { id: string; orgId: string; role: string }
} catch {
return null
}
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
+27
View File
@@ -0,0 +1,27 @@
import { AuthRequest } from './auth'
import prisma from '../lib/prisma'
export async function auditLog(
req: AuthRequest,
action: string,
entity: string,
entityId?: string,
detail?: Record<string, unknown>,
) {
if (!req.user) return
try {
await prisma.auditLog.create({
data: {
orgId: req.user.orgId,
userId: req.user.id,
action,
entity,
entityId,
detail: detail ? JSON.parse(JSON.stringify(detail)) : undefined,
ip: req.ip,
},
})
} catch (err) {
console.error('Audit log error:', err)
}
}
+28
View File
@@ -0,0 +1,28 @@
import { Request, Response, NextFunction } from 'express'
import { verifyAccessToken } from '../lib/jwt'
export interface AuthRequest extends Request {
user?: { id: string; orgId: string; role: string }
orgId?: string
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
}
const token = authHeader.substring(7)
const payload = verifyAccessToken(token)
if (!payload) {
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
}
req.user = payload
next()
}
export function orgFilterMiddleware(req: AuthRequest, _res: Response, next: NextFunction) {
if (req.user) {
req.orgId = req.user.orgId
}
next()
}
+37
View File
@@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from 'express'
import { ZodError } from 'zod'
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (err instanceof ZodError) {
return res.status(422).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '输入校验失败',
details: err.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
},
})
}
if (err instanceof PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
return res.status(400).json({
success: false,
error: { code: 'DUPLICATE', message: '数据已存在,请勿重复操作' },
})
}
if (err.code === 'P2025') {
return res.status(404).json({
success: false,
error: { code: 'NOT_FOUND', message: '记录不存在' },
})
}
}
console.error('Unhandled error:', err)
return res.status(500).json({
success: false,
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
})
}
+19
View File
@@ -0,0 +1,19 @@
import rateLimit from 'express-rate-limit'
export const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { success: false, error: { code: 'RATE_LIMIT', message: '操作过于频繁,请稍后再试' } },
})
export const loginLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
message: { success: false, error: { code: 'RATE_LIMIT', message: '登录尝试过于频繁,请稍后再试' } },
})
export const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
message: { success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请稍后再试' } },
})
+401
View File
@@ -0,0 +1,401 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
FREE: { chat: 10, review: 3, case: 3 },
PRO: { chat: 100, review: 20, case: 20 },
ENTERPRISE: { chat: 0, review: 0, case: 0 },
}
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
const limit = limits[type]
if (limit === 0) return
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const count = await prisma.auditLog.count({
where: {
orgId,
action: `AI_${type.toUpperCase()}`,
createdAt: { gte: monthStart },
},
})
if (count >= limit) {
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
}
}
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const month = new Date().toISOString().slice(0, 7)
await prisma.auditLog.create({
data: {
orgId,
userId,
action: `AI_${type.toUpperCase()}`,
entity: 'AI',
entityId: null,
detail: { month, type } as any,
ip: '',
},
})
}
async function buildOrgContext(orgId: string): Promise<string> {
const [employees, risks] = await Promise.all([
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
}),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
include: { employee: true },
}),
])
const now = new Date()
const empSummary = employees.map((e) => {
const contract = e.contracts[0]
const daysToExpire = contract?.endDate
? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null
const specialStatus: string[] = []
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
if (e.isWorkInjured) specialStatus.push('工伤')
return `- ${e.name}${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}${contract ? `合同:${contract.contractType}${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
}).join('\n')
const riskSummary = risks.map((r) => `- ${r.title}${r.level}):${r.description || '无详细描述'}`).join('\n')
return `员工列表(${employees.length}人):
${empSummary}
当前风险项(${risks.length}项):
${riskSummary}`
}
router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
const reply = await chat(messages, orgContext)
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
res.json({ success: true, data: { reply } })
} catch (err) {
next(err)
}
})
router.post('/chat-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
let usageRecorded = false
try {
for await (const delta of chatStream(messages, orgContext)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
}
res.write('data: [DONE]\n\n')
} finally {
if (!usageRecorded) {
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
usageRecorded = true
}
}
res.end()
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractText } = req.body as { contractText: string }
if (!contractText) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
}
await checkUsageLimit(req.user!.orgId, 'review')
const result = await reviewContract(contractText)
await recordUsage(req.user!.orgId, req.user!.id, 'review')
res.json({ success: true, data: { text: result.text, structured: result.structured } })
} catch (err) {
next(err)
}
})
router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { scenario } = req.body as { scenario: string }
if (!scenario) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
}
await checkUsageLimit(req.user!.orgId, 'case')
const result = await matchCase(scenario)
await recordUsage(req.user!.orgId, req.user!.id, 'case')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// 案例匹配结果转待办(RiskItem)
router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'),
type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'),
})
const data = schema.parse(req.body)
const risk = await prisma.riskItem.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
title: data.title,
description: data.description,
level: data.level,
type: data.type,
status: 'PENDING',
},
})
res.json({ success: true, data: risk })
} catch (err) {
next(err)
}
})
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const department = req.query.department as string
const employeeId = req.query.employeeId as string
const riskType = req.query.riskType as string
let orgContext = await buildOrgContext(req.user!.orgId)
if (employeeId) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
if (emp) {
const contract = emp.contracts[0]
orgContext = `员工详情:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
}
} else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
}
if (riskType && riskType !== 'all') {
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
}
const result = await predictRisks(orgContext)
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// ========== AI 会话历史 ==========
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conversations = await prisma.aIConversation.findMany({
where: { orgId: req.user!.orgId, userId: req.user!.id },
orderBy: { updatedAt: 'desc' },
take: 50,
select: { id: true, title: true, createdAt: true, updatedAt: true },
})
res.json({ success: true, data: conversations })
} catch (err) {
next(err)
}
})
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages: any[] }
const conv = await prisma.aIConversation.create({
data: {
orgId: req.user!.orgId,
userId: req.user!.id,
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
messages: messages || [],
},
})
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages?: any[] }
const conv = await prisma.aIConversation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
data: {
...(title ? { title } : {}),
...(messages ? { messages } : {}),
},
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== AI 审查记录保存到员工档案 ==========
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string(),
type: z.enum(['REVIEW', 'CASE']),
input: z.string(),
result: z.string(),
})
const data = schema.parse(req.body)
const record = await prisma.aIReviewRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
type: data.type,
input: data.input,
result: data.result,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.aIReviewRecord.findMany({
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
orderBy: { createdAt: 'desc' },
take: 20,
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (_req: AuthRequest, res, next) => {
try {
await seedKnowledgeBase()
res.json({ success: true, data: { message: '知识库初始化完成' } })
} catch (err) {
next(err)
}
})
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, content, source, category } = req.body
if (!title || !content) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
}
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { query, topK } = req.body
if (!query) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
}
const results = await searchKnowledge(query, topK || 5)
res.json({ success: true, data: { results } })
} catch (err) {
next(err)
}
})
// 知识库列表
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
const category = req.query.category as string | undefined
const items = category
? await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge WHERE category = ${category} ORDER BY created_at DESC LIMIT 200` as any[]
: await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge ORDER BY created_at DESC LIMIT 200` as any[]
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 删除知识条目
router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+63
View File
@@ -0,0 +1,63 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
// 获取员工附件列表
router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const attachments = await prisma.employeeAttachment.findMany({
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: attachments })
} catch (err) {
next(err)
}
})
// 添加附件记录(文件URL由前端上传后传入)
const attachmentSchema = z.object({
employeeId: z.string().min(1),
fileName: z.string().min(1),
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']),
fileUrl: z.string().min(1),
fileSize: z.number().int().default(0),
})
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = attachmentSchema.parse(req.body)
const attachment = await prisma.employeeAttachment.create({
data: {
orgId: req.user!.orgId,
...data,
uploadedBy: req.user!.id,
},
})
res.json({ success: true, data: attachment })
} catch (err) {
next(err)
}
})
// 删除附件
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const attachment = await prisma.employeeAttachment.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!attachment) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
}
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+91
View File
@@ -0,0 +1,91 @@
import { Router } from 'express'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
import { register, login, refresh, resetPassword } from '../services/auth.service'
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
router.post('/register', authLimiter, async (req, res, next) => {
try {
const data = registerSchema.parse(req.body)
const result = await register(data.orgName, data.phone, data.password)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/login', loginLimiter, async (req, res, next) => {
try {
const data = loginSchema.parse(req.body)
const result = await login(data.phone, data.password)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/refresh', async (req, res, next) => {
try {
const data = refreshSchema.parse(req.body)
const result = await refresh(data.refreshToken)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 发送重置验证码
router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => {
try {
const data = forgotPasswordSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { phone: data.phone } })
if (!user) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未注册' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
}
})
// 验证码重置密码
router.post('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.code !== data.code) {
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
}
codeStore.delete(data.phone)
const passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
data: { passwordHash },
})
res.json({ success: true, data: { message: '密码重置成功' } })
} catch (err) {
next(err)
}
})
router.post('/reset-password', authLimiter, async (req, res, next) => {
try {
const data = resetPasswordSchema.parse(req.body)
const result = await resetPassword(data.phone, data.newPassword)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+80
View File
@@ -0,0 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = await getDashboardData(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 标记待办为已完成
router.patch('/todos/:id/resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.riskItem.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
if (item.count === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
}
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 忽略待办
router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.riskItem.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
if (item.count === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
}
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 批量标记待办为已完成
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
// 批量忽略待办
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
export default router
+200
View File
@@ -0,0 +1,200 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
updateEmployeeSchema,
batchRenewSchema,
addContractSchema,
} from '../schemas/contract.schema'
import {
getEmployees,
getEmployeeDetail,
createEmployee,
rehireEmployee,
updateEmployee,
deleteEmployee,
batchRenew,
addContract,
} from '../services/contract.service'
const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getEmployees(req.user!.orgId, {
page: parseInt(req.query.page as string) || 1,
pageSize: parseInt(req.query.pageSize as string) || 20,
search: req.query.search as string,
department: req.query.department as string,
})
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: employee })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = createEmployeeSchema.parse(req.body)
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = updateEmployeeSchema.parse(req.body)
const result = await updateEmployee(req.user!.orgId, req.params.id, data)
await auditLog(req, 'UPDATE', 'EMPLOYEE', req.params.id, data)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/:id/rehire', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await rehireEmployee(req.user!.orgId, req.user!.id, req.params.id, req.body)
await auditLog(req, 'REHIRE', 'EMPLOYEE', req.params.id, { hireDate: req.body.hireDate })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
if (err?.code === 'VALIDATION_ERROR') {
return res.status(400).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await deleteEmployee(req.user!.orgId, req.params.id)
await auditLog(req, 'DELETE', 'EMPLOYEE', req.params.id)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 批量续签合规预检
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractIds } = req.body as { contractIds: string[] }
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
}
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId: req.user!.orgId },
include: { employee: true },
orderBy: { startDate: 'asc' },
})
if (contracts.length === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
}
// 合规检查:按员工分组,检查历史固定期合同次数
const results = []
for (const contract of contracts) {
const employee = contract.employee
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: contract.employeeId,
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
// 2. 员工连续工作满10年
const shouldBeUnfixed = renewalCount >= 2
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
let warning: string | null = null
let suggestion: string | null = null
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
warning = shouldBeUnfixed
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
} else {
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
}
results.push({
contractId: contract.id,
employeeId: contract.employeeId,
employeeName: employee.name,
department: employee.department,
currentContractType: contract.contractType,
renewalCount,
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
warning,
suggestion,
canRenewFixed: !warning,
})
}
res.json({
success: true,
data: {
total: results.length,
warnings: results.filter((r) => r.warning).length,
results,
},
})
} catch (err) {
next(err)
}
})
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = batchRenewSchema.parse(req.body)
const result = await batchRenew(req.user!.orgId, req.user!.id, data.contractIds, data.years)
await auditLog(req, 'BATCH_RENEW', 'CONTRACT', undefined, { count: data.contractIds.length })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = addContractSchema.parse(req.body)
const result = await addContract(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
export default router
+243
View File
@@ -0,0 +1,243 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import ExcelJS from 'exceljs'
import { createGzip } from 'zlib'
import { Writable } from 'stream'
const router = Router()
// 敏感字段脱敏
function maskIdCard(idCard: string | null): string | null {
if (!idCard) return null
if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4)
return idCard
}
function maskBankAccount(account: string | null): string | null {
if (!account) return null
if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4)
return account
}
// 导出全部数据(支持模块选择、格式选择、脱敏)
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const format = (req.query.format as string) || 'json'
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
const fetchMap: Record<string, () => Promise<any>> = {
employees: () => prisma.employee.findMany({ where: { orgId } }),
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
}
const useGzip = req.query.gzip !== 'false'
const batchSize = 500
if (format === 'excel') {
const data: any = { exportedAt: new Date().toISOString(), orgId }
if (modules.includes('employees')) {
const employees = await fetchMap.employees()
data.employees = employees.map((e: any) => {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
})
}
for (const mod of modules) {
if (mod === 'employees') continue
if (fetchMap[mod]) {
data[mod] = await fetchMap[mod]()
}
}
const workbook = new ExcelJS.Workbook()
for (const mod of modules) {
if (!data[mod] || !data[mod].length) continue
const ws = workbook.addWorksheet(mod.slice(0, 31))
const rows = data[mod]
const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
ws.columns = keys.map(k => ({ header: k, key: k, width: 18 }))
ws.getRow(1).font = { bold: true }
for (const row of rows) {
const flat: any = {}
for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]
ws.addRow(flat)
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} else {
// JSON 流式导出 + gzip 压缩
if (useGzip) {
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
}
const gzip = useGzip ? createGzip() : null
const output: Writable = gzip || res
if (gzip) { gzip.pipe(res) }
const write = (chunk: string) => {
output.write(Buffer.from(chunk))
}
write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"')
for (const mod of modules) {
write(',"' + mod + '":[')
if (mod === 'employees') {
// 员工数据分批查询,避免内存溢出
let skip = 0
let first = true
while (true) {
const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize })
if (batch.length === 0) break
for (const e of batch) {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
write((first ? '' : ',') + JSON.stringify(row))
first = false
}
skip += batchSize
if (batch.length < batchSize) break
}
} else if (fetchMap[mod]) {
const rows = await fetchMap[mod]()
for (let i = 0; i < rows.length; i++) {
write((i === 0 ? '' : ',') + JSON.stringify(rows[i]))
}
}
write(']')
}
write('}')
if (gzip) gzip.end()
else res.end()
}
} catch (err) {
next(err)
}
})
// 导出本月薪税汇总 Excel
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const entries = await prisma.batchEntry.findMany({
where: { orgId, batch: { month, status: 'ARCHIVED' } },
include: { employee: true, batch: true },
orderBy: { employee: { name: 'asc' } },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('薪税汇总')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '基本工资', key: 'baseSalary', width: 12 },
{ header: '加班费', key: 'overtimePay', width: 12 },
{ header: '津贴补贴', key: 'allowance', width: 12 },
{ header: '奖金', key: 'bonus', width: 12 },
{ header: '扣款', key: 'deduction', width: 12 },
{ header: '应发合计', key: 'totalPay', width: 12 },
{ header: '个人社保', key: 'socialEmp', width: 12 },
{ header: '个人公积金', key: 'housingEmp', width: 12 },
{ header: '个人所得税', key: 'tax', width: 12 },
{ header: '实发工资', key: 'netPay', width: 12 },
{ header: '企业社保', key: 'socialOrg', width: 12 },
{ header: '企业公积金', key: 'housingOrg', width: 12 },
{ header: '企业总成本', key: 'orgCost', width: 12 },
]
ws.getRow(1).font = { bold: true }
for (const e of entries) {
ws.addRow({
name: e.employee.name,
department: e.employee.department,
baseSalary: e.baseSalary,
overtimePay: e.overtimePay,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
totalPay: e.totalPay,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
netPay: e.netPay,
socialOrg: e.socialOrg,
housingOrg: e.housingOrg,
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
})
}
// 汇总行
const totalRow = ws.addRow({
name: '合计',
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
tax: { formula: `SUM(K2:K${entries.length + 1})` },
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
})
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
export default router
+608
View File
@@ -0,0 +1,608 @@
import { Router, Response } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 身份证号格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true }
const s = idCard.trim()
// 15位身份证号升级为18位
if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s)
return { valid: true, upgraded }
}
if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '身份证号格式错误(应为18位)' }
}
// 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '身份证号校验位错误' }
}
return { valid: true }
}
function upgrade15To18(s15: string): string {
const born = '19' + s15.substring(6, 12)
const body = s15.substring(0, 6) + born + s15.substring(12)
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
return body + checkCodes[sum % 11]
}
// 社保基数范围校验
const SOCIAL_INS_LIMITS: Record<string, { min: number; max: number }> = {
'北京': { min: 6326, max: 33891 },
'上海': { min: 7310, max: 36549 },
'广州': { min: 5284, max: 27501 },
'深圳': { min: 3523, max: 27501 },
'杭州': { min: 4812, max: 24060 },
}
function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } {
if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true }
const limits = SOCIAL_INS_LIMITS[city]
if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` }
if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` }
return { valid: true }
}
function dateToMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function parseDate(v: any): Date | null {
if (!v) return null
if (v instanceof Date) return v
if (typeof v === 'number') {
const d = XLSX.SSF.parse_date_code(v)
if (d) return new Date(d.y, d.m - 1, d.d)
}
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
return null
}
function val(v: any): string {
if (v == null) return ''
return String(v).trim()
}
function num(v: any): number {
const n = Number(v)
return isNaN(n) ? 0 : n
}
// ========== 导入预览(不写入数据库) ==========
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') }
if (row.idCard) {
const idCheck = validateIdCard(row.idCard)
if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) }
if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') }
}
if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors })
preview.employees.push(row)
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const sd = parseDate(r['合同开始日期'])
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
preview.contracts.push(row)
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const otType = val(r['加班类型']) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
preview.overtime.push(row)
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(r['描述']), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row)
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
preview.attendance.push(row)
}
}
const summary = {
totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length,
normalRows: 0,
warningRows: 0,
errorRows: preview.errors.length,
sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')),
}
summary.normalRows = summary.totalRows - summary.errorRows
preview.summary = summary
res.json({ success: true, data: preview })
} catch (err) {
next(err)
}
})
// ========== 错误日志导出 ==========
router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const { errors } = req.body as { errors: any[] }
if (!errors || !errors.length) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } })
}
const data = errors.map(e => ({
'Sheet': e.sheet || '',
'行号': e.row || '',
'员工姓名': e.name || '',
'错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''),
}))
const ws = XLSX.utils.json_to_sheet(data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
res.send(buf)
} catch (err) {
next(err)
}
})
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(r['姓名'])
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
const dept = val(r['部门']) || '未分配'
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
const salary = String(num(r['月工资']))
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
let idCard = val(r['身份证号'])
if (idCard) {
const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
if (idCheck.upgraded) idCard = idCheck.upgraded
}
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
gender: val(r['性别']) || null,
phone: val(r['手机号']) || null,
idCardNumber: idCard ? encrypt(idCard) : null,
idCardHash: idCard ? sha256(idCard) : null,
emergencyContact: val(r['紧急联系人']) || null,
emergencyPhone: val(r['紧急联系电话']) || null,
address: val(r['住址']) || null,
bankName: val(r['开户行']) || null,
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
result.employees++
} catch (e: any) {
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const startDate = parseDate(r['合同开始日期'])
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(r['签订日期']) || null,
startDate,
endDate: parseDate(r['合同结束日期']) || null,
contractType,
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(r['合同年限']) || 3,
probationMonths: num(r['试用期月数']) || 0,
probationSalary: num(r['试用期工资']) || 0,
createdBy: userId,
},
})
result.contracts++
}
} catch (e: any) {
result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const month = dateToMonth(date)
const otType = val(r['加班类型']) || '工作日加班'
const hours = num(r['加班时长'])
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
result.overtime++
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
result.disciplinary++
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
result.attendance++
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
const contractData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.send(buf)
})
// ========== 月度导入 ==========
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const month = val(req.body.month) || dateToMonth(new Date())
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
function findEmp(r: any) {
const idCard = val(r['身份证号'])
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(r['姓名']))
}
// 考勤记录
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
if (salarySheet) {
const rows = XLSX.utils.sheet_to_json(salarySheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const newSalary = num(r['调整后月薪'])
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
const effMonth = dateToMonth(effDate)
let oldSalary = 0
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
result.salaryChanges++
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 社保增减员
const socialSheet = wb.Sheets['社保变动']
if (socialSheet) {
const rows = XLSX.utils.sheet_to_json(socialSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
const city = val(r['城市']) || '北京'
if (changeType === '增员' || changeType === '调基') {
const baseCheck = validateSocialBase(base, city)
if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`)
// 关闭之前有效记录
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
}
result.socialInsChanges++
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 公积金增减员
const hfSheet = wb.Sheets['公积金变动']
if (hfSheet) {
const rows = XLSX.utils.sheet_to_json(hfSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
}
result.housingFundChanges++
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.send(buf)
})
export default router
+169
View File
@@ -0,0 +1,169 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
// 获取通知设置
router.get('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let setting = await prisma.notificationSetting.findUnique({
where: { orgId: req.user!.orgId },
})
if (!setting) {
setting = await prisma.notificationSetting.create({
data: { orgId: req.user!.orgId },
})
}
res.json({ success: true, data: setting })
} catch (err) {
next(err)
}
})
// 更新通知设置
const settingSchema = z.object({
contractExpiry: z.boolean().optional(),
expiryDays: z.number().int().min(1).max(365).optional(),
contractUnsigned: z.boolean().optional(),
overtimeAlert: z.boolean().optional(),
payslipReady: z.boolean().optional(),
payrollDay: z.number().int().min(1).max(28).optional(),
socialInsDay: z.number().int().min(1).max(28).optional(),
housingFundDay: z.number().int().min(1).max(28).optional(),
taxDay: z.number().int().min(1).max(28).optional(),
wechatWebhook: z.string().url().nullable().optional(),
emailNotify: z.boolean().optional(),
email: z.string().email().nullable().optional(),
})
router.put('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = settingSchema.parse(req.body)
const setting = await prisma.notificationSetting.upsert({
where: { orgId: req.user!.orgId },
update: data,
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: setting })
} catch (err) {
next(err)
}
})
// 获取通知列表
router.get('/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const [logs, total] = await Promise.all([
prisma.notificationLog.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.notificationLog.count({ where: { orgId: req.user!.orgId } }),
])
res.json({ success: true, data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
} catch (err) {
next(err)
}
})
// 手动触发合同到期检查
router.post('/check-contracts', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const setting = await prisma.notificationSetting.findUnique({
where: { orgId: req.user!.orgId },
})
const expiryDays = setting?.expiryDays || 30
const now = new Date()
const threshold = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000)
const contracts = await prisma.laborContract.findMany({
where: {
orgId: req.user!.orgId,
endDate: { lte: threshold, gte: now },
},
include: { employee: { select: { id: true, name: true, department: true } } },
})
const logs: any[] = []
for (const contract of contracts) {
const daysLeft = Math.ceil((contract.endDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const title = `${contract.employee.name}的合同将在${daysLeft}天后到期`
const content = `员工 ${contract.employee.name}${contract.employee.department})的合同将于 ${contract.endDate!.toISOString().slice(0, 10)} 到期,请及时处理续签或终止事宜。`
const log = await prisma.notificationLog.create({
data: {
orgId: req.user!.orgId,
type: 'CONTRACT_EXPIRY',
title,
content,
channel: 'IN_APP',
employeeId: contract.employeeId,
},
})
logs.push(log)
if (setting?.wechatWebhook) {
try {
await fetch(setting.wechatWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'text',
text: { content: `【合同到期提醒】${title}\n${content}` },
}),
})
} catch (e) {
// webhook 发送失败不阻断流程
}
}
}
res.json({ success: true, data: { checked: contracts.length, notified: logs.length } })
} catch (err) {
next(err)
}
})
// 测试通知渠道
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { channel } = req.body as { channel: 'wechat' | 'email' }
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
if (channel === 'wechat') {
if (!setting.wechatWebhook) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置企业微信 Webhook' } })
try {
const resp = await fetch(setting.wechatWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: '【测试消息】通知渠道连接正常,配置有效。' } }),
})
const data = await resp.json() as any
if (data.errcode && data.errcode !== 0) {
return res.json({ success: false, error: { code: 'TEST_FAILED', message: `Webhook 返回错误: ${data.errmsg || data.errcode}` } })
}
res.json({ success: true, data: { message: '测试消息已发送到企业微信' } })
} catch (e: any) {
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
}
} else if (channel === 'email') {
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
// 邮件发送(开发阶段仅返回成功)
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
} else {
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
}
} catch (err) {
next(err)
}
})
export default router
+577
View File
@@ -0,0 +1,577 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
// ========== 加班费记录 ==========
const overtimeSchema = z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
monthlyWage: z.number().positive(),
weekdayHours: z.number().min(0).default(0),
weekendHours: z.number().min(0).default(0),
holidayHours: z.number().min(0).default(0),
})
// 获取加班费记录列表
router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month } = req.query
const records = await prisma.overtimeRecord.findMany({
where: {
orgId: req.user!.orgId,
...(employeeId ? { employeeId: String(employeeId) } : {}),
...(month ? { month: String(month) } : {}),
},
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// 保存加班费记录
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeSchema.parse(req.body)
const hourlyWage = data.monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
const weekendPay = hourlyWage * 2.0 * data.weekendHours
const holidayPay = hourlyWage * 3.0 * data.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.upsert({
where: {
employeeId_month: { employeeId: data.employeeId, month: data.month },
},
update: {
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 更新加班记录(按ID
const overtimeUpdateSchema = z.object({
weekdayHours: z.number().min(0).optional(),
weekendHours: z.number().min(0).optional(),
holidayHours: z.number().min(0).optional(),
monthlyWage: z.number().positive().optional(),
})
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const data = overtimeUpdateSchema.parse(req.body)
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
if (!existing) {
res.status(404).json({ success: false, message: '记录不存在' })
return
}
const monthlyWage = data.monthlyWage ?? 0
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
const weekendHours = data.weekendHours ?? existing.weekendHours
const holidayHours = data.holidayHours ?? existing.holidayHours
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.update({
where: { id },
data: {
weekdayHours,
weekendHours,
holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
baseSalary: z.number().min(0).default(0),
overtimePay: z.number().min(0).default(0),
weekdayOvertimePay: z.number().min(0).default(0),
weekendOvertimePay: z.number().min(0).default(0),
holidayOvertimePay: z.number().min(0).default(0),
allowance: z.number().min(0).default(0),
deduction: z.number().min(0).default(0),
})
// 获取工资条列表
router.get('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, employeeId } = req.query
const payslips = await prisma.payslip.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(employeeId ? { employeeId: String(employeeId) } : {}),
},
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: [{ month: 'desc' }, { employee: { name: 'asc' } }],
})
res.json({ success: true, data: payslips })
} catch (err) {
next(err)
}
})
// 创建/更新工资条
router.post('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = payslipSchema.parse(req.body)
const totalPay = data.baseSalary + data.overtimePay + data.allowance - data.deduction
const payslip = await prisma.payslip.upsert({
where: {
employeeId_month: { employeeId: data.employeeId, month: data.month },
},
update: {
baseSalary: data.baseSalary,
overtimePay: data.overtimePay,
weekdayOvertimePay: data.weekdayOvertimePay,
weekendOvertimePay: data.weekendOvertimePay,
holidayOvertimePay: data.holidayOvertimePay,
allowance: data.allowance,
deduction: data.deduction,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
baseSalary: data.baseSalary,
overtimePay: data.overtimePay,
weekdayOvertimePay: data.weekdayOvertimePay,
weekendOvertimePay: data.weekendOvertimePay,
holidayOvertimePay: data.holidayOvertimePay,
allowance: data.allowance,
deduction: data.deduction,
totalPay,
},
})
res.json({ success: true, data: payslip })
} catch (err) {
next(err)
}
})
// 从加班费记录自动生成工资条
router.post('/payslip/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, employeeId, baseSalary, allowance, deduction } = req.body as {
month: string
employeeId: string
baseSalary: number
allowance?: number
deduction?: number
}
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId, month } },
})
const overtimePay = overtime?.totalPay || 0
const totalPay = baseSalary + overtimePay + (allowance || 0) - (deduction || 0)
const payslip = await prisma.payslip.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId,
month,
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
totalPay,
},
})
res.json({ success: true, data: payslip })
} catch (err) {
next(err)
}
})
// 删除工资条
router.delete('/payslip/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.payslip.delete({
where: { id: req.params.id, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== 批量生成工资条 ==========
const batchGenerateSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
allowances: z.record(z.string(), z.number().default(0)).optional(),
deductions: z.record(z.string(), z.number().default(0)).optional(),
})
// 批量生成全员工资条
router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, allowances = {}, deductions = {} } = batchGenerateSchema.parse(req.body)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
})
const results: any[] = []
for (const emp of employees) {
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId: emp.id, month } },
})
const overtimePay = overtime?.totalPay || 0
const allowance = allowances[emp.id] || 0
const deduction = deductions[emp.id] || 0
let baseSalary = 0
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try {
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
} catch {
baseSalary = Number(emp.monthlySalary) || 0
}
}
const totalPay = baseSalary + overtimePay + allowance - deduction
const payslip = await prisma.payslip.upsert({
where: { employeeId_month: { employeeId: emp.id, month } },
update: { baseSalary, overtimePay, allowance, deduction, totalPay },
create: {
orgId: req.user!.orgId,
employeeId: emp.id,
month,
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance,
deduction,
totalPay,
},
})
results.push(payslip)
}
res.json({ success: true, data: { generated: results.length, payslips: results } })
} catch (err) {
next(err)
}
})
// ========== 加班费计算规则配置 ==========
const overtimeConfigSchema = z.object({
weekdayRate: z.number().min(1).default(1.5),
weekendRate: z.number().min(1).default(2.0),
holidayRate: z.number().min(1).default(3.0),
monthlyDays: z.number().min(1).default(21.75),
dailyHours: z.number().min(1).default(8),
})
// 获取加班费计算规则
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
if (!config) {
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 保存加班费计算规则
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeConfigSchema.parse(req.body)
const config = await prisma.overtimeConfig.upsert({
where: { orgId: req.user!.orgId },
update: data,
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// ========== 批量导入加班工时 ==========
const batchOvertimeSchema = z.array(
z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
weekdayHours: z.number().min(0).default(0),
weekendHours: z.number().min(0).default(0),
holidayHours: z.number().min(0).default(0),
}),
)
router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const items = batchOvertimeSchema.parse(req.body)
const results: any[] = []
for (const data of items) {
const record = await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
update: {
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
},
})
results.push(record)
}
res.json({ success: true, data: { imported: results.length } })
} catch (err) {
next(err)
}
})
// ========== 批次导入加班费 ==========
router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
// 获取加班费计算规则
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
// 获取该月未关联批次的加班记录
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, month: batch.month, batchId: null },
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
})
if (overtimeRecords.length === 0) {
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
}
const results: any[] = []
for (const ot of overtimeRecords) {
// 获取员工月工资
let monthlyWage = 0
try {
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
} catch {
monthlyWage = Number(ot.employee.monthlySalary) || 0
}
if (!monthlyWage) continue
// 根据规则计算加班费
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
// 更新加班记录:计算金额并锁定到批次
await prisma.overtimeRecord.update({
where: { id: ot.id },
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
})
// 更新批次条目的加班费
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
})
if (entry) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { overtimePay: totalPay },
})
// 重新计算条目
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
await prisma.batchEntry.update({
where: { id: entry.id },
data: { totalPay: newTotalPay },
})
}
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
}
res.json({ success: true, data: { imported: results.length, details: results } })
} catch (err) {
next(err)
}
})
// ========== 税率试算 ==========
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
const orgId = req.user!.orgId
// 获取员工和配置
const [employee, socialConfig, housingConfig] = await Promise.all([
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
const socialBase = emp.socialInsBase || baseSalary
const housingBase = emp.housingFundBase || baseSalary
// 计算社保公积金
let socialEmp = 0, housingEmp = 0
if (socialConfig) {
const { calcSocialInsurance } = await import('../services/payroll.service')
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
}
if (housingConfig) {
const { calcHousingFund } = await import('../services/payroll.service')
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
}
// 获取 YTD 数据计算累计个税
const year = month.slice(0, 4)
const ytdPayslips = employeeId
? await prisma.payslip.findMany({
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
orderBy: { month: 'asc' },
})
: []
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
const { calcCumulativeTax } = await import('../services/payroll.service')
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
const netPay = totalPay - socialEmp - housingEmp - tax
res.json({
success: true,
data: {
baseSalary: baseSalary || 0,
overtimePay: overtimePay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
bonus: bonus || 0,
totalPay,
socialEmp,
housingEmp,
specialDeduction: specialDeduction || 0,
taxableIncome,
estimatedTax: tax,
netPay,
ytdPayslipCount: ytdPayslips.length,
breakdown: [
{ label: '应发合计', value: totalPay },
{ label: '个人社保', value: -socialEmp },
{ label: '个人公积金', value: -housingEmp },
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
{ label: '应纳税所得额', value: taxableIncome },
{ label: '当月个税', value: -tax },
{ label: '实发工资', value: netPay },
],
},
})
} catch (err) {
next(err)
}
})
export default router
+673
View File
@@ -0,0 +1,673 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
import {
getTemplate,
calcBatchEntry,
getPayrollRiskWarnings,
generatePayslipFromBatches,
} from '../services/payroll.service'
const router = Router()
router.use(authMiddleware)
// ========== 薪酬模版 ==========
// 获取薪酬模版
router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const items = await getTemplate(req.user!.orgId)
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 更新薪酬模版项
const updateTemplateItemSchema = z.object({
name: z.string().min(1).optional(),
formula: z.string().nullable().optional(),
order: z.number().int().optional(),
isEditable: z.boolean().optional(),
})
router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = updateTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
const updateData: any = {}
if (data.name !== undefined && !item.isDefault) updateData.name = data.name
if (data.formula !== undefined) updateData.formula = data.formula
if (data.order !== undefined) updateData.order = data.order
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 新增薪酬模版项
const createTemplateItemSchema = z.object({
name: z.string().min(1),
code: z.string().min(1),
type: z.enum(['INPUT', 'CALCULATED']),
formula: z.string().nullable().optional(),
order: z.number().int().default(99),
isEditable: z.boolean().default(true),
})
router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.create({
data: { ...data, orgId: req.user!.orgId, isDefault: false },
})
res.json({ success: true, data: item })
} catch (err) {
next(err)
}
})
// 删除薪酬模版项(仅非预置项)
router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } })
await prisma.payslipItem.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== 发薪批次 ==========
// 检查本月是否已发薪
router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' },
})
const draftBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' },
})
const publishedPayslips = await prisma.payslip.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' },
})
res.json({
success: true,
data: {
hasArchivedBatch: archivedBatches > 0,
archivedCount: archivedBatches,
draftCount: draftBatches,
payslipsPublished: publishedPayslips > 0,
},
})
} catch (err) {
next(err)
}
})
// 获取可复制的归档批次列表
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const batches = await prisma.payrollBatch.findMany({
where: { orgId, status: 'ARCHIVED' },
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
take: 20,
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, monthFrom, monthTo, status, type } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次详情
router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
entries: {
include: {
employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } },
},
orderBy: { employee: { name: 'asc' } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
// 重命名批次
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name } = req.body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
}
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
}
const updated = await prisma.payrollBatch.update({
where: { id: req.params.id },
data: { name: name.trim() },
})
res.json({ success: true, data: { id: updated.id, name: updated.name } })
} catch (err) {
next(err)
}
})
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
sourceBatchId: z.string().optional(),
name: z.string().optional(),
remark: z.string().optional(),
})
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月已有批次数
const existingBatches = await prisma.payrollBatch.count({
where: { orgId, month },
})
const batchNo = existingBatches + 1
// 获取在职员工 + 本月离职员工
const monthStart = new Date(`${month}-01`)
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
// 获取上月发薪数据
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
const batchName = name || `${month}${batchNo}${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
// 根据模式确定员工列表和数据来源
let employees: any[] = []
let sourceEntries: any[] | null = null
if (mode === 'blank_all') {
// 全空白:不拉入员工
employees = []
} else if (mode === 'copy_batch' && sourceBatchId) {
// 复制指定批次:从源批次复制条目
const sourceBatch = await prisma.payrollBatch.findFirst({
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
include: { entries: true },
})
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
sourceEntries = sourceBatch.entries
// 提取员工 ID,后续按此创建条目
const employeeIds = sourceEntries.map(e => e.employeeId)
employees = await prisma.employee.findMany({
where: { id: { in: employeeIds }, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
} else {
// copy_last 或 blank_employees:拉入员工
if (type === 'TERMINATION' || type === 'SEVERANCE') {
const terminations = await prisma.terminationRecord.findMany({
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
})
employees = terminations.map(t => t.employee)
} else {
employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ status: 'ACTIVE' },
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
],
},
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
}
}
// 创建批次
const batch = await prisma.payrollBatch.create({
data: {
orgId,
month,
batchNo,
name: batchName,
type,
remark,
createdBy: req.user!.id,
employeeCount: employees.length,
},
})
// 创建批次条目
const entries: any[] = []
for (const emp of employees) {
let baseSalary = 0
let overtimePay = 0
let allowance = 0
let deduction = 0
let bonus = 0
if (mode === 'copy_batch' && sourceEntries) {
// 复制指定批次:从源条目复制数据
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
if (srcEntry) {
baseSalary = srcEntry.baseSalary
overtimePay = srcEntry.overtimePay
allowance = srcEntry.allowance
deduction = srcEntry.deduction
bonus = srcEntry.bonus
}
} else if (mode === 'copy_last') {
// 复制上月:从上月工资条复制
const prevPayslip = await prisma.payslip.findUnique({
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
})
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId: emp.id, month } },
})
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
if (prevPayslip) baseSalary = prevPayslip.baseSalary
overtimePay = overtime?.totalPay || 0
allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0
}
// blank_employees 和 blank_all: 所有金额默认 0
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
})
// 计算社保、个税等
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
// 风险提示
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
const entry = await prisma.batchEntry.create({
data: {
batchId: batch.id,
orgId,
employeeId: emp.id,
baseSalary,
overtimePay,
allowance,
deduction,
bonus,
socialEmp: calcResult.socialEmp,
socialOrg: calcResult.socialOrg,
housingEmp: calcResult.housingEmp,
housingOrg: calcResult.housingOrg,
tax: calcResult.tax,
totalPay: calcResult.totalPay,
netPay: calcResult.netPay,
riskWarnings,
},
})
entries.push(entry)
}
// 更新批次汇总
const totals = entries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.totalPay,
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
const updatedBatch = await prisma.payrollBatch.update({
where: { id: batch.id },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
})
res.json({ success: true, data: updatedBatch })
} catch (err) {
next(err)
}
})
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
const updateEntrySchema = z.object({
baseSalary: z.number().min(0).optional(),
overtimePay: z.number().min(0).optional(),
allowance: z.number().min(0).optional(),
deduction: z.number().min(0).optional(),
bonus: z.number().min(0).optional(),
socialEmp: z.number().min(0).optional(),
socialOrg: z.number().min(0).optional(),
housingEmp: z.number().min(0).optional(),
housingOrg: z.number().min(0).optional(),
})
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const data = updateEntrySchema.parse(req.body)
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 合并输入项
const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary,
overtimePay: data.overtimePay ?? entry.overtimePay,
allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus,
}
// 构建社保覆盖参数(如果请求中包含社保字段)
const overrideSocial: any = {}
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...calcResult },
})
// 更新批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 批次增加人员
router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const { employeeIds } = req.body as { employeeIds: string[] }
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const results: any[] = []
for (const employeeId of employeeIds) {
// 检查是否已在批次中
const existing = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (existing) continue
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!emp) continue
let baseSalary = 0
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
}
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId, month: batch.month } },
})
const overtimePay = overtime?.totalPay || 0
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...calcResult, riskWarnings,
},
})
results.push(entry)
}
// 更新批次人数
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true, data: { added: results.length } })
} catch (err) {
next(err)
}
})
// 批次移除人员
router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } })
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 删除批次(仅限草稿状态)
router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } })
await prisma.batchEntry.deleteMany({ where: { batchId } })
await prisma.payrollBatch.delete({ where: { id: batchId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 归档批次
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
await prisma.payrollBatch.update({
where: { id: batchId },
data: { status: 'ARCHIVED', archivedAt: new Date() },
})
res.json({ success: true, data: { archived: true } })
} catch (err) {
next(err)
}
})
// 从已归档批次汇总生成工资条
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.body
const orgId = req.user!.orgId
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM' } })
}
// 检查是否有已归档批次
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED' },
})
if (archivedBatches === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } })
}
const result = await generatePayslipFromBatches(orgId, month)
// 自动标记"生成工资条"待办为已完成
await prisma.riskItem.updateMany({
where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { generated: result.generated } })
} catch (err) {
next(err)
}
})
// 银行代发文件导出(接口预留)
router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const { format = 'csv' } = req.query
const batch = await prisma.payrollBatch.findFirst({
where: { id: batchId, orgId },
include: {
entries: {
include: { employee: { select: { name: true, bankAccount: true, bankName: true } } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status !== 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅归档批次可导出' } })
if (format === 'csv') {
const header = '姓名,银行账号,开户行,实发金额\n'
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
return res.send('\ufeff' + header + rows)
}
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
export default router
+425
View File
@@ -0,0 +1,425 @@
import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
const router = Router()
// 验证码临时存储(生产环境应使用 Redis)
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: number }>()
// 员工端认证中间件
function portalAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
}
const token = authHeader.substring(7)
try {
const payload = verifyAccessToken(token)
if (!payload || payload.role !== 'EMPLOYEE') {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
}
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
next()
} catch {
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
}
}
// 密码登录
router.post('/login', async (req, res, next) => {
try {
const data = portalLoginSchema.parse(req.body)
const employee = await prisma.employee.findFirst({
where: { phone: data.phone, status: 'ACTIVE' },
})
if (!employee || !employee.passwordHash) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
}
const valid = await bcrypt.compare(data.password, employee.passwordHash)
if (!valid) {
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
}
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
} catch (err) {
next(err)
}
})
// 发送验证码(页面内显示)
router.post('/send-code', async (req, res, next) => {
try {
const data = portalSendCodeSchema.parse(req.body)
const employee = await prisma.employee.findFirst({
where: { phone: data.phone, status: 'ACTIVE' },
})
if (!employee) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
}
// 频率限制:60秒内不可重复发送
const existing = codeStore.get(data.phone)
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
}
})
// 验证码登录
router.post('/verify-code', async (req, res, next) => {
try {
const data = portalVerifyCodeSchema.parse(req.body)
const stored = codeStore.get(data.phone)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
// 错误次数限制:5次后锁定
if (stored.failCount >= 5) {
codeStore.delete(data.phone)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.code) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
}
codeStore.delete(data.phone)
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
if (!employee) {
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
} catch (err) {
next(err)
}
})
// 工资条
router.get('/payslip', portalAuth, async (req: any, res, next) => {
try {
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
const payslip = await prisma.payslip.findFirst({
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month },
})
if (!payslip) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: payslip })
} catch (err) {
next(err)
}
})
// 工资条历史(最近6个月)
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
try {
const payslips = await prisma.payslip.findMany({
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
orderBy: { month: 'desc' },
take: 6,
})
res.json({ success: true, data: payslips })
} catch (err) {
next(err)
}
})
// 工资条确认已阅
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
try {
const payslip = await prisma.payslip.findFirst({
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
include: { employee: true },
})
if (!payslip) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
}
await prisma.payslip.update({
where: { id: req.params.id },
data: { confirmedAt: new Date(), confirmedIp: req.ip },
})
// 通知 HR
await prisma.notificationLog.create({
data: {
orgId: req.employee.orgId,
title: '工资条确认通知',
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip}`,
type: 'PAYSLIP_CONFIRM',
channel: 'IN_APP',
},
})
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 我的合同
router.get('/contract', portalAuth, async (req: any, res, next) => {
try {
const contract = await prisma.laborContract.findFirst({
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
orderBy: { createdAt: 'desc' },
})
if (!contract) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: contract })
} catch (err) {
next(err)
}
})
// 入职填报提交
router.post('/onboarding', async (req, res, next) => {
try {
const data = onboardingSchema.parse(req.body)
const link = await prisma.onboardingLink.findFirst({
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
await prisma.onboardingLink.update({
where: { id: link.id },
data: {
employeeName: data.name,
phone: data.phone,
formData: {
name: data.name,
phone: data.phone,
idCard: data.idCard,
emergencyContact: data.emergencyContact,
emergencyPhone: data.emergencyPhone,
address: data.address,
bankCard: data.bankCard,
bankName: data.bankName,
},
status: 'APPROVED',
usedAt: new Date(),
},
})
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
} catch (err) {
next(err)
}
})
// 合同签署验证码发送
router.post('/contract-confirm/send-code', async (req, res, next) => {
try {
const data = contractSendCodeSchema.parse(req.body)
const link = await prisma.contractConfirmLink.findFirst({
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
const phone = link.contract.employee.phone
if (!phone) {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} catch (err) {
next(err)
}
})
// 合同签署确认
router.post('/contract-confirm', async (req, res, next) => {
try {
const data = contractConfirmSchema.parse(req.body)
const link = await prisma.contractConfirmLink.findFirst({
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
// 验证码校验
const stored = codeStore.get(`contract-${data.token}`)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
codeStore.delete(`contract-${data.token}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.verifyCode) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
}
codeStore.delete(`contract-${data.token}`)
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
ip: req.ip,
userAgent,
timestamp: new Date().toISOString(),
})
await prisma.contractConfirmLink.update({
where: { id: link.id },
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
})
await prisma.laborContract.update({
where: { id: link.contractId },
data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` },
})
res.json({ success: true, data: { message: '合同签署确认成功' } })
} catch (err) {
next(err)
}
})
// 获取入职填报信息(通过 token)
router.get('/onboarding/:token', async (req, res, next) => {
try {
const link = await prisma.onboardingLink.findFirst({
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
include: { org: { select: { name: true } } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
res.json({ success: true, data: { orgName: link.org.name } })
} catch (err) {
next(err)
}
})
// 撤回入职链接(HR 端调用,需要认证)
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
try {
const link = await prisma.onboardingLink.findFirst({
where: { id: req.params.id, orgId: req.employee.orgId },
})
if (!link) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
}
if (link.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
}
await prisma.onboardingLink.update({
where: { id: link.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: { message: '入职链接已撤回' } })
} catch (err) {
next(err)
}
})
// 获取合同确认信息(通过 token)
router.get('/contract-confirm/:token', async (req, res, next) => {
try {
const link = await prisma.contractConfirmLink.findFirst({
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
include: {
contract: {
include: {
employee: { select: { name: true, org: { select: { name: true } } } },
},
},
},
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
res.json({
success: true,
data: {
orgName: link.contract.employee.org.name,
employeeName: link.contract.employee.name,
contract: link.contract,
},
})
} catch (err) {
next(err)
}
})
// 重发合同确认链接(HR 端调用,需要认证)
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
try {
const link = await prisma.contractConfirmLink.findFirst({
where: { id: req.params.id, orgId: req.employee.orgId },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
}
if (link.status === 'CONFIRMED') {
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
}
// 生成新 token 并延长过期时间
const crypto = await import('crypto')
const newToken = crypto.randomUUID()
await prisma.contractConfirmLink.update({
where: { id: link.id },
data: {
token: newToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
status: 'UNCONFIRMED',
},
})
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
} catch (err) {
next(err)
}
})
// 入职文件上传
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
const onboardingUpload = multer({
storage: multer.diskStorage({
destination: uploadDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) cb(null, true)
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
},
})
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
}
const link = await prisma.onboardingLink.findFirst({
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
})
if (!link) {
fs.unlinkSync(req.file.path)
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
const fileType = (req.body.fileType as string) || 'OTHER'
const fileUrl = `/uploads/onboarding/${req.file.filename}`
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
} catch (err) {
next(err)
}
})
export default router
+791
View File
@@ -0,0 +1,791 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
const router = Router()
function safeDecrypt(encrypted: string): number {
try {
if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0
return Number(decrypt(encrypted))
} catch {
return Number(encrypted) || 0
}
}
// ========== 花名册聚合 API ==========
// 花名册列表(含汇总信息,支持分页和过滤)
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const skip = (page - 1) * pageSize
const today = new Date()
today.setHours(0, 0, 0, 0)
// 先查询满足 orgId 和搜索条件的员工
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
prisma.employee.findMany({
where: whereBase,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
}),
])
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
const contractInfo = latestContract
? getContractStatus({
signDate: latestContract.signDate,
startDate: latestContract.startDate,
endDate: latestContract.endDate,
contractType: latestContract.contractType,
hireDate: e.hireDate,
})
: getContractStatus({
signDate: null,
startDate: e.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: e.hireDate,
})
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > today
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
return {
id: e.id,
name: e.name,
department: e.department,
city: e.city,
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
latestTerminationType: e.terminations[0]?.type || null,
latestTerminationId: e.terminations[0]?.id || null,
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
monthlySalary: safeDecrypt(e.monthlySalary),
latestContract,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
counts: e._count,
}
})
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where
if (status) {
result = result.filter((e) => e.status === status)
}
if (contractStatus) {
result = result.filter((e) => e.contractStatus === contractStatus)
}
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
})
} catch (err) {
next(err)
}
})
// 员工完整档案(花名册详情)
router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' } },
payslips: { orderBy: { month: 'desc' } },
overtimeRecords: { orderBy: { month: 'desc' } },
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
attendanceRecords: { orderBy: { date: 'desc' }, take: 90 },
trainingRecords: { orderBy: { trainingDate: 'desc' } },
performanceRecords: { orderBy: { period: 'desc' } },
terminations: { orderBy: { createdAt: 'desc' } },
attachments: true,
},
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
const today = new Date()
today.setHours(0, 0, 0, 0)
const dynamicStatus = employee.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
res.json({
success: true,
data: {
...rest,
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
},
})
} catch (err) {
next(err)
}
})
// 仲裁证据链导出
router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' } },
payslips: { orderBy: { month: 'desc' } },
overtimeRecords: { orderBy: { month: 'desc' } },
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
attendanceRecords: { orderBy: { date: 'desc' } },
trainingRecords: { orderBy: { trainingDate: 'desc' } },
performanceRecords: { orderBy: { period: 'desc' } },
terminations: true,
},
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const evidence: any[] = []
const empName = employee.name
const empDept = employee.department
const hireDate = employee.hireDate.toISOString().slice(0, 10)
// 1. 劳动关系证据
evidence.push({
category: '劳动关系',
title: '入职登记',
date: hireDate,
description: `${empName}${hireDate}入职${empDept},建立劳动关系。`,
evidenceType: 'EMPLOYMENT',
})
employee.contracts.forEach((c) => {
evidence.push({
category: '劳动关系',
title: `劳动合同(${c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签订'}`,
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
description: `合同期限:${c.startDate.toISOString().slice(0, 10)}${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}`,
evidenceType: 'CONTRACT',
signed: !!c.signDate,
})
})
// 2. 薪酬证据
employee.payslips.forEach((p) => {
evidence.push({
category: '薪酬发放',
title: `${p.month}月工资条`,
date: p.month,
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
evidenceType: 'PAYSLIP',
confirmed: !!p.confirmedAt,
})
})
employee.overtimeRecords.forEach((o) => {
if (o.totalPay > 0) {
evidence.push({
category: '薪酬发放',
title: `${o.month}月加班费记录`,
date: o.month,
description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}`,
evidenceType: 'OVERTIME',
})
}
})
// 3. 考勤证据
const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL')
abnormalAttendance.forEach((a) => {
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
evidence.push({
category: '考勤记录',
title: `${a.date.toISOString().slice(0, 10)} 考勤异常`,
date: a.date.toISOString().slice(0, 10),
description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}${a.remark || ''}`,
evidenceType: 'ATTENDANCE',
})
})
// 4. 违纪证据
employee.disciplinaryRecords.forEach((d) => {
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
evidence.push({
category: '违纪处理',
title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`,
date: d.violationDate.toISOString().slice(0, 10),
description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}${d.employeeAck ? `员工已签字确认(${d.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}` : ''}`,
evidenceType: 'DISCIPLINARY',
acknowledged: d.employeeAck,
})
})
// 5. 培训签收证据
employee.trainingRecords.forEach((t) => {
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
evidence.push({
category: '培训签收',
title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`,
date: t.trainingDate.toISOString().slice(0, 10),
description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}`,
evidenceType: 'TRAINING',
acknowledged: t.ackStatus === 'SIGNED',
})
})
// 6. 绩效证据
employee.performanceRecords.forEach((p) => {
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
evidence.push({
category: '绩效考核',
title: `${p.period} 绩效考核`,
date: p.period,
description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}${p.summary ? `评语:${p.summary}` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`,
evidenceType: 'PERFORMANCE',
acknowledged: p.employeeAck,
})
})
// 7. 解聘证据
employee.terminations.forEach((t) => {
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' }
evidence.push({
category: '解聘记录',
title: `${t.terminationDate.toISOString().slice(0, 10)} 解聘记录`,
date: t.terminationDate.toISOString().slice(0, 10),
description: `解聘原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}${t.remark || ''}`,
evidenceType: 'TERMINATION',
})
})
res.json({
success: true,
data: {
employee: {
name: empName,
department: empDept,
hireDate,
status: employee.terminations.some((t) => t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE',
gender: employee.gender,
phone: employee.phone,
},
evidence,
summary: {
total: evidence.length,
signed: evidence.filter((e) => e.acknowledged === true).length,
unsigned: evidence.filter((e) => e.acknowledged === false).length,
},
},
})
} catch (err) {
next(err)
}
})
// ========== 违纪记录 CRUD ==========
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.disciplinaryRecord.findMany({
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
orderBy: { violationDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
violationDate: new Date(violationDate),
violationType,
description,
severity: severity || 'WARNING',
action: action || 'ORAL_WARNING',
actionDetail,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
ackMethod,
witness,
attachmentUrl,
createdBy: req.user!.id,
},
})
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
const updated = await prisma.disciplinaryRecord.update({
where: { id: req.params.recordId },
data: {
violationDate: violationDate ? new Date(violationDate) : undefined,
violationType,
description,
severity,
action,
actionDetail,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
ackMethod,
witness,
attachmentUrl,
},
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.disciplinaryRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 考勤记录 CRUD ==========
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.attendanceRecord.findMany({
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
orderBy: { date: 'desc' },
take: 90,
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body
const record = await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } },
create: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
date: new Date(date),
checkInTime,
checkOutTime,
status: status || 'NORMAL',
lateMinutes: lateMinutes || 0,
earlyMinutes: earlyMinutes || 0,
workHours: workHours || 0,
overtimeHours: overtimeHours || 0,
remark,
createdBy: req.user!.id,
},
update: {
checkInTime,
checkOutTime,
status,
lateMinutes,
earlyMinutes,
workHours,
overtimeHours,
remark,
},
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.attendanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 培训签收记录 CRUD ==========
router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.trainingRecord.findMany({
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
orderBy: { trainingDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
const record = await prisma.trainingRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
trainingDate: new Date(trainingDate),
topic,
content,
trainer,
duration: duration || 0,
ackStatus: ackStatus || 'PENDING',
ackDate: ackDate ? new Date(ackDate) : null,
attachmentUrl,
remark,
createdBy: req.user!.id,
},
})
await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
const updated = await prisma.trainingRecord.update({
where: { id: req.params.recordId },
data: {
trainingDate: trainingDate ? new Date(trainingDate) : undefined,
topic,
content,
trainer,
duration,
ackStatus,
ackDate: ackDate ? new Date(ackDate) : null,
attachmentUrl,
remark,
},
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.trainingRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
await prisma.trainingRecord.delete({ where: { id: req.params.recordId } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 绩效记录 CRUD ==========
router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.performanceRecord.findMany({
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
orderBy: { period: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const record = await prisma.performanceRecord.upsert({
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
create: {
orgId: req.user!.orgId,
employeeId: req.params.employeeId,
period,
score: score || 0,
grade: grade || 'B',
result: result || 'QUALIFIED',
summary,
improvementPlan,
employeeAck: employeeAck || false,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
createdBy: req.user!.id,
},
update: {
score,
grade,
result,
summary,
improvementPlan,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
},
})
await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
const updated = await prisma.performanceRecord.update({
where: { id: req.params.recordId },
data: {
period,
score,
grade,
result,
summary,
improvementPlan,
employeeAck,
ackDate: ackDate ? new Date(ackDate) : null,
reviewer,
},
})
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const record = await prisma.performanceRecord.findFirst({
where: { id: req.params.recordId, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
await prisma.performanceRecord.delete({ where: { id: req.params.recordId } })
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
// 调薪
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newSalary, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldSalary = safeDecrypt(employee.monthlySalary)
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新薪资记录
const record = await prisma.salaryChangeRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldSalary,
newSalary: Number(newSalary),
effectiveDate: new Date(`${effMonth}-01`),
effectiveMonth: effMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { monthlySalary: encrypt(String(newSalary)) },
})
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调薪历史
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.salaryChangeRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 调部门
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newDepartment, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldDepartment = employee.department
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新部门记录
const record = await prisma.employeeDepartmentRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldDepartment,
newDepartment,
effectiveMonth: effMonth,
endMonth: null,
changeType: 'TRANSFER',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { department: newDepartment },
})
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调部门历史
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.employeeDepartmentRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveMonth: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 30天内合同到期列表
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const days = parseInt(req.query.days as string) || 30
const today = new Date()
today.setHours(0, 0, 0, 0)
const future = new Date(today)
future.setDate(future.getDate() + days)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: {
where: {
endDate: { gte: today, lte: future },
contractType: 'FIXED',
},
orderBy: { endDate: 'asc' },
take: 1,
},
},
})
const result = employees
.filter(e => e.contracts.length > 0)
.map(e => {
const contract = e.contracts[0]
const endDate = new Date(contract.endDate!)
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
return {
employeeId: e.id,
employeeName: e.name,
department: e.department,
contractEndDate: contract.endDate,
daysLeft,
}
})
.sort((a, b) => a.daysLeft - b.daysLeft)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
export default router
+188
View File
@@ -0,0 +1,188 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
const updateUserSchema = z.object({
name: z.string().min(1).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
email: z.string().email().optional(),
role: z.enum(['ADMIN', 'HR', 'VIEWER']).optional(),
})
const createUserSchema = z.object({
name: z.string().min(1, '姓名不能为空'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(6, '密码至少6位'),
role: z.enum(['ADMIN', 'HR', 'VIEWER']).default('HR'),
})
// 获取企业信息
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, createdAt: true },
})
res.json({ success: true, data: org })
} catch (err) {
next(err)
}
})
// 更新企业信息
router.put('/org', async (req: AuthRequest, res, next) => {
try {
const { name, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: true },
})
res.json({ success: true, data: org })
} catch (err) {
next(err)
}
})
// 获取用户列表
router.get('/users', async (req: AuthRequest, res, next) => {
try {
const users = await prisma.user.findMany({
where: { orgId: req.user!.orgId },
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true, createdAt: true, lastLoginAt: true },
orderBy: { createdAt: 'asc' },
})
res.json({ success: true, data: users })
} catch (err) {
next(err)
}
})
// 添加用户
router.post('/users', async (req: AuthRequest, res, next) => {
try {
const data = createUserSchema.parse(req.body)
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
if (existing) {
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } })
}
const passwordHash = await bcrypt.hash(data.password, 10)
const user = await prisma.user.create({
data: {
orgId: req.user!.orgId,
name: data.name,
phone: data.phone,
passwordHash,
role: data.role,
},
select: { id: true, name: true, phone: true, role: true },
})
res.json({ success: true, data: user })
} catch (err) {
next(err)
}
})
// 更新用户
router.put('/users/:id', async (req: AuthRequest, res, next) => {
try {
const data = updateUserSchema.parse(req.body)
const user = await prisma.user.update({
where: { id: req.params.id },
data: data,
select: { id: true, name: true, phone: true, email: true, role: true, disabled: true },
})
res.json({ success: true, data: user })
} catch (err) {
next(err)
}
})
// 删除用户
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
try {
if (req.params.id === req.user!.id) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
}
await prisma.user.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 禁用/启用用户
router.patch('/users/:id/toggle-disable', async (req: AuthRequest, res, next) => {
try {
if (req.params.id === req.user!.id) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能禁用自己' } })
}
const existing = await prisma.user.findUnique({ where: { id: req.params.id } })
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } })
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { disabled: !existing.disabled },
select: { id: true, name: true, disabled: true },
})
res.json({ success: true, data: user })
} catch (err) {
next(err)
}
})
// 切换套餐
router.put('/plan', async (req: AuthRequest, res, next) => {
try {
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的套餐' } })
}
const maxEmployees = plan === 'FREE' ? 10 : plan === 'PRO' ? 100 : 999999
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: { plan, maxEmployees },
select: { id: true, name: true, plan: true, maxEmployees: true },
})
res.json({ success: true, data: org })
} catch (err) {
next(err)
}
})
// 用量统计
router.get('/usage', async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const [employeeCount, aiConversations, contracts] = await Promise.all([
prisma.employee.count({ where: { orgId } }),
prisma.aIConversation.count({ where: { orgId } }),
prisma.laborContract.count({ where: { orgId } }),
])
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { plan: true, maxEmployees: true } })
res.json({
success: true,
data: {
plan: org?.plan || 'FREE',
maxEmployees: org?.maxEmployees || 10,
employeeCount,
aiConversations,
contracts,
employeeUsage: `${employeeCount}/${org?.maxEmployees || 10}`,
},
})
} catch (err) {
next(err)
}
})
export default router
+956
View File
@@ -0,0 +1,956 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { decrypt } from '../lib/crypto'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
city: z.string().optional(),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
medicalEmp: z.number().optional(),
unemploymentOrg: z.number().optional(),
unemploymentEmp: z.number().optional(),
injuryOrg: z.number().optional(),
maternityOrg: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
}
const housingConfigFields = {
city: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
}
// 获取当前生效版本(支持按城市筛选)
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.socialInsuranceConfig.findFirst({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
try {
config = await prisma.socialInsuranceConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
city: city || '北京',
isCurrent: true,
createdBy: req.user!.id,
},
})
} catch {
// 唯一约束冲突,查询同城市任意配置
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, city: city || '北京' },
orderBy: { effectiveFrom: 'desc' },
})
}
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 获取所有城市列表(从配置中提取)
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const configs = await prisma.socialInsuranceConfig.findMany({
where: { orgId: req.user!.orgId },
select: { city: true },
distinct: ['city'],
})
const cities = configs.map(c => c.city).filter(Boolean)
if (!cities.includes('北京')) cities.unshift('北京')
res.json({ success: true, data: cities })
} catch (err) {
next(err)
}
})
// 获取所有版本列表(支持按城市筛选)
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
const versions = await prisma.socialInsuranceConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 按月份获取适用版本
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.params
const config = await prisma.socialInsuranceConfig.findFirst({
where: {
orgId: req.user!.orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
// 回退到当前版本
const current = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
})
return res.json({ success: true, data: current })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 新建版本(年度调基/比例变更)
const createVersionSchema = z.object({
...socialConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createVersionSchema.parse(req.body)
const orgId = req.user!.orgId
// 检查同一城市同一生效月份是否已有版本
const existing = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.socialInsuranceConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
// 创建新版本
const version = await prisma.socialInsuranceConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: {
orgId,
month: { gte: lastYearStart, lte: lastYearEnd },
},
select: { employeeId: true, totalPay: true },
})
// 按员工汇总上年月均工资
const avgSalaryMap = new Map<string, number>()
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
for (const [empId, pays] of empPayslipMap) {
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
avgSalaryMap.set(empId, avg)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldSocialBase = emp.socialInsBase ?? monthlyWage
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase: oldSocialBase,
avgSalary,
monthlyWage,
suggestedBase: suggestedSocialBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行员工基数调整(接收用户编辑后的数据)
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const { items } = adjustApplySchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧社保记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新社保记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base: socialBase,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
})
adjusted++
}
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置社保基数调整(撤销本次调整,重新来过)
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更(按城市筛选)
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
city: config.city,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsBase: prevRecord?.base ?? null,
socialInsStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
city: z.string().optional(),
})
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.socialInsuranceConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const pensionOrg = actualBase * config.pensionOrg / 100
const pensionEmp = actualBase * config.pensionEmp / 100
const medicalOrg = actualBase * config.medicalOrg / 100
const medicalEmp = actualBase * config.medicalEmp / 100
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
const injuryOrg = actualBase * config.injuryOrg / 100
const maternityOrg = actualBase * config.maternityOrg / 100
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
const total = totalOrg + totalEmp
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
items: [
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
],
totalOrg,
totalEmp,
total,
},
})
} catch (err) {
next(err)
}
})
// ========== 公积金配置 ==========
// 获取当前公积金配置(支持按城市筛选)
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.housingFundConfig.findFirst({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
try {
config = await prisma.housingFundConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
city: city || '北京',
createdBy: req.user!.id,
},
})
} catch {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, city: city || '北京' },
orderBy: { effectiveFrom: 'desc' },
})
}
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到公积金配置' } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 公积金配置版本列表(支持按城市筛选)
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
const versions = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 新建公积金配置版本
const createHousingVersionSchema = z.object({
...housingConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const existing = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
})
if (prevCurrent) {
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.housingFundConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const version = await prisma.housingFundConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 公积金计算
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.housingFundConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.housingFundConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.housingFundConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingOrg = actualBase * config.housingOrg / 100
const housingEmp = actualBase * config.housingEmp / 100
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
housingOrg,
housingEmp,
total: housingOrg + housingEmp,
},
})
} catch (err) {
next(err)
}
})
// 公积金调基预览
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
select: { employeeId: true, totalPay: true },
})
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldBase = emp.housingFundBase ?? monthlyWage
const payslips = empPayslipMap.get(emp.id)
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase,
avgSalary,
monthlyWage,
suggestedBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行公积金调基
const adjustHousingSchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const { items } = adjustHousingSchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
})
adjusted++
}
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置公积金基数调整(撤销本次调整,重新来过)
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有公积金记录变更
await prisma.employeeHousingFundRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工公积金基数为调整前
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
housingFundBase: prevRecord?.base ?? null,
housingFundStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
// 社保月度增减员
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
// 增员:startMonth == month
const additions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
// 减员:endMonth == month 且 changeType == TERMINATION
const reductions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
additions: additions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
changeType: r.changeType,
})),
reductions: reductions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金月度增减员
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const additions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
const reductions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
additions: additions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
changeType: r.changeType,
})),
reductions: reductions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// ========== 在职申报 ==========
// 社保在保人员
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeSocialInsRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
items: records.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金在保人员
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeHousingFundRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
items: records.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
export default router
+272
View File
@@ -0,0 +1,272 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems } from '../services/termination.service'
import prisma from '../lib/prisma'
const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const result = await getTerminations(req.user!.orgId, page, pageSize)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/checklist/:reason', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employeeId = req.query.employeeId as string
let employee: any = undefined
if (employeeId) {
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId },
include: {
trainingRecords: true,
},
})
if (emp) {
employee = {
isInMedicalPeriod: emp.isInMedicalPeriod,
trainingRecords: emp.trainingRecords,
}
}
}
const checklist = getChecklistForReason(req.params.reason, employee)
res.json({ success: true, data: checklist })
} catch (err) {
next(err)
}
})
router.get('/assess/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employee = await prisma.employee.findFirst({ where: { id: req.params.employeeId, orgId: req.user!.orgId } })
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const assessment = assessRisk(employee, req.query.reason as string || '')
res.json({ success: true, data: assessment })
} catch (err) {
next(err)
}
})
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = terminationChecklistSchema.parse(req.body)
const result = await createTermination(req.user!.orgId, req.user!.id, data)
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId, terminationDate, resignationReason, remark } = req.body
if (!employeeId || !terminationDate) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
}
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await revokeTermination(req.user!.orgId, req.params.id)
await auditLog(req, 'REVOKE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 批量解聘预检
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const results = await batchTerminatePreview(req.user!.orgId, items)
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
} catch (err) {
next(err)
}
})
// 批量解聘执行
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
for (const id of result.success) {
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// ============================================================
// 解聘流程状态机 API
// ============================================================
// 获取草稿/流程列表
router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status as string | undefined
const result = await getDrafts(req.user!.orgId, status)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 获取单条记录详情
router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getTerminationDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 获取默认工作交接清单模板
router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) => {
res.json({ success: true, data: getDefaultHandoverItems() })
})
// 创建草稿
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, { reason: req.body.reason })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 更新草稿
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 提交审批
router.post('/draft/:id/submit', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await submitForApproval(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'SUBMIT_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 审批通过
router.post('/draft/:id/approve', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { comment } = req.body
const result = await approveTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
await auditLog(req, 'APPROVE_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 审批驳回
router.post('/draft/:id/reject', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { comment } = req.body
const result = await rejectTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
await auditLog(req, 'REJECT_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 执行解聘
router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 撤销
router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await cancelTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'CANCEL_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod'
export const registerSchema = z.object({
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
})
export const loginSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(1, '请输入密码'),
})
export const refreshSchema = z.object({
refreshToken: z.string().min(1, '缺少 refreshToken'),
})
export const forgotPasswordSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
})
export const resetPasswordSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
export const verifyCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
+62
View File
@@ -0,0 +1,62 @@
import { z } from 'zod'
export const createEmployeeSchema = z.object({
name: z.string().min(1, '姓名不能为空').max(30, '姓名最多30个字'),
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
hireDate: z.string().datetime(),
monthlySalary: z.string().min(1, '月薪不能为空'),
gender: z.enum(['男', '女']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
isPregnant: z.boolean().default(false),
isInMedicalPeriod: z.boolean().default(false),
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
endDate: z.string().datetime().nullable(),
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
contractYears: z.number().int().min(1).max(10).default(3),
probationMonths: z.number().int().min(0).max(6).default(0),
probationSalary: z.number().min(0).default(0),
}).optional(),
})
export const updateEmployeeSchema = z.object({
name: z.string().min(1).max(30).optional(),
department: z.string().min(1).max(50).optional(),
hireDate: z.string().datetime().optional(),
monthlySalary: z.string().min(1).optional(),
gender: z.enum(['男', '女']).optional(),
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
bankName: z.string().max(50).optional(),
bankAccount: z.string().max(30).optional(),
emergencyContact: z.string().max(30).optional(),
emergencyPhone: z.string().max(20).optional(),
address: z.string().max(200).optional(),
isPregnant: z.boolean().optional(),
isInMedicalPeriod: z.boolean().optional(),
isWorkInjured: z.boolean().optional(),
socialInsBase: z.number().min(0).nullable().optional(),
housingFundBase: z.number().min(0).nullable().optional(),
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
})
export const batchRenewSchema = z.object({
contractIds: z.array(z.string()).min(1, '至少选择一个合同'),
years: z.number().int().min(1).max(5).default(3),
})
export const addContractSchema = z.object({
employeeId: z.string().min(1),
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
endDate: z.string().datetime().nullable(),
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
contractYears: z.number().int().min(1).max(10).default(3),
probationMonths: z.number().int().min(0).max(6).default(0),
probationSalary: z.number().min(0).default(0),
})
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
export const portalLoginSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(6, '密码至少6位'),
})
export const portalSendCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
})
export const portalVerifyCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
})
export const onboardingSchema = z.object({
token: z.string().min(1, '缺少 token'),
name: z.string().min(1, '姓名不能为空'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
idCard: z.string().min(15, '身份证号格式不正确').max(18),
emergencyContact: z.string().optional(),
emergencyPhone: z.string().optional(),
address: z.string().optional(),
bankCard: z.string().optional(),
bankName: z.string().optional(),
})
export const contractConfirmSchema = z.object({
token: z.string().min(1, '缺少 token'),
agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'),
verifyCode: z.string().length(6, '验证码为6位数字'),
})
export const contractSendCodeSchema = z.object({
token: z.string().min(1, '缺少 token'),
})
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod'
export const terminationChecklistSchema = z.object({
employeeId: z.string().min(1, '请选择员工'),
reason: z.enum(['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF', 'EXPIRED']),
terminationDate: z.string().datetime(),
compensation: z.number().min(0).default(0),
checklist: z.record(z.boolean()).default({}),
remark: z.string().max(500).optional(),
})
export const terminationQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(50).default(20),
})
+195
View File
@@ -0,0 +1,195 @@
import OpenAI from 'openai'
import { searchKnowledge } from './rag.service'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 })
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
你的职责:
1. 回答用户关于劳动用工的合规问题
2. 基于企业实际数据给出针对性建议
3. 引用具体法律条文作为依据
4. 用通俗易懂的语言解释法律问题
回答要求:
- 先给出直接结论,再展开解释
- 引用法律条文时标注具体法律名称和条款号
- 涉及金额时给出计算过程
- 如有关联的企业数据,在回答中提及
- 回答简洁有力,避免冗长`
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const response = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
temperature: 0.7,
max_tokens: 2000,
})
return response.choices[0]?.message?.content || ''
}
export async function* chatStream(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
temperature: 0.7,
max_tokens: 2000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> {
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
合同文本:
${contractText}
请按以下格式输出:
【风险项】
🔴/🟡/🟢 [问题标题] - [说明] - [修改建议]
【合规评分】XX/100
【总体建议】
一段话总结`
const response = await client.chat.completions.create({
model: 'qwen-max',
messages: [
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' },
{ role: 'user', content: prompt },
],
temperature: 0.3,
max_tokens: 3000,
})
const text = response.choices[0]?.message?.content || ''
// 解析结构化数据
const riskItems: { level: string; title: string; description: string; suggestion: string }[] = []
const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g
let match
while ((match = riskRegex.exec(text)) !== null) {
riskItems.push({
level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN',
title: match[2],
description: match[3],
suggestion: match[4],
})
}
const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/)
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0
const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/)
const summary = summaryMatch ? summaryMatch[1].trim() : ''
return { text, structured: { riskItems, score, summary } }
}
export async function matchCase(scenario: string) {
const prompt = `作为一个劳动法案例匹配专家,请分析以下劳动争议情形,匹配相似的仲裁/诉讼案例,评估败诉风险。
争议情形:
${scenario}
请按以下格式输出:
【相似案例】
案例1[案例标题]
- 情形:[简要描述]
- 结果:[判决结果]
- 赔偿金额:[金额]
- 相似度:XX%
案例2...
【败诉风险评估】
风险等级:高/中/低(XX%
原因:[分析]
【建议】
[降低风险的具体建议]`
const response = await client.chat.completions.create({
model: 'qwen-max',
messages: [
{ role: 'system', content: '你是劳动法案例分析专家,熟悉劳动仲裁和诉讼案例。' },
{ role: 'user', content: prompt },
],
temperature: 0.3,
max_tokens: 3000,
})
return response.choices[0]?.message?.content || ''
}
export async function predictRisks(orgContext: string) {
const prompt = `基于以下企业用工数据,预测未来30天可能出现的合规风险,并给出优先级建议。
企业数据:
${orgContext}
请按以下格式输出:
【未来30天预计风险】
- [员工姓名/风险描述] → [建议措施]
【优先级建议】
[先处理什么,再处理什么]`
const response = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: '你是劳动用工风险预测专家,能基于企业数据分析未来风险趋势。' },
{ role: 'user', content: prompt },
],
temperature: 0.5,
max_tokens: 1500,
})
return response.choices[0]?.message?.content || ''
}
+103
View File
@@ -0,0 +1,103 @@
import bcrypt from 'bcryptjs'
import prisma from '../lib/prisma'
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/jwt'
export async function register(orgName: string, phone: string, password: string) {
const existing = await prisma.user.findUnique({ where: { phone } })
if (existing) {
throw { code: 'DUPLICATE', message: '该手机号已注册' }
}
const org = await prisma.organization.create({
data: {
name: orgName,
plan: 'FREE',
maxEmployees: 20,
},
})
const passwordHash = await bcrypt.hash(password, 10)
const user = await prisma.user.create({
data: {
orgId: org.id,
phone,
name: '管理员',
passwordHash,
role: 'ADMIN',
},
})
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
})
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
return {
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
accessToken,
refreshToken,
}
}
export async function login(phone: string, password: string) {
const user = await prisma.user.findUnique({ where: { phone } })
if (!user) {
throw { code: 'NOT_FOUND', message: '手机号或密码错误' }
}
const valid = await bcrypt.compare(password, user.passwordHash)
if (!valid) {
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
}
if (user.disabled) {
throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用,请联系管理员' }
}
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
})
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
return {
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
accessToken,
refreshToken,
}
}
export async function refresh(refreshToken: string) {
const payload = verifyRefreshToken(refreshToken)
if (!payload) {
throw { code: 'TOKEN_INVALID', message: 'Refresh Token 无效或已过期' }
}
const user = await prisma.user.findUnique({ where: { id: payload.id } })
if (!user) {
throw { code: 'NOT_FOUND', message: '用户不存在' }
}
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
return { accessToken }
}
export async function resetPassword(phone: string, newPassword: string) {
const user = await prisma.user.findUnique({ where: { phone } })
if (!user) {
throw { code: 'NOT_FOUND', message: '手机号未注册' }
}
const passwordHash = await bcrypt.hash(newPassword, 10)
await prisma.user.update({
where: { id: user.id },
data: { passwordHash },
})
return { success: true }
}
+611
View File
@@ -0,0 +1,611 @@
import prisma from '../lib/prisma'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import { runRiskDetection } from './risk.service'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
}
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function getContractStatus(contract: {
signDate: Date | null
startDate: Date
endDate: Date | null
contractType: string
hireDate: Date
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
const today = new Date()
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
const days = daysBetween(today, contract.hireDate)
if (days > 365) {
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
} else if (days > 30) {
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
}
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
}
if (contract.endDate) {
const daysToExpire = daysBetween(contract.endDate, today)
if (daysToExpire < 0) {
return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
}
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
}
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
}
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
let max = 0
if (contractMonths >= 36) max = 6
else if (contractMonths >= 12) max = 2
else if (contractMonths >= 3) max = 1
if (probationMonths > max) {
return {
valid: false,
max,
message: `${contractMonths}个月合同试用期最多${max}个月,当前${probationMonths}个月不合法`,
}
}
return { valid: true, max }
}
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
const page = params.page || 1
const pageSize = params.pageSize || 20
const skip = (page - 1) * pageSize
const where: any = { orgId, status: 'ACTIVE' }
if (params.search) {
where.OR = [
{ name: { contains: params.search } },
{ phone: { contains: params.search } },
]
}
if (params.department) {
where.department = params.department
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where }),
prisma.employee.findMany({
where,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
])
const items = employees.map((emp) => {
const latestContract = emp.contracts[0]
const contractInfo = latestContract
? getContractStatus({
signDate: latestContract.signDate,
startDate: latestContract.startDate,
endDate: latestContract.endDate,
contractType: latestContract.contractType,
hireDate: emp.hireDate,
})
: getContractStatus({
signDate: null,
startDate: emp.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: emp.hireDate,
})
let decryptedSalary = 0
try {
decryptedSalary = Number(decrypt(emp.monthlySalary)) || 0
} catch {
decryptedSalary = Number(emp.monthlySalary) || 0
}
return {
id: emp.id,
name: emp.name,
department: emp.department,
hireDate: emp.hireDate.toISOString().slice(0, 10),
status: emp.status,
monthlySalary: decryptedSalary,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
isPregnant: emp.isPregnant,
isInMedicalPeriod: emp.isInMedicalPeriod,
isWorkInjured: emp.isWorkInjured,
}
})
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
}
export async function getEmployeeDetail(orgId: string, id: string) {
const employee = await prisma.employee.findFirst({
where: { id, orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' } },
riskItems: { where: { status: 'PENDING' }, orderBy: { level: 'asc' } },
},
})
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
let decryptedSalary = 0
try {
decryptedSalary = Number(decrypt(employee.monthlySalary)) || 0
} catch {
decryptedSalary = Number(employee.monthlySalary) || 0
}
return {
...employee,
monthlySalary: decryptedSalary,
}
}
export async function createEmployee(orgId: string, userId: string, data: any) {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
if (activeCount >= org.maxEmployees) {
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
}
}
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
const employee = await prisma.employee.create({
data: {
orgId,
name: data.name,
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
},
})
// 创建社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建初始薪资变更记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: employee.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建初始部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: employee.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await prisma.laborContract.create({
data: {
orgId,
employeeId: employee.id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
createdBy: userId,
},
})
}
await runRiskDetection(orgId)
return { id: employee.id }
}
// 重新入职:复用已有员工基本信息,更新入职日期和状态,可选创建新合同
export async function rehireEmployee(orgId: string, userId: string, id: string, data: any) {
const employee = await prisma.employee.findFirst({
where: { id, orgId },
include: { terminations: { orderBy: { terminationDate: 'desc' }, take: 1 } },
})
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = employee.terminations.some((t) => t.terminationDate <= today)
if (!isResigned) {
throw { code: 'CONFLICT', message: '该员工当前在职,无需重新入职' }
}
const newHireDate = new Date(data.hireDate)
const latestTerm = employee.terminations[0]
if (latestTerm && newHireDate <= latestTerm.terminationDate) {
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
}
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
// 关闭旧社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧薪资记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧部门记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
await prisma.employee.update({
where: { id },
data: {
hireDate: newHireDate,
status: 'ACTIVE',
department: data.department || employee.department,
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
socialInsEndMonth: null,
housingFundStartMonth,
housingFundEndMonth: null,
city: data.city || employee.city || '北京',
},
})
// 创建新社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新薪资记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary: salaryNum,
newSalary: salaryNum,
effectiveDate: newHireDate,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: id,
oldDepartment: employee.department,
newDepartment: data.department || employee.department,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
const contractMonths = data.contract.endDate
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
: data.contract.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
await prisma.laborContract.create({
data: {
orgId,
employeeId: id,
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
startDate: new Date(data.contract.startDate),
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
contractType: data.contract.contractType,
signMethod: data.contract.signMethod || 'PAPER',
contractYears: data.contract.contractYears || 3,
probationMonths: data.contract.probationMonths || 0,
probationSalary: data.contract.probationSalary || 0,
createdBy: userId,
},
})
}
await runRiskDetection(orgId)
return { id }
}
export async function updateEmployee(orgId: string, id: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const updateData: any = {}
if (data.name !== undefined) updateData.name = data.name
if (data.department !== undefined) updateData.department = data.department
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
if (data.monthlySalary !== undefined) {
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
const newSalary = Number(data.monthlySalary) || 0
updateData.monthlySalary = encrypt(data.monthlySalary)
// 记录薪资变更
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = dateToMonth(now)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: data.salaryChangeReason || '手动调整',
createdBy: '',
},
})
}
}
if (data.gender !== undefined) updateData.gender = data.gender
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)
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
if (data.address !== undefined) updateData.address = data.address
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
await prisma.employee.update({ where: { id }, data: updateData })
await runRiskDetection(orgId)
return { id }
}
export async function deleteEmployee(orgId: string, id: string) {
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
await prisma.employee.update({ where: { id }, data: { status: 'RESIGNED' } })
await prisma.riskItem.updateMany({
where: { employeeId: id, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id }
}
export async function batchRenew(orgId: string, userId: string, contractIds: string[], years: number) {
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId },
})
if (contracts.length === 0) {
throw { code: 'NOT_FOUND', message: '未找到符合条件的合同' }
}
for (const contract of contracts) {
const newStartDate = contract.endDate || new Date()
const newEndDate = new Date(newStartDate)
newEndDate.setFullYear(newEndDate.getFullYear() + years)
await prisma.laborContract.create({
data: {
orgId,
employeeId: contract.employeeId,
signDate: new Date(),
startDate: newStartDate,
endDate: newEndDate,
contractType: contract.contractType,
signMethod: contract.signMethod,
contractYears: years,
probationMonths: 0,
probationSalary: 0,
renewalCount: contract.renewalCount + 1,
createdBy: userId,
},
})
}
await runRiskDetection(orgId)
return { renewed: contracts.length }
}
export async function addContract(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const contractMonths = data.endDate
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
: data.contractYears * 12
const probationCheck = validateProbation(contractMonths, data.probationMonths)
if (!probationCheck.valid) {
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
}
const contract = await prisma.laborContract.create({
data: {
orgId,
employeeId: data.employeeId,
signDate: data.signDate ? new Date(data.signDate) : null,
startDate: new Date(data.startDate),
endDate: data.endDate ? new Date(data.endDate) : null,
contractType: data.contractType,
signMethod: data.signMethod || 'PAPER',
contractYears: data.contractYears || 3,
probationMonths: data.probationMonths || 0,
probationSalary: data.probationSalary || 0,
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null,
electronicContractUrl: data.electronicContractUrl || null,
createdBy: userId,
},
})
await runRiskDetection(orgId)
return { id: contract.id }
}
+342
View File
@@ -0,0 +1,342 @@
import prisma from '../lib/prisma'
// ========== 薪酬模版 ==========
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
]
export async function ensureDefaultTemplate(orgId: string) {
const existing = await prisma.payslipItem.count({ where: { orgId } })
if (existing === 0) {
await prisma.payslipItem.createMany({
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
})
}
}
export async function getTemplate(orgId: string) {
await ensureDefaultTemplate(orgId)
return prisma.payslipItem.findMany({
where: { orgId },
orderBy: { order: 'asc' },
})
}
// ========== 社保计算 ==========
export function calcSocialInsurance(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
return { actualBase, socialEmp, socialOrg }
}
export function calcHousingFund(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingEmp = actualBase * config.housingEmp / 100
const housingOrg = actualBase * config.housingOrg / 100
return { actualBase, housingEmp, housingOrg }
}
// ========== 累计预扣个税 ==========
export function calcTax(taxableIncome: number): number {
if (taxableIncome <= 0) return 0
let tax = 0
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
else tax = taxableIncome * 0.45 - 181920
return Math.max(0, Math.round(tax * 100) / 100)
}
/**
* 累计预扣法计算当月个税
* @param ytdTaxableIncome 当年累计应纳税所得额(含当月)
* @param ytdTaxDeducted 当年累计已预扣税额
* @returns 当月应预扣税额
*/
export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number {
const ytdTax = calcTax(ytdTaxableIncome)
const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted)
return Math.round(currentMonthTax * 100) / 100
}
/**
* 年终奖单独计税
* @param bonusAmount 奖金金额
* @returns 应纳税额
*/
export function calcBonusTax(bonusAmount: number): number {
if (bonusAmount <= 0) return 0
const monthlyBonus = bonusAmount / 12
let rate = 0.03
let quickDeduction = 0
if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 }
else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 }
else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 }
else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 }
else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 }
else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 }
else { rate = 0.45; quickDeduction = 15160 }
const tax = bonusAmount * rate - quickDeduction
return Math.max(0, Math.round(tax * 100) / 100)
}
// ========== 批次计算 ==========
export async function calcBatchEntry(
orgId: string,
employeeId: string,
month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
) {
const [employee, socialConfig, housingConfig] = await Promise.all([
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
prisma.socialInsuranceConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
])
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
// 社保基数:优先用员工核定基数,否则用基本工资
const socialBase = employee.socialInsBase || inputs.baseSalary
const housingBase = employee.housingFundBase || inputs.baseSalary
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
socialOrg = social.socialOrg
}
if (housingConfig) {
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
housingOrg = housing.housingOrg
}
}
// 手动覆盖社保值
if (options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
}
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
// 个税计算
let tax = 0
if (batchType === 'BONUS') {
// 年终奖单独计税
tax = calcBonusTax(inputs.bonus)
} else {
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
const year = month.slice(0, 4)
const prevPayslips = await prisma.payslip.findMany({
where: {
orgId,
employeeId,
month: { startsWith: year, lt: month },
},
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
})
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0)
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
}
const netPay = totalPay - socialEmp - housingEmp - tax
return {
socialEmp: Math.round(socialEmp * 100) / 100,
socialOrg: Math.round(socialOrg * 100) / 100,
housingEmp: Math.round(housingEmp * 100) / 100,
housingOrg: Math.round(housingOrg * 100) / 100,
tax,
totalPay: Math.round(totalPay * 100) / 100,
netPay: Math.round(netPay * 100) / 100,
}
}
// ========== 风险提示 ==========
export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise<string[]> {
const warnings: string[] = []
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
})
if (!employee) return warnings
if (employee.status === 'RESIGNED') {
warnings.push('该员工已离职,需进行离职结算')
}
if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') {
warnings.push('未签订书面劳动合同')
}
if (employee.contracts.length) {
const contract = employee.contracts[0]
if (contract.endDate) {
const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
if (daysToExpiry <= 30 && daysToExpiry > 0) {
warnings.push(`合同将于 ${daysToExpiry} 天后到期`)
}
}
if (contract.probationMonths > 0 && contract.startDate) {
const probationEnd = new Date(contract.startDate)
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
if (probationEnd > new Date()) {
warnings.push('试用期员工,薪资可能不同')
}
}
}
if (!employee.socialInsBase) {
warnings.push('未设置社保缴费基数')
}
if (!employee.housingFundBase) {
warnings.push('未设置公积金缴费基数')
}
if (employee.terminations.length) {
warnings.push('已有解聘记录,请注意结算')
}
return warnings
}
// ========== 工资条汇总生成 ==========
export async function generatePayslipFromBatches(orgId: string, month: string) {
// 获取当月所有已归档批次
const batches = await prisma.payrollBatch.findMany({
where: { orgId, month, status: 'ARCHIVED' },
include: { entries: true },
})
if (batches.length === 0) return { generated: 0 }
// 按员工汇总
const employeeMap = new Map<string, any>()
for (const batch of batches) {
for (const entry of batch.entries) {
const existing = employeeMap.get(entry.employeeId) || {
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
totalPay: 0, netPay: 0,
}
existing.baseSalary += entry.baseSalary
existing.overtimePay += entry.overtimePay
existing.allowance += entry.allowance
existing.deduction += entry.deduction
existing.bonus += entry.bonus
existing.socialEmp += entry.socialEmp
existing.socialOrg += entry.socialOrg
existing.housingEmp += entry.housingEmp
existing.housingOrg += entry.housingOrg
existing.tax += entry.tax
existing.totalPay += entry.totalPay
existing.netPay += entry.netPay
employeeMap.set(entry.employeeId, existing)
}
}
// 计算累计数据
const year = month.slice(0, 4)
let generated = 0
for (const [employeeId, summary] of employeeMap) {
// 获取当年之前月份的累计数据
const prevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId, month: { startsWith: year, lt: month } },
select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true },
})
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp
await prisma.payslip.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
baseSalary: Math.round(summary.baseSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
bonus: Math.round(summary.bonus * 100) / 100,
totalPay: Math.round(summary.totalPay * 100) / 100,
socialEmp: Math.round(summary.socialEmp * 100) / 100,
housingEmp: Math.round(summary.housingEmp * 100) / 100,
tax: Math.round(summary.tax * 100) / 100,
netPay: Math.round(summary.netPay * 100) / 100,
ytdIncome: Math.round(ytdIncome * 100) / 100,
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
status: 'PUBLISHED',
publishedAt: new Date(),
},
create: {
orgId,
employeeId,
month,
baseSalary: Math.round(summary.baseSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
bonus: Math.round(summary.bonus * 100) / 100,
totalPay: Math.round(summary.totalPay * 100) / 100,
socialEmp: Math.round(summary.socialEmp * 100) / 100,
housingEmp: Math.round(summary.housingEmp * 100) / 100,
tax: Math.round(summary.tax * 100) / 100,
netPay: Math.round(summary.netPay * 100) / 100,
ytdIncome: Math.round(ytdIncome * 100) / 100,
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
status: 'PUBLISHED',
publishedAt: new Date(),
},
})
generated++
}
return { generated }
}
+97
View File
@@ -0,0 +1,97 @@
import OpenAI from 'openai'
import prisma from '../lib/prisma'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL })
const EMBEDDING_MODEL = 'text-embedding-v2'
interface KnowledgeSeed {
title: string
content: string
source: string
category: string
}
const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动合同法 第十条 建立劳动关系应当订立书面合同', content: '建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第八十二条 未签书面合同双倍工资', content: '用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十四条 无固定期限劳动合同', content: '连续订立二次固定期限劳动合同续订的,应当订立无固定期限劳动合同。劳动者在该用人单位连续工作满十年的,应当订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十九条 试用期期限', content: '三个月以上不满一年试用期不得超过一个月;一年以上不满三年不得超过二个月;三年以上不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第二十条 试用期工资', content: '试用期工资不得低于本单位相同岗位最低档工资或劳动合同约定工资的百分之八十,并不得低于最低工资标准。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第三十九条 过失性辞退', content: '严重违反规章制度、严重失职造成重大损害、被依法追究刑事责任等情形,用人单位可以解除劳动合同。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十条 无过失性辞退', content: '提前三十日书面通知或额外支付一个月工资后可解除:医疗期满不能从事原工作、不能胜任经培训仍不胜任、客观情况重大变化未能协商一致。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十一条 经济性裁员', content: '裁减二十人以上或占职工总数百分之十以上,需提前三十日向工会说明,方案报劳动行政部门。优先留用长期合同、无固定期限合同、家庭无其他就业人员。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十二条 不得解除的情形', content: '职业病、因工负伤丧失劳动能力、医疗期内、孕期产期哺乳期、连续工作满十五年距退休不足五年等情形,不得依第四十条第四十一条解除。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十七条 经济补偿计算', content: '每满一年支付一个月工资。六个月以上不满一年按一年计算;不满六个月支付半个月工资。月工资指解除前十二个月平均工资。高于社平工资三倍的按三倍计,年限最高十二年。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动合同法 第八十七条 违法解除赔偿金', content: '用人单位违反本法规定解除或终止劳动合同的,应当依照第四十七条经济补偿标准的二倍向劳动者支付赔偿金。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动法 第四十一条 加班时间上限', content: '一般每日不得超过一小时;特殊原因每日不得超过三小时,每月不得超过三十六小时。', source: '劳动法', category: '加班' },
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
]
let initialized = false
export async function ensureRAGTable() {
if (initialized) return
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS vector`
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS rag_knowledge (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
category TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
)
`
await prisma.$executeRaw`CREATE INDEX IF NOT EXISTS rag_knowledge_embedding_idx ON rag_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`
initialized = true
}
async function getEmbedding(text: string): Promise<number[]> {
const res = await client.embeddings.create({ model: EMBEDDING_MODEL, input: text })
return res.data[0]?.embedding || []
}
export async function seedKnowledgeBase() {
await ensureRAGTable()
const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge` as any
if (count[0]?.c > 0) return
for (let i = 0; i < SEED_DATA.length; i++) {
const item = SEED_DATA[i]
const embedding = await getEmbedding(`${item.title} ${item.content}`)
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${`rag-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector)
`
}
}
export async function searchKnowledge(query: string, topK: number = 3): Promise<string[]> {
await ensureRAGTable()
const queryEmbedding = await getEmbedding(query)
const results = await prisma.$queryRaw`
SELECT title, content, source, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM rag_knowledge
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${topK}
` as any[]
return results
.filter((r) => r.similarity > 0.3)
.map((r) => `${r.title}\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%`)
}
export async function addKnowledge(title: string, content: string, source: string, category: string) {
await ensureRAGTable()
const embedding = await getEmbedding(`${title} ${content}`)
const id = `rag-${Date.now()}`
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${id}, ${title}, ${content}, ${source}, ${category}, ${embedding}::vector)
`
return { id }
}
+525
View File
@@ -0,0 +1,525 @@
import prisma from '../lib/prisma'
import type { RiskLevel, RiskType } from '@prisma/client'
function daysBetween(a: Date, b: Date): number {
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
}
export async function detectContractRisks(orgId: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
include: { contracts: { orderBy: { createdAt: 'desc' } } },
})
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const emp of employees) {
const latestContract = emp.contracts[0]
if (!latestContract || latestContract.contractType === 'UNSIGNED') {
const days = daysBetween(new Date(), emp.hireDate)
if (days > 365) {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'HIGH',
title: `${emp.name}入职${days}天未签合同,已视为无固定期限`,
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过1年未签订书面合同,法律上已视为无固定期限劳动合同。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
} else if (days > 30) {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'HIGH',
title: `${emp.name}入职${days}天未签合同`,
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过30天未签订书面合同,需尽快补签。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
} else {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'LOW',
title: `${emp.name}入职${days}天,尚未签合同`,
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)}30天内需签订书面合同。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
}
continue
}
if (latestContract.endDate) {
const daysToExpire = daysBetween(latestContract.endDate, new Date())
if (daysToExpire < 0) {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'HIGH',
title: `${emp.name}的合同已到期${Math.abs(daysToExpire)}天未续签`,
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},已过期未续签。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
} else if (daysToExpire <= 30) {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'MEDIUM',
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
}
}
if (latestContract.probationMonths > 0) {
const contractMonths = latestContract.endDate
? Math.ceil(daysBetween(latestContract.endDate, latestContract.startDate) / 30.44)
: 36
let maxProbation = 0
if (contractMonths >= 36) maxProbation = 6
else if (contractMonths >= 12) maxProbation = 2
else if (contractMonths >= 3) maxProbation = 1
if (latestContract.probationMonths > maxProbation) {
risks.push({
employeeId: emp.id,
type: 'CONTRACT',
level: 'MEDIUM',
title: `${emp.name}试用期${latestContract.probationMonths}个月可能不合法`,
description: `${contractMonths}个月合同试用期最多${maxProbation}个月,当前${latestContract.probationMonths}个月超出法定上限。`,
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
})
}
}
}
return risks
}
// 预入职检查:入职日期已到但未签合同 → 待办
export async function detectOnboardingRisks(orgId: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { where: { terminationDate: { lte: today } }, take: 1 },
},
})
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const emp of employees) {
// 已离职的跳过
if (emp.terminations.length > 0) continue
const latestContract = emp.contracts[0]
const hasSignedContract = latestContract && latestContract.contractType !== 'UNSIGNED'
if (!hasSignedContract) {
const daysSinceHire = daysBetween(today, emp.hireDate)
risks.push({
employeeId: emp.id,
type: 'ONBOARDING',
level: daysSinceHire > 30 ? 'HIGH' : 'MEDIUM',
title: `${emp.name}入职手续未完成${daysSinceHire > 30 ? `(已超${daysSinceHire}天)` : ''}`,
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},尚未签订劳动合同,请尽快完成入职手续。`,
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
})
}
}
return risks
}
export async function detectTerminationRisks(orgId: string) {
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
})
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const emp of employees) {
if (emp.isPregnant) {
risks.push({
employeeId: emp.id,
type: 'TERMINATION',
level: 'HIGH',
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
})
}
if (emp.isInMedicalPeriod) {
risks.push({
employeeId: emp.id,
type: 'TERMINATION',
level: 'MEDIUM',
title: `${emp.name}处于医疗期,解聘需谨慎`,
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
})
}
if (emp.isWorkInjured) {
risks.push({
employeeId: emp.id,
type: 'TERMINATION',
level: 'HIGH',
title: `${emp.name}工伤期间,解聘受限`,
description: '工伤职工在停工留薪期内不得解除劳动合同。',
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
})
}
}
return risks
}
export async function detectMonthlyTasks(orgId: string) {
const setting = await prisma.notificationSetting.findUnique({ where: { orgId } })
if (!setting) return []
const now = new Date()
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const today = now.getDate()
const tasks = [
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
]
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const task of tasks) {
// 当月已过截止日或正好到截止日时生成提醒
if (today >= task.day) {
risks.push({
employeeId: null,
type: 'MONTHLY',
level: today > task.day + 3 ? 'HIGH' : 'MEDIUM',
title: task.title,
description: task.desc,
actionUrl: task.url,
})
}
}
// 工资条生成提醒:当月有已归档批次时提醒生成工资条
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId, month: currentMonth, status: 'ARCHIVED' },
})
if (archivedBatches > 0) {
risks.push({
employeeId: null,
type: 'SALARY',
level: 'MEDIUM',
title: `${currentMonth}月 生成工资条`,
description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`,
actionUrl: '/money',
})
}
return risks
}
export async function runRiskDetection(orgId: string) {
const existingRisks = await prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
})
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}:${r.actionUrl}`))
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
const monthlyExisting = await prisma.riskItem.findMany({
where: { orgId, title: { startsWith: `${currentMonth}` } },
select: { employeeId: true, title: true },
})
const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`))
const contractRisks = await detectContractRisks(orgId)
const terminationRisks = await detectTerminationRisks(orgId)
const onboardingRisks = await detectOnboardingRisks(orgId)
const monthlyTasks = await detectMonthlyTasks(orgId)
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
]
if (toCreate.length > 0) {
await prisma.riskItem.createMany({
data: toCreate.map((r) => ({
orgId,
employeeId: r.employeeId,
type: r.type,
level: r.level,
title: r.title,
description: r.description,
actionUrl: r.actionUrl,
})),
})
}
return toCreate.length
}
export async function getDashboardData(orgId: string) {
await runRiskDetection(orgId)
const now = new Date()
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
const [
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
monthSeverancePay,
] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }),
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
include: { employee: true },
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
take: 10,
}),
prisma.riskItem.findMany({
where: { orgId, status: 'RESOLVED' },
include: { employee: true },
orderBy: { resolvedAt: 'desc' },
take: 10,
}),
prisma.overtimeRecord.findMany({
where: { orgId, month: currentMonth },
select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true },
}),
prisma.payslip.findMany({
where: { orgId, month: currentMonth },
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true },
}),
// 已归档批次的条目(用于总览汇总)
prisma.batchEntry.findMany({
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
}),
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.laborContract.count({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
}),
prisma.terminationRecord.count({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
}),
prisma.disciplinaryRecord.count({
where: { orgId, violationDate: { gte: monthStart, lte: monthEnd } },
}),
prisma.attendanceRecord.count({
where: { orgId, date: { gte: monthStart, lte: monthEnd } },
}),
prisma.terminationRecord.aggregate({
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
_sum: { compensation: true },
}),
])
const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0)
// 本月薪税汇总:优先从已归档批次汇总,无归档批次则用工资条数据
const archivedEntries = batchEntries
const useArchivedData = archivedEntries.length > 0
let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number
let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number
let payslipCount: number, confirmedPayslips: number
if (useArchivedData) {
// 从已归档批次条目汇总(同一员工多批次的金额累加)
const empMap = new Map<string, any>()
for (const e of archivedEntries) {
const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 }
ex.baseSalary += e.baseSalary
ex.overtimePay += e.overtimePay
ex.allowance += e.allowance
ex.deduction += e.deduction
ex.bonus += e.bonus
ex.totalPay += e.totalPay
ex.socialEmp += e.socialEmp
ex.socialOrg += e.socialOrg
ex.housingEmp += e.housingEmp
ex.housingOrg += e.housingOrg
ex.tax += e.tax
ex.netPay += e.netPay
empMap.set(e.employeeId, ex)
}
const summary = Array.from(empMap.values())
totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0)
totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0)
totalAllowance = summary.reduce((s, e) => s + e.allowance, 0)
totalDeduction = summary.reduce((s, e) => s + e.deduction, 0)
totalPay = summary.reduce((s, e) => s + e.totalPay, 0)
totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0)
totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0)
totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0)
totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0)
totalTax = summary.reduce((s, e) => s + e.tax, 0)
totalNetPay = summary.reduce((s, e) => s + e.netPay, 0)
payslipCount = summary.length
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
} else {
// fallback:从工资条表汇总
totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
totalSocialOrg = 0
totalSocialEmp = 0
totalHousingOrg = 0
totalHousingEmp = 0
totalTax = 0
totalNetPay = 0
payslipCount = payslips.length
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
}
// 社保公积金:优先用归档批次的实际计算值,否则估算
let socialOrgTotal = 0
let socialEmpTotal = 0
let housingOrgTotal = 0
let housingEmpTotal = 0
if (useArchivedData) {
socialOrgTotal = totalSocialOrg
socialEmpTotal = totalSocialEmp
housingOrgTotal = totalHousingOrg
housingEmpTotal = totalHousingEmp
} else if (socialConfig && employeeCount > 0) {
// 用平均工资作为估算基数
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount
housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount
}
// 个税:优先用归档批次的实际计算值,否则估算
let estimatedTax = 0
if (useArchivedData) {
estimatedTax = totalTax
} else {
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03
else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1
else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2
else if (taxableIncome <= 35000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + (taxableIncome - 25000) * 0.25
else if (taxableIncome <= 55000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + (taxableIncome - 35000) * 0.3
else if (taxableIncome <= 80000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + (taxableIncome - 55000) * 0.35
else estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + 25000 * 0.35 + (taxableIncome - 80000) * 0.45
}
const payrollSummary = {
month: currentMonth,
employeeCount,
payslipCount,
confirmedPayslips,
unconfirmedPayslips: payslipCount - confirmedPayslips,
baseSalary: totalBaseSalary,
overtimePay: totalOvertimePay,
allowance: totalAllowance,
deduction: totalDeduction,
totalPay,
socialOrg: socialOrgTotal,
socialEmp: socialEmpTotal,
housingOrg: housingOrgTotal,
housingEmp: housingEmpTotal,
estimatedTax,
severancePay: monthSeverancePay._sum.compensation || 0,
// 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金
orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0),
// 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税
empNetPay: useArchivedData ? totalNetPay : totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
}
// 本月工作动态
const monthlyActivities = {
month: currentMonth,
newContracts: monthContracts,
terminations: monthTerminations,
disciplinaryActions: monthDisciplinary,
attendanceRecords: monthAttendance,
overtimeHours: overtimeRecords.reduce((s: number, r: typeof overtimeRecords[number]) => s + r.weekdayHours + r.weekendHours + r.holidayHours, 0),
overtimePay: monthlyOvertimePay,
}
const riskDistribution = {
contract: riskItems.filter((r: typeof riskItems[number]) => r.type === 'CONTRACT').length,
salary: riskItems.filter((r: typeof riskItems[number]) => r.type === 'SALARY').length,
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
}
const topRisks = riskItems
.filter((r: typeof riskItems[number]) => r.level === 'HIGH')
.slice(0, 5)
.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as string,
level: r.level.toLowerCase() as string,
title: r.title,
description: r.description,
employeeName: r.employee?.name || null,
actionUrl: r.actionUrl || '/',
}))
const todos = riskItems.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
title: r.title,
description: r.description,
actionUrl: r.actionUrl || '/',
}))
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
title: r.title,
description: r.description,
actionUrl: r.actionUrl || '/',
resolvedAt: r.resolvedAt?.toISOString() || null,
}))
const hour = new Date().getHours()
const greeting = hour < 12
? `早上好!今天有 ${pendingRisks} 件事需要处理`
: hour < 18
? `下午好!今天有 ${pendingRisks} 件事需要处理`
: `晚上好!今天有 ${pendingRisks} 件事需要处理`
return {
greeting,
stats: {
employeeCount,
highRiskCount: highRisks,
todoCount: pendingRisks,
monthlyOvertimePay,
},
todos,
resolvedTodos,
riskDistribution,
topRisks,
aiPrediction: null,
payrollSummary,
monthlyActivities,
}
}
+827
View File
@@ -0,0 +1,827 @@
import prisma from '../lib/prisma'
import { RiskAssessment, TerminationReason } from '@prisma/client'
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
export interface ChecklistItem {
key: string
label: string
autoChecked?: boolean | null // null=无法自动判断,true/false=系统判断结果
autoSource?: string // 系统判断依据说明
suggestion?: string // 系统建议说明
suggestionType?: 'info' | 'warning' | 'required'
}
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] {
switch (reason) {
case 'NEGOTIATED':
return [
{
key: 'compensation_paid', label: '是否已支付经济补偿金',
autoChecked: null,
suggestion: '协商解除需支付经济补偿金(N),建议在协商协议中明确金额',
suggestionType: 'required',
},
{ key: 'agreement_signed', label: '是否签署协商解除协议', autoChecked: null },
{ key: 'final_pay_ready', label: '是否结清最后工资', autoChecked: null },
]
case 'FAULT':
return [
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
{ key: 'notify_union', label: '是否事先通知工会', autoChecked: null },
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
]
case 'NONFAULT': {
const items: ChecklistItem[] = []
// 医疗期是否已届满 — 系统自动判断
if (employee?.isInMedicalPeriod) {
items.push({
key: 'medical_period_end', label: '医疗期是否已届满',
autoChecked: false,
autoSource: '系统记录显示该员工正处于医疗期内,医疗期未届满',
suggestion: '医疗期内不得以非过错理由解除,需等待医疗期届满',
suggestionType: 'warning',
})
} else {
items.push({
key: 'medical_period_end', label: '医疗期是否已届满',
autoChecked: null,
autoSource: '系统未记录该员工处于医疗期,如实际已届满请勾选确认',
})
}
// 是否经过培训或调岗 — 系统自动判断
const hasTraining = employee?.trainingRecords?.length > 0
items.push({
key: 'training_given', label: '是否经过培训或调岗',
autoChecked: hasTraining ? true : null,
autoSource: hasTraining
? `系统记录显示该员工有${employee.trainingRecords.length}条培训记录`
: '系统未找到培训或调岗记录,请人工确认',
suggestion: hasTraining
? '已有培训记录,满足"不胜任工作经培训或调岗"的前提条件'
: '以不胜任工作为由解除前,必须先经过培训或调岗,否则违法解除风险极高',
suggestionType: hasTraining ? 'info' : 'warning',
})
// 是否支付经济补偿金 — 系统建议
items.push({
key: 'compensation_paid', label: '是否支付经济补偿金',
autoChecked: null,
suggestion: '非过错解除需支付经济补偿金(N),并在Step 4费用结算中确认金额',
suggestionType: 'required',
})
// 是否提前30天通知或支付代通知金 — 系统建议
items.push({
key: 'advance_notice', label: '是否提前30天通知或支付代通知金',
autoChecked: null,
suggestion: '非过错解除需提前30天书面通知,或额外支付1个月工资作为代通知金(N+1)',
suggestionType: 'required',
})
return items
}
case 'LAYOFF':
return [
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
{
key: 'compensation_paid', label: '是否支付经济补偿金',
autoChecked: null,
suggestion: '裁员需支付经济补偿金(N)',
suggestionType: 'required',
},
]
case 'EXPIRED':
return [
{
key: 'compensation_paid', label: '是否支付经济补偿金(如需)',
autoChecked: null,
suggestion: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
suggestionType: 'info',
},
{ key: 'written_notice', label: '是否提前通知员工不续签', autoChecked: null },
]
default:
return []
}
}
export function assessRisk(employee: any, reason: string): { level: RiskAssessment; warnings: string[] } {
const warnings: string[] = []
if (employee.isPregnant) {
warnings.push('该员工在孕期/哺乳期,法律禁止以非过错理由解除')
}
if (employee.isWorkInjured) {
warnings.push('工伤期间不得解除劳动合同')
}
if (employee.isInMedicalPeriod && reason !== 'FAULT') {
warnings.push('医疗期内不得解除劳动合同(非过错理由)')
}
let level: RiskAssessment = 'SAFE'
if (warnings.length > 0) {
level = 'DANGER'
}
return { level, warnings }
}
export async function createTermination(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' }
}
const { level } = assessRisk(employee, data.reason)
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'TERMINATION',
reason: data.reason,
terminationDate: termDate,
compensation: data.compensation || 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark,
createdBy: userId,
},
})
// 关闭社保缴费记录(设置 endMonth)
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据解聘日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
}
// 员工主动离职
export async function createResignation(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
}
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: termDate,
resignationReason: data.resignationReason || null,
compensation: 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: 'SAFE',
checklist: {},
remark: data.remark || null,
createdBy: userId,
},
})
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据离职日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
}
// 撤回离职/解聘(仅未到日期可撤回)
export async function revokeTermination(orgId: string, recordId: string) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '离职/解聘记录不存在' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
if (record.terminationDate <= today) {
throw { code: 'CONFLICT', message: '离职/解聘日期已到或已过,无法撤回' }
}
await prisma.terminationRecord.delete({ where: { id: recordId } })
// 恢复员工状态为 ACTIVE
await prisma.employee.update({
where: { id: record.employeeId },
data: { status: 'ACTIVE' },
})
return { id: recordId }
}
export async function getTerminations(orgId: string, page: number, pageSize: number) {
const skip = (page - 1) * pageSize
const [total, records] = await Promise.all([
prisma.terminationRecord.count({ where: { orgId } }),
prisma.terminationRecord.findMany({
where: { orgId },
include: { employee: true },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
])
return {
items: records.map((r) => ({
id: r.id,
employeeName: r.employee.name,
department: r.employee.department,
type: r.type,
reason: r.reason,
resignationReason: r.resignationReason,
terminationDate: r.terminationDate.toISOString().slice(0, 10),
compensation: r.compensation,
riskLevel: r.riskLevel,
remark: r.remark,
createdAt: r.createdAt.toISOString().slice(0, 10),
})),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWage: number, socialAvgWage: number = 0): {
years: number
remainingMonths: number
compMonths: number
totalPay: number
capped: boolean
} {
const totalMonths = (leaveDate.getFullYear() - hireDate.getFullYear()) * 12 + (leaveDate.getMonth() - hireDate.getMonth())
const years = Math.floor(totalMonths / 12)
const remainingMonths = totalMonths % 12
let compMonths: number
if (remainingMonths >= 6) compMonths = years + 1
else if (remainingMonths > 0) compMonths = years + 0.5
else compMonths = years
if (compMonths <= 0) compMonths = 0.5
let wage = monthlyWage
let capped = false
if (socialAvgWage > 0 && monthlyWage > socialAvgWage * 3) {
wage = socialAvgWage * 3
compMonths = Math.min(compMonths, 12)
capped = true
}
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
}
// 批量解聘:支持合规预检和执行
export interface BatchTerminatePreview {
employeeId: string
employeeName: string
department: string
reason: string
terminationDate: string
riskLevel: RiskAssessment | null
warnings: string[]
canTerminate: boolean
}
export async function batchTerminatePreview(
orgId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
): Promise<BatchTerminatePreview[]> {
const results: BatchTerminatePreview[] = []
for (const item of items) {
const employee = await prisma.employee.findFirst({
where: { id: item.employeeId, orgId },
})
if (!employee) {
results.push({
employeeId: item.employeeId,
employeeName: '(未找到)',
department: '',
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: null,
warnings: ['员工不存在或无权操作'],
canTerminate: false,
})
continue
}
const { level, warnings } = assessRisk(employee, item.reason)
results.push({
employeeId: item.employeeId,
employeeName: employee.name,
department: employee.department,
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: level,
warnings,
canTerminate: warnings.length === 0,
})
}
return results
}
export interface BatchTerminateResult {
success: string[]
failed: Array<{ employeeId: string; reason: string }>
total: number
}
export async function batchTerminate(
orgId: string,
userId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
): Promise<BatchTerminateResult> {
const success: string[] = []
const failed: Array<{ employeeId: string; reason: string }> = []
for (const item of items) {
try {
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
// 校验:已有离职/解聘记录
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: item.employeeId },
orderBy: { terminationDate: 'desc' },
})
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
if (!employee) {
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
continue
}
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
continue
}
const { level } = assessRisk(employee, item.reason)
await prisma.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭社保和公积金
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
success.push(item.employeeId)
} catch (err: any) {
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
}
}
return { success, failed, total: items.length }
}
// ============================================================
// 解聘流程状态机:DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
// ↘ REJECTED → 可修改重新提交
// 任意非 COMPLETED → CANCELLED
// ============================================================
/** 标准工作交接清单模板 */
export function getDefaultHandoverItems(): Array<{ key: string; label: string; done: boolean; remark: string }> {
return [
{ key: 'work_handover', label: '工作交接完成', done: false, remark: '' },
{ key: 'equipment_return', label: '办公设备归还', done: false, remark: '' },
{ key: 'access_revoke', label: '系统权限收回', done: false, remark: '' },
{ key: 'docs_signed', label: '离职文件签署', done: false, remark: '' },
{ key: 'finance_settled', label: '财务结算完成', done: false, remark: '' },
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
]
}
/** 创建草稿 */
export async function createDraft(orgId: string, userId: string, data: any) {
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const { level } = assessRisk(employee, data.reason || 'NEGOTIATED')
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: data.type || 'TERMINATION',
reason: data.reason || 'NEGOTIATED',
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
resignationReason: data.resignationReason || null,
compensation: data.compensation || 0,
socialInsEndMonth: data.socialInsEndMonth || null,
housingFundEndMonth: data.housingFundEndMonth || null,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark || null,
createdBy: userId,
status: 'DRAFT',
currentStep: data.currentStep || 0,
compensationBreakdown: data.compensationBreakdown || null,
checklistOverrides: data.checklistOverrides || null,
handoverItems: data.handoverItems || getDefaultHandoverItems(),
},
})
return { id: record.id }
}
/** 更新草稿(仅 DRAFT/REJECTED 状态可编辑) */
export async function updateDraft(orgId: string, recordId: string, userId: string, data: any) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
throw { code: 'CONFLICT', message: '当前状态不可编辑' }
}
const updateData: any = { updatedBy: userId }
if (data.reason !== undefined) {
updateData.reason = data.reason
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId, orgId } })
if (employee) {
const { level } = assessRisk(employee, data.reason)
updateData.riskLevel = level
}
}
if (data.terminationDate !== undefined) updateData.terminationDate = new Date(data.terminationDate)
if (data.compensation !== undefined) updateData.compensation = data.compensation
if (data.socialInsEndMonth !== undefined) updateData.socialInsEndMonth = data.socialInsEndMonth
if (data.housingFundEndMonth !== undefined) updateData.housingFundEndMonth = data.housingFundEndMonth
if (data.checklist !== undefined) updateData.checklist = data.checklist
if (data.remark !== undefined) updateData.remark = data.remark
if (data.currentStep !== undefined) updateData.currentStep = data.currentStep
if (data.compensationBreakdown !== undefined) updateData.compensationBreakdown = data.compensationBreakdown
if (data.checklistOverrides !== undefined) updateData.checklistOverrides = data.checklistOverrides
if (data.handoverItems !== undefined) updateData.handoverItems = data.handoverItems
if (data.resignationReason !== undefined) updateData.resignationReason = data.resignationReason
await prisma.terminationRecord.update({ where: { id: recordId }, data: updateData })
return { id: recordId }
}
/** 提交审批 */
export async function submitForApproval(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
throw { code: 'CONFLICT', message: '仅草稿状态可提交审批' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'PENDING_APPROVAL', updatedBy: userId },
})
return { id: recordId }
}
/** 审批通过 */
export async function approveTermination(orgId: string, recordId: string, userId: string, comment: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'PENDING_APPROVAL') {
throw { code: 'CONFLICT', message: '仅待审批状态可审批' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: {
status: 'APPROVED',
approvedBy: userId,
approvedAt: new Date(),
approvalComment: comment || null,
updatedBy: userId,
},
})
return { id: recordId }
}
/** 审批驳回 */
export async function rejectTermination(orgId: string, recordId: string, userId: string, comment: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'PENDING_APPROVAL') {
throw { code: 'CONFLICT', message: '仅待审批状态可驳回' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: {
status: 'REJECTED',
approvalComment: comment || '驳回',
updatedBy: userId,
},
})
return { id: recordId }
}
/** 执行解聘(APPROVED → EXECUTING → COMPLETED */
export async function executeTermination(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'APPROVED' && record.status !== 'DRAFT') {
throw { code: 'CONFLICT', message: '仅已审批或草稿状态可执行' }
}
// 标记为执行中
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'EXECUTING', updatedBy: userId },
})
const termDate = record.terminationDate
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = record.socialInsEndMonth || termMonth
const housingFundEndMonth = record.housingFundEndMonth || termMonth
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: record.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: record.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
// 标记为已完成
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'COMPLETED', updatedBy: userId },
})
return { id: recordId }
}
/** 撤销(状态→CANCELLED,不删除记录) */
export async function cancelTermination(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status === 'COMPLETED') {
throw { code: 'CONFLICT', message: '已完成的解聘不可撤销' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'CANCELLED', updatedBy: userId },
})
// 如果之前已执行(社保已关闭),恢复员工状态
if (record.status === 'EXECUTING' || record.status === 'COMPLETED') {
await prisma.employee.update({
where: { id: record.employeeId },
data: { status: 'ACTIVE' },
})
}
return { id: recordId }
}
/** 获取草稿列表 */
export async function getDrafts(orgId: string, status?: string) {
const where: any = { orgId }
if (status) {
where.status = status
} else {
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED'] }
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
return records.map((r) => ({
id: r.id,
employeeId: r.employeeId,
employeeName: r.employee.name,
department: r.employee.department,
type: r.type,
reason: r.reason,
terminationDate: r.terminationDate.toISOString().slice(0, 10),
compensation: r.compensation,
riskLevel: r.riskLevel,
status: r.status,
currentStep: r.currentStep,
remark: r.remark,
createdAt: r.createdAt.toISOString().slice(0, 10),
updatedAt: r.updatedAt.toISOString().slice(0, 10),
}))
}
/** 获取单条记录详情(含所有流程字段) */
export async function getTerminationDetail(orgId: string, recordId: string) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
include: { employee: true },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
return {
id: record.id,
employeeId: record.employeeId,
employeeName: record.employee.name,
department: record.employee.department,
type: record.type,
reason: record.reason,
terminationDate: record.terminationDate.toISOString().slice(0, 10),
resignationReason: record.resignationReason,
compensation: record.compensation,
socialInsEndMonth: record.socialInsEndMonth,
housingFundEndMonth: record.housingFundEndMonth,
riskLevel: record.riskLevel,
checklist: record.checklist,
remark: record.remark,
status: record.status,
currentStep: record.currentStep,
compensationBreakdown: record.compensationBreakdown,
checklistOverrides: record.checklistOverrides,
handoverItems: record.handoverItems,
approvedBy: record.approvedBy,
approvedAt: record.approvedAt?.toISOString().slice(0, 10),
approvalComment: record.approvalComment,
createdBy: record.createdBy,
createdAt: record.createdAt.toISOString().slice(0, 10),
updatedAt: record.updatedAt.toISOString().slice(0, 10),
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*", "prisma/**/*"],
"exclude": ["node_modules", "dist"],
"ignoreDeprecations": "6.0"
}
+888
View File
@@ -0,0 +1,888 @@
# AIHR 前端 UI/UX 优化实施方案
> 配套文档:`docs/ui-ux-review.md`(现状梳理 + 竞品标杆 + 问题诊断)
>
> 本文档为可执行的实施计划,包含具体文件修改清单、代码示例和验收标准。
>
> 日期:2026-07-24
---
## 目录
- [依赖安装清单](#依赖安装清单)
- [Phase 1:基础体验修复(1-2 天)](#phase-1基础体验修复1-2-天)
- [Phase 2:信息架构调整(2-3 天)](#phase-2信息架构调整2-3-天)
- [Phase 3:性能与组件化(2-3 天)](#phase-3性能与组件化2-3-天)
- [Phase 4:视觉与数据可视化(2-3 天)](#phase-4视觉与数据可视化2-3-天)
- [Phase 5a11y 与细节打磨(1-2 天)](#phase-5a11y-与细节打磨1-2-天)
- [新增文件清单](#新增文件清单)
- [验收检查表](#验收检查表)
---
## 依赖安装清单
```bash
# Phase 1 — Toast
npm install sonner
# Phase 4 — 数据可视化
npm install recharts
# Phase 5 — 虚拟列表
npm install @tanstack/react-virtual
```
---
## Phase 1:基础体验修复(1-2 天)
### P1.1 全局字号提升
**目标**:正文 12px → 14px,页面标题 → 18px,辅助文字保持 12px。
| 文件 | 修改内容 | 行号参考 |
|------|----------|----------|
| `src/index.css` | `.btn` text-xs → text-sm`.input` text-xs → text-sm`.label` text-xs → text-sm | L24, L39, L42 |
| `src/index.css` | `h1` text-base → text-lg`h2` text-sm → text-base | L17-18 |
| `src/components/ui/Button.tsx` | size md: text-xs → text-smlg: text-sm → text-base | L18-20 |
| `src/components/ui/Input.tsx` | Input/Select text-xs → text-sm | L10, L25 |
| `src/components/ui/Pagination.tsx` | text-xs → text-sm(页码、条数信息) | L42, L69, L74 |
| `src/components/ui/EmptyState.tsx` | title text-sm → text-basedescription text-xs → text-sm | L19-20 |
**验收标准**:正文内容 14px,页面标题 18px,辅助文字 12px,按钮 14px。
---
### P1.2 卡片间距增大
**目标**:增加呼吸感,信息密度从"紧凑"到"舒适"。
| 文件 | 当前 | 目标 |
|------|------|------|
| `src/components/ui/Card.tsx` | `p-3` | `p-4` |
| `src/index.css` `.card` | `p-3` | `p-4` |
| `src/pages/Dashboard.tsx` | `space-y-3` / `gap-2` | `space-y-4` / `gap-3` | L157, L197 |
| `src/pages/Money.tsx` | `space-y-3` | `space-y-4` | L28 |
| `src/pages/Roster.tsx` | 列表行 `py-1.5` | `py-2.5` | 表格行 |
| `src/pages/SocialInsurance.tsx` | `space-y-3` | `space-y-4` | |
| `src/pages/Settings.tsx` | `space-y-3` | `space-y-4` | L45 |
**验收标准**:卡片内边距 16px,页面模块间距 16px,表格行高 ≥ 40px。
---
### P1.3 引入 Toastsonner
**安装**`npm install sonner`
**修改文件清单**37 处 alert/confirm):
| 文件 | alert 数量 | confirm 数量 | 行号参考 |
|------|-----------|-------------|----------|
| `src/App.tsx` | — | — | 顶层添加 `<Toaster>` |
| `src/pages/AIAssistant.tsx` | 6 | 0 | L137, L433, L435, L585, L587, L605, L607 |
| `src/pages/Money.tsx` | 5 | 3 | L284, L342, L352, L354, L358, L493, L506, L883, L1216, L1255 |
| `src/pages/Roster.tsx` | 4 | 1 | L400, L507, L985, L993, L1930, L1937 |
| `src/pages/SocialInsurance.tsx` | 7 | 0 | L122, L132, L168, L182, L191, L200, L294 |
| `src/pages/Settings.tsx` | 6 | 0 | 搜索结果 |
| `src/pages/portal/ContractConfirm.tsx` | 1 | 0 | |
**App.tsx 修改**
```tsx
import { Toaster } from 'sonner'
export default function App() {
return (
<>
<Routes>...</Routes>
<Toaster position="top-center" richColors closeButton />
</>
)
}
```
**各页面替换规则**
```tsx
// 旧:alert('已保存到员工档案')
// 新:toast.success('已保存到员工档案')
// 旧:alert('保存失败:' + msg)
// 新:toast.error('保存失败:' + msg)
// 旧:alert('不支持的文件格式')
// 新:toast.error('不支持的文件格式,请上传 PDF、JPG、PNG 或 HEIC 格式')
```
**验收标准**:全局 `grep -r "alert(" src/` 返回 0 结果,所有操作反馈通过 toast。
---
### P1.4 批量操作二次确认组件
**新增文件**`src/components/ui/ConfirmDialog.tsx`
```tsx
import Modal from './Modal'
import Button from './Button'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'primary'
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({
open, title, message,
confirmLabel = '确认', cancelLabel = '取消',
variant = 'danger', onConfirm, onCancel,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onCancel} title={title} size="sm">
<p className="text-sm text-gray-600 mb-4">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>{cancelLabel}</Button>
<Button variant={variant} onClick={onConfirm}>{confirmLabel}</Button>
</div>
</Modal>
)
}
```
**替换清单**(所有 `confirm()` 调用):
| 文件 | 行号 | 当前代码 | 替换为 |
|------|------|----------|--------|
| Money.tsx | L284 | `confirm('确认删除批次?')` | `<ConfirmDialog>` |
| Money.tsx | L493 | `confirm('确认归档?')` | `<ConfirmDialog>` |
| Money.tsx | L506 | `confirm('确认删除批次?')` | `<ConfirmDialog>` |
| Money.tsx | L1255 | `confirm('确认生成工资条?')` | `<ConfirmDialog>` |
| Roster.tsx | L400 | `confirm('确认撤回离职记录?')` | `<ConfirmDialog>` |
**使用示例**
```tsx
const [confirmOpen, setConfirmOpen] = useState(false)
// 触发
onClick={() => setConfirmOpen(true)}
// 渲染
<ConfirmDialog
open={confirmOpen}
title="确认删除"
message={`确认删除批次「${batch.name}」?此操作不可撤销。`}
onConfirm={() => { deleteBatchMutation.mutate(batch.id); setConfirmOpen(false) }}
onCancel={() => setConfirmOpen(false)}
/>
```
**验收标准**:危险操作弹出 Dialog 而非浏览器原生 confirm,有明确文案说明后果。
---
### P1.5 对比度修复
**目标**:所有文字对比度 ≥ 4.5:1(WCAG AA)。
| 文件 | 当前 | 目标 | 说明 |
|------|------|------|------|
| 全局 `text-gray-400` | #9CA3AF (2.5:1) | `text-gray-500` #6B7280 (4.6:1) | 全局替换 |
| `src/components/ui/Pagination.tsx` | L61, L69 | `text-gray-500` | 翻页按钮 |
| `src/pages/Dashboard.tsx` | L229 | `text-gray-500` | "等人"文字 |
| `src/components/layout/TopNav.tsx` | L50 | `text-gray-500` | ChevronDown 图标 |
**验收标准**:使用 axe DevTools 扫描,0 个对比度违规。
---
### P1.6 内容宽度限制
| 文件 | 修改 |
|------|------|
| `tailwind.config.js` | `maxWidth: { content: '1280px' }`(当前 `none` |
**验收标准**:1920px 屏幕内容居中,最大宽度 1280px,两侧留白。
---
## Phase 2:信息架构调整(2-3 天)
### P2.1 顶部导航精简为 4 入口
**修改文件**`src/components/layout/TopNav.tsx`
```tsx
// 当前 6 个 tab
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '花名册' },
{ path: '/money', label: '薪税' },
{ path: '/social', label: '社保公积金' },
{ path: '/termination', label: '解聘补偿' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
// 目标 4 个 tab
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '员工管理' },
{ path: '/money', label: '薪税社保' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
```
**右侧操作区修改**
```tsx
import { Settings, Bell } from 'lucide-react'
// 当前:仅用户下拉菜单
// 目标:通知铃铛(badge) + 设置齿轮 + 用户头像
<div className="flex items-center gap-2 shrink-0">
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
<Settings className="w-4 h-4 text-gray-600" />
</Link>
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
<Bell className="w-4 h-4 text-gray-600" />
{riskCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</button>
{/* 用户菜单保持 */}
</div>
```
**验收标准**:顶部导航 4 个 tab + 设置齿轮 + 通知铃铛 + 用户菜单。
---
### P2.2 移动端底部导航精简为 4
**修改文件**`src/components/layout/MobileTabBar.tsx`
```tsx
import { Home, Users, Calculator, Bot } from 'lucide-react'
// 当前 6 个 → 目标 4 个
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '员工', icon: Users },
{ path: '/money', label: '薪税', icon: Calculator },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
]
```
**验收标准**iPhone SE 上每个 tab ≥ 80px 宽度,图标+文字不挤压。
---
### P2.3 路由调整
**修改文件**`src/App.tsx`
| 变更 | 说明 |
|------|------|
| `/social` 路由保留 | 导航不直接暴露,作为 `/money` 的子 tab 或页面内跳转 |
| `/termination` 路由保留 | 导航不直接暴露,作为 `/roster` 内的功能入口 |
**修改文件**`src/pages/Dashboard.tsx`
```tsx
// 移除 payroll tab
type Tab = 'overview' | 'risk' | 'task' // 移除 'payroll'
const tabs = [
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
// 删除 activeTab === 'payroll' 相关的所有 JSX 块
// 删除 payrollSummary / payrollItems / deductionItems 等相关变量
```
**验收标准**Dashboard 3 个 tab(概览/风险/任务),无薪税重复入口。
---
### P2.4 合并未挂载页面
| 操作 | 源文件 | 目标文件 | 说明 |
|------|--------|----------|------|
| Compensation → Money | `src/pages/Compensation.tsx` | `src/pages/Money.tsx` | 作为薪税页面的子 tab |
| Contracts → Roster | `src/pages/Contracts.tsx` | `src/pages/Roster.tsx` | Roster 详情已有合同 tab,删除或合并 |
**Money.tsx 修改**
```tsx
type Tab = 'batch' | 'template' | 'overtime' | 'payslip' | 'adjust'
const tabs: { key: Tab; label: string }[] = [
{ key: 'batch', label: '发薪批次' },
{ key: 'template', label: '薪酬模版' },
{ key: 'overtime', label: '加班费计算' },
{ key: 'payslip', label: '工资条管理' },
{ key: 'adjust', label: '薪酬调整' }, // 新增
]
// 渲染
{tab === 'adjust' && <CompensationManager />}
```
**验收标准**Contracts/Compensation 功能可访问,无孤立页面。
---
## Phase 3:性能与组件化(2-3 天)
### P3.1 路由懒加载
**修改文件**`src/App.tsx`
```tsx
import { lazy, Suspense } from 'react'
import { Loader2 } from 'lucide-react'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Roster = lazy(() => import('./pages/Roster'))
const Money = lazy(() => import('./pages/Money'))
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
const Termination = lazy(() => import('./pages/Termination'))
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
const Settings = lazy(() => import('./pages/Settings'))
const Login = lazy(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register'))
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
const Payslip = lazy(() => import('./pages/portal/Payslip'))
const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
function PageSkeleton() {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 text-primary animate-spin" />
</div>
)
}
export default function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>...</Routes>
</Suspense>
)
}
```
**验收标准**:首屏仅加载 Dashboard chunk,其他页面按需加载,Network 面板可见独立 chunk。
---
### P3.2 大文件拆分
#### Roster.tsx140KB → 拆分为 17 个文件)
```
src/pages/roster/
├── Roster.tsx # 主页面:列表 + 搜索 + 筛选 + 分页
├── EmployeeDetail.tsx # 详情面板:tab 切换容器
├── tabs/
│ ├── BasicInfoTab.tsx # 基本信息
│ ├── ContractTab.tsx # 合同信息
│ ├── PayslipTab.tsx # 工资条
│ ├── OvertimeTab.tsx # 加班记录
│ ├── DisciplinaryTab.tsx # 违纪记录
│ ├── AttendanceTab.tsx # 考勤记录
│ ├── TrainingTab.tsx # 培训记录
│ ├── PerformanceTab.tsx # 绩效记录
│ ├── TerminationTab.tsx # 解聘记录
│ ├── AttachmentTab.tsx # 附件管理
│ └── EvidenceTab.tsx # 仲裁证据链
├── AddEmployeeModal.tsx # 新增员工弹窗
├── ResignModal.tsx # 离职弹窗
├── RehireModal.tsx # 重新入职弹窗
├── SalaryModal.tsx # 调薪弹窗
├── DeptModal.tsx # 调岗弹窗
└── BatchRenewModal.tsx # 批量续签弹窗
```
#### Money.tsx63KB → 拆分为 6 个文件)
```
src/pages/money/
├── Money.tsx # 主页面:tab 切换
├── BatchManager.tsx # 发薪批次
├── TemplateManager.tsx # 薪酬模版
├── OvertimeCalculator.tsx # 加班费计算
├── PayslipManager.tsx # 工资条管理
└── CompensationManager.tsx # 薪酬调整(从 Compensation.tsx 合入)
```
#### Termination.tsx55KB → 拆分为 6 个文件)
```
src/pages/termination/
├── Termination.tsx # 主页面:向导容器
├── StepSelectEmployee.tsx # 步骤1:选择员工
├── StepSelectReason.tsx # 步骤2:解聘方式
├── StepCompliance.tsx # 步骤3:合规检查
├── StepSettlement.tsx # 步骤4:费用结算
└── StepConfirm.tsx # 步骤5:确认完成
```
#### Settings.tsx46KB → 拆分为 6 个文件)
```
src/pages/settings/
├── Settings.tsx # 主页面:section 切换
├── OrgSettings.tsx # 企业信息
├── UserSettings.tsx # 用户管理
├── PlanSettings.tsx # 套餐
├── NotificationSettings.tsx # 通知设置
└── ImportSettings.tsx # 数据导入
```
**验收标准**:单个文件不超过 500 行,每个子组件独立可测。
---
### P3.3 骨架屏组件
**新增文件**`src/components/ui/Skeleton.tsx`
```tsx
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 rounded animate-pulse" />
))}
</div>
)
}
export function CardSkeleton() {
return (
<div className="p-4 bg-white rounded-lg border border-gray-200">
<div className="h-4 bg-gray-100 rounded w-1/3 mb-3 animate-pulse" />
<div className="h-8 bg-gray-100 rounded w-1/2 animate-pulse" />
</div>
)
}
export function DetailSkeleton() {
return (
<div className="space-y-3">
<div className="h-6 bg-gray-100 rounded w-1/4 animate-pulse" />
<div className="h-4 bg-gray-100 rounded w-full animate-pulse" />
<div className="h-4 bg-gray-100 rounded w-3/4 animate-pulse" />
</div>
)
}
```
**替换清单**13 处 "加载中..."):
| 文件 | 行号 | 替换为 |
|------|------|--------|
| Dashboard.tsx | L112 | `<TableSkeleton rows={4} />` |
| Roster.tsx | L249, L702 | `<TableSkeleton />` |
| Money.tsx | L239, L371, L719, L933, L1268 | `<TableSkeleton />` |
| SocialInsurance.tsx | L314, L623 | `<TableSkeleton rows={3} />` |
| AIAssistant.tsx | L773 | `<TableSkeleton />` |
| portal/Payslip.tsx | L131 | `<CardSkeleton />` |
| portal/MyContract.tsx | L70 | `<CardSkeleton />` |
| portal/ContractConfirm.tsx | L85 | `<CardSkeleton />` |
| Contracts.tsx | L82 | `<TableSkeleton />` |
**验收标准**`grep -r "加载中" src/` 返回 0 结果,加载时显示骨架屏动画。
---
### P3.4 搜索防抖
**修改文件**`src/pages/Roster.tsx`
```tsx
import { useDeferredValue } from 'react'
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
// queryKey 使用 deferredSearch 而非 search
const { data: rosterData } = useQuery({
queryKey: ['roster', page, pageSize, deferredSearch, filterStatus, filterContractStatus],
queryFn: async () => {
const params: any = { page, pageSize }
if (deferredSearch) params.search = deferredSearch
// ...
},
})
```
**验收标准**:快速输入时不会每次按键触发 API 请求,停止输入 ~200ms 后才发请求。
---
## Phase 4:视觉与数据可视化(2-3 天)
### P4.1 主色调暖
**修改文件**`tailwind.config.js`
```js
// 当前:冷蓝
primary: { DEFAULT: '#2563EB', light: '#3B82F6', dark: '#1D4ED8' }
// 目标:indigo-600(略带紫调,专业且亲和)
primary: { DEFAULT: '#4F46E5', light: '#6366F1', dark: '#4338CA' }
```
**影响范围**:所有使用 `text-primary``bg-primary``border-primary` 的组件自动生效。
**验收标准**:主色从冷蓝变为 indigo,与 Tailwind indigo-600 色卡一致。
---
### P4.2 Dashboard 数据可视化
**安装**`npm install recharts`
**修改文件**`src/pages/Dashboard.tsx`
在概览 tab 的统计卡片下方增加:
```tsx
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, PieChart, Pie, Cell } from 'recharts'
// 月度薪税趋势迷你折线图
<Card>
<h2 className="font-medium mb-3"></h2>
<ResponsiveContainer width="100%" height={120}>
<LineChart data={data.payrollHistory}>
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Line type="monotone" dataKey="totalPay" stroke="#4F46E5" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</Card>
// 风险分布环形图
<Card>
<h2 className="font-medium mb-3"></h2>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie data={riskData} dataKey="count" nameKey="label" cx="50%" cy="50%" innerRadius={40} outerRadius={60}>
{riskData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</Card>
```
**验收标准**:Dashboard 概览页有折线图和环形图,图表响应式,tooltip 正常显示。
---
### P4.3 Modal 过渡动画
**修改文件**`src/components/ui/Modal.tsx`
```tsx
import { ReactNode, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
if (open) {
setShow(true)
} else {
const timer = setTimeout(() => setShow(false), 200)
return () => clearTimeout(timer)
}
}, [open])
// body overflow 控制(保持原有逻辑)
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [open])
if (!show && !open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className={clsx(
'fixed inset-0 bg-black/40 transition-opacity duration-200',
open ? 'opacity-100' : 'opacity-0'
)} onClick={onClose} />
<div className={clsx(
'relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto transition-all duration-200',
open ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
sizeClass,
)}>
{/* title + children 保持不变 */}
</div>
</div>
)
}
```
**验收标准**:弹窗有淡入+缩放动画,关闭有淡出动画,~200ms。
---
### P4.4 移动端表格响应式
**新增组件**`src/components/ui/ResponsiveTable.tsx`
```tsx
import { ReactNode } from 'react'
import clsx from 'clsx'
interface Column<T> {
key: string
label: string
render?: (row: T) => ReactNode
priority: 'high' | 'medium' | 'low'
className?: string
}
interface ResponsiveTableProps<T> {
columns: Column<T>[]
data: T[]
rowKey: (row: T) => string
onRowClick?: (row: T) => void
}
export default function ResponsiveTable<T>({ columns, data, rowKey, onRowClick }: ResponsiveTableProps<T>) {
return (
<>
{/* 桌面端/平板:表格 */}
<table className="hidden md:table w-full text-sm">
<thead>
<tr className="border-b">
{columns.map(col => (
<th key={col.key} className={clsx('text-left py-2 px-3 font-medium text-gray-600', col.className)}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map(row => (
<tr key={rowKey(row)} className="border-b hover:bg-gray-50 cursor-pointer" onClick={() => onRowClick?.(row)}>
{columns.map(col => (
<td key={col.key} className="py-2.5 px-3">
{col.render ? col.render(row) : (row as any)[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* 手机端:卡片列表 */}
<div className="md:hidden space-y-2">
{data.map(row => {
const highCols = columns.filter(c => c.priority === 'high')
return (
<div key={rowKey(row)} className="bg-white rounded-lg border border-gray-200 p-3" onClick={() => onRowClick?.(row)}>
{highCols.map(col => (
<div key={col.key} className="flex justify-between py-1">
<span className="text-gray-500 text-sm">{col.label}</span>
<span className="text-gray-900 text-sm font-medium">
{col.render ? col.render(row) : (row as any)[col.key]}
</span>
</div>
))}
</div>
)
})}
</div>
</>
)
}
```
**应用页面**Roster、Money(工资条列表)、SocialInsurance(月度申报表)
**验收标准**:iPhone SE 上列表为卡片模式,iPad 上为表格,桌面端完整表格。
---
## Phase 5a11y 与细节打磨(1-2 天)
### P5.1 div onClick → button + aria
**修改文件**`src/components/layout/TopNav.tsx`
```tsx
// 添加 aria 属性
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-1 ..."
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label="用户菜单"
>
```
---
### P5.2 aria-label 覆盖
| 文件 | 位置 | 添加 |
|------|------|------|
| TopNav.tsx | Logo Link | `aria-label="用工合规助手首页"` |
| TopNav.tsx | 设置齿轮 | `aria-label="设置"` |
| TopNav.tsx | 通知铃铛 | `aria-label="通知"` |
| MobileTabBar.tsx | 每个 Link | `aria-label={tab.label}` |
| Modal.tsx | 关闭按钮 | `aria-label="关闭"` |
| Pagination.tsx | 上一页按钮 | `aria-label="上一页"` |
| Pagination.tsx | 下一页按钮 | `aria-label="下一页"` |
---
### P5.3 focus-visible 样式
**修改文件**`src/index.css`
```css
@layer base {
*:focus-visible {
@apply outline-none ring-2 ring-primary ring-offset-1;
}
}
```
---
### P5.4 表单防离开
**新增文件**`src/hooks/useUnsavedChanges.ts`
```tsx
import { useEffect } from 'react'
/** 表单未保存时阻止页面离开 */
export function useUnsavedChanges(isDirty: boolean) {
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [isDirty])
}
```
**应用**:所有表单页面
```tsx
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
const form = useForm({ mode: 'onChange' })
useUnsavedChanges(form.formState.isDirty)
```
---
### P5.5 虚拟列表
**安装**`npm install @tanstack/react-virtual`
**修改文件**`src/pages/Roster.tsx`(当员工数 > 100 时)
```tsx
import { useVirtualizer } from '@tanstack/react-virtual'
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: employees.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 5,
})
```
**验收标准**:1000+ 员工时列表滚动流畅,DOM 节点数 < 30。
---
## 新增文件清单
| 文件路径 | Phase | 说明 |
|----------|-------|------|
| `src/components/ui/ConfirmDialog.tsx` | P1.4 | 二次确认弹窗 |
| `src/components/ui/Skeleton.tsx` | P3.3 | 骨架屏组件 |
| `src/components/ui/ResponsiveTable.tsx` | P4.4 | 响应式表格 |
| `src/hooks/useUnsavedChanges.ts` | P5.4 | 表单防离开 Hook |
| `src/pages/roster/` 目录(17 文件) | P3.2 | Roster 拆分 |
| `src/pages/money/` 目录(6 文件) | P3.2 | Money 拆分 |
| `src/pages/termination/` 目录(6 文件) | P3.2 | Termination 拆分 |
| `src/pages/settings/` 目录(6 文件) | P3.2 | Settings 拆分 |
---
## 验收检查表
### Phase 1
- [ ] `grep -r "text-xs" src/components/ui/` 仅出现在 size="sm" 和辅助文字处
- [ ] `grep -r "alert(" src/` 返回 0 结果
- [ ] `grep -r "confirm(" src/` 返回 0 结果
- [ ] `grep -r "text-gray-400" src/` 返回 0 结果(全部替换为 gray-500
- [ ] 1920px 屏幕内容居中,最大宽度 1280px
- [ ] axe DevTools 扫描 0 个对比度违规
### Phase 2
- [ ] 顶部导航 4 个 tab
- [ ] 移动端底部导航 4 个 tab
- [ ] Dashboard 3 个 tab(无薪税)
- [ ] Compensation 功能可通过薪税页面访问
- [ ] Contracts 功能可通过花名册访问
### Phase 3
- [ ] 首屏仅加载 Dashboard chunk
- [ ] 单个文件不超过 500 行
- [ ] `grep -r "加载中" src/` 返回 0 结果
- [ ] 搜索快速输入时不触发多余请求
### Phase 4
- [ ] 主色为 indigo-600 (#4F46E5)
- [ ] Dashboard 有折线图和环形图
- [ ] Modal 有淡入淡出动画
- [ ] iPhone SE 上列表为卡片模式
### Phase 5
- [ ] `grep -r "div onClick" src/` 返回 0 结果
- [ ] 所有图标按钮有 aria-label
- [ ] 键盘 Tab 导航可见 focus ring
- [ ] 表单填写中关闭页面有浏览器提示
- [ ] 1000 条数据滚动流畅
+1147
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>用工合规助手</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3915
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "hr-compliance-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@tanstack/react-query": "^5.51.0",
"axios": "^1.7.0",
"clsx": "^2.1.0",
"jspdf": "^4.2.1",
"lucide-react": "^0.428.0",
"qrcode.react": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.10.0",
"sonner": "^2.0.7",
"xlsx": "^0.18.5",
"zod": "^3.23.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.5.0",
"vite": "^5.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+95
View File
@@ -0,0 +1,95 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Toaster } from 'sonner'
import { useAuthStore } from './store/authStore'
import TopNav from './components/layout/TopNav'
import MobileTabBar from './components/layout/MobileTabBar'
import PageContainer from './components/layout/PageContainer'
import { SkeletonPage } from './components/ui/Skeleton'
import OnboardingGuide from './components/OnboardingGuide'
const Login = lazy(() => import('./pages/auth/Login'))
const Register = lazy(() => import('./pages/auth/Register'))
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Money = lazy(() => import('./pages/Money'))
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
const Roster = lazy(() => import('./pages/Roster'))
const Termination = lazy(() => import('./pages/Termination'))
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
const Settings = lazy(() => import('./pages/Settings'))
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
const Payslip = lazy(() => import('./pages/portal/Payslip'))
const MyContract = lazy(() => import('./pages/portal/MyContract'))
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (isAuthenticated) return <Navigate to="/" replace />
return <>{children}</>
}
function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
<TopNav />
<main className="flex-1 py-6 pb-20 md:pb-6">
<PageContainer>
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</PageContainer>
</main>
<MobileTabBar />
<OnboardingGuide />
</div>
)
}
function PortalLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-surface">
<main className="max-w-md mx-auto py-6 px-4">
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</main>
</div>
)
}
export default function App() {
return (
<>
<Routes>
{/* 管理端认证页面 */}
<Route path="/login" element={<Suspense fallback={<SkeletonPage />}><PublicRoute><Login /></PublicRoute></Suspense>} />
<Route path="/register" element={<Suspense fallback={<SkeletonPage />}><PublicRoute><Register /></PublicRoute></Suspense>} />
<Route path="/forgot-password" element={<Suspense fallback={<SkeletonPage />}><PublicRoute><ForgotPassword /></PublicRoute></Suspense>} />
{/* 管理端业务页面 */}
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
{/* 员工端 */}
<Route path="/portal/login" element={<PortalLayout><PortalLogin /></PortalLayout>} />
<Route path="/portal/payslip" element={<PortalLayout><Payslip /></PortalLayout>} />
<Route path="/portal/contract" element={<PortalLayout><MyContract /></PortalLayout>} />
<Route path="/portal/onboarding" element={<PortalLayout><Onboarding /></PortalLayout>} />
<Route path="/portal/contract-confirm" element={<PortalLayout><ContractConfirm /></PortalLayout>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster position="top-center" richColors closeButton />
</>
)
}
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { X, ArrowRight } from 'lucide-react'
const STORAGE_KEY = 'hr-onboarding-completed'
const steps = [
{
icon: '🏠',
title: '这里看风险',
description: '首页展示企业用工风险总览,红色代表高风险项,点击「去处理」直接跳转操作。',
},
{
icon: '',
title: '这里管花名册',
description: '花名册页面管理员工档案、劳动合同、附件,以及违纪、考勤、培训、绩效记录,可生成仲裁证据链。',
},
{
icon: '💰',
title: '这里算薪税',
description: '薪税页面提供加班费、双倍工资、社保公积金计算器和工资条管理,输入参数实时计算。',
},
]
export default function OnboardingGuide() {
const [visible, setVisible] = useState(false)
const [step, setStep] = useState(0)
useEffect(() => {
const completed = localStorage.getItem(STORAGE_KEY)
if (!completed) {
setVisible(true)
}
}, [])
const close = () => {
localStorage.setItem(STORAGE_KEY, '1')
setVisible(false)
}
if (!visible) return null
const current = steps[step]
const isLast = step === steps.length - 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="flex justify-end p-2">
<button onClick={close} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 pb-6">
<div className="text-5xl text-center mb-4">{current.icon}</div>
<h2 className="text-lg font-semibold text-center mb-2">{current.title}</h2>
<p className="text-sm text-gray-600 text-center mb-6">{current.description}</p>
{/* 进度指示器 */}
<div className="flex justify-center gap-1.5 mb-6">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 rounded-full transition-all ${i === step ? 'w-6 bg-primary' : 'w-1.5 bg-gray-300'}`}
/>
))}
</div>
<div className="flex justify-between">
{step > 0 ? (
<button onClick={() => setStep(step - 1)} className="text-sm text-gray-500"></button>
) : <span />}
<button
onClick={() => isLast ? close() : setStep(step + 1)}
className="flex items-center gap-1 text-sm font-medium text-primary"
>
{isLast ? '开始使用' : '下一步'}
{!isLast && <ArrowRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,37 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, Users, Calculator, UserX, Bot, Shield } from 'lucide-react'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览', icon: Home },
{ path: '/roster', label: '员工', icon: Users },
{ path: '/money', label: '薪税', icon: Calculator },
{ path: '/social', label: '社保', icon: Shield },
{ path: '/termination', label: '解聘', icon: UserX },
{ path: '/ai-assistant', label: 'AI', icon: Bot },
]
export default function MobileTabBar() {
const location = useLocation()
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex justify-around items-center h-14 z-50">
{tabs.map((tab) => {
const Icon = tab.icon
const active = location.pathname === tab.path
return (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'flex flex-col items-center justify-center gap-0.5 flex-1 h-full',
active ? 'text-primary' : 'text-gray-500',
)}
>
<Icon className="w-5 h-5" />
<span className="text-xs">{tab.label}</span>
</Link>
)
})}
</nav>
)
}
@@ -0,0 +1,10 @@
import { ReactNode } from 'react'
import clsx from 'clsx'
export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) {
return (
<div className={clsx('max-w-content mx-auto px-4', className)}>
{children}
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import clsx from 'clsx'
const tabs = [
{ path: '/', label: '总览' },
{ path: '/roster', label: '员工管理' },
{ path: '/money', label: '薪税' },
{ path: '/social', label: '社保公积金' },
{ path: '/termination', label: '解聘补偿' },
{ path: '/ai-assistant', label: 'AI顾问' },
]
export default function TopNav() {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [menuOpen, setMenuOpen] = useState(false)
const { data: dashboardData } = useQuery<any>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
refetchInterval: 60000,
})
const riskCount = dashboardData?.riskSummary?.pending || 0
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 shrink-0">
<Building2 className="w-5 h-5 text-primary" />
<span className="hidden sm:inline"></span>
</Link>
<nav className="hidden md:flex items-center gap-1 flex-1">
{tabs.map((tab) => (
<Link
key={tab.path}
to={tab.path}
className={clsx(
'px-3 py-1.5 rounded-md text-sm font-medium transition-colors relative',
location.pathname === tab.path
? 'bg-primary/10 text-primary'
: 'text-gray-600 hover:bg-gray-100',
)}
>
{tab.label}
{tab.path === '/' && riskCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</Link>
))}
</nav>
<div className="flex items-center gap-2 shrink-0">
<Link to="/settings" className="p-1.5 rounded-md hover:bg-gray-100" aria-label="设置">
<SettingsIcon className="w-4 h-4 text-gray-600" />
</Link>
<button className="relative p-1.5 rounded-md hover:bg-gray-100" aria-label="通知">
<Bell className="w-4 h-4 text-gray-600" />
{riskCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
{riskCount > 99 ? '99+' : riskCount}
</span>
)}
</button>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex items-center gap-1 px-2 py-1.5 rounded-md hover:bg-gray-100"
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label="用户菜单"
>
<span className="text-sm text-gray-700 hidden sm:inline">{user?.name || '用户'}</span>
<ChevronDown className="w-4 h-4 text-gray-500" />
</button>
{menuOpen && (
<>
<button className="fixed inset-0 z-10 cursor-default" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />
<div className="absolute right-0 mt-1 w-40 bg-white rounded-md shadow-lg border border-gray-200 z-20">
<Link
to="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setMenuOpen(false)}
>
</Link>
<button
onClick={() => {
logout()
navigate('/login')
}}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
退
</button>
</div>
</>
)}
</div>
</div>
</div>
</header>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { ButtonHTMLAttributes } from 'react'
import clsx from 'clsx'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export default function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) {
return (
<button
className={clsx(
'inline-flex items-center justify-center font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary text-white hover:bg-primary-dark': variant === 'primary',
'bg-gray-100 text-gray-700 hover:bg-gray-200': variant === 'secondary',
'bg-danger text-white hover:bg-red-700': variant === 'danger',
'px-2.5 py-1 text-xs': size === 'sm',
'px-4 py-2 text-sm': size === 'md',
'px-5 py-2.5 text-base': size === 'lg',
},
className,
)}
{...props}
>
{children}
</button>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { HTMLAttributes } from 'react'
import clsx from 'clsx'
export default function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx('bg-white rounded-lg shadow-sm border border-gray-200 p-4', className)} {...props}>
{children}
</div>
)
}
@@ -0,0 +1,38 @@
import Modal from './Modal'
import Button from './Button'
interface ConfirmDialogProps {
open: boolean
title: string
message: string
confirmLabel?: string
cancelLabel?: string
variant?: 'danger' | 'primary'
onConfirm: () => void
onCancel: () => void
}
/**
* 二次确认弹窗组件
* 用于危险操作(删除、归档、批量操作等)的二次确认
*/
export default function ConfirmDialog({
open,
title,
message,
confirmLabel = '确认',
cancelLabel = '取消',
variant = 'danger',
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onCancel} title={title} size="sm">
<p className="text-sm text-gray-600 mb-4">{message}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onCancel}>{cancelLabel}</Button>
<Button variant={variant} onClick={onConfirm}>{confirmLabel}</Button>
</div>
</Modal>
)
}
+26
View File
@@ -0,0 +1,26 @@
import { ReactNode } from 'react'
import { Inbox } from 'lucide-react'
import Button from './Button'
interface EmptyStateProps {
icon?: ReactNode
title: string
description?: string
actionLabel?: string
onAction?: () => void
}
export default function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="text-gray-300 mb-4">
{icon || <Inbox className="w-12 h-12" />}
</div>
<h3 className="text-base font-medium text-gray-900 mb-1">{title}</h3>
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
{actionLabel && onAction && (
<Button onClick={onAction}>{actionLabel}</Button>
)}
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { InputHTMLAttributes, SelectHTMLAttributes, forwardRef } from 'react'
import clsx from 'clsx'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...props }, ref) {
return (
<input
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm',
className,
)}
{...props}
/>
)
}
)
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...props }, ref) {
return (
<select
ref={ref}
className={clsx(
'w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm bg-white',
className,
)}
{...props}
>
{children}
</select>
)
}
)
export function Label({ children, className }: { children: React.ReactNode; className?: string }) {
return <label className={clsx('block text-sm font-medium text-gray-700 mb-1', className)}>{children}</label>
}
+62
View File
@@ -0,0 +1,62 @@
import { ReactNode, useEffect, useState } from 'react'
import { X } from 'lucide-react'
import clsx from 'clsx'
interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
className?: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
const [show, setShow] = useState(false)
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden'
requestAnimationFrame(() => setShow(true))
} else {
document.body.style.overflow = ''
setShow(false)
}
return () => {
document.body.style.overflow = ''
}
}, [open])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={clsx('fixed inset-0 bg-black/40 transition-opacity duration-200', show ? 'opacity-100' : 'opacity-0')}
onClick={onClose}
/>
<div
className={clsx(
'relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto transition-all duration-200',
show ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
{
'max-w-md': size === 'sm',
'max-w-lg': size === 'md',
'max-w-2xl': size === 'lg',
'max-w-4xl': size === 'xl',
},
className,
)}>
{title && (
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="p-4">{children}</div>
</div>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import clsx from 'clsx'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
page: number // 当前页(1-based
pageSize: number // 每页条数
total: number // 总条数
onPageChange: (page: number) => void
onPageSizeChange?: (size: number) => void
pageSizeOptions?: number[]
}
export default function Pagination({
page,
pageSize,
total,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50],
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const start = total === 0 ? 0 : (page - 1) * pageSize + 1
const end = Math.min(page * pageSize, total)
// 生成页码按钮(最多显示 7 个)
const pages: (number | '...')[] = []
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i)
} else {
pages.push(1)
if (page > 3) pages.push('...')
const s = Math.max(2, page - 1)
const e = Math.min(totalPages - 1, page + 1)
for (let i = s; i <= e; i++) pages.push(i)
if (page < totalPages - 2) pages.push('...')
pages.push(totalPages)
}
return (
<div className="flex items-center justify-between gap-4 py-2">
{/* 左侧:条数信息 + 每页条数选择 */}
<div className="flex items-center gap-3 text-sm text-gray-500">
<span> {total} </span>
{onPageSizeChange && (
<select
className="border rounded px-1.5 py-0.5 text-sm text-gray-600 focus:outline-none focus:border-primary"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>{n} /</option>
))}
</select>
)}
<span> {start}-{end} </span>
</div>
{/* 右侧:页码导航 */}
<div className="flex items-center gap-1">
<button
className="p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
>
<ChevronLeft className="w-4 h-4" />
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-gray-500 text-sm"></span>
) : (
<button
key={p}
className={clsx(
'min-w-[28px] h-7 rounded text-sm font-medium transition-colors',
p === page
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100',
)}
onClick={() => onPageChange(p)}
>
{p}
</button>
),
)}
<button
className="p-1 rounded text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import clsx from 'clsx'
type Level = 'high' | 'medium' | 'low' | 'safe'
const colors: Record<Level, string> = {
high: 'bg-danger',
medium: 'bg-warning',
low: 'bg-yellow-400',
safe: 'bg-safe',
}
const labels: Record<Level, string> = {
high: '🔴',
medium: '🟡',
low: '🟡',
safe: '🟢',
}
export default function Signal({ level, label }: { level: Level; label?: string }) {
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className={clsx('w-2 h-2 rounded-full', colors[level])} />
{label && <span className="text-gray-700">{label}</span>}
{!label && <span>{labels[level]}</span>}
</span>
)
}
+61
View File
@@ -0,0 +1,61 @@
import clsx from 'clsx'
interface SkeletonProps {
className?: string
lines?: number
}
/**
* 骨架屏组件
* 用于数据加载时的占位显示,减少布局闪烁
*/
export function Skeleton({ className }: SkeletonProps) {
return <div className={clsx('animate-pulse rounded bg-gray-200', className)} />
}
/**
* 多行文本骨架屏
*/
export function SkeletonText({ lines = 3, className }: SkeletonProps) {
return (
<div className={clsx('space-y-2', className)}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} className={clsx('h-4', i === lines - 1 && 'w-2/3')} />
))}
</div>
)
}
/**
* 卡片骨架屏
*/
export function SkeletonCard() {
return (
<div className="p-4 rounded-lg border border-gray-200 space-y-3">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
)
}
/**
* 页面级骨架屏
*/
export function SkeletonPage() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="flex gap-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-20" />
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { useState, useEffect } from 'react'
/**
* 防抖 Hook
* 延迟更新值,适用于搜索输入框等频繁触发的场景
* @param value 原始值
* @param delay 延迟毫秒数,默认 300ms
* @returns 防抖后的值
*/
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
+52
View File
@@ -0,0 +1,52 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-surface text-gray-900 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
}
* {
@apply box-border;
}
h1 { @apply text-lg font-semibold; }
h2 { @apply text-base font-semibold; }
h3 { @apply text-sm font-medium; }
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-3 py-1.5 rounded font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary text-white hover:bg-primary-dark;
}
.btn-secondary {
@apply btn bg-gray-100 text-gray-700 hover:bg-gray-200;
}
.btn-danger {
@apply btn bg-danger text-white hover:bg-red-700;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-4;
}
.input {
@apply w-full px-2.5 py-1.5 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-sm;
}
.label {
@apply block text-sm font-medium text-gray-700 mb-1;
}
}
@media print {
header, nav, .no-print { display: none !important; }
main { padding: 0 !important; max-width: 100% !important; }
.card { box-shadow: none !important; border: 1px solid #ccc !important; break-inside: avoid; }
body { background: white !important; }
a { color: inherit !important; text-decoration: none !important; }
}
+47
View File
@@ -0,0 +1,47 @@
import axios from 'axios'
import { useAuthStore } from '../store/authStore'
const api = axios.create({
baseURL: '/api/v1',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
let isRefreshing = false
api.interceptors.response.use(
(response) => response.data,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
if (isRefreshing) return Promise.reject(error)
isRefreshing = true
try {
const refreshToken = useAuthStore.getState().refreshToken
if (!refreshToken) throw new Error('No refresh token')
const res = await axios.post('/api/v1/auth/refresh', { refreshToken })
const newToken = res.data.data.accessToken
useAuthStore.getState().updateToken(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return api(originalRequest)
} catch {
useAuthStore.getState().logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
},
)
export default api
+26
View File
@@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)
+847
View File
@@ -0,0 +1,847 @@
import { useState, useRef, useEffect } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen } from 'lucide-react'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge'
interface Message {
role: 'user' | 'assistant'
content: string
}
const QUICK_QUESTIONS = [
'员工入职没签合同怎么办?',
'加班费怎么算?',
'辞退员工需要赔多少?',
'试用期最长可以约定几个月?',
]
export default function AIAssistant() {
const [tab, setTab] = useState<Tab>('chat')
const tabs: { key: Tab; label: string; icon: typeof Bot }[] = [
{ key: 'chat', label: '智能问答', icon: Bot },
{ key: 'predict', label: '风险预测', icon: Sparkles },
{ key: 'review', label: '合同审查', icon: FileSearch },
{ key: 'case', label: '案例匹配', icon: Scale },
{ key: 'knowledge', label: '知识库', icon: BookOpen },
]
return (
<div className="space-y-4">
<h1 className="text-xs font-medium">AI </h1>
<div className="flex gap-1 border-b overflow-x-auto">
{tabs.map((t) => {
const Icon = t.icon
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-4 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
)
})}
</div>
{tab === 'chat' && <ChatTab />}
{tab === 'predict' && <PredictTab />}
{tab === 'review' && <ReviewTab />}
{tab === 'case' && <CaseTab />}
{tab === 'knowledge' && <KnowledgeTab />}
</div>
)
}
function ChatTab() {
const queryClient = useQueryClient()
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' },
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [recording, setRecording] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [currentConvId, setCurrentConvId] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const recognitionRef = useRef<any>(null)
const saveTimerRef = useRef<any>(null)
const { data: conversations } = useQuery<any[]>({
queryKey: ['ai-conversations'],
queryFn: async () => {
const res = await api.get('/ai/conversations') as any
return res.data
},
})
const deleteConvMutation = useMutation({
mutationFn: (id: string) => api.delete(`/ai/conversations/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }),
})
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}, [messages])
// 自动保存会话(debounce
useEffect(() => {
if (messages.length <= 1) return
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const title = messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'
if (currentConvId) {
await api.put(`/ai/conversations/${currentConvId}`, { messages }).catch(() => {})
} else {
const res = await api.post('/ai/conversations', { title, messages }) as any
if (res.data?.id) {
setCurrentConvId(res.data.id)
queryClient.invalidateQueries({ queryKey: ['ai-conversations'] })
}
}
}, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
}, [messages])
const loadConversation = async (id: string) => {
try {
const res = await api.get(`/ai/conversations/${id}`) as any
if (res.data?.messages) {
setMessages(res.data.messages)
setCurrentConvId(id)
setShowHistory(false)
}
} catch {}
}
const newConversation = () => {
setMessages([{ role: 'assistant', content: '你好!我是你的用工合规顾问,有什么劳动法问题可以直接问我。\n\n你可以问我:\n· 员工入职没签合同怎么办?\n· 加班费怎么算?\n· 辞退员工需要赔多少?' }])
setCurrentConvId(null)
setShowHistory(false)
}
const toggleVoice = () => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
toast.error('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
return
}
if (recording) {
recognitionRef.current?.stop()
setRecording(false)
return
}
const recognition = new SpeechRecognition()
recognition.lang = 'zh-CN'
recognition.continuous = false
recognition.interimResults = false
recognition.onresult = (event: any) => {
const transcript = event.results[0]?.[0]?.transcript || ''
setInput((prev) => prev + transcript)
}
recognition.onerror = () => setRecording(false)
recognition.onend = () => setRecording(false)
recognition.start()
recognitionRef.current = recognition
setRecording(true)
}
const send = async (text?: string) => {
const content = text || input.trim()
if (!content || loading) return
const newMessages = [...messages, { role: 'user' as const, content }]
setMessages([...newMessages, { role: 'assistant', content: '' }])
setInput('')
setLoading(true)
try {
const token = useAuthStore.getState().accessToken
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 35 * 1000)
const response = await fetch('/api/v1/ai/chat-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ messages: newMessages }),
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
const errData = await response.json().catch(() => null)
throw new Error(errData?.error?.message || '请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
let accumulated = ''
let buffer = ''
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim()
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
if (parsed.delta) {
accumulated += parsed.delta
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
}
} catch {
// ignore parse errors
}
}
}
}
}
if (!accumulated) {
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
}
} catch (err: any) {
const isTimeout = err.name === 'AbortError'
setMessages([...newMessages, { role: 'assistant', content: isTimeout ? '请求超时,AI 服务响应时间过长,请稍后重试或简化问题。' : `抱歉,出错了:${err.message || '请稍后重试'}` }])
} finally {
setLoading(false)
}
}
return (
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
{/* 顶部操作栏 */}
<div className="flex items-center gap-2 pb-2 border-b">
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowHistory(!showHistory)}><MessageSquare className="w-4 h-4 mr-1" /></Button>
{conversations && conversations.length > 0 && (
<span className="text-xs text-gray-400">{conversations.length} </span>
)}
</div>
{/* 历史会话列表 */}
{showHistory && (
<div className="border-b pb-2 max-h-40 overflow-y-auto">
{conversations && conversations.length > 0 ? conversations.map((c: any) => (
<div key={c.id} className="flex items-center justify-between px-2 py-1.5 hover:bg-gray-50 rounded cursor-pointer text-xs">
<span className="flex-1 truncate" onClick={() => loadConversation(c.id)}>{c.title}</span>
<span className="text-gray-400 ml-2">{new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
<button onClick={(e) => { e.stopPropagation(); deleteConvMutation.mutate(c.id) }} className="ml-2 text-gray-400 hover:text-danger"><Trash2 className="w-3 h-3" /></button>
</div>
)) : <div className="text-xs text-gray-400 py-2 text-center"></div>}
</div>
)}
<div ref={scrollRef} className="flex-1 overflow-y-auto space-y-4 pb-4">
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] px-4 py-3 rounded-lg text-xs whitespace-pre-wrap ${
msg.role === 'user' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-800'
}`}>
{msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')}
</div>
</div>
))}
</div>
{/* 快捷问题 */}
{messages.length <= 1 && (
<div className="flex flex-wrap gap-2 pb-3">
{QUICK_QUESTIONS.map((q) => (
<button
key={q}
onClick={() => send(q)}
className="px-3 py-1.5 text-xs rounded-full border border-gray-300 text-gray-600 hover:bg-gray-50"
>
{q}
</button>
))}
</div>
)}
{/* 输入框 */}
<div className="flex gap-2 pt-2 border-t">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && send()}
placeholder="输入问题..."
disabled={loading}
/>
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
<Mic className="w-4 h-4" />
</Button>
<Button onClick={() => send()} disabled={loading || !input.trim()}>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
</Button>
</div>
</div>
)
}
function PredictTab() {
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [scope, setScope] = useState('all')
const [riskType, setRiskType] = useState('all')
const [department, setDepartment] = useState('')
const [employeeId, setEmployeeId] = useState('')
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-list'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data?.items || res.data || []
},
})
const departments = [...new Set((employees || []).map((e: any) => e.department).filter(Boolean))]
const fetchPrediction = async () => {
setLoading(true)
try {
const params: Record<string, string> = {}
if (scope === 'department' && department) params.department = department
if (scope === 'employee' && employeeId) params.employeeId = employeeId
if (riskType !== 'all') params.riskType = riskType
const res = await api.get('/ai/predict', { params }) as any
setResult(res.data.result)
} catch (err: any) {
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPrediction()
}, [])
return (
<Card>
<div className="flex items-center gap-2 mb-4">
<Sparkles className="w-5 h-5 text-primary" />
<h2 className="font-medium">AI </h2>
</div>
{/* 筛选条件 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
<div>
<Label></Label>
<Select value={scope} onChange={(e) => setScope(e.target.value)}>
<option value="all"></option>
<option value="department"></option>
<option value="employee"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={riskType} onChange={(e) => setRiskType(e.target.value)}>
<option value="all"></option>
<option value="contract"></option>
<option value="salary"></option>
<option value="termination"></option>
</Select>
</div>
{scope === 'department' && (
<div>
<Label></Label>
<Select value={department} onChange={(e) => setDepartment(e.target.value)}>
<option value=""></option>
{departments.map((d: string) => <option key={d} value={d}>{d}</option>)}
</Select>
</div>
)}
{scope === 'employee' && (
<div>
<Label></Label>
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}</option>)}
</Select>
</div>
)}
</div>
{loading ? (
<div className="flex items-center gap-2 text-gray-400 py-8">
<Loader2 className="w-5 h-5 animate-spin" /> ...
</div>
) : (
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
)}
<div className="mt-4">
<Button variant="secondary" size="sm" onClick={fetchPrediction} disabled={loading}></Button>
</div>
</Card>
)
}
function ReviewTab() {
const [contractText, setContractText] = useState('')
const [result, setResult] = useState<any>(null)
const [loading, setLoading] = useState(false)
const [showSaveModal, setShowSaveModal] = useState(false)
const [saveEmployeeId, setSaveEmployeeId] = useState('')
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-list'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data?.items || res.data || []
},
})
const handleReview = async () => {
if (!contractText.trim()) return
setLoading(true)
setResult(null)
try {
const res = await api.post('/ai/review', { contractText }) as any
setResult(res.data)
} catch (err: any) {
setResult({ error: `出错了:${err.response?.data?.error?.message || '请稍后重试'}` })
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!saveEmployeeId || !result) return
try {
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'REVIEW', input: contractText, result: result.text || JSON.stringify(result) })
setShowSaveModal(false)
setSaveEmployeeId('')
toast.success('已保存到员工档案')
} catch (err: any) {
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
}
}
const levelConfig: Record<string, { color: string; bg: string; label: string }> = {
RED: { color: 'text-red-600', bg: 'bg-red-50', label: '高风险' },
YELLOW: { color: 'text-yellow-600', bg: 'bg-yellow-50', label: '中风险' },
GREEN: { color: 'text-green-600', bg: 'bg-green-50', label: '低风险' },
}
return (
<div className="space-y-4">
<Card>
<div className="flex items-center gap-2 mb-4">
<FileSearch className="w-5 h-5 text-primary" />
<h2 className="font-medium"></h2>
</div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[200px] resize-y"
placeholder="粘贴劳动合同文本..."
value={contractText}
onChange={(e) => setContractText(e.target.value)}
/>
<div className="mt-3">
<Button onClick={handleReview} disabled={loading || !contractText.trim()}>
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />...</> : '开始审查'}
</Button>
</div>
</Card>
{result && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" /></Button>
</div>
{result.error ? (
<div className="text-xs text-danger">{result.error}</div>
) : result.structured ? (
<div className="space-y-3">
{/* 合规评分 */}
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500"></span>
<span className={`text-lg font-bold ${result.structured.score >= 80 ? 'text-safe' : result.structured.score >= 60 ? 'text-warning' : 'text-danger'}`}>
{result.structured.score}/100
</span>
</div>
{/* 风险项列表 */}
{result.structured.riskItems.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium">{result.structured.riskItems.length}</h4>
{result.structured.riskItems.map((item: any, i: number) => {
const cfg = levelConfig[item.level] || levelConfig.YELLOW
return (
<div key={i} className={`rounded-md p-3 ${cfg.bg}`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
<span className="text-xs font-medium">{item.title}</span>
</div>
<div className="text-xs text-gray-600 mb-1">{item.description}</div>
<div className="text-xs text-gray-500">{item.suggestion}</div>
</div>
)
})}
</div>
)}
{/* 总体建议 */}
{result.structured.summary && (
<div className="border-t pt-2">
<h4 className="text-xs font-medium mb-1"></h4>
<p className="text-xs text-gray-600">{result.structured.summary}</p>
</div>
)}
{/* 原始文本可展开 */}
<details className="border-t pt-2">
<summary className="text-xs text-gray-400 cursor-pointer"></summary>
<div className="text-xs text-gray-700 whitespace-pre-wrap mt-2">{result.text}</div>
</details>
</div>
) : (
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result.text || JSON.stringify(result)}</div>
)}
</Card>
)}
{showSaveModal && (
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
<div className="space-y-3">
<h3 className="font-medium"></h3>
<Label></Label>
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}></Button>
</div>
</div>
</Modal>
)}
</div>
)
}
function CaseTab() {
const [scenario, setScenario] = useState('')
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [showSaveModal, setShowSaveModal] = useState(false)
const [saveEmployeeId, setSaveEmployeeId] = useState('')
const [showTodoModal, setShowTodoModal] = useState(false)
const [todoEmployeeId, setTodoEmployeeId] = useState('')
const [todoTitle, setTodoTitle] = useState('')
const [todoLevel, setTodoLevel] = useState('MEDIUM')
const [todoType, setTodoType] = useState('TERMINATION')
const [creatingTodo, setCreatingTodo] = useState(false)
const { data: employees } = useQuery<any[]>({
queryKey: ['roster-list'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data?.items || res.data || []
},
})
const handleMatch = async () => {
if (!scenario.trim()) return
setLoading(true)
setResult('')
try {
const res = await api.post('/ai/match-case', { scenario }) as any
setResult(res.data.result)
} catch (err: any) {
setResult(`出错了:${err.response?.data?.error?.message || '请稍后重试'}`)
} finally {
setLoading(false)
}
}
const handleSave = async () => {
if (!saveEmployeeId || !result) return
try {
await api.post('/ai/review/save', { employeeId: saveEmployeeId, type: 'CASE', input: scenario, result })
setShowSaveModal(false)
setSaveEmployeeId('')
toast.success('已保存到员工档案')
} catch (err: any) {
toast.error('保存失败:' + (err.response?.data?.error?.message || '请稍后重试'))
}
}
const handleCreateTodo = async () => {
if (!todoEmployeeId || !todoTitle) return
setCreatingTodo(true)
try {
await api.post('/ai/case-to-todo', {
employeeId: todoEmployeeId,
title: todoTitle,
description: result.slice(0, 500),
level: todoLevel,
type: todoType,
})
setShowTodoModal(false)
setTodoEmployeeId('')
setTodoTitle('')
toast.success('已创建待办风险项')
} catch (err: any) {
toast.error('创建失败:' + (err.response?.data?.error?.message || '请稍后重试'))
} finally {
setCreatingTodo(false)
}
}
return (
<div className="space-y-4">
<Card>
<div className="flex items-center gap-2 mb-4">
<Scale className="w-5 h-5 text-primary" />
<h2 className="font-medium"></h2>
</div>
<Label></Label>
<textarea
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-xs min-h-[150px] resize-y"
placeholder="例如:员工入职3个月没签合同,现在要辞退他..."
value={scenario}
onChange={(e) => setScenario(e.target.value)}
/>
<div className="mt-3">
<Button onClick={handleMatch} disabled={loading || !scenario.trim()}>
{loading ? <><Loader2 className="w-4 h-4 animate-spin mr-1" />...</> : '分析'}
</Button>
</div>
</Card>
{result && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTodoModal(true)}><Plus className="w-4 h-4 mr-1" /></Button>
<Button size="sm" variant="secondary" onClick={() => setShowSaveModal(true)}><Save className="w-4 h-4 mr-1" /></Button>
</div>
</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap">{result}</div>
</Card>
)}
{showSaveModal && (
<Modal open onClose={() => setShowSaveModal(false)} size="sm">
<div className="space-y-3">
<h3 className="font-medium"></h3>
<Label></Label>
<Select value={saveEmployeeId} onChange={(e) => setSaveEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowSaveModal(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={!saveEmployeeId}></Button>
</div>
</div>
</Modal>
)}
{showTodoModal && (
<Modal open onClose={() => setShowTodoModal(false)}>
<div className="space-y-3">
<h3 className="font-medium"></h3>
<div>
<Label></Label>
<Select value={todoEmployeeId} onChange={(e) => setTodoEmployeeId(e.target.value)}>
<option value=""></option>
{(employees || []).map((e: any) => <option key={e.id} value={e.id}>{e.name}{e.department}</option>)}
</Select>
</div>
<div>
<Label></Label>
<Input value={todoTitle} onChange={(e) => setTodoTitle(e.target.value)} placeholder="如:未签合同风险处理" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={todoLevel} onChange={(e) => setTodoLevel(e.target.value)}>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
<option value="LOW"></option>
</Select>
</div>
<div>
<Label></Label>
<Select value={todoType} onChange={(e) => setTodoType(e.target.value)}>
<option value="CONTRACT"></option>
<option value="SALARY"></option>
<option value="TERMINATION"></option>
<option value="MONTHLY"></option>
<option value="ONBOARDING"></option>
</Select>
</div>
</div>
<div className="text-xs text-gray-400"></div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowTodoModal(false)}></Button>
<Button size="sm" onClick={handleCreateTodo} disabled={!todoEmployeeId || !todoTitle || creatingTodo}>
{creatingTodo ? '创建中...' : '创建待办'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
function KnowledgeTab() {
const queryClient = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [newItem, setNewItem] = useState({ title: '', content: '', source: '自定义', category: '其他' })
const [adding, setAdding] = useState(false)
const { data: knowledgeList, isLoading } = useQuery<any[]>({
queryKey: ['rag-knowledge'],
queryFn: async () => {
const res = await api.get('/ai/rag/list') as any
return res.data
},
})
const addMutation = useMutation({
mutationFn: async (data: typeof newItem) => {
return await api.post('/ai/rag/add', data)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] })
setShowAdd(false)
setNewItem({ title: '', content: '', source: '自定义', category: '其他' })
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/ai/rag/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
})
const seedMutation = useMutation({
mutationFn: () => api.post('/ai/rag/seed'),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['rag-knowledge'] }),
})
const handleAdd = async () => {
if (!newItem.title || !newItem.content) return
setAdding(true)
try {
await addMutation.mutateAsync(newItem)
} finally {
setAdding(false)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {knowledgeList?.length || 0} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => seedMutation.mutate()} disabled={seedMutation.isPending}>
{seedMutation.isPending ? '初始化中...' : '初始化知识库'}
</Button>
<Button size="sm" onClick={() => setShowAdd(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !knowledgeList || knowledgeList.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400"></div></Card>
) : (
<div className="space-y-2">
{knowledgeList.map((item: any) => (
<Card key={item.id}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium">{item.title}</span>
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-500 text-xs">{item.category}</span>
</div>
<p className="text-xs text-gray-500 line-clamp-2">{item.content}</p>
<div className="text-xs text-gray-400 mt-1">{item.source}</div>
</div>
<button
onClick={() => deleteMutation.mutate(item.id)}
className="text-gray-400 hover:text-danger flex-shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
</div>
)}
{showAdd && (
<Modal open onClose={() => setShowAdd(false)}>
<div className="space-y-3">
<h3 className="font-medium"></h3>
<div>
<Label></Label>
<Input value={newItem.title} onChange={(e) => setNewItem({ ...newItem, title: e.target.value })} placeholder="如:劳动合同法第十条" />
</div>
<div>
<Label></Label>
<textarea
value={newItem.content}
onChange={(e) => setNewItem({ ...newItem, content: e.target.value })}
placeholder="法律条文或知识内容"
rows={5}
className="w-full px-3 py-2 rounded-md border border-gray-300 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={newItem.source} onChange={(e) => setNewItem({ ...newItem, source: e.target.value })} placeholder="如:劳动合同法" />
</div>
<div>
<Label></Label>
<Select value={newItem.category} onChange={(e) => setNewItem({ ...newItem, category: e.target.value })}>
<option value="其他"></option>
<option value="法律法规"></option>
<option value="司法解释"></option>
<option value="地方性法规"></option>
<option value="案例分析"></option>
</Select>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}></Button>
<Button size="sm" onClick={handleAdd} disabled={!newItem.title || !newItem.content || adding}>
{adding ? '添加中...' : '添加'}
</Button>
</div>
</div>
</Modal>
)}
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Calculator, Info, AlertCircle } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
interface EmployeeOption {
id: string
name: string
department: string
hireDate: string
monthlySalary: number
status: string
contracts?: any[]
}
function useEmployees() {
return useQuery<EmployeeOption[]>({
queryKey: ['roster-for-compensation'],
queryFn: async () => {
const res = await api.get('/roster') as any
return res.data
},
})
}
function EmployeeSelector({ employees, selectedId, onSelect }: {
employees?: EmployeeOption[]
selectedId: string
onSelect: (emp: EmployeeOption | null) => void
}) {
return (
<div>
<Label></Label>
<Select value={selectedId} onChange={(e) => {
const emp = employees?.find((x) => x.id === e.target.value)
onSelect(emp || null)
}}>
<option value="">-- --</option>
{employees?.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.name}{emp.department}
</option>
))}
</Select>
</div>
)
}
export default function Compensation() {
const [tab, setTab] = useState<'severance' | 'double'>('severance')
const tabs: { key: typeof tab; label: string }[] = [
{ key: 'severance', label: '经济补偿金' },
{ key: 'double', label: '双倍工资' },
]
return (
<div className="space-y-3">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-1 border-b">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'severance' && <SeveranceCalculator />}
{tab === 'double' && <DoubleSalaryCalculator />}
</div>
)
}
function SeveranceCalculator() {
const { data: employees } = useEmployees()
const [selectedEmpId, setSelectedEmpId] = useState('')
const [hireDate, setHireDate] = useState('')
const [leaveDate, setLeaveDate] = useState('')
const [avgWage, setAvgWage] = useState(8000)
const [reason, setReason] = useState('negotiated')
const [socialAvgWage, setSocialAvgWage] = useState(0)
const [result, setResult] = useState<any>(null)
const handleSelectEmp = (emp: EmployeeOption | null) => {
setSelectedEmpId(emp?.id || '')
if (emp) {
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
setAvgWage(emp.monthlySalary || 8000)
}
}
const reasonMap: Record<string, { label: string; multiplier: number; extra: string; illegal: boolean }> = {
negotiated: { label: '协商一致解除', multiplier: 1, extra: '', illegal: false },
fault: { label: '员工过错解除', multiplier: 0, extra: '员工过错解除,无需支付经济补偿金', illegal: false },
nonfault: { label: '非过错解除', multiplier: 1, extra: '额外支付1个月代通知金', illegal: false },
layoff: { label: '经济性裁员', multiplier: 1, extra: '', illegal: false },
expired: { label: '合同到期不续签', multiplier: 1, extra: '用人单位不续签或降低条件续签', illegal: false },
illegal: { label: '违法解除', multiplier: 2, extra: '违法解除劳动合同,按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)', illegal: true },
}
const handleCalculate = () => {
if (!hireDate || !leaveDate) return
const hire = new Date(hireDate)
const leave = new Date(leaveDate)
const totalMonths = (leave.getFullYear() - hire.getFullYear()) * 12 + (leave.getMonth() - hire.getMonth())
const years = Math.floor(totalMonths / 12)
const remainingMonths = totalMonths % 12
let compMonths: number
if (remainingMonths >= 6) compMonths = years + 1
else if (remainingMonths > 0) compMonths = years + 0.5
else compMonths = years
if (compMonths <= 0) compMonths = 0.5
let wage = avgWage
let capped = false
if (socialAvgWage > 0 && avgWage > socialAvgWage * 3) {
wage = socialAvgWage * 3
compMonths = Math.min(compMonths, 12)
capped = true
}
const r = reasonMap[reason]
const basePay = wage * compMonths
let totalPay = basePay * r.multiplier
let noticePay = 0
if (reason === 'nonfault') {
noticePay = wage
totalPay += noticePay
}
setResult({ years, remainingMonths, compMonths, wage, totalPay, basePay, totalMonths, capped, reason: r.label, reasonNote: r.extra, noticePay, noComp: r.multiplier === 0, isIllegal: r.illegal })
}
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
<Label></Label>
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="date" value={leaveDate} onChange={(e) => setLeaveDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Input type="number" value={avgWage} onChange={(e) => setAvgWage(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Select value={reason} onChange={(e) => setReason(e.target.value)}>
<option value="negotiated"></option>
<option value="fault"></option>
<option value="nonfault">1</option>
<option value="layoff"></option>
<option value="expired"></option>
<option value="illegal">×2</option>
</Select>
</div>
<div>
<Label></Label>
<Input type="number" value={socialAvgWage} onChange={(e) => setSocialAvgWage(Number(e.target.value) || 0)} placeholder="用于三倍封顶计算" />
</div>
<Button onClick={handleCalculate} disabled={!hireDate || !leaveDate} className="w-full">
<Calculator className="w-4 h-4 mr-1" />
</Button>
</div>
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><Calculator className="w-5 h-5" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.reason}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.years}{result.remainingMonths}</span></div>
{result.noComp ? (
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
{result.reasonNote}
</div>
) : (
<>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.compMonths}</span></div>
{result.capped && (
<div className="text-xs text-warning"> 312</div>
)}
<div className="text-xs text-gray-500"><span className="text-gray-900">¥{fmt(result.wage)}/</span></div>
<div className="border-t pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium">{result.isIllegal ? '经济补偿金' : '应付金额'}</span>
<span className="font-medium">¥{fmt(result.basePay)}</span>
</div>
{result.isIllegal ? (
<>
<div className="flex items-center justify-between">
<span className="font-medium text-danger">×2</span>
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)} × 2</div>
</>
) : (
<>
<div className="flex items-center justify-between">
<span className="font-medium">{result.reason}</span>
<span className="text-lg font-bold text-primary">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400">{result.compMonths} × ¥{fmt(result.wage)})
{result.noticePay > 0 && <span className="block"> ¥{fmt(result.noticePay)}</span>}
</div>
</>
)}
</div>
{result.reasonNote && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-md text-xs ${result.isIllegal ? 'bg-red-50 text-red-700' : 'bg-blue-50 text-blue-700'}`}>
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>{result.reasonNote}</span>
</div>
)}
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>116116</span>
</div>
</>
)}
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
)
}
function DoubleSalaryCalculator() {
const { data: employees } = useEmployees()
const [selectedEmpId, setSelectedEmpId] = useState('')
const [monthlyWage, setMonthlyWage] = useState(8000)
const [hireDate, setHireDate] = useState('')
const [hasContract, setHasContract] = useState(false)
const [contractDate, setContractDate] = useState('')
const handleSelectEmp = (emp: EmployeeOption | null) => {
setSelectedEmpId(emp?.id || '')
if (emp) {
setHireDate(emp.hireDate?.toString().slice(0, 10) || '')
setMonthlyWage(emp.monthlySalary || 8000)
const latestContract = emp.contracts?.find((c: any) => c.signDate)
if (latestContract) {
setHasContract(true)
setContractDate(latestContract.signDate?.toString().slice(0, 10) || '')
} else {
setHasContract(false)
setContractDate('')
}
}
}
const result = useMemo(() => {
if (!hireDate) return null
const hire = new Date(hireDate)
const startDate = new Date(hire)
startDate.setMonth(startDate.getMonth() + 1)
startDate.setDate(startDate.getDate() + 1)
let endDate = new Date(hire)
endDate.setFullYear(endDate.getFullYear() + 1)
if (hasContract && contractDate) {
const contract = new Date(contractDate)
const daysDiff = Math.floor((contract.getTime() - hire.getTime()) / (1000 * 60 * 60 * 24))
if (daysDiff > 30) {
endDate = contract
}
}
const months = Math.min(
Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24 * 30.44)),
11,
)
const totalPay = monthlyWage * Math.max(months, 0)
return { startDate, endDate, months: Math.max(months, 0), totalPay }
}, [monthlyWage, hireDate, hasContract, contractDate])
return (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="font-medium mb-4"></h2>
<div className="space-y-3">
<EmployeeSelector employees={employees} selectedId={selectedEmpId} onSelect={handleSelectEmp} />
<div>
<Label></Label>
<Input type="number" value={monthlyWage} onChange={(e) => setMonthlyWage(Number(e.target.value) || 0)} />
</div>
<div>
<Label></Label>
<Input type="date" value={hireDate} onChange={(e) => setHireDate(e.target.value)} />
</div>
<div>
<Label></Label>
<Select value={hasContract ? 'yes' : 'no'} onChange={(e) => setHasContract(e.target.value === 'yes')}>
<option value="no"></option>
<option value="yes"></option>
</Select>
</div>
{hasContract && (
<div>
<Label></Label>
<Input type="date" value={contractDate} onChange={(e) => setContractDate(e.target.value)} />
</div>
)}
</div>
</Card>
<Card>
<h2 className="font-medium mb-4 flex items-center gap-2"><AlertCircle className="w-5 h-5 text-warning" /></h2>
{result ? (
<div className="space-y-3">
<div className="text-xs text-gray-500"><span className="text-gray-900">{hireDate}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{hasContract ? contractDate || '未填写' : '未签订'}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.startDate.toISOString().slice(0, 10)}</span></div>
<div className="text-xs text-gray-500"><span className="text-gray-900">{result.endDate.toISOString().slice(0, 10)}</span></div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-danger">¥{fmt(result.totalPay)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">{result.months} × ¥{fmt(monthlyWage)}</div>
</div>
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<span>1211</span>
</div>
</div>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</Card>
</div>
)
}
+519
View File
@@ -0,0 +1,519 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Paperclip, Trash2, X } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import Modal from '../components/ui/Modal'
import EmptyState from '../components/ui/EmptyState'
interface EmployeeItem {
id: string
name: string
department: string
hireDate: string
status: string
contractStatus: string
contractStatusText: string
riskLevel: 'high' | 'medium' | 'low' | 'safe'
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
}
interface EmployeeListResponse {
items: EmployeeItem[]
total: number
page: number
pageSize: number
totalPages: number
}
export default function Contracts() {
const queryClient = useQueryClient()
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [showAddModal, setShowAddModal] = useState(false)
const [selectedEmpId, setSelectedEmpId] = useState<string | null>(null)
const { data, isLoading } = useQuery<EmployeeListResponse>({
queryKey: ['employees', search, page],
queryFn: async () => {
const res = await api.get('/employees', { params: { search, page, pageSize: 20 } }) as any
return res.data
},
})
const addMutation = useMutation({
mutationFn: (data: any) => api.post('/employees', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['employees'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setShowAddModal(false)
},
})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold"></h1>
<Button onClick={() => setShowAddModal(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 搜索栏 */}
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="搜索员工姓名或手机号"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="pl-9"
/>
</div>
</div>
{/* 员工列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !data || data.items.length === 0 ? (
<EmptyState
title="暂无员工"
description="点击「添加员工」开始管理合同"
actionLabel="添加员工"
onAction={() => setShowAddModal(true)}
/>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
<th className="py-2 px-3 font-medium"></th>
</tr>
</thead>
<tbody>
{data.items.map((emp) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50 cursor-pointer" onClick={() => setSelectedEmpId(emp.id)}>
<td className="py-3 px-3 font-medium">{emp.name}</td>
<td className="py-3 px-3 text-gray-600">{emp.department}</td>
<td className="py-3 px-3 text-gray-600">{emp.hireDate}</td>
<td className="py-3 px-3">
{(() => {
const tagStyles: Record<string, string> = {
expired: 'bg-red-50 text-danger',
unsigned_over_year: 'bg-red-50 text-danger',
unsigned_over_30: 'bg-red-50 text-danger',
unsigned: 'bg-yellow-50 text-yellow-700',
expiring: 'bg-yellow-50 text-yellow-700',
active: 'bg-green-50 text-safe',
unfixed: 'bg-blue-50 text-blue-700',
}
const style = tagStyles[emp.contractStatus] || 'bg-gray-100 text-gray-600'
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{emp.contractStatusText}</span>
})()}
</td>
<td className="py-3 px-3">
<div className="flex gap-1">
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600"></span>}
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600"></span>}
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600"></span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{data.totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-4">
<Button
variant="secondary"
size="sm"
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
></Button>
<span className="text-xs text-gray-500">{page} / {data.totalPages}</span>
<Button
variant="secondary"
size="sm"
disabled={page === data.totalPages}
onClick={() => setPage(p => p + 1)}
></Button>
</div>
)}
</>
)}
</Card>
{/* 添加员工 Modal */}
<AddEmployeeModal
open={showAddModal}
onClose={() => setShowAddModal(false)}
onSubmit={(data) => addMutation.mutate(data)}
loading={addMutation.isPending}
error={addMutation.error as any}
/>
{/* 员工详情抽屉 */}
{selectedEmpId && (
<EmployeeDetailDrawer employeeId={selectedEmpId} onClose={() => setSelectedEmpId(null)} />
)}
</div>
)
}
function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
open: boolean
onClose: () => void
onSubmit: (data: any) => void
loading: boolean
error: any
}) {
const [form, setForm] = useState({
name: '',
department: '',
hireDate: '',
monthlySalary: '',
gender: '男' as '男' | '女',
phone: '',
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
signDate: '',
startDate: '',
endDate: '',
contractYears: 3,
probationMonths: 0,
probationSalary: 0,
})
const handleSubmit = () => {
const data: any = {
name: form.name,
department: form.department,
hireDate: new Date(form.hireDate).toISOString(),
monthlySalary: form.monthlySalary,
gender: form.gender,
phone: form.phone || undefined,
isPregnant: form.isPregnant,
isInMedicalPeriod: form.isInMedicalPeriod,
isWorkInjured: form.isWorkInjured,
}
if (form.contractType !== 'UNSIGNED' && form.startDate) {
data.contract = {
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
contractType: form.contractType,
contractYears: form.contractYears,
probationMonths: form.probationMonths,
probationSalary: form.probationSalary,
}
}
onSubmit(data)
}
return (
<Modal open={open} onClose={onClose} title="添加员工">
<div className="space-y-4">
{error && (
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
{error.response?.data?.error?.message || '操作失败'}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" />
</div>
<div>
<Label> *</Label>
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<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: e.target.value })} placeholder="元" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}>
<option value="男"></option>
<option value="女"></option>
</Select>
</div>
<div>
<Label></Label>
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
</div>
</div>
{/* 特殊状态 */}
<div className="flex gap-4">
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
/
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
</label>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
</label>
</div>
{/* 合同信息 */}
<div className="border-t pt-3">
<Label></Label>
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any })}>
<option value="FIXED"></option>
<option value="UNFIXED"></option>
<option value="UNSIGNED"></option>
</Select>
</div>
{form.contractType !== 'UNSIGNED' && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
</div>
<div>
<Label> *</Label>
<Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} />
</div>
</div>
{form.contractType === 'FIXED' && (
<div className="grid grid-cols-3 gap-3">
<div>
<Label></Label>
<Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} />
</div>
<div>
<Label>()</Label>
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
</div>
<div>
<Label></Label>
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
</div>
</div>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={onClose}></Button>
<Button onClick={handleSubmit} disabled={loading || !form.name || !form.department || !form.hireDate || !form.monthlySalary}>
{loading ? '保存中...' : '保存'}
</Button>
</div>
</div>
</Modal>
)
}
function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'OTHER'>('ID_CARD')
const { data: employee } = useQuery<any>({
queryKey: ['employee-detail', employeeId],
queryFn: async () => {
const res = await api.get(`/employees/${employeeId}`) as any
return res.data
},
})
const { data: attachments } = useQuery<any[]>({
queryKey: ['employee-attachments', employeeId],
queryFn: async () => {
const res = await api.get(`/attachments/${employeeId}`) as any
return res.data
},
})
const addAttachmentMutation = useMutation({
mutationFn: (data: any) => api.post('/attachments', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
})
const deleteAttachmentMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }),
})
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const fileUrl = event.target?.result as string
addAttachmentMutation.mutate({
employeeId,
fileName: file.name,
fileType,
fileUrl,
fileSize: file.size,
})
}
reader.readAsDataURL(file)
}
const fileTypeLabels: Record<string, string> = {
ID_CARD: '身份证',
BANK_CARD: '银行卡',
CONTRACT_SCAN: '合同扫描件',
EDUCATION: '学历证书',
OTHER: '其他',
}
const emp = employee?.data || employee
return (
<div className="fixed inset-0 z-50 flex justify-end">
<button className="fixed inset-0 bg-black/40 cursor-default" onClick={onClose} aria-label="关闭" />
<div className="relative w-full max-w-2xl bg-white h-full overflow-y-auto shadow-xl">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 sticky top-0 bg-white z-10">
<h3 className="font-medium text-gray-900"></h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700" aria-label="关闭">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5 space-y-4">
{emp && (
<>
<div className="space-y-2">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{emp.name}</h2>
<span className="text-xs text-gray-500">{emp.department}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span className="text-gray-400"></span>{emp.hireDate?.slice(0, 10)}</div>
<div><span className="text-gray-400"></span>{emp.gender || '-'}</div>
<div><span className="text-gray-400"></span>{emp.phone || '-'}</div>
<div><span className="text-gray-400"></span>{emp.status === 'ACTIVE' ? '在职' : '离职'}</div>
</div>
{(emp.isPregnant || emp.isInMedicalPeriod || emp.isWorkInjured) && (
<div className="flex gap-1">
{emp.isPregnant && <span className="text-xs px-1.5 py-0.5 rounded bg-pink-50 text-pink-600"></span>}
{emp.isInMedicalPeriod && <span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-600"></span>}
{emp.isWorkInjured && <span className="text-xs px-1.5 py-0.5 rounded bg-red-50 text-red-600"></span>}
</div>
)}
</div>
{emp.contracts && emp.contracts.length > 0 && (
<div className="border-t pt-3">
<h3 className="font-medium text-xs mb-2"></h3>
<div className="space-y-2 text-xs">
{emp.contracts.map((c: any) => (
<div key={c.id} className="bg-gray-50 rounded p-2">
<div className="flex items-center gap-2">
{(() => {
const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签'
const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700'
return <span className={`px-2 py-0.5 rounded text-xs ${typeStyle}`}>{typeLabel}</span>
})()}
</div>
<div className="text-gray-500 text-xs mt-1">
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
</div>
</div>
))}
</div>
</div>
)}
</>
)}
<div className="border-t pt-3">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-xs flex items-center gap-1">
<Paperclip className="w-4 h-4" />
</h3>
</div>
<div className="flex gap-2 mb-3">
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs">
<option value="ID_CARD"></option>
<option value="BANK_CARD"></option>
<option value="CONTRACT_SCAN"></option>
<option value="EDUCATION"></option>
<option value="OTHER"></option>
</Select>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileUpload}
/>
<Button
size="sm"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={addAttachmentMutation.isPending}
>
{addAttachmentMutation.isPending ? '上传中...' : '上传'}
</Button>
</div>
{attachments && attachments.length > 0 ? (
<div className="space-y-2">
{attachments.map((att: any) => (
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
<div className="min-w-0">
<div className="truncate">{att.fileName}</div>
<div className="text-xs text-gray-400">
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
<button
onClick={() => deleteAttachmentMutation.mutate(att.id)}
className="text-gray-400 hover:text-danger shrink-0 ml-2"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
) : (
<div className="text-gray-400 text-xs text-center py-4"></div>
)}
</div>
</div>
</div>
</div>
)
}
+583
View File
@@ -0,0 +1,583 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import EmptyState from '../components/ui/EmptyState'
import Pagination from '../components/ui/Pagination'
import type { DashboardData } from '../types'
function fmt(n: number) {
return `¥${(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
const TODO_ICON_CONFIG: Record<string, { icon: typeof FileText; color: string; bg: string }> = {
CONTRACT: { icon: FileText, color: 'text-blue-600', bg: 'bg-blue-50' },
SALARY: { icon: DollarSign, color: 'text-amber-600', bg: 'bg-amber-50' },
TERMINATION: { icon: ShieldAlert, color: 'text-red-600', bg: 'bg-red-50' },
MONTHLY: { icon: Calendar, color: 'text-purple-600', bg: 'bg-purple-50' },
ONBOARDING: { icon: UserPlus, color: 'text-cyan-600', bg: 'bg-cyan-50' },
}
function TodoIcon({ type }: { type: string; level: string }) {
const config = TODO_ICON_CONFIG[type] || TODO_ICON_CONFIG.MONTHLY
const Icon = config.icon
return (
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.bg} ${config.color} flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
)
}
export default function Dashboard() {
const [todoPage, setTodoPage] = useState(1)
const [todoPageSize, setTodoPageSize] = useState(10)
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'overview' | 'payroll' | 'risk' | 'task'>('overview')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [drillDownType, setDrillDownType] = useState<string | null>(null)
const { data, isLoading, refetch, isFetching } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => {
const res = await api.get('/dashboard') as any
return res.data
},
})
const { data: expiringContracts } = useQuery<any>({
queryKey: ['expiring-contracts'],
queryFn: async () => {
const res = await api.get('/roster/contracts/expiring') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const ignoreMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/ignore`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
})
const batchResolveMutation = useMutation({
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-resolve', { ids }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setSelectedIds(new Set())
},
})
const batchIgnoreMutation = useMutation({
mutationFn: (ids: string[]) => api.patch('/dashboard/todos/batch-ignore', { ids }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
setSelectedIds(new Set())
},
})
const handleExportPayroll = () => {
const month = payroll?.month || new Date().toISOString().slice(0, 7)
window.open(`/api/v1/export/payroll?month=${month}`, '_blank')
}
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = (ids: string[]) => {
setSelectedIds(prev => {
const allSelected = ids.every(id => prev.has(id))
const next = new Set(prev)
if (allSelected) ids.forEach(id => next.delete(id))
else ids.forEach(id => next.add(id))
return next
})
}
const riskTodos = data?.todos.filter((t) => t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'ONBOARDING') || []
const taskTodos = data?.todos.filter((t) => t.type === 'MONTHLY' || t.type === 'SALARY') || []
const filteredTodos = activeTab === 'risk' ? riskTodos : taskTodos
if (isLoading) {
return <div className="text-center py-8 text-gray-500">...</div>
}
if (!data) return null
const stats = [
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
{ label: '月加班费', value: fmt(data.stats.monthlyOvertimePay), icon: DollarSign, color: 'text-safe' },
]
const payroll = data.payrollSummary
const activities = data.monthlyActivities
const activityItems = [
{ label: '新签合同', value: activities?.newContracts ?? 0, icon: FileText, color: 'text-primary' },
{ label: '解聘人数', value: activities?.terminations ?? 0, icon: Users, color: 'text-danger' },
{ label: '违纪处理', value: activities?.disciplinaryActions ?? 0, icon: AlertTriangle, color: 'text-warning' },
{ label: '考勤记录', value: activities?.attendanceRecords ?? 0, icon: Calendar, color: 'text-gray-600' },
{ label: '加班时长', value: `${activities?.overtimeHours ?? 0}h`, icon: TrendingUp, color: 'text-safe' },
{ label: '加班费', value: fmt(activities?.overtimePay ?? 0), icon: DollarSign, color: 'text-safe' },
]
const payrollItems = [
{ label: '基本工资', value: payroll?.baseSalary ?? 0, icon: Wallet, color: 'text-gray-700' },
{ label: '加班费', value: payroll?.overtimePay ?? 0, icon: TrendingUp, color: 'text-gray-700' },
{ label: '津贴补贴', value: payroll?.allowance ?? 0, icon: Wallet, color: 'text-gray-700' },
{ label: '扣款', value: -(payroll?.deduction ?? 0), icon: Wallet, color: 'text-danger' },
]
const deductionItems = [
{ label: '个人社保', value: -(payroll?.socialEmp ?? 0) },
{ label: '个人公积金', value: -(payroll?.housingEmp ?? 0) },
{ label: '个人所得税', value: -(payroll?.estimatedTax ?? 0) },
]
const tabs = [
{ key: 'overview' as const, label: '概览', icon: LayoutDashboard, badge: data.stats.todoCount },
{ key: 'risk' as const, label: '风险提醒', icon: AlertTriangle, badge: riskTodos.length },
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xs font-medium">{data.greeting}</h1>
<p className="text-xs text-gray-500 mt-0.5">{payroll?.month} </p>
</div>
<Button variant="secondary" size="sm" onClick={() => refetch()} disabled={isFetching} className={activeTab === 'risk' || activeTab === 'task' ? 'opacity-50 pointer-events-none' : ''}>
<RefreshCw className={`w-4 h-4 mr-1 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? '刷新中...' : activeTab === 'payroll' ? '刷新薪税' : '刷新概览'}
</Button>
</div>
{/* Tab 导航 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${
activeTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
{tab.badge > 0 && (
<span className={`ml-1 px-1.5 py-0.5 rounded-full text-xs ${activeTab === tab.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'}`}>
{tab.badge}
</span>
)}
</button>
)
})}
</div>
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-3">
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => {
const Icon = stat.icon
return (
<Card key={stat.label} className="flex items-center gap-2.5">
<Icon className={`w-6 h-6 ${stat.color}`} />
<div>
<div className="text-base font-bold">{stat.value}</div>
<div className="text-xs text-gray-500">{stat.label}</div>
</div>
</Card>
)
})}
</div>
{/* 合同到期预警 */}
{expiringContracts && expiringContracts.length > 0 && (
<Link to="/roster?contractStatus=expiring">
<Card className="border-danger/30 bg-danger/5 hover:bg-danger/10 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-danger" />
<div>
<div className="text-sm font-medium text-danger"></div>
<div className="text-xs text-gray-500 mt-0.5">
{expiringContracts.slice(0, 3).map((c: any, i: number) => (
<span key={c.employeeId}>
{i > 0 && '、'}
{c.employeeName}
<span className="text-danger ml-1">({c.daysLeft})</span>
</span>
))}
{expiringContracts.length > 3 && <span className="text-gray-500"> {expiringContracts.length}</span>}
</div>
</div>
</div>
<ArrowRight className="w-4 h-4 text-danger" />
</div>
</Card>
</Link>
)}
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<span className="text-xs text-gray-500">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2">
{activityItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex flex-col items-center p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 mb-1 ${item.color}`} />
<div className="text-xs font-bold">{item.value}</div>
<div className="text-xs text-gray-500">{item.label}</div>
</div>
)
})}
</div>
</Card>
{/* 风险分布 */}
<Card>
<h2 className="font-medium mb-3"></h2>
<div className="flex items-center gap-4">
<div className="w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={[
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
].filter(d => d.value > 0)}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={30}
outerRadius={55}
paddingAngle={2}
>
{[
{ name: '合同风险', value: data.riskDistribution.contract, color: '#4F46E5' },
{ name: '薪资风险', value: data.riskDistribution.salary, color: '#F59E0B' },
{ name: '解聘风险', value: data.riskDistribution.termination, color: '#EF4444' },
].filter(d => d.value > 0).map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v: any) => `${v}`} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-2">
<button
onClick={() => setDrillDownType(drillDownType === 'CONTRACT' ? null : 'CONTRACT')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'CONTRACT' ? 'bg-primary/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-primary" />
</span>
<span className="text-sm font-bold text-primary">{data.riskDistribution.contract}</span>
</button>
<button
onClick={() => setDrillDownType(drillDownType === 'SALARY' ? null : 'SALARY')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'SALARY' ? 'bg-warning/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-warning" />
</span>
<span className="text-sm font-bold text-warning">{data.riskDistribution.salary}</span>
</button>
<button
onClick={() => setDrillDownType(drillDownType === 'TERMINATION' ? null : 'TERMINATION')}
className={`flex items-center justify-between w-full p-2 rounded-lg transition-colors ${drillDownType === 'TERMINATION' ? 'bg-danger/10' : 'hover:bg-gray-50'}`}
>
<span className="flex items-center gap-2 text-sm">
<span className="w-2.5 h-2.5 rounded-full bg-danger" />
</span>
<span className="text-sm font-bold text-danger">{data.riskDistribution.termination}</span>
</button>
</div>
</div>
{/* 下钻明细 */}
{drillDownType && (
<div className="mt-3 border-t pt-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-600">
{drillDownType === 'CONTRACT' ? '合同' : drillDownType === 'SALARY' ? '薪资' : '解聘'}
</span>
<button onClick={() => setDrillDownType(null)} className="text-xs text-gray-500 hover:text-gray-600"></button>
</div>
{data.topRisks.filter(r => r.type === drillDownType).length > 0 ? (
data.topRisks.filter(r => r.type === drillDownType).map((r) => (
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
<div className="flex-1 min-w-0">
<div className="truncate text-gray-800">{r.title}</div>
{r.employeeName && <div className="text-gray-500">{r.employeeName}</div>}
</div>
<ArrowRight className="w-3 h-3 text-gray-500" />
</Link>
))
) : (
<div className="text-xs text-gray-500 text-center py-2"></div>
)}
</div>
)}
</Card>
</div>
)}
{/* 薪税 Tab */}
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
<Download className="w-4 h-4 mr-1" />
</Button>
<Link to="/money" className="text-xs text-primary hover:underline flex items-center gap-1">
<ArrowRight className="w-3 h-3" />
</Link>
</div>
</div>
{payroll && payroll.payslipCount > 0 ? (
<div className="space-y-3">
{/* 工资构成 */}
<div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{payrollItems.map((item) => {
const Icon = item.icon
return (
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-gray-50">
<div className="flex items-center gap-1.5">
<Icon className={`w-4 h-4 ${item.color}`} />
<span className="text-xs text-gray-500">{item.label}</span>
</div>
<span className={`text-xs font-medium ${item.value < 0 ? 'text-danger' : ''}`}>{fmt(item.value)}</span>
</div>
)
})}
</div>
</div>
{/* 应发合计 */}
<div className="flex items-center justify-between border-t border-b py-2">
<span className="font-medium"></span>
<span className="text-base font-bold text-primary">{fmt(payroll.totalPay)}</span>
</div>
{/* 扣减项 */}
<div>
<div className="text-xs font-medium text-gray-600 mb-1.5"></div>
<div className="grid grid-cols-3 gap-2">
{deductionItems.map((item) => (
<div key={item.label} className="flex items-center justify-between p-2 rounded-md bg-red-50">
<span className="text-xs text-gray-500">{item.label}</span>
<span className="text-xs font-medium text-danger">{fmt(item.value)}</span>
</div>
))}
</div>
</div>
{/* 员工实发 */}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><Wallet className="w-4 h-4 text-safe" /></span>
<span className="text-base font-bold text-safe">{fmt(payroll.empNetPay)}</span>
</div>
{/* 企业成本 */}
<div className="border-t pt-2 space-y-2">
<div className="text-xs font-medium text-gray-600 mb-1"></div>
<div className="grid grid-cols-3 gap-2">
<div className="flex items-center justify-between p-2 rounded-md bg-blue-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-xs font-medium text-blue-700">{fmt(payroll.socialOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-purple-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Building2 className="w-3 h-3" /></span>
<span className="text-xs font-medium text-purple-700">{fmt(payroll.housingOrg)}</span>
</div>
<div className="flex items-center justify-between p-2 rounded-md bg-green-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><Receipt className="w-3 h-3" /></span>
<span className="text-xs font-medium text-green-700">{fmt(payroll.totalPay)}</span>
</div>
</div>
{payroll.severancePay > 0 && (
<div className="flex items-center justify-between p-2 rounded-md bg-orange-50">
<span className="text-xs text-gray-500 flex items-center gap-1"><DollarSign className="w-3 h-3" /></span>
<span className="text-xs font-medium text-orange-700">{fmt(payroll.severancePay)}</span>
</div>
)}
<div className="flex items-center justify-between py-2">
<span className="font-medium flex items-center gap-2"><DollarSign className="w-4 h-4 text-danger" /></span>
<span className="text-base font-bold text-danger">{fmt(payroll.orgTotalCost)}</span>
</div>
</div>
{/* 工资条确认状态 */}
<div className="flex items-center gap-3 text-xs border-t pt-2">
<span className="text-gray-500"></span>
<span className="text-safe"> {payroll.confirmedPayslips}</span>
<span className="text-warning"> {payroll.unconfirmedPayslips}</span>
<span className="text-gray-500"> {payroll.payslipCount} </span>
</div>
</div>
) : (
<EmptyState title="本月暂无工资数据" description="请先在薪税页面生成本月工资条" />
)}
</Card>
)}
{/* 风险提醒 Tab */}
{(activeTab === 'risk' || activeTab === 'task') && (
<div className="space-y-3">
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-xs text-gray-500">{filteredTodos.length} </span>
</div>
{filteredTodos.length === 0 ? (
<EmptyState title="暂无待办" description="所有事项已处理完毕" />
) : (
<>
{/* 批量操作栏 */}
<div className="flex items-center gap-2 mb-2 pb-2 border-b">
<button
onClick={() => toggleSelectAll(filteredTodos.map(t => t.id))}
className="text-xs text-primary hover:underline"
>
{filteredTodos.every(t => selectedIds.has(t.id)) ? '取消全选' : '全选'}
</button>
{selectedIds.size > 0 && (
<>
<span className="text-xs text-gray-500"> {selectedIds.size} </span>
<Button
size="sm"
variant="secondary"
onClick={() => batchResolveMutation.mutate([...selectedIds])}
disabled={batchResolveMutation.isPending}
>
<Check className="w-3 h-3 mr-1" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => batchIgnoreMutation.mutate([...selectedIds])}
disabled={batchIgnoreMutation.isPending}
>
<X className="w-3 h-3 mr-1" />
</Button>
</>
)}
</div>
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={(s) => { setTodoPageSize(s); setTodoPage(1) }} />
<div className="space-y-2">
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-2.5 flex-1">
<input
type="checkbox"
checked={selectedIds.has(todo.id)}
onChange={() => toggleSelect(todo.id)}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-xs text-gray-800">{todo.title}</span>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => resolveMutation.mutate(todo.id)}
disabled={resolveMutation.isPending}
className="p-1.5 rounded hover:bg-safe/10 text-safe"
title="标记完成"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => ignoreMutation.mutate(todo.id)}
disabled={ignoreMutation.isPending}
className="p-1.5 rounded hover:bg-gray-200 text-gray-500"
title="忽略"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</>
)}
</Card>
{/* 已办事项 */}
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<span className="text-xs text-gray-500">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-1.5">
{data.resolvedTodos.map((todo) => (
<div
key={todo.id}
className="flex items-center justify-between px-2.5 py-2 rounded-md bg-gray-50"
>
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-xs text-gray-600 line-through">{todo.title}</span>
<span className="text-xs text-gray-500">{todo.description}</span>
</div>
</Link>
<span className="text-xs text-gray-500">
{todo.resolvedAt ? new Date(todo.resolvedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
))}
</div>
</Card>
)}
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+754
View File
@@ -0,0 +1,754 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function SocialInsurance() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<'social' | 'housing' | 'monthly'>('social')
const [city, setCity] = useState<string>('北京')
const [base, setBase] = useState(8000)
const [showNewVersion, setShowNewVersion] = useState(false)
const [showVersions, setShowVersions] = useState(false)
const [showAdjust, setShowAdjust] = useState(false)
const [adjustData, setAdjustData] = useState<any>(null)
const [editItems, setEditItems] = useState<Record<string, number>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
const [newVersion, setNewVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
pensionOrg: 16, pensionEmp: 8,
medicalOrg: 9.8, medicalEmp: 2,
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
injuryOrg: 0.2, maternityOrg: 0.8,
baseMin: 6326, baseMax: 33891,
})
const [newHousingVersion, setNewHousingVersion] = useState<any>({
effectiveFrom: new Date().toISOString().slice(0, 7),
city: '北京',
housingOrg: 12, housingEmp: 12,
baseMin: 6326, baseMax: 33891,
})
// 获取城市列表
const { data: cities = [] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
const res = await api.get('/social/config/cities') as any
return res.data
},
})
const { data: config, isLoading: configLoading } = useQuery<any>({
queryKey: ['social-config', city],
queryFn: async () => {
const res = await api.get('/social/config', { params: { city } }) as any
return res.data
},
})
const { data: housingConfig, isLoading: housingLoading } = useQuery<any>({
queryKey: ['housing-config', city],
queryFn: async () => {
const res = await api.get('/social/housing-config', { params: { city } }) as any
return res.data
},
})
const { data: versions } = useQuery<any[]>({
queryKey: ['social-config-versions', city],
queryFn: async () => {
const res = await api.get('/social/config/versions', { params: { city } }) as any
return res.data
},
enabled: showVersions && tab === 'social',
})
const { data: housingVersions } = useQuery<any[]>({
queryKey: ['housing-config-versions'],
queryFn: async () => {
const res = await api.get('/social/housing-config/versions') as any
return res.data
},
enabled: showVersions && tab === 'housing',
})
const { data: monthlyChanges } = useQuery<any>({
queryKey: ['monthly-changes', monthlyMonth],
queryFn: async () => {
const [socialRes, housingRes, socialActiveRes, housingActiveRes] = await Promise.all([
api.get('/social/monthly-changes', { params: { month: monthlyMonth } }) as any,
api.get('/social/housing/monthly-changes', { params: { month: monthlyMonth } }) as any,
api.get('/social/active-declaration', { params: { month: monthlyMonth } }) as any,
api.get('/social/housing/active-declaration', { params: { month: monthlyMonth } }) as any,
])
return {
social: socialRes.data,
housing: housingRes.data,
socialActive: socialActiveRes.data,
housingActive: housingActiveRes.data,
}
},
enabled: tab === 'monthly',
})
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/calculate', { base }) as any
return res.data
},
})
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
mutationFn: async () => {
const res = await api.post('/social/housing-calculate', { base }) as any
return res.data
},
})
const createVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowNewVersion(false)
toast.success('新版本已创建,旧版本已自动归档')
},
})
const createHousingVersionMutation = useMutation({
mutationFn: (data: any) => api.post('/social/housing-config/versions', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowNewVersion(false)
toast.success('公积金新版本已创建,旧版本已自动归档')
},
})
const previewAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/config/${config?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const previewHousingAdjustMutation = useMutation({
mutationFn: async () => {
const res = await api.get(`/social/housing-config/${housingConfig?.id}/adjust-preview`) as any
return res.data
},
onSuccess: (data) => {
setAdjustData(data)
setShowAdjust(true)
},
})
const applyAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/config/${config?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['social-config'] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
},
})
const applyHousingAdjustMutation = useMutation({
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
api.post(`/social/housing-config/${housingConfig?.id}/adjust-apply`, data),
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
setShowAdjust(false)
setAdjustData(null)
setEditItems({})
setEditingId(null)
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
},
})
const resetAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`, { city }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
toast.success('社保基数调整已重置,可以重新调整')
},
})
const resetHousingAdjustMutation = useMutation({
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`, { city }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
toast.success('公积金基数调整已重置,可以重新调整')
},
})
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
if (!data?.items?.length) return
const headers = type === 'social'
? ['姓名', '部门', '社保基数', '开始年月', '截止年月', '变更类型']
: ['姓名', '部门', '公积金基数', '开始年月', '截止年月', '变更类型']
const rows = data.items.map((i: any) => [
i.name, i.department, i.base, i.startMonth, i.endMonth || '', i.changeType
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${type === 'social' ? '社保' : '公积金'}_${data.month || monthlyMonth}.csv`
a.click()
URL.revokeObjectURL(url)
}
const isHousing = tab === 'housing'
const activeConfig = isHousing ? housingConfig : config
const activeVersions = isHousing ? housingVersions : versions
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
const activeNewVersion = isHousing ? newHousingVersion : newVersion
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-xs font-medium"></h1>
<div className="flex gap-2">
{tab !== 'monthly' && (
<>
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
<History className="w-4 h-4 mr-1" />
{showVersions ? '收起历史' : '版本历史'}
</Button>
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</>
)}
</div>
</div>
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['social', 'housing', 'monthly'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
>
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
</button>
))}
<div className="flex items-center gap-2 ml-auto">
<label className="text-xs text-gray-500">:</label>
<select
className="text-xs border rounded px-2 py-1.5"
value={city}
onChange={(e) => setCity(e.target.value)}
>
{cities.length > 0 ? (
cities.map((c) => <option key={c} value={c}>{c}</option>)
) : (
<option value="北京"></option>
)}
</select>
</div>
</div>
{/* ========== 社保 / 公积金 Tab ========== */}
{tab !== 'monthly' && (
(isHousing ? housingLoading : configLoading) ? (
<Card><div className="text-center py-8 text-gray-500">...</div></Card>
) : activeConfig ? (
<Card>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
<span className="text-xs text-gray-500">{activeConfig.effectiveFrom}</span>
<span className="text-xs text-gray-500">· {activeConfig.city}</span>
{activeConfig.adjustmentDone && (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500"></span>
)}
</div>
<div className="flex items-center gap-2">
{activeConfig.adjustmentDone && (
<Button
variant="secondary"
size="sm"
onClick={() => {
if (window.confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
}
}}
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => activePreviewMut.mutate()}
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
>
<SettingsIcon className="w-4 h-4 mr-1" />
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
</Button>
</div>
</div>
{isHousing ? (
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
</div>
) : (
<div className="grid md:grid-cols-4 gap-3 text-xs">
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500"></span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">(/)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">()</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
</div>
)}
</Card>
) : (
<Card><div className="text-center py-8 text-gray-500">{isHousing ? '公积金' : '社保'}</div></Card>
)
)}
{/* 调整预览 */}
{tab !== 'monthly' && showAdjust && adjustData && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />{isHousing ? '公积金' : '社保'}
</h3>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)}{isHousing ? '公积金' : '社保'}
=
</div>
</div>
<div className="flex items-center gap-2 mb-3">
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
setEditItems(newEdits)
}}>
<Check className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => {
const newEdits: Record<string, number> = {}
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
setEditItems(newEdits)
}}>
</Button>
<span className="text-xs text-gray-400"> {adjustData.total} </span>
</div>
<div className="overflow-x-auto mb-4">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}()</th>
</tr>
</thead>
<tbody>
{adjustData.items.map((item: any) => {
const edit = editItems[item.employeeId]
const newBase = edit ?? item.suggestedBase
const changed = newBase !== item.oldBase
return (
<tr key={item.employeeId} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-gray-500">{item.department}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
<td className="py-1.5 text-right">
{editingId === item.employeeId ? (
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
onBlur={() => setEditingId(null)}
autoFocus />
) : (
<span className="cursor-text inline-block !w-28 text-right"
onClick={() => setEditingId(item.employeeId)}>
¥{fmt(newBase)}
</span>
)}
{changed && <span className="text-warning ml-1"></span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button onClick={() => {
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
activeApplyMut.mutate({ items })
}} disabled={activeApplyMut.isPending}>
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
</Button>
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}></Button>
</div>
</Card>
)}
{/* 版本历史 */}
{tab !== 'monthly' && showVersions && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
{!activeVersions || activeVersions.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-xs"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-right"></th>
{isHousing ? (
<th className="py-2 text-right">%</th>
) : (
<>
<th className="py-2 text-right">%</th>
<th className="py-2 text-right">%</th>
</>
)}
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{activeVersions.map((v: any) => (
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2">{v.effectiveFrom}</td>
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
<td className="py-2">{v.city}</td>
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
{isHousing ? (
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
) : (
<>
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
</>
)}
<td className="py-2 text-center">
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新建版本 */}
{tab !== 'monthly' && showNewVersion && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />{isHousing ? '公积金' : '社保'}</h3>
<div className="space-y-3">
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>7</div>
</div>
<div className="grid md:grid-cols-3 gap-3">
<div><Label></Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
<div><Label></Label><Input value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
<div><Label></Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
</div>
{isHousing ? (
<div className="grid md:grid-cols-2 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
</div>
) : (
<div className="grid md:grid-cols-4 gap-3">
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
<div><Label>(%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
</Button>
<Button variant="secondary" onClick={() => setShowNewVersion(false)}></Button>
</div>
</div>
</Card>
)}
{/* 试算工具 */}
{tab !== 'monthly' && (
<div className="grid md:grid-cols-2 gap-4">
<Card>
<h2 className="text-xs font-medium mb-3">{isHousing ? '公积金' : '社保'}</h2>
<div className="space-y-3">
<div>
<Label></Label>
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
</div>
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
<Calculator className="w-4 h-4 mr-1" />
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
</Button>
{activeConfig && (
<div className="text-xs text-gray-400">
{activeConfig.city} | {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
</div>
)}
</div>
</Card>
<Card>
<h2 className="text-xs font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" /></h2>
{(() => {
const r = isHousing ? housingResult : result
if (!r) return <div className="text-gray-400 text-xs"></div>
return (
<div className="space-y-3">
<div className="text-xs text-gray-500">
<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
{r.capped && <span className="text-warning ml-2"></span>}
{r.floored && <span className="text-warning ml-2"></span>}
{r.configVersion && <span className="text-gray-400 ml-2">| {r.configVersion}</span>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-gray-500">
<th className="py-1.5"></th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right">%</th>
<th className="py-1.5 text-right"></th>
<th className="py-1.5 text-right"></th>
</tr>
</thead>
<tbody>
{r.items.map((item: any) => (
<tr key={item.name} className="border-b last:border-0">
<td className="py-1.5">{item.name}</td>
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-bold">
<td className="py-2" colSpan={3}></td>
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
</tr>
</tfoot>
</table>
</div>
<div className="border-t pt-3">
<div className="flex items-center justify-between">
<span className="font-medium"></span>
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
</div>
<div className="text-xs text-gray-400 mt-1">
¥{fmt(r.totalOrg)} + ¥{fmt(r.totalEmp)}
</div>
</div>
</div>
)
})()}
</Card>
</div>
)}
{/* ========== 月度办理 Tab ========== */}
{tab === 'monthly' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-medium"></h2>
<div className="flex items-center gap-2">
<Input type="month" value={monthlyMonth} onChange={(e) => setMonthlyMonth(e.target.value)} className="!w-32" />
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('social', monthlyChanges.social)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="secondary" size="sm" onClick={() => monthlyChanges && handleExportCSV('housing', monthlyChanges.housing)}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md mb-3">
///
</div>
{(() => {
if (!monthlyChanges) return <div className="text-center py-4 text-gray-400 text-xs">...</div>
const sAdd = monthlyChanges.social?.additions || []
const sSub = monthlyChanges.social?.subtractions || []
const sNormal = monthlyChanges.socialActive?.items || []
const hAdd = monthlyChanges.housing?.additions || []
const hSub = monthlyChanges.housing?.subtractions || []
const hNormal = monthlyChanges.housingActive?.items || []
if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0 && sNormal.length === 0 && hNormal.length === 0) {
return <div className="text-center py-4 text-gray-400 text-xs">{monthlyMonth} </div>
}
return (
<div className="space-y-4">
{/* 社保 */}
<div>
<h3 className="text-xs font-medium mb-2"></h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{sAdd.map((i: any) => (
<tr key={`sa-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5">{i.startMonth}</td>
<td className="py-1.5 text-gray-400"></td>
</tr>
))}
{sSub.map((i: any) => (
<tr key={`ss-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400"></td>
<td className="py-1.5">{i.endMonth}</td>
</tr>
))}
{sNormal.map((i: any) => (
<tr key={`sn-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 公积金 */}
<div>
<h3 className="text-xs font-medium mb-2"></h3>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
</tr>
</thead>
<tbody>
{hAdd.map((i: any) => (
<tr key={`ha-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-green-50 text-safe"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5">{i.startMonth}</td>
<td className="py-1.5 text-gray-400"></td>
</tr>
))}
{hSub.map((i: any) => (
<tr key={`hs-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-red-50 text-danger"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400"></td>
<td className="py-1.5">{i.endMonth}</td>
</tr>
))}
{hNormal.map((i: any) => (
<tr key={`hn-${i.employeeId}`} className="border-b last:border-0">
<td className="py-1.5">{i.name}</td>
<td className="py-1.5 text-gray-500">{i.department}</td>
<td className="py-1.5"><span className="px-2 py-0.5 rounded bg-gray-100 text-gray-500"></span></td>
<td className="py-1.5 text-right">¥{fmt(i.base)}</td>
<td className="py-1.5 text-gray-400">{i.startMonth}</td>
<td className="py-1.5 text-gray-400">{i.endMonth || '在保'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
})()}
</Card>
)}
<p className="text-xs text-gray-400">
/7
</p>
</div>
)
}
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
export default function ForgotPassword() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<1 | 2>(1)
const [phone, setPhone] = useState('')
const [code, setCode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [sentCode, setSentCode] = useState('')
const sendCode = async () => {
setError('')
if (!/^1[3-9]\d{9}$/.test(phone)) {
setError('手机号格式不正确')
return
}
setLoading(true)
try {
const res = await api.post('/auth/forgot-password/send-code', { phone }) as any
setSentCode(res.data?.code || '')
setStep(2)
} catch (err: any) {
setError(err.response?.data?.error?.message || '发送失败,请稍后重试')
} finally {
setLoading(false)
}
}
const resetPwd = async () => {
setError('')
if (code.length !== 6) {
setError('请输入6位验证码')
return
}
if (newPassword.length < 8) {
setError('密码至少8位')
return
}
setLoading(true)
try {
await api.post('/auth/forgot-password/verify', { phone, code, newPassword })
setSuccess(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '重置失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
</div>
<div className="card">
<h1 className="text-lg font-semibold mb-4"></h1>
{success ? (
<div className="text-center py-4">
<div className="text-green-600 mb-3"></div>
<Link to="/login" className="text-primary hover:underline text-sm"></Link>
</div>
) : (
<>
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
{sentCode && step === 2 && (
<div className="mb-4 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
{sentCode}
</div>
)}
{step === 1 ? (
<div className="space-y-4">
<div>
<Label></Label>
<Input
type="tel"
placeholder="请输入注册手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={11}
/>
</div>
<Button className="w-full" disabled={loading} onClick={sendCode}>
{loading ? '发送中...' : '获取验证码'}
</Button>
</div>
) : (
<div className="space-y-4">
<div>
<Label></Label>
<Input type="tel" value={phone} disabled />
</div>
<div>
<Label></Label>
<Input
type="text"
placeholder="6位验证码"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div>
<Label></Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="至少8位"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<Button className="w-full" disabled={loading} onClick={resetPwd}>
{loading ? '重置中...' : '重置密码'}
</Button>
<button
className="w-full text-xs text-gray-500 hover:text-gray-700"
onClick={() => { setStep(1); setCode(''); setSentCode('') }}
>
</button>
</div>
)}
<div className="mt-4 text-center text-sm">
<Link to="/login" className="text-primary hover:underline"></Link>
</div>
</>
)}
</div>
</div>
</div>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const schema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(1, '请输入密码'),
})
type FormData = z.infer<typeof schema>
export default function Login() {
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phone: '13800000001',
password: '12345678',
},
})
const onSubmit = async (data: FormData) => {
setError('')
setLoading(true)
try {
const res = await api.post('/auth/login', data) as any
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
navigate('/')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
</div>
<div className="card">
<h1 className="text-lg font-semibold mb-4"></h1>
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label></Label>
<Input
type="tel"
placeholder="请输入手机号"
{...register('phone')}
maxLength={11}
/>
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
</div>
<div>
<Label></Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '登录中...' : '登录'}
</Button>
</form>
<div className="mt-4 flex items-center justify-between text-sm">
<Link to="/forgot-password" className="text-primary hover:underline"></Link>
<Link to="/register" className="text-primary hover:underline"></Link>
</div>
</div>
</div>
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Building2, Eye, EyeOff } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const schema = z.object({
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '两次密码不一致',
path: ['confirmPassword'],
})
type FormData = z.infer<typeof schema>
export default function Register() {
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
})
const onSubmit = async (data: FormData) => {
setError('')
setLoading(true)
try {
const res = await api.post('/auth/register', data) as any
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
navigate('/')
} catch (err: any) {
setError(err.response?.data?.error?.message || '注册失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-xl font-bold"></span>
</div>
<div className="card">
<h1 className="text-lg font-semibold mb-4"></h1>
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label></Label>
<Input
placeholder="请输入企业名称"
{...register('orgName')}
/>
{errors.orgName && <p className="text-xs text-red-500 mt-1">{errors.orgName.message}</p>}
</div>
<div>
<Label></Label>
<Input
type="tel"
placeholder="请输入手机号"
{...register('phone')}
maxLength={11}
/>
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
</div>
<div>
<Label></Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="至少8位"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-500 mt-1">{errors.password.message}</p>}
</div>
<div>
<Label></Label>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="请再次输入密码"
{...register('confirmPassword')}
/>
{errors.confirmPassword && <p className="text-xs text-red-500 mt-1">{errors.confirmPassword.message}</p>}
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? '注册中...' : '注册'}
</Button>
</form>
<div className="mt-4 text-center text-sm">
<Link to="/login" className="text-primary hover:underline"></Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { PenTool, Check, AlertCircle } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
export default function ContractConfirm() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const [data, setData] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [agreed, setAgreed] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [confirmed, setConfirmed] = useState(false)
const [verifyCode, setVerifyCode] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [devCode, setDevCode] = useState('')
useEffect(() => {
if (token) {
api.get(`/portal/contract-confirm/${token}`).then((res: any) => {
setData(res.data)
}).catch((err: any) => {
setError(err.response?.data?.error?.message || '链接无效或已过期')
}).finally(() => setLoading(false))
} else {
setError('缺少 token 参数')
setLoading(false)
}
}, [token])
const handleSendCode = async () => {
setSendingCode(true)
setError('')
try {
const res = await api.post('/portal/contract-confirm/send-code', { token }) as any
setCodeSent(true)
setDevCode(res.data?.data?.code || '')
} catch (err: any) {
setError(err.response?.data?.error?.message || '验证码发送失败')
} finally {
setSendingCode(false)
}
}
const handleConfirm = async () => {
setSubmitting(true)
try {
await api.post('/portal/contract-confirm', { token, agreed: true, verifyCode })
setConfirmed(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '确认失败')
} finally {
setSubmitting(false)
}
}
if (confirmed) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500"> IP </p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 px-4 py-6">
<div className="max-w-md mx-auto">
<div className="flex items-center gap-2 mb-6">
<PenTool className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
{loading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : error ? (
<Card>
<div className="flex items-center gap-2 text-danger">
<AlertCircle className="w-5 h-5" />
<span>{error}</span>
</div>
</Card>
) : data ? (
<Card>
<div className="space-y-3">
<div className="text-sm text-gray-600">
{data.orgName} {data.employeeName}
</div>
{data.contract && (
<div className="space-y-2 text-sm">
<Row label="合同类型" value={data.contract.contractType === 'FIXED' ? '固定期限' : data.contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
{data.contract.contractYears > 0 && <Row label="合同期限" value={`${data.contract.contractYears}`} />}
<Row label="合同开始" value={new Date(data.contract.startDate).toISOString().slice(0, 10)} />
{data.contract.endDate && <Row label="合同结束" value={new Date(data.contract.endDate).toISOString().slice(0, 10)} />}
{data.contract.probationMonths > 0 && <Row label="试用期" value={`${data.contract.probationMonths}个月`} />}
{data.contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(data.contract.probationSalary))}`} />}
</div>
)}
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
</label>
{/* 验证码区域 */}
{agreed && (
<div className="space-y-2">
<div className="flex gap-2">
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="请输入6位验证码"
maxLength={6}
className="flex-1 px-3 py-2 rounded-md border border-gray-300 text-sm"
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeSent}
className="px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50 whitespace-nowrap"
>
{sendingCode ? '发送中' : codeSent ? '已发送' : '发送验证码'}
</button>
</div>
{devCode && (
<div className="text-xs text-blue-500">{devCode}</div>
)}
</div>
)}
<Button className="w-full" onClick={handleConfirm} disabled={!agreed || submitting || !verifyCode}>
{submitting ? '确认中...' : '确认签署'}
</Button>
<div className="text-xs text-gray-400 text-center">📌 IP </div>
</div>
</Card>
) : null}
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-gray-500">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { FileText, AlertCircle, Check, RefreshCw } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function MyContract() {
const [resending, setResending] = useState(false)
const [resendMsg, setResendMsg] = useState('')
const { data, isLoading } = useQuery<any>({
queryKey: ['my-contract'],
queryFn: async () => {
const res = await portalApi.get('/contract') as any
return res.data?.data ?? null
},
})
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
const contract = data
const daysToExpire = contract?.endDate
? Math.floor((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
const isConfirmed = contract?.attachmentName?.startsWith('confirmed:')
const handleResend = async () => {
setResending(true)
setResendMsg('')
try {
const res = await portalApi.post('/contract-confirm/resend', { contractId: contract?.id }) as any
setResendMsg(res.data?.data?.message || '重发成功')
} catch (err: any) {
setResendMsg(err.response?.data?.error?.message || '重发失败')
} finally {
setResending(false)
}
}
return (
<div className="min-h-screen bg-gray-50 px-4 py-6">
<div className="max-w-md mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<FileText className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{employee.name}</span>
<Link to="/portal/payslip" className="text-sm text-primary"></Link>
</div>
</div>
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !contract ? (
<EmptyState title="暂无合同" description="HR 尚未录入您的合同信息" />
) : (
<div className="space-y-3">
{/* 到期提醒 */}
{daysToExpire !== null && daysToExpire <= 30 && daysToExpire >= 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md bg-yellow-50 text-yellow-700 text-sm">
<AlertCircle className="w-4 h-4" />
{daysToExpire}
</div>
)}
<div className="space-y-2 text-sm">
<Row label="合同类型" value={contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : '未签订'} />
<Row label="签订方式" value={contract.signMethod === 'PAPER' ? '纸质合同' : '电子合同'} />
{contract.signDate && <Row label="签订日期" value={new Date(contract.signDate).toISOString().slice(0, 10)} />}
<Row label="合同开始" value={new Date(contract.startDate).toISOString().slice(0, 10)} />
{contract.endDate && <Row label="合同结束" value={new Date(contract.endDate).toISOString().slice(0, 10)} />}
{contract.contractYears > 0 && <Row label="合同期限" value={`${contract.contractYears}`} />}
{contract.probationMonths > 0 && <Row label="试用期" value={`${contract.probationMonths}个月`} />}
{contract.probationSalary > 0 && <Row label="试用期工资" value={`¥${fmt(Number(contract.probationSalary))}`} />}
</div>
{/* 签署确认记录 */}
<div className="border-t pt-3">
<h3 className="font-medium text-sm mb-2"></h3>
{isConfirmed ? (
<div className="flex items-center gap-2 text-sm text-safe">
<Check className="w-4 h-4" />
{new Date(contract.attachmentName.slice(10).split('|')[0]).toLocaleString()}
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-warning">
<AlertCircle className="w-4 h-4" />
</div>
<Button size="sm" variant="secondary" onClick={handleResend} disabled={resending}>
<RefreshCw className="w-3 h-3 mr-1" />{resending ? '重发中...' : '重发确认链接'}
</Button>
{resendMsg && <div className="text-xs text-gray-500">{resendMsg}</div>}
</div>
)}
</div>
</div>
)}
</Card>
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between">
<span className="text-gray-500">{label}</span>
<span className="font-medium">{value}</span>
</div>
)
}
+216
View File
@@ -0,0 +1,216 @@
import { useState, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ClipboardList, Check, FileText, X } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { Input, Label } from '../../components/ui/Input'
const FILE_TYPES = [
{ key: 'ID_CARD_FRONT', label: '身份证正面' },
{ key: 'ID_CARD_BACK', label: '身份证反面' },
{ key: 'EDUCATION', label: '学历证明' },
{ key: 'BANK_CARD', label: '银行卡照片' },
{ key: 'OTHER', label: '其他材料' },
]
interface UploadedFile {
fileType: string
fileName: string
fileUrl: string
fileSize: number
}
export default function Onboarding() {
const [params] = useSearchParams()
const token = params.get('token') || ''
const [orgName, setOrgName] = useState('')
const [loading, setLoading] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState('')
const [uploading, setUploading] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [currentFileType, setCurrentFileType] = useState('ID_CARD_FRONT')
const [form, setForm] = useState({
name: '',
phone: '',
idCard: '',
emergencyContact: '',
emergencyPhone: '',
address: '',
bankCard: '',
bankName: '',
})
// 获取链接信息
useState(() => {
if (token) {
api.get(`/portal/onboarding/${token}`).then((res: any) => {
setOrgName(res.data.orgName)
}).catch((err: any) => {
setError(err.response?.data?.error?.message || '链接无效')
})
}
})
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError('')
try {
const formData = new FormData()
formData.append('file', file)
formData.append('fileType', currentFileType)
const res = await api.post(`/portal/onboarding/${token}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
}) as any
setUploadedFiles([...uploadedFiles, res.data.data])
} catch (err: any) {
setError(err.response?.data?.error?.message || '文件上传失败')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const removeFile = (idx: number) => {
setUploadedFiles(uploadedFiles.filter((_, i) => i !== idx))
}
const handleSubmit = async () => {
setError('')
setLoading(true)
try {
await api.post('/portal/onboarding', { ...form, token, attachments: uploadedFiles })
setSubmitted(true)
} catch (err: any) {
setError(err.response?.data?.error?.message || '提交失败')
} finally {
setLoading(false)
}
}
if (submitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-sm w-full text-center">
<Check className="w-16 h-16 text-safe mx-auto mb-4" />
<h1 className="text-sm font-semibold mb-2"></h1>
<p className="text-sm text-gray-500">HR </p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 px-4 py-6">
<div className="max-w-md mx-auto">
<div className="flex items-center gap-2 mb-6">
<ClipboardList className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
{orgName && (
<div className="mb-4 text-sm text-gray-600">
{orgName}
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
)}
<Card>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="请输入姓名" />
</div>
<div>
<Label> *</Label>
<Input type="tel" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="请输入手机号" maxLength={11} />
</div>
<div>
<Label> *</Label>
<Input value={form.idCard} onChange={(e) => setForm({ ...form, idCard: e.target.value })} placeholder="请输入身份证号" maxLength={18} />
</div>
<div>
<Label></Label>
<Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input type="tel" value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" maxLength={11} />
</div>
<div>
<Label></Label>
<Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input value={form.bankCard} onChange={(e) => setForm({ ...form, bankCard: e.target.value })} placeholder="选填" />
</div>
<div>
<Label></Label>
<Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" />
</div>
{/* 文件上传区域 */}
<div className="border-t pt-3">
<Label></Label>
<div className="flex flex-wrap gap-2 mb-2">
{FILE_TYPES.map((ft) => (
<button
key={ft.key}
type="button"
onClick={() => setCurrentFileType(ft.key)}
className={`px-2 py-1 rounded text-xs ${currentFileType === ft.key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600'}`}
>
{ft.label}
</button>
))}
</div>
<input
ref={fileInputRef}
type="file"
accept=".jpg,.jpeg,.png,.pdf,.bmp"
onChange={handleFileUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-md text-xs text-gray-500 hover:border-primary"
>
{uploading ? '上传中...' : `点击上传${FILE_TYPES.find(f => f.key === currentFileType)?.label || ''}`}
</button>
{uploadedFiles.length > 0 && (
<div className="mt-2 space-y-1">
{uploadedFiles.map((f, i) => (
<div key={i} className="flex items-center justify-between px-2 py-1 bg-gray-50 rounded text-xs">
<div className="flex items-center gap-1 min-w-0">
<FileText className="w-3 h-3 flex-shrink-0 text-gray-400" />
<span className="truncate">{FILE_TYPES.find(ft => ft.key === f.fileType)?.label || f.fileType}: {f.fileName}</span>
</div>
<button onClick={() => removeFile(i)} className="text-gray-400 hover:text-danger flex-shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
<Button className="w-full" onClick={handleSubmit} disabled={loading || !form.name || !form.phone || !form.idCard}>
{loading ? '提交中...' : '提交'}
</Button>
<div className="text-xs text-gray-400 text-center">📌 HR </div>
</div>
</Card>
</div>
</div>
)
}
+186
View File
@@ -0,0 +1,186 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { DollarSign, Check, TrendingUp, Download } from 'lucide-react'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import EmptyState from '../../components/ui/EmptyState'
// 金额格式化:保留两位小数 + 千分位
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const portalApi = api.create({ baseURL: '/api/v1/portal' })
portalApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('portalToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default function Payslip() {
const queryClient = useQueryClient()
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [showHistory, setShowHistory] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['payslip', month],
queryFn: async () => {
const res = await portalApi.get('/payslip', { params: { month } }) as any
return res.data?.data ?? null
},
})
const { data: history } = useQuery<any[]>({
queryKey: ['payslip-history'],
queryFn: async () => {
const res = await portalApi.get('/payslip/history') as any
return res.data?.data ?? []
},
})
const confirmMutation = useMutation({
mutationFn: (id: string) => portalApi.post(`/payslip/${id}/confirm`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['payslip'] }),
})
const employee = JSON.parse(localStorage.getItem('portalEmployee') || '{}')
const handleExport = () => {
if (!history || history.length === 0) return
const headers = ['月份', '基本工资', '加班费', '津贴', '扣款', '应发合计', '确认状态']
const rows = history.map((p: any) => [
p.month,
p.baseSalary,
p.overtimePay,
p.allowance,
p.deduction,
p.totalPay,
p.confirmedAt ? '已确认' : '未确认',
])
const csv = [headers, ...rows].map(r => r.join(',')).join('\n')
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `工资条_${employee.name || '员工'}_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
const sortedHistory = [...(history || [])].sort((a: any, b: any) => a.month.localeCompare(b.month))
const maxPay = Math.max(...sortedHistory.map((p: any) => Number(p.totalPay) || 0), 1)
return (
<div className="min-h-screen bg-gray-50 px-4 py-6">
<div className="max-w-md mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<DollarSign className="w-6 h-6 text-primary" />
<h1 className="text-sm font-semibold"></h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-gray-500">{employee.name}</span>
<Link to="/portal/contract" className="text-sm text-primary"></Link>
</div>
</div>
<div className="flex items-center gap-2 mb-4">
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="px-3 py-2 rounded-md border border-gray-300 text-sm"
/>
<button
onClick={() => setShowHistory(!showHistory)}
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium"
>
<TrendingUp className="w-4 h-4" />
</button>
<button
onClick={handleExport}
disabled={!history || history.length === 0}
className="flex items-center gap-1 px-3 py-2 rounded-md bg-gray-100 text-xs font-medium disabled:opacity-50"
>
<Download className="w-4 h-4" />
</button>
</div>
{showHistory && sortedHistory.length > 0 && (
<Card className="mb-4">
<h3 className="text-xs font-medium mb-3"> {sortedHistory.length} </h3>
<div className="space-y-2">
{sortedHistory.map((p: any) => (
<div key={p.id} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-16 flex-shrink-0">{p.month}</span>
<div className="flex-1 bg-gray-100 rounded-full h-5 relative overflow-hidden">
<div
className="bg-primary h-full rounded-full transition-all"
style={{ width: `${(Number(p.totalPay) / maxPay) * 100}%` }}
/>
</div>
<span className="text-xs font-medium w-20 text-right">¥{fmt(Number(p.totalPay))}</span>
</div>
))}
</div>
</Card>
)}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : !data ? (
<EmptyState title="暂无工资条" description={`该月份(${month})暂无工资记录`} />
) : (
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.baseSalary))}</span>
</div>
{data.overtimePay > 0 && (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.overtimePay))}</span>
</div>
</div>
)}
{data.allowance > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">¥{fmt(Number(data.allowance))}</span>
</div>
)}
{data.deduction > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium text-danger">-¥{fmt(Number(data.deduction))}</span>
</div>
)}
<div className="border-t pt-3">
<div className="flex justify-between">
<span className="font-medium"></span>
<span className="text-base font-bold text-primary">¥{fmt(Number(data.totalPay))}</span>
</div>
</div>
{data.confirmedAt ? (
<div className="flex items-center gap-2 text-sm text-safe">
<Check className="w-4 h-4" /> {new Date(data.confirmedAt).toLocaleString()}
</div>
) : (
<Button
className="w-full"
onClick={() => confirmMutation.mutate(data.id)}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? '确认中...' : '确认已阅'}
</Button>
)}
</div>
)}
</Card>
</div>
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Building2 } from 'lucide-react'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
type LoginMode = 'password' | 'code'
export default function PortalLogin() {
const navigate = useNavigate()
const [mode, setMode] = useState<LoginMode>('password')
const [phone, setPhone] = useState('')
const [password, setPassword] = useState('')
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [codeSent, setCodeSent] = useState(false)
const [displayedCode, setDisplayedCode] = useState('')
const handlePasswordLogin = async () => {
setError('')
setLoading(true)
try {
const res = await api.post('/portal/login', { phone, password }) as any
localStorage.setItem('portalToken', res.data.token)
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
navigate('/portal/payslip')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败')
} finally {
setLoading(false)
}
}
const handleSendCode = async () => {
setError('')
try {
const res = await api.post('/portal/send-code', { phone }) as any
setCodeSent(true)
setDisplayedCode(res.data.code)
} catch (err: any) {
setError(err.response?.data?.error?.message || '发送失败')
}
}
const handleCodeLogin = async () => {
setError('')
setLoading(true)
try {
const res = await api.post('/portal/verify-code', { phone, code }) as any
localStorage.setItem('portalToken', res.data.token)
localStorage.setItem('portalEmployee', JSON.stringify(res.data.employee))
navigate('/portal/payslip')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<Building2 className="w-8 h-8 text-primary" />
<span className="text-base font-bold"> </span>
</div>
<div className="card">
<div className="flex border-b mb-4">
<button
onClick={() => { setMode('password'); setError('') }}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'password' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
></button>
<button
onClick={() => { setMode('code'); setError('') }}
className={`flex-1 py-2 text-sm font-medium border-b-2 ${mode === 'code' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
></button>
</div>
{error && <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>}
{mode === 'password' ? (
<div className="space-y-3">
<div>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
</div>
<div>
<Label></Label>
<Input type="password" placeholder="请输入密码" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<Button className="w-full" onClick={handlePasswordLogin} disabled={loading || !phone || !password}>
{loading ? '登录中...' : '登录'}
</Button>
</div>
) : (
<div className="space-y-3">
<div>
<Label></Label>
<Input type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} maxLength={11} />
</div>
<div className="flex gap-2">
<div className="flex-1">
<Label></Label>
<Input placeholder="6位验证码" value={code} onChange={(e) => setCode(e.target.value)} maxLength={6} />
</div>
<div className="flex items-end">
<Button variant="secondary" onClick={handleSendCode} disabled={!phone || codeSent}>
{codeSent ? '已发送' : '获取验证码'}
</Button>
</div>
</div>
{codeSent && displayedCode && (
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
{displayedCode}
</div>
)}
<Button className="w-full" onClick={handleCodeLogin} disabled={loading || !phone || !code}>
{loading ? '登录中...' : '登录'}
</Button>
</div>
)}
</div>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
orgId: string
name: string
phone: string
role: 'ADMIN' | 'HR' | 'VIEWER'
}
interface AuthState {
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
setAuth: (user: User, accessToken: string, refreshToken: string) => void
updateToken: (accessToken: string) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setAuth: (user, accessToken, refreshToken) =>
set({ user, accessToken, refreshToken, isAuthenticated: true }),
updateToken: (accessToken) => set({ accessToken }),
logout: () =>
set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false }),
}),
{ name: 'auth-storage' },
),
)
+174
View File
@@ -0,0 +1,174 @@
export interface ApiResponse<T> {
success: boolean
data: T
error: { code: string; message: string } | null
}
export interface PaginatedData<T> {
items: T[]
total: number
page: number
pageSize: number
}
export interface Organization {
id: string
name: string
plan: 'FREE' | 'PRO' | 'ENTERPRISE'
maxEmployees: number
city: string | null
}
export interface User {
id: string
orgId: string
name: string
phone: string
email: string | null
role: 'ADMIN' | 'HR' | 'VIEWER'
}
export interface Employee {
id: string
orgId: string
name: string
department: string
hireDate: string
monthlySalary: string
status: 'ACTIVE' | 'RESIGNED'
gender: string | null
phone: string | null
isPregnant: boolean
isInMedicalPeriod: boolean
isWorkInjured: boolean
contracts: LaborContract[]
}
export interface LaborContract {
id: string
employeeId: string
signDate: string | null
startDate: string
endDate: string | null
contractType: 'FIXED' | 'UNFIXED' | 'UNSIGNED'
signMethod: 'PAPER' | 'ELECTRONIC'
contractYears: number
probationMonths: number
probationSalary: number
renewalCount: number
attachmentName: string | null
attachmentUrl: string | null
electronicContractNo: string | null
electronicContractUrl: string | null
}
export interface RiskItem {
id: string
orgId: string
employeeId: string | null
type: 'CONTRACT' | 'SALARY' | 'TERMINATION'
level: 'HIGH' | 'MEDIUM' | 'LOW'
status: 'PENDING' | 'RESOLVED' | 'IGNORED'
title: string
description: string
actionUrl: string | null
createdAt: string
}
export interface DashboardData {
greeting: string
stats: {
employeeCount: number
highRiskCount: number
todoCount: number
monthlyOvertimePay: number
}
todos: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
level: 'high' | 'medium' | 'low'
title: string
description: string
actionUrl: string
}[]
resolvedTodos: {
id: string
type: 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING'
level: 'high' | 'medium' | 'low'
title: string
description: string
actionUrl: string
resolvedAt: string | null
}[]
riskDistribution: {
contract: number
salary: number
termination: number
}
topRisks: {
id: string
type: string
level: string
title: string
description: string
employeeName: string | null
actionUrl: string
}[]
aiPrediction: {
risks: unknown[]
suggestion: string
} | null
payrollSummary: {
month: string
employeeCount: number
payslipCount: number
confirmedPayslips: number
unconfirmedPayslips: number
baseSalary: number
overtimePay: number
allowance: number
deduction: number
totalPay: number
socialOrg: number
socialEmp: number
housingOrg: number
housingEmp: number
estimatedTax: number
severancePay: number
orgTotalCost: number
empNetPay: number
}
monthlyActivities: {
month: string
newContracts: number
terminations: number
disciplinaryActions: number
attendanceRecords: number
overtimeHours: number
overtimePay: number
}
}
export interface TerminationRecord {
id: string
employeeId: string
employeeName: string
reason: 'NEGOTIATED' | 'FAULT' | 'NONFAULT' | 'LAYOFF' | 'EXPIRED'
terminationDate: string
compensation: number
riskLevel: 'SAFE' | 'WARNING' | 'DANGER'
checklist: { item: string; passed: boolean; remark?: string }[]
createdAt: string
}
export interface Payslip {
id: string
month: string
baseSalary: number
overtimePay: number
weekdayOvertimePay: number
weekendOvertimePay: number
holidayOvertimePay: number
totalPay: number
confirmedAt: string | null
}

Some files were not shown because too many files have changed in this diff Show More