From 0df8aa77d98cce6caf51eaf50248f2868d766fe1 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Fri, 24 Jul 2026 13:53:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AIHR=20=E6=99=BA=E8=83=BD=E4=BA=BA?= =?UTF-8?q?=E5=8A=9B=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知 --- .gitignore | 5 + .vscode/settings.json | 16 + 0-req.md | 1387 ++++++ 1-prd.md | 1107 +++++ 2-task.md | 762 ++++ 20260723-优化-1.md | 377 ++ 20260723-优化-2.md | 111 + 20260723-优化-3.md | 210 + 20260723-优化-4.md | 75 + 20260723-优化-5.md | 219 + 20260723-优化-6.md | 219 + backend/.env.example | 21 + backend/package-lock.json | 3696 ++++++++++++++++ backend/package.json | 48 + .../termination_workflow.sql | 16 + .../20260724011853_init/migration.sql | 931 ++++ backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/schema.prisma | 826 ++++ backend/prisma/seed-wufang.ts | 120 + backend/prisma/seed.ts | 298 ++ backend/scripts/migrate-records.ts | 150 + backend/src/app.ts | 68 + backend/src/index.ts | 7 + backend/src/lib/crypto.ts | 26 + backend/src/lib/jwt.ts | 28 + backend/src/lib/prisma.ts | 5 + backend/src/middleware/auditLog.ts | 27 + backend/src/middleware/auth.ts | 28 + backend/src/middleware/errorHandler.ts | 37 + backend/src/middleware/rateLimit.ts | 19 + backend/src/routes/ai.routes.ts | 401 ++ backend/src/routes/attachment.routes.ts | 63 + backend/src/routes/auth.routes.ts | 91 + backend/src/routes/dashboard.routes.ts | 80 + backend/src/routes/employee.routes.ts | 200 + backend/src/routes/export.routes.ts | 243 + backend/src/routes/import.routes.ts | 608 +++ backend/src/routes/notification.routes.ts | 169 + backend/src/routes/payroll.routes.ts | 577 +++ backend/src/routes/payroll2.routes.ts | 673 +++ backend/src/routes/portal.routes.ts | 425 ++ backend/src/routes/roster.routes.ts | 791 ++++ backend/src/routes/settings.routes.ts | 188 + backend/src/routes/social.routes.ts | 956 ++++ backend/src/routes/termination.routes.ts | 272 ++ backend/src/schemas/auth.schema.ts | 35 + backend/src/schemas/contract.schema.ts | 62 + backend/src/schemas/portal.schema.ts | 37 + backend/src/schemas/termination.schema.ts | 15 + backend/src/services/ai.service.ts | 195 + backend/src/services/auth.service.ts | 103 + backend/src/services/contract.service.ts | 611 +++ backend/src/services/payroll.service.ts | 342 ++ backend/src/services/rag.service.ts | 97 + backend/src/services/risk.service.ts | 525 +++ backend/src/services/termination.service.ts | 827 ++++ backend/tsconfig.json | 22 + docs/ui-ux-optimization-plan.md | 888 ++++ docs/ui-ux-review.md | 1147 +++++ frontend/index.html | 13 + frontend/package-lock.json | 3915 +++++++++++++++++ frontend/package.json | 40 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 95 + frontend/src/components/OnboardingGuide.tsx | 84 + .../src/components/layout/MobileTabBar.tsx | 37 + .../src/components/layout/PageContainer.tsx | 10 + frontend/src/components/layout/TopNav.tsx | 115 + frontend/src/components/ui/Button.tsx | 29 + frontend/src/components/ui/Card.tsx | 10 + frontend/src/components/ui/ConfirmDialog.tsx | 38 + frontend/src/components/ui/EmptyState.tsx | 26 + frontend/src/components/ui/Input.tsx | 38 + frontend/src/components/ui/Modal.tsx | 62 + frontend/src/components/ui/Pagination.tsx | 95 + frontend/src/components/ui/Signal.tsx | 27 + frontend/src/components/ui/Skeleton.tsx | 61 + frontend/src/hooks/useDebouncedValue.ts | 19 + frontend/src/index.css | 52 + frontend/src/lib/api.ts | 47 + frontend/src/main.tsx | 26 + frontend/src/pages/AIAssistant.tsx | 847 ++++ frontend/src/pages/Compensation.tsx | 358 ++ frontend/src/pages/Contracts.tsx | 519 +++ frontend/src/pages/Dashboard.tsx | 583 +++ frontend/src/pages/Money.tsx | 1662 +++++++ frontend/src/pages/Roster.tsx | 2796 ++++++++++++ frontend/src/pages/Settings.tsx | 1058 +++++ frontend/src/pages/SocialInsurance.tsx | 754 ++++ frontend/src/pages/Termination.tsx | 1740 ++++++++ frontend/src/pages/auth/ForgotPassword.tsx | 157 + frontend/src/pages/auth/Login.tsx | 107 + frontend/src/pages/auth/Register.tsx | 126 + frontend/src/pages/portal/ContractConfirm.tsx | 161 + frontend/src/pages/portal/MyContract.tsx | 130 + frontend/src/pages/portal/Onboarding.tsx | 216 + frontend/src/pages/portal/Payslip.tsx | 186 + frontend/src/pages/portal/PortalLogin.tsx | 129 + frontend/src/store/authStore.ts | 37 + frontend/src/types/index.ts | 174 + frontend/tailwind.config.ts | 23 + frontend/tsconfig.json | 26 + frontend/tsconfig.node.json | 12 + frontend/tsconfig.node.tsbuildinfo | 1 + frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.d.ts | 2 + frontend/vite.config.js | 20 + frontend/vite.config.ts | 21 + netlify.toml | 14 + 109 files changed, 38190 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 0-req.md create mode 100644 1-prd.md create mode 100644 2-task.md create mode 100644 20260723-优化-1.md create mode 100644 20260723-优化-2.md create mode 100644 20260723-优化-3.md create mode 100644 20260723-优化-4.md create mode 100644 20260723-优化-5.md create mode 100644 20260723-优化-6.md create mode 100644 backend/.env.example create mode 100644 backend/package-lock.json create mode 100644 backend/package.json create mode 100644 backend/prisma/manual_migrations/termination_workflow.sql create mode 100644 backend/prisma/migrations/20260724011853_init/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed-wufang.ts create mode 100644 backend/prisma/seed.ts create mode 100644 backend/scripts/migrate-records.ts create mode 100644 backend/src/app.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/lib/crypto.ts create mode 100644 backend/src/lib/jwt.ts create mode 100644 backend/src/lib/prisma.ts create mode 100644 backend/src/middleware/auditLog.ts create mode 100644 backend/src/middleware/auth.ts create mode 100644 backend/src/middleware/errorHandler.ts create mode 100644 backend/src/middleware/rateLimit.ts create mode 100644 backend/src/routes/ai.routes.ts create mode 100644 backend/src/routes/attachment.routes.ts create mode 100644 backend/src/routes/auth.routes.ts create mode 100644 backend/src/routes/dashboard.routes.ts create mode 100644 backend/src/routes/employee.routes.ts create mode 100644 backend/src/routes/export.routes.ts create mode 100644 backend/src/routes/import.routes.ts create mode 100644 backend/src/routes/notification.routes.ts create mode 100644 backend/src/routes/payroll.routes.ts create mode 100644 backend/src/routes/payroll2.routes.ts create mode 100644 backend/src/routes/portal.routes.ts create mode 100644 backend/src/routes/roster.routes.ts create mode 100644 backend/src/routes/settings.routes.ts create mode 100644 backend/src/routes/social.routes.ts create mode 100644 backend/src/routes/termination.routes.ts create mode 100644 backend/src/schemas/auth.schema.ts create mode 100644 backend/src/schemas/contract.schema.ts create mode 100644 backend/src/schemas/portal.schema.ts create mode 100644 backend/src/schemas/termination.schema.ts create mode 100644 backend/src/services/ai.service.ts create mode 100644 backend/src/services/auth.service.ts create mode 100644 backend/src/services/contract.service.ts create mode 100644 backend/src/services/payroll.service.ts create mode 100644 backend/src/services/rag.service.ts create mode 100644 backend/src/services/risk.service.ts create mode 100644 backend/src/services/termination.service.ts create mode 100644 backend/tsconfig.json create mode 100644 docs/ui-ux-optimization-plan.md create mode 100644 docs/ui-ux-review.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/OnboardingGuide.tsx create mode 100644 frontend/src/components/layout/MobileTabBar.tsx create mode 100644 frontend/src/components/layout/PageContainer.tsx create mode 100644 frontend/src/components/layout/TopNav.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Card.tsx create mode 100644 frontend/src/components/ui/ConfirmDialog.tsx create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/Pagination.tsx create mode 100644 frontend/src/components/ui/Signal.tsx create mode 100644 frontend/src/components/ui/Skeleton.tsx create mode 100644 frontend/src/hooks/useDebouncedValue.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AIAssistant.tsx create mode 100644 frontend/src/pages/Compensation.tsx create mode 100644 frontend/src/pages/Contracts.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Money.tsx create mode 100644 frontend/src/pages/Roster.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/SocialInsurance.tsx create mode 100644 frontend/src/pages/Termination.tsx create mode 100644 frontend/src/pages/auth/ForgotPassword.tsx create mode 100644 frontend/src/pages/auth/Login.tsx create mode 100644 frontend/src/pages/auth/Register.tsx create mode 100644 frontend/src/pages/portal/ContractConfirm.tsx create mode 100644 frontend/src/pages/portal/MyContract.tsx create mode 100644 frontend/src/pages/portal/Onboarding.tsx create mode 100644 frontend/src/pages/portal/Payslip.tsx create mode 100644 frontend/src/pages/portal/PortalLogin.tsx create mode 100644 frontend/src/store/authStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/tsconfig.node.tsbuildinfo create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/vite.config.d.ts create mode 100644 frontend/vite.config.js create mode 100644 frontend/vite.config.ts create mode 100644 netlify.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec4d0f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.env +*.local +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8d7d41c --- /dev/null +++ b/.vscode/settings.json @@ -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" +} \ No newline at end of file diff --git a/0-req.md b/0-req.md new file mode 100644 index 0000000..5eaf18a --- /dev/null +++ b/0-req.md @@ -0,0 +1,1387 @@ +# 劳动用工合规助手 — 需求规格说明书 + +> **文档编号**: 0-req.md +> **版本**: v2.0 +> **日期**: 2026-07-23 +> **状态**: 草案 +> **修订**: v2.0 — 面向中小企业优化 UI/UX 与交互逻辑,强调极简易用 +> **修订**: v3.0 — 升级为 SaaS 多租户架构,支持注册登录/多用户/云端存储 + +--- + +## 1. 背景与问题分析 + +### 1.1 劳动仲裁高发领域 + +根据司法大数据及各地仲裁委公开统计,劳动争议仲裁案件集中在以下三大领域,占比超过 **85%**: + +| 领域 | 典型争议点 | 占比(约) | +|------|-----------|-----------| +| **劳动合同签订** | 未签书面合同(双倍工资)、逾期签订、到期未续签、违法约定试用期 | 30% | +| **工资与加班费** | 拖欠/克扣工资、加班费计算基数错误、未支付加班费、未足额支付 | 35% | +| **解聘/离职** | 违法解除劳动合同、未支付经济补偿金/赔偿金、程序不合规、未提前通知 | 20% | + +### 1.2 中小企业特殊痛点 + +- **没有专职 HR**:很多中小企业由老板或行政兼任,完全不懂劳动法 +- **Excel 都嫌麻烦**:现有管理方式靠记忆和纸质文件,连 Excel 都没用好 +- **请不起法律顾问**:遇到问题不知道找谁,也不知道合不合规 +- **系统太复杂学不会**:市面上的 HR 系统功能太多,上手成本高,中小企业用不起来 +- **怕出事但不知道怎么防**:知道劳动仲裁麻烦,但不知道从哪里开始预防 + +### 1.3 系统定位 + +构建一个 **中小企业也能用起来的极简用工合规工具**: + +1. **像计算器一样简单** — 打开就能用,不需要培训 +2. **像导航一样引导** — 该做什么、怎么做,一步步引导 +3. **像警报器一样提醒** — 有风险自动弹窗,不怕遗漏 +4. **像顾问一样解释** — 每个风险都附上法律依据和操作建议,不用自己查法条 + +--- + +## 2. 目标用户 + +**核心用户**:中小企业老板 / 行政兼 HR / 初级 HR + +| 用户画像 | 典型场景 | +|---------|---------| +| **老板兼管 HR**(10-30人企业) | 偶尔打开看看有没有风险,解聘时算一下补偿金 | +| **行政兼 HR**(30-80人企业) | 每天花5分钟看看待办,合同到期前续签,每月算加班费 | +| **初级 HR**(80-200人企业) | 日常合同管理、工资计算、解聘流程走查 | + +**用户特征假设**: +- 不懂劳动法,需要系统主动告知合规要求 +- 不愿看长文,偏好"告诉我该做什么" +- 可能第一次用类似系统,需要新手引导 +- 手机/平板也会用,不能只考虑桌面端 + +--- + +## 3. UI/UX 设计原则 + +### 3.1 核心原则:三看三不用 + +| 原则 | 含义 | 设计要求 | +|------|------|---------| +| **看一眼就懂** | 不需要说明书 | 每个页面标题用大白话,不用法律术语 | +| **看一眼会点** | 知道下一步做什么 | 每个页面有且只有一个主操作按钮(蓝色高亮) | +| **看一眼放心** | 知道当前状态 | 用红/黄/绿三色信号灯表示风险状态 | +| **不用记法条** | 系统自动判断合规 | 风险自动检测,附人话版法律解释 | +| **不用手算** | 系统自动计算 | 输入最少信息,自动算出结果 | +| **不用怕漏掉** | 系统主动提醒 | 到期/风险自动弹窗 + 角标提醒 | + +### 3.2 交互设计规范 + +**信息层级 — 渐进式披露**: +``` +第一眼:看到什么 → 数字 + 信号灯(绿/黄/红) +第二眼:想知道为什么 → 点击展开风险说明 +第三眼:想知道怎么办 → 展开操作建议 + 法律依据 +``` + +**操作流 — 一屏一焦点**: +- 每个页面只做一件事,不堆砌功能 +- 主操作按钮固定在视觉焦点位置(右上角或底部居中) +- 次要操作收起在"更多"菜单中 +- 表单分步填写,一屏不超过 5 个输入项 + +**视觉语言 — 信号灯体系**: + +| 颜色 | 含义 | 使用场景 | +|------|------|---------| +| 🟢 绿色 | 合规/正常 | 合同在签、工资正常、无风险 | +| 🟡 黄色 | 需关注 | 合同即将到期、加班超时、待处理 | +| 🔴 红色 | 有风险 | 未签合同、到期未续签、违法解聘 | +| ⚪ 灰色 | 不适用 | 离职员工、已完成事项 | + +**文案风格 — 说人话**: + +| ❌ 法律术语 | ✅ 人话版本 | +|------------|-----------| +| 「依据《劳动合同法》第82条」 | 「入职1个月没签合同,员工可以要求双倍工资」 | +| 「经济补偿金N」 | 「需要赔 X 个月工资,共 ¥XX,XXX」 | +| 「非过失性解除」 | 「员工没犯错但要辞退」 | +| 「法定节假日300%」 | 「国庆节加班1天 = 平时3天工资」 | + +### 3.3 新手引导 + +- **首次打开**:3 步引导弹窗("这里看风险"→"这里管合同"→"这里算钱") +- **空数据状态**:展示示例截图 + "添加第一个员工"按钮,不让用户面对空白页 +- **关键操作前**:简短提示卡片(如解聘前提示"建议先咨询律师") + +--- + +## 4. 功能需求 + +### 4.1 模块总览 + +#### 当前版本(v1.0 — 劳动合规基础 + AI 顾问 + 员工参与) + +**管理端(HR/老板使用)**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ 劳动用工合规助手(管理端) │ +├───────────┬──────────┬──────────┬──────────┬────────────┬─────┤ +│ 风险总览 │ 合同管理 │ 钱的计算 │ 解聘助手 │ AI合规顾问 │设置 │ +│ (首页) │ │ │ │ │ │ +│ 红黄绿信号灯│ 员工合同 │ 加班费 │ 补偿金计算│ 智能问答 │企业 │ +│ 待办清单 │ 到期提醒 │ 双倍工资 │ 合规检查 │ 风险预测 │用户 │ +│ 一句话风险 │ 一键续签 │ 经济补偿 │ 流程引导 │ 合同审查 │套餐 │ +│ AI风险预测 │纸质/电子 │ │ │ 案例匹配 │ │ +└───────────┴──────────┴──────────┴──────────┴────────────┴─────┘ + ↓ 所有风险汇总到首页 ↓ +``` + +**员工端(员工使用,独立入口)**: +``` +┌──────────────────────────────────────────────┐ +│ 员工端(手机号验证码登录) │ +├──────────┬──────────┬──────────────────────┤ +│ 工资条 │ 我的合同 │ 入职填报/合同确认 │ +│ │ │ │ +│ 月度工资 │ 合同信息 │ 扫码填报基本信息 │ +│ 加班明细 │ 签订记录 │ 电子合同签署确认 │ +│ 确认已阅 │ 下载查看 │ HR审核后入库 │ +└──────────┴──────────┴──────────────────────┘ +``` + +#### 演进路线图 + +``` +v1.0 (当前) v2.0 (近期) v3.0 (中期) +┌───────────────────────────┐ ┌────────────────┐ ┌──────────────────┐ +│ 劳动合规基础 + AI + 员工参与 │ ──→ │ + 社保公积金 │ ──→ │ + 人力成本分析 │ +│ 合同/工资/解聘 │ │ 五险一金计算 │ │ 成本报表/趋势 │ +│ AI 问答/风险预测/审查 │ │ 缴费基数/比例 │ │ 部门成本/人均 │ +│ 员工端:工资条/合同/入职填报 │ │ │ │ │ +└───────────────────────────┘ └────────────────┘ └──────────────────┘ + +v4.0 (远期) +┌────────────────┐ +│ + 员工自助门户 │ +│ 考勤/请假/调薪 │ +│ 在线审批流程 │ +└────────────────┘ +``` + +**精简策略**: +- v1.0 聚焦劳动仲裁三大高发领域 + AI 合规顾问 + 员工参与,管理端 5 个页面 + 员工端 3 个页面 +- 架构上预留扩展接口,后续模块即插即用,不重构现有代码 +- 每个页面最多 2 层深度,避免复杂导航 + +--- + +### 4.2 首页:风险总览(极简版) + +**目标**:打开就知道有没有事、该做什么 + +**页面布局**: +``` +┌─────────────────────────────────────────────┐ +│ 👋 早上好!今天有 3 件事需要处理 │ +│ │ +│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ │ 12 │ │ 🔴 2 │ │ 🟡 3 │ │ ¥8k │ │ +│ │ 员工 │ │ 高风险│ │ 待办 │ │加班费│ │ +│ └──────┘ └──────┘ └──────┘ └──────┘ │ +│ │ +│ 📋 今日待办 │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🔴 张三入职35天未签合同 → 去处理 │ │ +│ │ 🟡 李四合同还有20天到期 → 去续签 │ │ +│ │ 🟡 王五上月加班48小时超标 → 查看 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ 📊 风险分布 │ +│ ┌─────────────────────────────────────┐ │ +│ │ 合同风险 ████████ 5项 │ │ +│ │ 工资风险 ████ 2项 │ │ +│ │ 解聘风险 ██ 1项 │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**设计要点**: +- 顶部一句话总结:「今天有 X 件事需要处理」,让用户立刻知道是否有事 +- 4 个数字卡片:员工总数 / 高风险数 / 待办数 / 本月加班费 +- 待办列表:每条一行,红/黄信号灯 + 一句话描述 + 「去处理」按钮 +- 风险分布:用简单进度条代替复杂图表,一眼看出哪类问题多 +- **无风险时**:显示绿色大勾「✅ 暂无风险,继续保持!」 +- **首次使用空状态**:展示「添加第一个员工」引导卡片 + +--- + +### 4.3 合同管理 + +**目标**:不用记日期,不怕忘签,风险自动提醒 + +#### 4.3.1 员工合同列表(极简表格) + +**列表只显示 5 列**(避免信息过载): + +| 姓名 | 部门 | 入职日期 | 合同状态 | 操作 | +|------|------|---------|---------|------| +| 张三 | 技术部 | 2026-06-18 | 🔴 未签合同(35天) | 签订 / 详情 | +| 李四 | 销售部 | 2025-08-01 | 🟡 即将到期(20天) | 续签 / 详情 | +| 王五 | 财务部 | 2024-03-15 | 🟢 正常 | 详情 | + +- 状态列用信号灯 + 简短文字,一眼看出问题 +- 点击「签订」/「续签」直接弹出表单,不需要进详情页 +- 搜索框 + 部门筛选,支持快速查找 +- **批量操作**:全选 → 批量续签(适合到期合同多的情况) + +#### 4.3.2 添加/编辑员工(分步表单) + +**Step 1 — 基本信息(4 项)**: +``` +姓名 *: [___________] +部门 *: [下拉选择___] +入职日期 *:[日期选择器__] +月工资 *: [___________] 元 +``` + +**Step 2 — 合同信息(5 项,可跳过)**: +``` +合同类型: ○ 固定期限 ○ 无固定期限 ○ 暂未签订 +签订方式: ○ 纸质合同 ○ 电子合同 +签订日期: [日期选择器__] +合同期限: [下拉:1年/2年/3年/无固定] +试用期: [下拉:无/1个月/2个月/3个月/6个月] +``` + +- 如果选「暂未签订」,系统自动标记为高风险并提醒 +- 试用期下拉自动校验合法性(选了1年合同但选6个月试用期 → 红色提示) +- **智能默认值**:合同期限默认3年,试用期默认1个月,减少选择成本 +- **必填项最小化**:只有姓名和入职日期是必填,其他都可以后补 + +**签订方式说明**: + +| 方式 | 说明 | 系统支持 | +|------|------|---------| +| **纸质合同** | 打印后双方线下签字盖章 | 记录签订日期 + 可上传扫描件/拍照存档 | +| **电子合同** | 在线签署电子合同(如通过第三方电子签平台) | 记录签订日期 + 可填写电子合同链接/编号 | + +- 选择「纸质合同」时,显示「上传扫描件」按钮(可选),支持拍照或选择文件上传 +- 选择「电子合同」时,显示「电子合同编号」输入框(可选)和「合同链接」输入框(可选) +- 两种方式法律效力等同,系统不强制选择,默认为「纸质合同」 + +#### 4.3.3 合规风险检查规则(后台自动运行) + +| 规则 | 检查内容 | 信号灯 | 人话提示 | +|------|---------|--------|---------| +| C-01 | 入职超1个月未签合同 | 🔴 | 「张三入职35天了还没签合同,要赔双倍工资」 | +| C-02 | 入职超1年未签合同 | 🔴 | 「超过1年没签合同,法律上等于已签无固定期限合同」 | +| C-03 | 合同30天内到期 | 🟡 | 「李四合同还有20天到期,该准备续签了」 | +| C-04 | 合同到期未续签仍用工 | 🔴 | 「合同已到期但还在上班,要赔双倍工资」 | +| C-05 | 试用期超法定上限 | 🔴 | 「1年期合同试用期最多2个月,当前3个月不合法」 | +| C-06 | 试用期工资过低 | 🟡 | 「试用期工资不能低于转正工资的80%」 | +| C-07 | 两次固定期限后应签无固定 | 🔴 | 「已经签了2次固定合同,第3次应签无固定期限」 | + +#### 4.3.4 一键续签 + +- 合同到期前30天,列表出现「续签」按钮 +- 点击后弹出确认弹窗: + ``` + ┌───────────────────────────────┐ + │ 续签李四的劳动合同? │ + │ │ + │ 原合同:2025-08-01 ~ 2026-07-31│ + │ 新合同期限:[3年 ▼] │ + │ 新到期日:2029-07-31 │ + │ 试用期:无 │ + │ 签订方式:○ 纸质合同 ○ 电子合同 │ + │ │ + │ [取消] [确认续签] │ + └───────────────────────────────┘ + ``` +- 续签时选择签订方式(纸质/电子),默认沿用上次签订方式 +- 续签后自动更新状态为 🟢 正常,风险消除 + +--- + +### 4.4 钱的计算(Tab 切换,一页搞定) + +**目标**:输入最少信息,立刻算出该赔多少、该付多少 + +**页面结构**:3 个 Tab,共享一个页面 +``` +┌─────────────────────────────────────────────┐ +│ [ 加班费计算 ] [ 双倍工资 ] [ 经济补偿金 ] │ +├─────────────────────────────────────────────┤ +│ │ +│ (当前 Tab 的计算器内容) │ +│ │ +└─────────────────────────────────────────────┘ +``` + +#### 4.4.1 加班费计算器(Tab 1) + +**界面设计**:左输入右结果,实时计算 +``` +┌──────────────────┬──────────────────────┐ +│ 填写信息 │ 计算结果 │ +│ │ │ +│ 月工资:[8000__] │ 📊 加班费明细 │ +│ 元 │ │ +│ │ 小时工资:¥46.00 │ +│ 工作日加班: │ │ +│ [__10__] 小时 │ 工作日 ¥690 (10h×1.5) │ +│ │ 休息日 ¥736 (8h×2.0) │ +│ 休息日加班: │ 节假日 ¥552 (4h×3.0) │ +│ [__8___] 小时 │ │ +│ │ ─────────────────── │ +│ 节假日加班: │ 💰 合计:¥1,978 │ +│ [__4___] 小时 │ │ +│ │ ⚠️ 月加班22小时, │ +│ [计算] │ 未超36小时上限 ✅ │ +└──────────────────┴──────────────────────┘ +``` + +**交互优化**: +- 输入数字后**实时计算**,不需要点「计算」按钮 +- 结果区固定在右侧,输入时数字实时跳动更新 +- 超过36小时/月时,结果区显示黄色警告 +- 可选关联员工:选择员工后自动填入月工资 + +**计算规则**(后台自动,用户不需要知道公式): +``` +小时工资 = 月工资 ÷ 21.75 ÷ 8 +工作日加班费 = 小时工资 × 1.5 × 小时数 +休息日加班费 = 小时工资 × 2.0 × 小时数 +节假日加班费 = 小时工资 × 3.0 × 小时数 +``` + +#### 4.4.2 双倍工资计算器(Tab 2) + +**界面设计**: +``` +┌──────────────────┬──────────────────────┐ +│ 填写信息 │ 计算结果 │ +│ │ │ +│ 月工资:[8000__] │ ⚠️ 风险提示 │ +│ 元 │ │ +│ │ 入职日期:2026-06-01 │ +│ 入职日期: │ 合同签订:未签订 │ +│ [2026-06-01] │ │ +│ │ 双倍工资起算: │ +│ 合同签订日期: │ 2026-07-02 │ +│ [未签订 ▼] │ │ +│ 或选择日期 │ 双倍工资截止: │ +│ │ 2027-05-31 │ +│ │ │ +│ │ 💰 需赔:¥88,000 │ +│ │ (11个月 × ¥8,000) │ +│ │ │ +│ │ 📌 法律规定:入职1个月 │ +│ │ 没签合同,从第2个月起 │ +│ │ 要付双倍工资,最多11个月│ +└──────────────────┴──────────────────────┘ +``` + +**交互优化**: +- 「合同签订日期」默认显示「未签订」,也可选择日期 +- 如果已签订但逾期,自动计算逾期月数的双倍工资 +- 结果区用醒目大字显示金额 +- 底部附「人话版」法律解释 + +#### 4.4.3 经济补偿金计算器(Tab 3) + +**界面设计**: +``` +┌──────────────────┬──────────────────────┐ +│ 填写信息 │ 计算结果 │ +│ │ │ +│ 入职日期: │ 📊 补偿金计算 │ +│ [2022-03-01] │ │ +│ │ 工作年限:4年2个月 │ +│ 离职日期: │ → 按4.5个月计算 │ +│ [2026-05-15] │ │ +│ │ 月工资:¥8,000 │ +│ 月平均工资: │ │ +│ [8000___] 元 │ 💰 经济补偿金: │ +│ │ ¥36,000 │ +│ 离职原因: │ (4.5 × 8,000) │ +│ [协商解除 ▼] │ │ +│ │ ⚠️ 如果是违法解除: │ +│ 当地社平工资: │ 赔偿金 = ¥72,000 │ +│ [8000___] 元 │ (补偿金 × 2) │ +│ (选填,用于封顶) │ │ +│ │ 📌 6个月以上算1年, │ +│ │ 不满6个月算半个月 │ +└──────────────────┴──────────────────────┘ +``` + +**交互优化**: +- 「离职原因」用下拉选择,选项用人话: + - 「协商解除(双方同意)」 + - 「员工犯错被辞退」 + - 「员工没犯错但干不了」 + - 「公司裁员」 + - 「公司单方面违法辞退」 +- 根据离职原因自动判断是否需要支付补偿金 +- 「当地社平工资」选填,不填则跳过封顶计算 +- 结果同时显示正常补偿金和违法解除赔偿金(×2) + +#### 4.4.4 工资合规自动检查(后台规则) + +| 规则 | 检查内容 | 信号灯 | 人话提示 | +|------|---------|--------|---------| +| S-01 | 工资低于当地最低工资 | 🔴 | 「工资不能低于当地最低工资标准」 | +| S-02 | 加班超36小时/月 | 🟡 | 「月加班超过36小时有法律风险」 | +| S-03 | 试用期工资过低 | 🟡 | 「试用期工资至少是转正工资的80%」 | + +--- + +### 4.5 解聘助手(向导式流程) + +**目标**:一步步引导完成合规解聘,不怕漏步骤 + +**设计理念**:不展示复杂清单,而是用**向导式流程**,一次只看一步 + +#### 4.5.1 解聘向导(5 步) + +``` +┌─────────────────────────────────────────────┐ +│ 解聘助手 ●●○○○ │ +│ ───────────────────────────────────────── │ +│ │ +│ Step 1/5:为什么解聘? │ +│ │ +│ ○ 协商解除(双方同意分开了) │ +│ ○ 员工犯错被辞退(严重违纪/失职等) │ +│ ○ 员工没犯错但干不了(生病/不胜任等) │ +│ ○ 公司裁员(经营困难/技术调整等) │ +│ ○ 合同到期不续签 │ +│ │ +│ 💡 选不同原因,后续步骤和法律要求不同 │ +│ │ +│ [下一步 →] │ +└─────────────────────────────────────────────┘ +``` + +**5 步流程**: + +| 步骤 | 标题 | 内容 | 交互 | +|------|------|------|------| +| Step 1 | 为什么解聘? | 选择解聘原因(用人话选项) | 单选 | +| Step 2 | 员工信息 | 选择员工 → 自动带入入职日期/工资 → 填写解聘日期 | 下拉 + 日期 | +| Step 3 | 合规检查 | 根据解聘原因自动展示相关检查项(3-5项) | 逐项勾选 | +| Step 4 | 算钱 | 自动计算补偿金/代通知金,展示金额 | 自动计算 | +| Step 5 | 确认完成 | 汇总检查结果 + 金额 + 风险评估 → 保存记录 | 确认按钮 | + +**关键交互**: +- 顶部进度条 `●●○○○` 直观显示进度 +- 每步只有 1 个主操作「下一步」 +- Step 3 合规检查根据 Step 1 的选择**动态展示**: + - 选「协商解除」→ 只检查「是否支付补偿金」「是否签协议」 + - 选「员工犯错」→ 检查「是否有规章制度依据」「是否有证据」「是否通知工会」 + - 选「裁员」→ 检查「是否提前30天说明」「是否听取意见」「是否报劳动部门」 +- 检查项用人话描述,不是法条原文 +- Step 5 如果有未通过检查项,显示红色警告但**不阻止保存**(尊重用户决策) + +#### 4.5.2 禁止解聘情形检查(自动弹出) + +在 Step 2 选择员工后,自动检查是否属于**不得解除**情形: + +| 检查项 | 触发条件 | 提示 | +|--------|---------|------| +| 孕期/产期/哺乳期 | 员工标记为女性 + 在孕期/哺乳期 | 🔴 「该员工在孕期,法律禁止解除」 | +| 工伤期间 | 员工标记为工伤 | 🔴 「工伤期间不得解除劳动合同」 | +| 医疗期 | 员工在规定的医疗期内 | 🔴 「医疗期内不得解除」 | + +- 如果触发禁止情形,弹出醒目红色警告框 +- **不阻止继续操作**,但要求用户确认「我已了解风险,继续操作」 + +#### 4.5.3 解聘记录 + +- 完成向导后自动生成解聘记录 +- 记录包含:员工信息、解聘原因、检查结果、补偿金额、操作日期 +- 可在解聘助手页面底部查看历史记录 +- 支持导出单条记录为 PDF(可选) + +--- + +### 4.6 AI 合规顾问 + +**目标**:像有个法律顾问在身边,随时问、自动查、提前预警 + +**设计理念**:不是冷冰冰的搜索框,而是对话式交互,用户用大白话提问,AI 用大白话回答 + +#### 4.6.1 智能问答 + +**界面设计**:聊天式对话界面 +``` +┌─────────────────────────────────────────────┐ +│ 🤖 AI 合规顾问 │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🤖 你好!我是你的用工合规顾问, │ │ +│ │ 有什么劳动法问题可以直接问我。 │ │ +│ │ │ │ +│ │ 你可以问我: │ │ +│ │ · 员工入职没签合同怎么办? │ │ +│ │ · 加班费怎么算? │ │ +│ │ · 辞退员工需要赔多少? │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 👤 试用期最长可以约定几个月? │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 🤖 根据《劳动合同法》第19条: │ │ +│ │ │ │ +│ │ · 合同期 3个月~1年 → 试用期最多1个月 │ │ +│ │ · 合同期 1年~3年 → 试用期最多2个月 │ │ +│ │ · 合同期 3年以上 → 试用期最多6个月 │ │ +│ │ │ │ +│ │ ⚠️ 你的员工王五:合同2年但试用期3个月│ │ +│ │ 超过法定上限,建议调整为2个月 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ [输入问题...] [发送] │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**交互特点**: +- 聊天式界面,支持多轮对话(上下文记忆) +- 回答时**关联本企业数据**(如「你的员工王五试用期超限」) +- 回答附法律依据引用(可折叠查看法条原文) +- 预设常见问题快捷按钮(「没签合同怎么办」「加班费怎么算」等) +- 支持语音输入(移动端) + +#### 4.6.2 风险预测 + +**界面设计**:首页风险总览中的智能分析卡片 +``` +┌─────────────────────────────────────────────┐ +│ 🔮 AI 风险预测 │ +│ │ +│ ⚠️ 未来30天预计新增 3 个风险: │ +│ │ +│ · 李四合同7月31日到期 → 续签提醒 │ +│ · 张三入职将满1年 → 未签合同风险升级 │ +│ · 上月加班总时长上升40% → 超时风险 │ +│ │ +│ 💡 建议:本周优先处理合同到期和未签问题 │ +└─────────────────────────────────────────────┘ +``` + +- 基于企业当前数据(合同到期日、入职日期、加班趋势)预测未来风险 +- 每日自动刷新,展示在首页风险总览下方 +- 给出优先级建议(先处理什么) + +#### 4.6.3 合同审查 + +**功能**:上传合同文本或粘贴条款,AI 审查合法性 + +``` +┌─────────────────────────────────────────────┐ +│ 📄 合同审查 │ +│ │ +│ [粘贴合同条款文本...] │ +│ [或上传合同文件(.txt/.docx)] │ +│ │ +│ [开始审查] │ +│ │ +│ ──────────── 审查结果 ──────────── │ +│ │ +│ 🔴 试用期6个月超过法定上限(合同期2年最多2月)│ +│ 🟡 竞业限制未约定补偿标准 │ +│ 🟡 加班费计算基数未明确约定 │ +│ 🟢 社保缴纳条款符合规定 │ +│ 🟢 工资支付条款符合规定 │ +│ │ +│ 📊 合规评分:72/100 │ +└─────────────────────────────────────────────┘ +``` + +- 逐条审查合同条款,标注红/黄/绿 +- 给出修改建议(人话版) +- 合规评分直观展示合同整体合法程度 + +#### 4.6.4 案例匹配 + +**功能**:输入争议情况,匹配相似仲裁案例,评估败诉风险 + +``` +┌─────────────────────────────────────────────┐ +│ ⚖️ 案例匹配 │ +│ │ +│ 描述你的情况: │ +│ [员工入职3个月没签合同,现在要辞退他...] │ +│ │ +│ [分析] │ +│ │ +│ ──────────── 相似案例 ──────────── │ +│ │ +│ 📋 案例1:某科技公司 vs 员工张某 │ +│ · 情形:入职3个月未签合同后辞退 │ +│ · 结果:企业败诉,赔双倍工资+违法解除赔偿 │ +│ · 赔偿金额:¥XX,XXX │ +│ · 相似度:92% │ +│ │ +│ 📋 案例2:某贸易公司 vs 员工李某 │ +│ · 情形:未签合同 + 协商解除 │ +│ · 结果:企业赔双倍工资差额 │ +│ · 赔偿金额:¥XX,XXX │ +│ · 相似度:85% │ +│ │ +│ ⚠️ 你的败诉风险:高(90%) │ +│ 💡 建议:先补签合同再协商解除,可降低风险 │ +└─────────────────────────────────────────────┘ +``` + +- 内置劳动仲裁案例库(公开裁判文书) +- AI 语义匹配相似案例,计算相似度 +- 评估败诉概率和预估赔偿金额 +- 给出风险降低建议 + +#### 4.6.5 技术方案 + +| 组件 | 方案 | +|------|------| +| **LLM** | 阿里通义千问(Qwen),通过 DashScope API 调用,中文劳动法领域表现优秀 | +| **模型选择** | qwen-plus(日常问答)/ qwen-max(合同审查/案例匹配等复杂任务) | +| **API Key** | 通过环境变量 `DASHSCOPE_API_KEY` 注入,不硬编码 | +| **RAG 知识库** | 劳动法/劳动合同法/司法解释/地方条例 向量化存储 | +| **向量数据库** | Supabase pgvector(与业务数据库共用,减少依赖) | +| **Embedding** | DashScope text-embedding-v2(中文支持好,与 Qwen 生态统一) | +| **上下文关联** | 每次问答注入当前企业数据摘要(员工数/风险项/合同状态) | +| **案例库** | 爬取公开裁判文书,结构化存储 + 向量检索 | +| **流式输出** | DashScope SSE 流式返回,打字机效果,提升体验 | +| **安全过滤** | 敏感问题兜底回复(「建议咨询专业律师」) | + +#### 4.6.6 使用限制 + +| 套餐 | AI 问答次数/月 | 合同审查次数/月 | 案例匹配次数/月 | +|------|---------------|---------------|---------------| +| free | 10 | 3 | 3 | +| pro | 100 | 20 | 20 | +| enterprise | 无限 | 无限 | 无限 | + +### 4.7 员工参与(员工端,独立入口) + +**目标**:让员工也能查看自己的合同和工资,参与入职填报和合同确认,减少 HR 沟通成本 + +**设计理念**:员工端独立入口,不需要注册账号,手机号验证码登录,极简操作 + +#### 4.7.1 员工登录 + +- **入口**:独立页面 `/portal/login`,与管理端完全分离 +- **登录方式**:支持两种方式,员工可自由选择 + - **方式一:手机号 + 密码**(推荐,无需短信服务) + - HR 创建员工时设置初始密码,员工首次登录后可修改 + - 密码 6 位以上,支持数字+字母组合 + - **方式二:手机号 + 验证码**(无需记密码) + - v1.0 方案:页面内显示验证码(暂不接入短信服务,降低成本) + - 后续版本:接入阿里云短信服务,发送真实短信验证码 +- **验证码发送**:优先通过页面内验证码(暂不接入短信服务,降低成本) + - v1.0 方案:HR 通过微信分享员工端二维码/链接,员工扫码进入后输入手机号,系统发送验证码(页面内弹窗显示验证码,后续版本再接入短信) + - 后续版本:接入阿里云短信服务,发送真实短信验证码 +- **身份识别**:根据手机号匹配企业员工记录,自动关联 orgId +- **安全**:验证码 5 分钟有效,同一手机号每小时最多 5 次;密码错误 5 次锁定 30 分钟 + +``` +┌─────────────────────────────────────────────┐ +│ 🏢 用工合规助手 — 员工端 │ +│ │ +│ [密码登录] [验证码登录] ← 切换 Tab │ +│ │ +│ ── 密码登录 ── │ +│ 手机号:[___________] │ +│ 密码: [___________] │ +│ [登录] │ +│ │ +│ ── 验证码登录 ── │ +│ 手机号:[___________] │ +│ [获取验证码] 验证码:[______] │ +│ [登录] │ +│ │ +│ 📱 也可扫描 HR 发送的二维码直接进入 │ +└─────────────────────────────────────────────┘ +``` + +**HR 端生成员工端二维码**: +- HR 在管理端可生成员工端入口二维码,扫码直接打开 `/portal/login` +- 二维码可保存为图片,HR 通过微信发给员工 +- 员工扫码后选择密码登录或验证码登录 +- HR 创建员工时设置初始密码,可通过微信单独告知员工 + +#### 4.7.2 工资条查看 + +**页面**:`/portal/payslip` + +``` +┌─────────────────────────────────────────────┐ +│ 💰 我的工资条 │ +│ │ +│ 月份选择:[2026年7月 ▼] │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ 基本工资: ¥8,000 │ │ +│ │ 加班费: ¥1,978 │ │ +│ │ └ 工作日加班: ¥690 (10h) │ │ +│ │ └ 休息日加班: ¥736 (8h) │ │ +│ │ └ 节假日加班: ¥552 (4h) │ │ +│ │ ───────────────────────── │ │ +│ │ 应发合计: ¥9,978 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [确认已阅] │ +└─────────────────────────────────────────────┘ +``` + +- 按月查看工资明细,包含基本工资和加班费拆分 +- 加班费明细可展开查看工作日/休息日/节假日分类 +- 「确认已阅」按钮,记录员工已查看该月工资条(时间 + IP) +- HR 端可查看哪些员工已确认、哪些未确认 + +#### 4.7.3 我的合同 + +**页面**:`/portal/contract` + +``` +┌─────────────────────────────────────────────┐ +│ 📄 我的劳动合同 │ +│ │ +│ 合同类型:固定期限 │ +│ 签订方式:纸质合同 │ +│ 签订日期:2026-03-15 │ +│ 合同期限:2026-03-15 ~ 2029-03-14(3年) │ +│ 试用期:2个月(2026-03-15 ~ 2026-05-14) │ +│ 试用期工资:¥6,400 │ +│ 转正工资:¥8,000 │ +│ │ +│ 📎 合同扫描件:劳动合同_张三.pdf [查看] │ +│ │ +│ ──────────── 签署记录 ──────────── │ +│ ✅ 已确认签署(2026-03-15 14:32) │ +│ 确认IP:192.168.x.x │ +└─────────────────────────────────────────────┘ +``` + +- 查看自己的劳动合同信息(只读) +- 可查看合同扫描件(纸质)或电子合同链接(电子) +- 展示签署确认记录(时间 + IP) +- 如合同即将到期,顶部显示提示「您的合同还有 XX 天到期」 + +#### 4.7.4 员工入职填报 + +**页面**:`/portal/onboarding?token=xxx` + +**使用场景**:HR 在管理端添加员工时选择「生成填报二维码」,员工扫码填写(HR 通过微信发送二维码图片或链接) + +``` +┌─────────────────────────────────────────────┐ +│ 📝 入职信息填报 │ +│ │ +│ 欢迎加入 XX公司!请填写以下信息: │ +│ │ +│ 姓名 *: [___________] │ +│ 手机号 *: [___________] │ +│ 身份证号 *:[___________] │ +│ 紧急联系人:[___________] │ +│ 联系电话: [___________] │ +│ 住址: [___________] │ +│ 银行卡号: [___________] │ +│ 开户行: [___________] │ +│ │ +│ [提交] │ +│ │ +│ 📌 提交后 HR 将审核您的信息 │ +└─────────────────────────────────────────────┘ +``` + +- HR 端创建员工时可选「生成填报二维码」或「自己填写」 +- 选择「生成填报二维码」后,页面显示二维码图片 + 链接,HR 可: + - 保存二维码图片,通过微信发给员工 + - 复制链接,通过微信直接发给员工 +- 填报链接含一次性 token,有效期 24 小时 +- 员工填写的信息进入「待审核」状态,HR 审核后正式入库 +- 填报信息加密传输(HTTPS) +- **暂不接入短信服务**,降低初期成本,后续版本可增加短信通知 + +#### 4.7.5 电子合同签署确认 + +**页面**:`/portal/contract-confirm?token=xxx` + +**使用场景**:HR 在管理端录入电子合同后,生成确认二维码/链接,通过微信发给员工 + +``` +┌─────────────────────────────────────────────┐ +│ ✍️ 合同签署确认 │ +│ │ +│ XX公司 与 张三 的劳动合同 │ +│ │ +│ 合同类型:固定期限(3年) │ +│ 合同期限:2026-03-15 ~ 2029-03-14 │ +│ 试用期:2个月 │ +│ 试用期工资:¥6,400 │ +│ 转正工资:¥8,000 │ +│ │ +│ 📎 查看合同文件:[点击查看] │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ ☐ 我已阅读合同内容,确认签署 │ │ +│ └─────────────────────────────────────┘ │ +│ │ +│ [确认签署] │ +│ │ +│ 📌 确认后将记录签署时间和 IP 地址 │ +└─────────────────────────────────────────────┘ +``` + +- 员工查看合同内容后勾选确认 + 点击「确认签署」 +- 系统记录确认时间、IP 地址、设备信息 +- HR 端合同状态自动更新为「已确认签署」 +- 确认链接含一次性 token,有效期 7 天 +- HR 生成确认二维码后,可保存图片通过微信发给员工,或复制链接直接发送 +- **暂不接入短信服务**,通过微信发送二维码/链接即可 +- **法律效力说明**:此确认为员工知情确认,正式电子签章需接入第三方电子签平台(后续版本) + +### 4.8 风险提醒(融入首页,不单独成页) + +**设计理念**:风险不需要单独页面,直接在首页和各模块中展示 + +**风险展示位置**: + +| 位置 | 展示方式 | +|------|---------| +| 首页 | 待办列表 + 风险分布进度条 | +| 合同管理 | 列表状态列信号灯 + 详情页风险卡片 | +| 钱的计算 | 计算结果区的警告提示 | +| 解聘助手 | 向导 Step 3/5 的检查结果 | +| **顶部导航栏** | 红色角标显示待处理风险总数,点击跳转首页 | + +**风险处理**: +- 在首页待办列表中,每条风险右侧有「去处理」按钮 +- 点击直接跳转到对应模块的操作页面 +- 处理完成后风险自动消除(如签了合同,未签风险消失) +- 也可手动标记「已忽略」并填写备注(记录决策原因) + +--- + +## 5. 数据模型(SaaS 多租户) + +### 5.0 多租户基础表 + +#### Organization(企业/组织) + +```typescript +interface Organization { + id: string; + name: string; // 企业名称 + plan: 'free' | 'pro' | 'enterprise'; // 套餐 + maxEmployees: number; // 套餐对应人数上限 + createdAt: string; +} +``` + +#### User(用户) + +```typescript +interface User { + id: string; + orgId: string; // 所属企业 + phone: string; // 手机号(登录用) + email?: string; // 邮箱(选填) + passwordHash: string; // bcrypt 加密密码 + name: string; // 用户姓名 + role: 'admin' | 'hr' | 'viewer'; // 角色 + createdAt: string; + lastLoginAt?: string; +} +``` + +**角色权限**: + +| 角色 | 权限 | +|------|------| +| admin | 全部操作 + 用户管理 + 套餐管理 | +| hr | 合同/加班/解聘的增删改查 | +| viewer | 只能查看,不能修改 | + +### 5.1 员工信息 (Employee) — 精简字段 + +```typescript +interface Employee { + id: string; + orgId: string; // 所属企业(多租户隔离) + name: string; // 姓名(必填) + department: string; // 部门(必填) + hireDate: string; // 入职日期(必填) + monthlySalary: number; // 月工资标准(必填,加密存储) + status: 'active' | 'resigned'; // 在职/离职 + gender?: 'male' | 'female'; // 性别(选填,用于禁止解聘检查) + isPregnant?: boolean; // 是否在孕期/哺乳期(选填) + isInMedicalPeriod?: boolean; // 是否在医疗期(选填) + isWorkInjured?: boolean; // 是否工伤期间(选填) + phone?: string; // 联系电话(选填) + createdBy: string; // 创建人 userId + createdAt: string; + updatedAt: string; +} +``` + +**精简策略**:必填字段仅 4 个(姓名/部门/入职日期/月工资),其余选填 + +### 5.2 劳动合同 (LaborContract) + +```typescript +interface LaborContract { + id: string; + orgId: string; // 所属企业 + employeeId: string; // 关联员工 + signDate: string | null; // 签订日期(null=未签订) + startDate: string; // 合同起始日期 + endDate: string | null; // 到期日期(null=无固定期限) + contractType: 'fixed' | 'unfixed' | 'unsigned'; // 简化:固定/无固定/未签 + signMethod: 'paper' | 'electronic'; // 签订方式:纸质/电子 + contractYears: number; // 合同年限(1/2/3等,无固定=0) + probationMonths: number; // 试用期月数(0=无试用期) + probationSalary: number; // 试用期工资(0=无试用期) + renewalCount: number; // 续签次数 + attachmentName?: string; // 纸质合同扫描件文件名(可选) + attachmentUrl?: string; // 扫描件存储 URL(可选) + electronicContractNo?: string; // 电子合同编号(可选) + electronicContractUrl?: string; // 电子合同链接(可选) + createdBy: string; + createdAt: string; + updatedAt: string; +} +``` + +**精简策略**:去掉 `task` 类型(中小企业极少使用)、去掉 `status`(由系统自动计算) + +**签订方式支持**:`signMethod` 区分纸质/电子,纸质可上传扫描件存档,电子可记录合同编号和链接 + +### 5.3 加班记录 (OvertimeRecord) + +```typescript +interface OvertimeRecord { + id: string; + orgId: string; // 所属企业 + employeeId: string; + month: string; // YYYY-MM + weekdayHours: number; // 工作日加班小时 + weekendHours: number; // 休息日加班小时 + holidayHours: number; // 法定节假日加班小时 + calculatedPay: number; // 应付加班费(自动计算) + createdBy: string; + createdAt: string; +} +``` + +**精简策略**:去掉 `overtimePay`(中小企业通常不记录已付金额,只算应付) + +### 5.4 解聘记录 (TerminationRecord) + +```typescript +interface TerminationRecord { + id: string; + orgId: string; // 所属企业 + employeeId: string; + terminationDate: string; + reason: 'negotiated' | 'fault' | 'nonfault' | 'layoff' | 'expired'; + compensation: number; // 经济补偿金(自动计算) + checklistResult: { // 向导检查结果 + item: string; + passed: boolean; + remark?: string; + }[]; + riskLevel: 'safe' | 'warning' | 'danger'; + createdBy: string; + createdAt: string; +} +``` + +**精简策略**:去掉 `reasonDetail`(向导中已选择)、去掉 `noticeType`(由向导检查项覆盖)、去掉单独的 `hasProofIssued`/`hasArchiveTransferred`(合并到 checklistResult) + +### 5.5 风险项 (RiskItem) + +```typescript +interface RiskItem { + id: string; + orgId: string; // 所属企业 + type: 'contract' | 'salary' | 'termination'; + level: 'high' | 'medium' | 'low'; + title: string; // 人话标题 + description: string; // 人话描述 + suggestion: string; // 人话建议 + legalBasis: string; // 法律依据(折叠展示) + employeeId?: string; + status: 'pending' | 'resolved' | 'ignored'; + actionUrl?: string; // 点击跳转的处理页面路由 + createdAt: string; + resolvedAt?: string; + resolvedBy?: string; +} +``` + +### 5.6 操作审计日志 (AuditLog) + +```typescript +interface AuditLog { + id: string; + orgId: string; + userId: string; // 操作人 + action: string; // 操作类型(如 'contract.sign', 'termination.create') + target: string; // 操作对象(如 'employee:张三') + detail: string; // 操作详情 + ipAddress: string; // IP 地址 + createdAt: string; +} +``` + +**新增 `actionUrl`**:风险项可以直接跳转到对应的处理页面,一键操作 + +--- + +## 6. 系统架构(SaaS 版) + +### 6.1 技术架构 + +``` +┌─────────────────────────────────────────────────────┐ +│ 前端(React 18) │ +│ Vite + TailwindCSS + Recharts + React Router │ +│ 部署:Vercel / Netlify │ +├─────────────────────────────────────────────────────┤ +│ API 层(RESTful) │ +│ Axios 请求 → JWT Token 认证 → 路由守卫 │ +├─────────────────────────────────────────────────────┤ +│ 后端(Node.js) │ +│ Express + Prisma ORM + JWT 认证 + Zod 校验 │ +│ 部署:Railway / Render │ +├─────────────────────────────────────────────────────┤ +│ 数据库(PostgreSQL) │ +│ 多租户隔离(org_id) + 全文搜索 + 自动备份 │ +│ 部署:Railway / Supabase / Neon │ +└─────────────────────────────────────────────────────┘ +``` + +### 6.2 多租户架构 + +**隔离策略**:共享数据库 + 行级隔离(`org_id` 字段) + +- 每个企业注册后创建一个 Organization(组织) +- 所有业务数据表均包含 `orgId` 字段 +- 所有 API 请求自动注入当前用户的 `orgId`,只能访问本企业数据 +- Prisma 中间件自动过滤 `orgId`,防止越权 + +### 6.3 认证体系 + +| 功能 | 说明 | +|------|------| +| 注册 | 企业手机号/邮箱注册,创建 Organization + Admin 用户 | +| 登录 | 手机号/邮箱 + 密码登录,返回 JWT Token | +| Token 管理 | Access Token(2h 过期)+ Refresh Token(7d 过期) | +| 路由守卫 | 前端 React Router 守卫,未登录跳转登录页 | +| API 拦截 | 后端中间件校验 JWT,提取 userId + orgId | +| 角色权限 | admin(管理员)/ hr(HR 操作员)/ viewer(只读) | + +### 6.4 安全要求 + +| 类别 | 要求 | +|------|------| +| **密码存储** | bcrypt 加密,不存明文 | +| **数据传输** | 全站 HTTPS | +| **JWT 密钥** | 环境变量管理,不硬编码 | +| **SQL 注入** | Prisma ORM 参数化查询,杜绝注入 | +| **XSS 防护** | React 自动转义 + CSP 头 | +| **数据隔离** | 每个请求校验 orgId,禁止跨企业访问 | +| **敏感数据** | 工资字段加密存储(AES-256) | +| **操作日志** | 关键操作(解聘/合同变更)记录审计日志 | +| **速率限制** | 登录接口限流(5次/分钟),防止暴力破解 | + +### 6.5 非功能需求 + +| 类别 | 要求 | +|------|------| +| **前端技术栈** | React 18 + Vite + TailwindCSS + Recharts | +| **后端技术栈** | Node.js + Express + Prisma ORM + Zod | +| **数据库** | PostgreSQL,支持多租户行级隔离 | +| **认证** | JWT(Access Token + Refresh Token) | +| **响应式** | 桌面端(1280px+)/ 平板端(768px+)/ 手机端(375px+) | +| **语言** | 全简体中文,文案用「人话」避免法律术语 | +| **性能** | 首屏加载 < 2s,API 响应 < 500ms | +| **浏览器** | Chrome / Edge / Safari 最新版 | +| **部署** | 前端 Vercel + 后端 Railway + 数据库 Neon/Supabase | +| **可用性** | 99.5%+,数据库每日自动备份 | +| **可扩展** | 后期可加 Redis 缓存、CDN 加速 | + +--- + +## 7. 界面规划 + +### 7.1 整体布局(顶部导航,非侧边栏) + +**设计理由**:中小企业用户更习惯顶部导航(类似常用网站),侧边栏对小屏幕不友好 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 🏢 用工合规助手 [总览] [合同] [算钱] [解聘] [AI顾问] 🔴3 👤张总 │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ │ +│ 主内容区 │ +│ (最多 960px 居中) │ +│ │ +│ │ +└─────────────────────────────────────────────────────┘ +``` + +**布局规范**: +- 顶部导航栏:Logo + 5 个 Tab + 风险角标 + 用户头像下拉菜单(设置/退出),固定不滚动 +- 主内容区:最大宽度 960px,居中,避免宽屏下内容过散 +- 移动端:导航栏变为底部 Tab Bar(类似 App) +- **无侧边栏**:减少视觉干扰,5 个 Tab 足够 + +### 7.2 页面清单 + +**管理端认证页面(未登录可访问)**: + +| 页面 | 路由 | 说明 | +|------|------|------| +| 登录 | `/login` | 手机号/邮箱 + 密码登录 | +| 注册 | `/register` | 企业名称 + 手机号 + 密码,注册即创建组织 | +| 忘记密码 | `/forgot-password` | 手机验证码重置密码 | + +**管理端业务页面(登录后访问)**: + +| 页面 | 路由 | 导航名称 | 说明 | +|------|------|---------|------| +| 风险总览 | `/` | 总览 | 一句话状态 + 数字卡片 + 待办列表 + 风险分布 | +| 合同管理 | `/contracts` | 合同 | 员工合同列表 + 添加/编辑 + 一键续签 + 发送填报/确认链接 | +| 钱的计算 | `/money` | 算钱 | 3 个 Tab:加班费 / 双倍工资 / 经济补偿金 | +| 解聘助手 | `/termination` | 解聘 | 5 步向导 + 历史记录 | +| AI 合规顾问 | `/ai-assistant` | AI顾问 | 智能问答 + 合同审查 + 案例匹配 | +| 系统设置 | `/settings` | 设置 | 企业信息 + 用户管理 + 套餐 | + +**员工端页面(独立入口,手机号验证码登录)**: + +| 页面 | 路由 | 说明 | +|------|------|------| +| 员工登录 | `/portal/login` | 手机号 + 短信验证码登录 | +| 工资条 | `/portal/payslip` | 按月查看工资明细 + 确认已阅 | +| 我的合同 | `/portal/contract` | 查看合同信息 + 扫描件/电子链接 | +| 入职填报 | `/portal/onboarding?token=xxx` | 填写入职信息(一次性链接) | +| 合同确认 | `/portal/contract-confirm?token=xxx` | 确认签署电子合同(一次性链接) | + +**设置页面子 Tab**: +- 企业信息:名称、地区(用于最低工资/社平工资默认值) +- 用户管理:添加/移除用户,分配角色(admin/hr/viewer) +- 套餐信息:当前套餐、人数上限、升级套餐 + +### 7.3 配色方案 + +| 用途 | 颜色 | 说明 | +|------|------|------| +| 主色 | `#2563EB`(蓝色) | 主操作按钮、导航高亮、链接 | +| 危险/高风险 | `#EF4444`(红色) | 高风险信号灯、危险提示 | +| 警告/中风险 | `#F59E0B`(橙黄) | 待办提醒、中风险信号灯 | +| 安全/正常 | `#22C55E`(绿色) | 合规状态、完成状态 | +| 背景 | `#F8FAFC`(浅灰) | 页面背景,减少视觉疲劳 | +| 卡片 | `#FFFFFF`(白色) | 内容卡片背景 | +| 文字主 | `#1E293B`(深灰) | 主要文字 | +| 文字次 | `#64748B`(中灰) | 辅助说明文字 | + +### 7.4 组件规范 + +| 组件 | 规范 | +|------|------| +| 按钮 | 主按钮蓝色填充,次按钮白色边框,危险按钮红色填充 | +| 卡片 | 白底 + 圆角12px + 轻阴影(`shadow-sm`),不用重阴影 | +| 表格 | 无边框简约表格,行间用浅灰分隔线,hover 高亮 | +| 表单 | 输入框圆角8px,focus 时蓝色边框,错误时红色边框 + 提示 | +| 弹窗 | 居中模态框,圆角16px,遮罩半透明黑色 | +| 信号灯 | 圆点12px + 文字,不用复杂图标 | +| 空状态 | 插图 + 引导文字 + 主操作按钮 | + +--- + +## 8. 实施计划 + +| 阶段 | 内容 | 优先级 | +|------|------|--------| +| P0 | 项目搭建:前端 Vite + React + TailwindCSS,后端 Express + Prisma + PostgreSQL,项目结构 + 路由骨架 | 高 | +| P1 | 认证体系:注册/登录/JWT Token + 路由守卫 + 多租户中间件 | 高 | +| P2 | 首页风险总览(信号灯 + 待办 + 分布) | 高 | +| P3 | 合同管理(列表 + 添加/编辑 + 一键续签 + 纸质/电子合同 + 风险标注) | 高 | +| P4 | 钱的计算(3 Tab 计算器 + 实时计算) | 高 | +| P5 | 解聘助手(5 步向导 + 禁止情形检查 + 记录) | 高 | +| P6 | AI 合规顾问(智能问答 + 风险预测 + 合同审查 + 案例匹配 + RAG 知识库) | 高 | +| P7 | 员工端(验证码登录 + 工资条 + 我的合同 + 入职填报 + 合同确认) | 高 | +| P8 | 系统设置(企业信息 + 用户管理 + 套餐) | 中 | +| P9 | 新手引导 + 空状态 + 移动端适配 | 中 | +| P10 | 部署上线(Vercel + Railway + Neon)+ 联调验证 | 中 | + +--- + +## 9. 法律依据索引 + +| 法律法规 | 关键条款 | 涉及模块 | +|---------|---------|---------| +| 《劳动法》 | 第36/41/44/48/50条 | 工资加班费 | +| 《劳动合同法》 | 第10/14/19/20/39-42/46-47/50/82条 | 合同/解聘/双倍工资 | +| 《劳动合同法实施条例》 | 第6/7/25/27条 | 双倍工资/补偿金 | +| 《工资支付暂行规定》 | 第13/15/18条 | 加班费/工资支付 | +| 最高人民法院劳动争议司法解释(一) | 第44/45条 | 举证责任 | + +--- + +## 10. 约束与假设 + +1. **SaaS 多租户**:共享数据库 + 行级隔离(`orgId`),每家企业数据互不可见 +2. **法律时效性**:系统内置规则基于现行法律法规,如法律更新需手动更新规则 +3. **地区差异**:最低工资标准、社平工资等参数需用户自行设置(提供常用城市默认值) +4. **非替代法律意见**:系统提供合规参考,不构成正式法律意见,重大决策建议咨询专业律师 +5. **套餐限制**:free 套餐限 20 人,pro 套餐限 200 人,enterprise 无限制 +6. **数据备份**:PostgreSQL 数据库每日自动备份,保留 30 天 +7. **合同附件存储**:纸质合同扫描件上传至云存储(Supabase Storage / S3),数据库只存 URL +8. **数据导出**:支持导出全部数据为 JSON/Excel,用户可随时备份 +9. **后续可扩展**:预留 API 接口,后期可接入电子签平台(如 e签宝、法大大)实现在线签署 + +--- + +## 11. 系统扩展架构 + +### 11.1 设计原则:插件化模块架构 + +系统采用**模块化插件架构**,每个业务模块独立开发、独立注册、独立路由,互不依赖: + +``` +┌─────────────────────────────────────────────────────┐ +│ 前端框架层 │ +│ 路由 / 导航 / 认证 / 布局 / 信号灯体系 / UI 组件库 │ +├─────────────────────────────────────────────────────┤ +│ 模块注册中心 (Module Registry) │ +│ 每个模块注册:路由前缀 / 导航菜单项 / 权限 / 图标 │ +├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤ +│ 合同 │ 工资 │ 解聘 │ 社保 │ 成本 │ 考勤 │ ... │ +│ 模块 │ 模块 │ 模块 │ 模块 │ 分析 │ 模块 │ 模块 │ +├──────┴──────┴──────┴──────┴──────┴──────┴──────────┤ +│ 共享数据层 (Prisma) │ +│ Employee / Organization / User / AuditLog │ +└─────────────────────────────────────────────────────┘ +``` + +**核心机制**: +- **模块注册**:每个模块通过统一接口注册路由、导航菜单、权限要求 +- **共享数据**:Employee/Organization/User 为核心共享表,所有模块复用 +- **模块独立**:新增模块不影响现有模块,可独立上线/下线 +- **渐进式加载**:前端按模块懒加载(React.lazy),不影响首屏性能 + +### 11.2 后端 API 扩展规范 + +``` +/api/v1/ + /auth ← 认证模块(v1.0) + /employees ← 员工管理(v1.0) + /contracts ← 合同管理(v1.0) + /overtime ← 加班记录(v1.0) + /termination ← 解聘管理(v1.0) + /risks ← 风险预警(v1.0) + /social-insurance ← 社保公积金(v2.0 预留) + /cost-analysis ← 人力成本分析(v3.0 预留) + /attendance ← 考勤管理(v4.0 预留) +``` + +- 所有 API 遵循 RESTful 规范,统一 `/api/v1/` 前缀 +- 统一响应格式:`{ success: boolean, data: any, error?: string }` +- 统一鉴权中间件,所有路由自动校验 JWT + orgId +- 新增模块只需添加路由文件 + Prisma model,不改现有代码 + +### 11.3 数据库扩展策略 + +- 核心表(Employee/Organization/User)稳定不变 +- 新模块新增独立表,通过 `employeeId` / `orgId` 关联 +- Prisma schema 按模块分文件管理(Prisma 多 schema 支持) +- 数据库迁移使用 Prisma Migrate,增量迁移不破坏现有数据 + +--- + +## 12. 后续模块规划 + +### 12.1 v2.0 — 社保公积金模块 + +**目标**:自动计算五险一金缴费金额,防止少缴/漏缴风险 + +| 功能 | 说明 | +|------|------| +| 缴费基数设置 | 各城市社保/公积金缴费基数上下限默认值 | +| 五险一金计算 | 养老/医疗/失业/工伤/生育 + 公积金,企业/个人分担 | +| 缴费明细表 | 按员工/按月生成缴费明细 | +| 合规检查 | 缴费基数低于最低标准预警、断缴提醒 | +| 对账单导出 | 导出社保公积金月度对账单 | + +**数据模型预留**: +```typescript +interface SocialInsuranceRecord { + id: string; + orgId: string; + employeeId: string; + month: string; // YYYY-MM + base: number; // 缴费基数 + pension: { company: number; personal: number }; + medical: { company: number; personal: number }; + unemployment: { company: number; personal: number }; + workInjury: { company: number; personal: number }; + maternity: { company: number; personal: number }; + housingFund: { company: number; personal: number }; + total: { company: number; personal: number }; +} +``` + +**页面路由**:`/social-insurance`(导航名称:社保) + +### 12.2 v3.0 — 人力资源成本分析模块 + +**目标**:可视化人力成本结构,辅助经营决策 + +| 功能 | 说明 | +|------|------| +| 成本总览 Dashboard | 月度/季度/年度人力成本总览 | +| 成本构成分析 | 工资/社保/公积金/加班费/补偿金 占比饼图 | +| 部门成本对比 | 各部门人力成本柱状图对比 | +| 人均成本趋势 | 人均成本月度趋势折线图 | +| 成本预警 | 人力成本占比超过营收 X% 预警 | +| 报表导出 | 导出 Excel/PDF 成本报表 | + +**数据模型预留**: +```typescript +interface CostReport { + id: string; + orgId: string; + period: string; // YYYY-MM 或 YYYY-Q1 等 + totalSalary: number; // 工资总额 + totalOvertimePay: number; // 加班费总额 + totalSocialInsurance: number; // 社保企业部分 + totalHousingFund: number; // 公积金企业部分 + totalCompensation: number; // 解聘补偿金 + totalCost: number; // 人力成本合计 + headcount: number; // 人数 + avgCostPerPerson: number; // 人均成本 +} +``` + +**页面路由**:`/cost-analysis`(导航名称:成本分析) + +### 12.3 v3.0 — 员工自助门户增强 + +**目标**:在 v1.0 员工端基础上增加考勤、请假等自助功能 + +| 功能 | 说明 | +|------|------| +| 考勤记录 | 查看个人考勤/加班记录 | +| 请假申请 | 在线请假审批流程 | +| 调薪记录 | 查看历史调薪记录 | +| 在线咨询 | 员工端直接问 AI 顾问 | + +**页面路由**:`/portal`(扩展已有员工端) + +**注**:v1.0 已包含员工端基础功能(工资条查看、合同查看、入职填报、合同确认),v3.0 在此基础上增强 diff --git a/1-prd.md b/1-prd.md new file mode 100644 index 0000000..e7ffa8a --- /dev/null +++ b/1-prd.md @@ -0,0 +1,1107 @@ +# 劳动用工合规助手 — 产品需求文档 (PRD) + +> **文档编号**: 1-prd.md +> **版本**: v1.0 +> **日期**: 2026-07-23 +> **状态**: 草案 +> **依据**: 0-req.md v3.0 需求规格说明书 + +--- + +## 1. 产品概述 + +### 1.1 产品定位 + +面向中小企业的**极简用工合规 SaaS 工具**,聚焦劳动仲裁三大高发领域(合同签订、工资加班费、解聘),辅以 AI 合规顾问,帮助企业事前预防、事中管控、事后追溯用工风险。 + +### 1.2 产品愿景 + +> 「让每个中小企业都有一个用得起的劳动法顾问」 + +### 1.3 核心价值主张 + +| 价值 | 说明 | +|------|------| +| 极简易用 | 三看三不用原则,不需要培训,打开就会用 | +| 自动合规 | 风险自动检测 + 人话提示,不用懂法也能合规 | +| AI 顾问 | 通义千问驱动,随时问、自动查、提前预警 | +| 安全可靠 | SaaS 云端存储,数据隔离,多设备访问 | + +### 1.4 目标用户 + +| 用户角色 | 企业规模 | 使用频率 | 核心诉求 | +|---------|---------|---------|---------| +| 老板兼管 HR | 10-30人 | 每周1-2次 | 快速了解风险,解聘时算钱 | +| 行政兼 HR | 30-80人 | 每天5分钟 | 合同到期提醒,每月算加班费 | +| 初级 HR | 80-200人 | 日常使用 | 合同管理,工资计算,解聘流程 | + +--- + +## 2. 用户故事 + +### 2.1 认证与注册 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-AUTH-01 | 作为企业管理者,我想注册账号,这样我就能开始使用系统管理用工合规 | 输入企业名称+手机号+密码 → 创建组织+管理员账号 → 自动登录跳转首页 | +| US-AUTH-02 | 作为用户,我想用手机号和密码登录,这样我能安全访问我的数据 | 输入手机号+密码 → 校验通过 → 返回 JWT Token → 跳转首页 | +| US-AUTH-03 | 作为管理员,我想添加 HR 用户,这样我的同事也能操作系统 | 设置页 → 用户管理 → 输入姓名+手机号+角色 → 创建成功 | +| US-AUTH-04 | 作为用户,我想找回密码,这样我忘记密码时还能登录 | 忘记密码 → 输入手机号 → 验证码重置 → 重新登录 | + +### 2.2 首页风险总览 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-HOME-01 | 作为用户,我想打开首页就知道有没有风险,这样我不用到处翻找 | 顶部一句话:「今天有 X 件事需要处理」 | +| US-HOME-02 | 作为用户,我想看到具体待办事项,这样我知道该先处理什么 | 待办列表:信号灯+一句话描述+「去处理」按钮 | +| US-HOME-03 | 作为用户,我想看到风险分布,这样我知道哪类问题最多 | 进度条展示合同/工资/解聘三类风险数量 | +| US-HOME-04 | 作为用户,当没有风险时我想看到安心提示,这样我知道一切正常 | 显示绿色「✅ 暂无风险,继续保持!」 | +| US-HOME-05 | 作为用户,我想看到 AI 风险预测,这样我能提前防范 | 首页下方展示 AI 预测的未来30天风险卡片 | + +### 2.3 合同管理 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-CON-01 | 作为 HR,我想添加员工和合同信息,这样系统帮我管理合规 | 添加按钮 → 分步表单(基本信息→合同信息)→ 保存成功 | +| US-CON-02 | 作为 HR,我想看到所有员工的合同状态,这样我一眼就知道谁有问题 | 列表5列:姓名/部门/入职日期/合同状态(信号灯)/操作 | +| US-CON-03 | 作为 HR,当合同即将到期时我想收到提醒,这样我不会忘记续签 | 到期前30天列表出现「续签」按钮 + 首页待办提醒 | +| US-CON-04 | 作为 HR,我想一键续签合同,这样我不用填一堆表单 | 点击「续签」→ 弹窗确认期限+签订方式 → 确认 → 状态变绿 | +| US-CON-05 | 作为 HR,我想选择纸质或电子合同签订方式,这样符合实际操作 | 添加/续签时可选「纸质合同」或「电子合同」 | +| US-CON-06 | 作为 HR,当员工入职很久没签合同时我想被提醒,这样避免双倍工资赔偿 | 入职超1个月未签 → 🔴 红色状态 + 首页待办 | +| US-CON-07 | 作为 HR,我想搜索员工,这样快速找到某人的合同 | 搜索框输入姓名 → 实时筛选列表 | +| US-CON-08 | 作为 HR,我想批量续签即将到期的合同,这样不用一个个点 | 全选 → 批量续签 → 弹窗确认 → 批量更新 | + +### 2.4 钱的计算 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-MONEY-01 | 作为 HR,我想计算加班费,这样我知道该付多少 | 输入月工资+加班小时数 → 实时显示各类加班费明细+合计 | +| US-MONEY-02 | 作为 HR,当加班超过法定上限时我想被提醒,这样避免违法 | 月加班超36小时 → 结果区显示黄色警告 | +| US-MONEY-03 | 作为 HR,我想计算未签合同的双倍工资,这样我知道风险金额 | 输入月工资+入职日期+签订状态 → 显示起止日期+赔偿金额 | +| US-MONEY-04 | 作为 HR,我想计算经济补偿金,这样解聘时知道该赔多少 | 输入入职/离职日期+月工资+原因 → 显示工作年限+补偿金+赔偿金(×2) | +| US-MONEY-05 | 作为 HR,我想关联员工自动填入工资,这样不用手动输入 | 选择员工 → 月工资自动填入 | + +### 2.5 解聘助手 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-TERM-01 | 作为 HR,我想一步步完成解聘流程,这样不会漏掉步骤 | 5步向导:选原因→选员工→合规检查→算钱→确认 | +| US-TERM-02 | 作为 HR,当解聘原因不同时我想看到对应的检查项,这样有针对性 | Step 3 根据Step 1选择动态展示检查项 | +| US-TERM-03 | 作为 HR,当员工属于禁止解聘情形时我想被警告,这样避免违法解聘 | 选员工后自动检查孕期/工伤/医疗期 → 红色警告弹窗 | +| US-TERM-04 | 作为 HR,我想看到解聘历史记录,这样可以追溯 | 解聘助手页面底部展示历史记录列表 | +| US-TERM-05 | 作为 HR,即使有未通过检查项我也想保存记录,这样尊重我的决策 | Step 5 显示红色警告但不阻止保存 | + +### 2.6 AI 合规顾问 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-AI-01 | 作为用户,我想用大白话问劳动法问题,这样不用自己查法条 | 聊天界面输入问题 → AI 流式回答 + 法律依据 + 关联本企业数据 | +| US-AI-02 | 作为用户,我想看到 AI 预测的未来风险,这样提前防范 | 首页 AI 风险预测卡片,展示未来30天预计风险 | +| US-AI-03 | 作为 HR,我想让 AI 审查合同条款,这样知道有没有违法 | 粘贴/上传合同文本 → 逐条审查 + 红/黄/绿标注 + 合规评分 | +| US-AI-04 | 作为 HR,我想匹配相似仲裁案例,这样评估败诉风险 | 输入争议情况 → 展示相似案例 + 败诉概率 + 赔偿预估 | +| US-AI-05 | 作为用户,AI 回答时我想看到打字机效果,这样体验更好 | SSE 流式输出,逐字显示 | + +### 2.7 系统设置 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-SET-01 | 作为管理员,我想设置企业地区,这样系统用对的最低工资标准 | 设置→企业信息→选择城市 → 自动填入最低工资/社平工资默认值 | +| US-SET-02 | 作为管理员,我想管理用户和权限,这样控制谁能看谁能改 | 设置→用户管理→添加/移除用户→分配角色 | +| US-SET-03 | 作为管理员,我想查看当前套餐和人数限制,这样知道是否需要升级 | 设置→套餐信息→显示当前套餐+已用人数+上限 | + +### 2.8 员工端 + +| 编号 | 用户故事 | 验收标准 | +|------|---------|---------| +| US-EMP-01 | 作为员工,我想用手机号+密码登录,这样不用等验证码 | 输入手机号+密码→登录成功→跳转工资条 | +| US-EMP-02 | 作为员工,我想用手机号+验证码登录,这样不用记密码 | 输入手机号→获取验证码→输入验证码→登录成功→跳转工资条 | +| US-EMP-03 | 作为员工,我想扫描 HR 发的二维码直接进入员工端,这样不用手动输入网址 | 扫码→打开员工端登录页→选择登录方式→登录 | +| US-EMP-04 | 作为员工,我想查看月度工资条,这样知道工资明细 | 选择月份→显示基本工资+加班费拆分+应发合计 | +| US-EMP-05 | 作为员工,我想确认已阅工资条,这样 HR 知道我看过了 | 点击「确认已阅」→记录时间+IP→HR端显示已确认 | +| US-EMP-06 | 作为员工,我想查看我的合同信息,这样了解合同条款 | 我的合同页→显示合同类型/期限/试用期/工资/扫描件 | +| US-EMP-07 | 作为员工,HR 发二维码让我填报入职信息,这样不用 HR 手动录入 | 扫码→填写姓名/身份证/银行卡等→提交→HR审核入库 | +| US-EMP-08 | 作为员工,HR 发二维码让我确认电子合同,这样完成签署确认 | 扫码→查看合同内容→勾选确认→点击签署→记录时间+IP | +| US-EMP-09 | 作为 HR,我想生成入职填报二维码发给员工,这样通过微信即可发送 | 添加员工时选「生成填报二维码」→显示二维码+链接→保存图片/复制链接 | +| US-EMP-10 | 作为 HR,我想生成合同确认二维码发给员工,这样完成电子合同签署 | 合同详情→生成确认二维码→微信发给员工→员工确认后状态自动更新 | +| US-EMP-11 | 作为 HR,我想查看员工工资条确认状态,这样知道谁还没看 | 合同/工资管理→显示各员工确认状态(已确认/未确认) | + +--- + +## 3. 用户流程 + +### 3.1 新用户注册流程 + +``` +访问网站 → 注册页 + │ + ├─ 输入企业名称 + ├─ 输入手机号 + ├─ 输入密码 + ├─ 点击注册 + │ + ├─ 创建 Organization + Admin User + ├─ 自动登录,返回 JWT Token + │ + └─ 跳转首页(空状态) + │ + ├─ 显示新手引导弹窗(3步) + └─ 显示「添加第一个员工」引导卡片 +``` + +### 3.2 日常使用流程 + +``` +登录 → 首页风险总览 + │ + ├─ 有待办? → 点击「去处理」→ 跳转对应模块 → 处理 → 返回首页 + │ + ├─ 无待办? → 查看AI风险预测 → 了解未来风险 + │ + └─ 日常操作: + ├─ 合同管理 → 添加员工/续签/查看详情 + ├─ 钱的计算 → 切换Tab计算加班费/双倍工资/补偿金 + ├─ 解聘助手 → 5步向导完成解聘 + └─ AI顾问 → 问答/合同审查/案例匹配 +``` + +### 3.3 合同到期续签流程 + +``` +首页待办显示「🟡 XX合同还有20天到期」 + │ + └─ 点击「去续签」→ 合同管理页 + │ + └─ 点击「续签」→ 弹窗 + │ + ├─ 确认新期限(默认3年) + ├─ 选择签订方式(默认沿用上次) + ├─ 确认新到期日 + │ + └─ 点击「确认续签」 + │ + ├─ 更新合同记录 + ├─ 状态变 🟢 正常 + ├─ 风险项自动消除 + └─ 首页待办减少 +``` + +### 3.4 解聘流程 + +``` +解聘助手 → Step 1: 选择解聘原因 + │ + └─ Step 2: 选择员工 + │ + ├─ 自动检查禁止解聘情形 + ├─ 触发?→ 红色警告 → 用户确认继续 + │ + └─ Step 3: 合规检查(根据原因动态展示) + │ + └─ Step 4: 自动计算补偿金 + │ + └─ Step 5: 确认汇总 + │ + ├─ 有未通过项?→ 红色警告(不阻止) + ├─ 点击保存 + │ + └─ 生成解聘记录 + ├─ 员工状态变更为离职 + ├─ 记录存档 + └─ 审计日志记录 +``` + +### 3.5 AI 智能问答流程 + +``` +AI顾问页 → 输入问题(或点击预设问题) + │ + ├─ 前端构建上下文: + │ ├─ 当前企业数据摘要(员工数/风险项/合同状态) + │ └─ RAG 检索相关法律条文 + │ + ├─ 调用 DashScope API(qwen-plus) + │ + ├─ SSE 流式返回 + │ ├─ 逐字显示回答 + │ └─ 显示完成后附法律依据(可折叠) + │ + └─ 支持多轮对话(保留上下文) +``` + +### 3.6 员工入职填报流程 + +``` +HR 管理端 → 添加员工 → 选择「生成填报二维码」 + │ + ├─ 生成一次性 token(24h 有效) + ├─ 页面显示二维码图片 + 可复制链接 + │ + └─ HR 通过微信发送给员工(二维码图片或链接) + │ + └─ 员工扫码/点击链接 → 填报页面 + │ + ├─ 填写姓名/手机号/身份证/银行卡等 + ├─ 提交 + │ + └─ 信息进入「待审核」状态 + │ + └─ HR 管理端收到通知 + │ + ├─ 审核 → 通过 → 正式入库 + │ ├─ 创建 Employee 记录 + │ └─ token 失效 + │ + └─ 审核 → 驳回 → 员工重新填报 +``` + +### 3.7 电子合同签署确认流程 + +``` +HR 管理端 → 录入电子合同 → 点击「生成确认二维码」 + │ + ├─ 生成一次性 token(7天有效) + ├─ 页面显示二维码图片 + 可复制链接 + │ + └─ HR 通过微信发送给员工(二维码图片或链接) + │ + └─ 员工扫码/点击链接 → 合同确认页面 + │ + ├─ 查看合同内容(类型/期限/试用期/工资) + ├─ 查看合同文件(如有链接) + ├─ 勾选「我已阅读,确认签署」 + ├─ 点击「确认签署」 + │ + └─ 系统记录:确认时间 + IP + 设备信息 + │ + └─ HR 端合同状态自动更新为「已确认签署」 + ├─ token 失效 + └─ 审计日志记录 +``` + +--- + +## 4. 功能规格 + +### 4.1 认证模块 + +#### 4.1.1 注册 + +- **页面**: `/register` +- **输入**: 企业名称、手机号、密码(8位以上)、确认密码 +- **校验**: 手机号格式、密码长度、手机号未被注册 +- **处理**: 创建 Organization(plan=free, maxEmployees=20)+ User(role=admin)+ bcrypt 加密密码 +- **输出**: JWT Token + 跳转首页 +- **限流**: 同一 IP 每小时最多 5 次注册 + +#### 4.1.2 登录 + +- **页面**: `/login` +- **输入**: 手机号、密码 +- **校验**: 手机号存在、密码匹配 +- **输出**: Access Token(2h)+ Refresh Token(7d) +- **限流**: 同一 IP 每分钟最多 5 次登录 + +#### 4.1.3 Token 刷新 + +- **接口**: `POST /api/v1/auth/refresh` +- **输入**: Refresh Token +- **输出**: 新 Access Token +- **逻辑**: 校验 Refresh Token 有效性 → 签发新 Access Token + +### 4.2 首页风险总览 + +#### 4.2.1 数据聚合接口 + +- **接口**: `GET /api/v1/dashboard` +- **返回数据**: +```json +{ + "success": true, + "data": { + "greeting": "早上好!今天有 3 件事需要处理", + "stats": { + "employeeCount": 12, + "highRiskCount": 2, + "todoCount": 3, + "monthlyOvertimePay": 8000 + }, + "todos": [ + { + "id": "risk_001", + "level": "high", + "title": "张三入职35天未签合同", + "actionUrl": "/contracts?employee=张三" + } + ], + "riskDistribution": { + "contract": 5, + "salary": 2, + "termination": 1 + }, + "aiPrediction": { + "risks": [...], + "suggestion": "本周优先处理合同到期和未签问题" + } + } +} +``` + +#### 4.2.2 风险检测引擎 + +- **触发时机**: 数据变更时实时检测 + 每日凌晨定时全量扫描 +- **检测规则**: 见 0-req.md 4.3.3 / 4.4.4 合规检查规则 +- **风险生命周期**: `pending` → `resolved`(自动/手动)/ `ignored`(手动) + +### 4.3 合同管理模块 + +#### 4.3.1 API 规格 + +| 接口 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 员工列表 | GET | `/api/v1/employees` | 支持分页、搜索、部门筛选 | +| 添加员工 | POST | `/api/v1/employees` | 含合同信息 | +| 员工详情 | GET | `/api/v1/employees/:id` | 含合同+风险信息 | +| 编辑员工 | PUT | `/api/v1/employees/:id` | 更新员工+合同信息 | +| 删除员工 | DELETE | `/api/v1/employees/:id` | 软删除(status=resigned) | +| 批量续签 | POST | `/api/v1/contracts/batch-renew` | 批量续签合同 | +| 上传附件 | POST | `/api/v1/contracts/:id/attachment` | 上传纸质合同扫描件 | + +#### 4.3.2 合同状态自动计算 + +``` +输入:signDate, startDate, endDate, contractType, renewalCount, hireDate +输出:status + statusText + riskLevel + +逻辑: + if signDate == null: + daysSinceHire = today - hireDate + if daysSinceHire > 365: → "已视为无固定期限" 🔴 + elif daysSinceHire > 30: → "未签合同(X天)" 🔴 + else: → "未签合同(X天)" 🟡 + elif endDate != null: + daysToExpire = endDate - today + if daysToExpire < 0: → "已到期未续签" 🔴 + elif daysToExpire <= 30: → "即将到期(X天)" 🟡 + else: → "正常" 🟢 + else: + → "无固定期限" 🟢 +``` + +#### 4.3.3 试用期合法性校验 + +``` +规则(劳动合同法第19条): + 合同期 < 3个月 → 不能约定试用期 + 合同期 3个月~1年 → 试用期 ≤ 1个月 + 合同期 1年~3年 → 试用期 ≤ 2个月 + 合同期 ≥ 3年 → 试用期 ≤ 6个月 + +校验时机:添加/编辑合同时实时校验 +校验失败:红色提示「X年期合同试用期最多Y个月,当前Z个月不合法」 +``` + +### 4.4 钱的计算模块 + +#### 4.4.1 加班费计算 + +- **页面**: `/money` Tab 1 +- **输入**: 月工资、工作日加班小时、休息日加班小时、节假日加班小时 +- **计算**: 纯前端实时计算,无需调后端 +- **公式**: +``` +hourlyWage = monthlyWage / 21.75 / 8 +weekdayPay = hourlyWage * 1.5 * weekdayHours +weekendPay = hourlyWage * 2.0 * weekendHours +holidayPay = hourlyWage * 3.0 * holidayHours +total = weekdayPay + weekendPay + holidayPay +``` +- **预警**: 总加班小时 > 36 → 黄色警告 + +#### 4.4.2 双倍工资计算 + +- **页面**: `/money` Tab 2 +- **输入**: 月工资、入职日期、合同签订日期(可选,默认未签订) +- **计算**: 纯前端实时计算 +- **公式**: +``` +if 未签订 or 签订日期 - 入职日期 > 30天: + 起算日 = 入职日 + 1个月 + 截止日 = 入职日 + 1年(如未签订)或 签订日期 + 月数 = min(截止日 - 起算日 的月数, 11) + 双倍工资差额 = 月工资 × 月数 +``` + +#### 4.4.3 经济补偿金计算 + +- **页面**: `/money` Tab 3 +- **输入**: 入职日期、离职日期、月平均工资、离职原因、当地社平工资(选填) +- **计算**: 纯前端实时计算 +- **公式**: +``` +工作年限 = (离职日期 - 入职日期) 转换为年月 + 满1年 → 1个月工资 + 满6个月不满1年 → 1个月工资 + 不满6个月 → 0.5个月工资 +补偿月数 = 向上取整(工作年限月数 / 12) 或 半月 + +if 社平工资 > 0 and 月工资 > 社平工资 × 3: + 月工资 = 社平工资 × 3 + 补偿月数 = min(补偿月数, 12) + +经济补偿金 = 月工资 × 补偿月数 +违法解除赔偿金 = 经济补偿金 × 2 +``` + +### 4.5 解聘助手模块 + +#### 4.5.1 解聘向导状态管理 + +```typescript +interface TerminationWizardState { + step: 1 | 2 | 3 | 4 | 5; + reason: 'negotiated' | 'fault' | 'nonfault' | 'layoff' | 'expired' | null; + employeeId: string | null; + terminationDate: string | null; + checklist: { + item: string; + passed: boolean; + remark?: string; + }[]; + compensation: number; + riskLevel: 'safe' | 'warning' | 'danger'; +} +``` + +#### 4.5.2 动态检查项规则 + +| 解聘原因 | 检查项 | +|---------|--------| +| 协商解除 | 是否支付经济补偿金、是否签署协商解除协议 | +| 员工犯错 | 是否有规章制度依据、是否有证据材料、是否通知工会 | +| 员工没犯错但干不了 | 是否提前30天通知或支付代通知金、是否经过培训/调岗 | +| 公司裁员 | 是否提前30天向工会说明、是否听取职工意见、是否报劳动部门 | +| 合同到期不续签 | 是否提前通知、是否支付经济补偿金 | + +#### 4.5.3 禁止解聘情形检查 + +- **触发**: Step 2 选择员工后自动检查 +- **检查字段**: `employee.isPregnant` / `employee.isWorkInjured` / `employee.isInMedicalPeriod` +- **交互**: 弹出红色警告框 + 「我已了解风险,继续操作」确认按钮 +- **不阻止流程**: 用户确认后可继续 + +### 4.6 AI 合规顾问模块 + +#### 4.6.1 智能问答 + +- **页面**: `/ai-assistant` +- **接口**: `POST /api/v1/ai/chat`(SSE 流式) +- **请求**: +```json +{ + "messages": [ + {"role": "user", "content": "试用期最长可以约定几个月?"} + ], + "context": { + "orgId": "xxx", + "employeeCount": 12, + "riskItems": [...] + } +} +``` +- **后端处理**: + 1. 构建系统 Prompt(劳动法专家角色 + 人话风格要求) + 2. RAG 检索相关法律条文(pgvector 语义搜索) + 3. 注入企业数据上下文 + 4. 调用 DashScope API(qwen-plus) + 5. SSE 流式返回前端 +- **响应**: SSE 事件流 +``` +data: {"type": "chunk", "content": "根据"} +data: {"type": "chunk", "content": "《劳动合同法》"} +data: {"type": "chunk", "content": "第19条"} +... +data: {"type": "done", "legalBasis": "《劳动合同法》第19条"} +``` + +#### 4.6.2 风险预测 + +- **触发**: 每日凌晨定时任务 + 首页加载时读取缓存 +- **接口**: `GET /api/v1/ai/prediction` +- **逻辑**: + 1. 查询未来30天将到期的合同 + 2. 查询入职将满1年未签合同的员工 + 3. 分析上月加班趋势变化 + 4. 调用 LLM 生成优先级建议 +- **缓存**: 结果存 Redis / 内存缓存,24h 有效 + +#### 4.6.3 合同审查 + +- **页面**: `/ai-assistant` 子 Tab +- **接口**: `POST /api/v1/ai/contract-review` +- **输入**: 合同文本(粘贴或文件上传解析) +- **后端处理**: + 1. 调用 qwen-max 分析合同条款 + 2. 逐条标注红/黄/绿 + 修改建议 + 3. 计算合规评分(0-100) +- **输出**: +```json +{ + "success": true, + "data": { + "score": 72, + "items": [ + {"level": "red", "clause": "试用期6个月", "issue": "超过法定上限", "suggestion": "调整为2个月"}, + {"level": "yellow", "clause": "竞业限制", "issue": "未约定补偿标准", "suggestion": "约定月补偿不低于离职前12个月平均工资的30%"} + ] + } +} +``` + +#### 4.6.4 案例匹配 + +- **页面**: `/ai-assistant` 子 Tab +- **接口**: `POST /api/v1/ai/case-match` +- **输入**: 争议情况描述 +- **后端处理**: + 1. 将描述向量化(DashScope text-embedding-v2) + 2. pgvector 检索相似案例(top 5) + 3. 调用 qwen-max 分析败诉概率和赔偿预估 +- **输出**: 相似案例列表 + 败诉概率 + 赔偿预估 + 建议 + +#### 4.6.5 使用次数限制 + +- **中间件**: 每次 AI 请求前检查当月已用次数 +- **计数**: Redis / 数据库按 `orgId + 月份 + 类型` 统计 +- **超限**: 返回 `429 Too Many Requests` + 提示升级套餐 + +--- + +## 5. 页面规格 + +### 5.1 页面清单 + +**管理端**: + +| 页面 | 路由 | 访问控制 | 布局 | +|------|------|---------|------| +| 登录 | `/login` | 公开 | 居中卡片 | +| 注册 | `/register` | 公开 | 居中卡片 | +| 忘记密码 | `/forgot-password` | 公开 | 居中卡片 | +| 风险总览 | `/` | 登录 | 顶部导航 + 主内容 | +| 合同管理 | `/contracts` | 登录 | 顶部导航 + 主内容 | +| 钱的计算 | `/money` | 登录 | 顶部导航 + 主内容 | +| 解聘助手 | `/termination` | 登录 | 顶部导航 + 主内容 | +| AI 合规顾问 | `/ai-assistant` | 登录 | 顶部导航 + 主内容 | +| 系统设置 | `/settings` | 登录(admin) | 顶部导航 + 主内容 | + +**员工端**: + +| 页面 | 路由 | 访问控制 | 布局 | +|------|------|---------|------| +| 员工登录 | `/portal/login` | 公开 | 居中卡片 | +| 工资条 | `/portal/payslip` | 员工Token | 极简布局 | +| 我的合同 | `/portal/contract` | 员工Token | 极简布局 | +| 入职填报 | `/portal/onboarding` | Token链接 | 极简布局 | +| 合同确认 | `/portal/contract-confirm` | Token链接 | 极简布局 | + +### 5.2 响应式断点 + +| 断点 | 宽度 | 布局变化 | +|------|------|---------| +| 桌面 | ≥1280px | 顶部导航 + 960px 居中内容 | +| 平板 | 768-1279px | 顶部导航 + 全宽内容 | +| 手机 | 375-767px | 底部 Tab Bar + 全宽内容 | + +### 5.3 空状态设计 + +| 场景 | 展示内容 | +|------|---------| +| 首页无员工 | 插图 + 「添加第一个员工」按钮 + 示例截图 | +| 合同列表无数据 | 插图 + 「还没有员工,点这里添加」按钮 | +| 无风险 | 绿色大勾 + 「✅ 暂无风险,继续保持!」 | +| AI 顾问无对话 | 欢迎语 + 预设问题快捷按钮 | +| 解聘无历史记录 | 插图 + 「还没有解聘记录」文字 | +| 员工端无工资条 | 插图 + 「暂无工资记录」文字 | +| 员工端无合同 | 插图 + 「暂无合同信息,请联系 HR」文字 | +| 入职填报链接失效 | 提示「链接已过期,请联系 HR 重新发送」 | +| 合同确认链接失效 | 提示「链接已过期,请联系 HR 重新发送」 | + +--- + +## 6. API 规格 + +### 6.1 统一规范 + +- **前缀**: `/api/v1/` +- **认证**: `Authorization: Bearer ` +- **响应格式**: +```json +{ + "success": true, + "data": {}, + "error": null +} +``` +- **错误格式**: +```json +{ + "success": false, + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "手机号格式不正确" + } +} +``` +- **分页**: `?page=1&pageSize=20` → 返回 `{ items: [], total: 100, page: 1, pageSize: 20 }` + +### 6.2 API 清单 + +| 模块 | 方法 | 路径 | 说明 | +|------|------|------|------| +| 认证 | POST | `/auth/register` | 注册 | +| 认证 | POST | `/auth/login` | 登录 | +| 认证 | POST | `/auth/refresh` | 刷新 Token | +| 认证 | GET | `/auth/me` | 获取当前用户 | +| Dashboard | GET | `/dashboard` | 首页数据聚合 | +| 员工 | GET | `/employees` | 员工列表 | +| 员工 | POST | `/employees` | 添加员工 | +| 员工 | GET | `/employees/:id` | 员工详情 | +| 员工 | PUT | `/employees/:id` | 编辑员工 | +| 员工 | DELETE | `/employees/:id` | 删除员工(软删除) | +| 合同 | POST | `/contracts/batch-renew` | 批量续签 | +| 合同 | POST | `/contracts/:id/attachment` | 上传扫描件 | +| 加班 | GET | `/overtime` | 加班记录列表 | +| 加班 | POST | `/overtime` | 添加加班记录 | +| 解聘 | GET | `/termination` | 解聘记录列表 | +| 解聘 | POST | `/termination` | 创建解聘记录 | +| 风险 | GET | `/risks` | 风险列表 | +| 风险 | PUT | `/risks/:id` | 更新风险状态 | +| AI | POST | `/ai/chat` | 智能问答(SSE) | +| AI | GET | `/ai/prediction` | 风险预测 | +| AI | POST | `/ai/contract-review` | 合同审查 | +| AI | POST | `/ai/case-match` | 案例匹配 | +| 设置 | GET | `/settings/org` | 企业信息 | +| 设置 | PUT | `/settings/org` | 更新企业信息 | +| 设置 | GET | `/settings/users` | 用户列表 | +| 设置 | POST | `/settings/users` | 添加用户 | +| 设置 | PUT | `/settings/users/:id` | 编辑用户 | +| 设置 | DELETE | `/settings/users/:id` | 移除用户 | +| 员工端 | POST | `/portal/auth/login` | 手机号+密码登录 | +| 员工端 | POST | `/portal/auth/send-code` | 发送验证码(v1.0 页面内显示) | +| 员工端 | POST | `/portal/auth/verify` | 验证码登录 | +| 员工端 | POST | `/portal/auth/change-password` | 修改密码 | +| 员工端 | GET | `/portal/payslip` | 工资条列表 | +| 员工端 | GET | `/portal/payslip/:month` | 指定月工资明细 | +| 员工端 | POST | `/portal/payslip/:month/confirm` | 确认已阅工资条 | +| 员工端 | GET | `/portal/contract` | 我的合同信息 | +| 员工端 | GET | `/portal/onboarding/:token` | 获取入职填报信息 | +| 员工端 | POST | `/portal/onboarding/:token` | 提交入职填报 | +| 员工端 | GET | `/portal/contract-confirm/:token` | 获取合同确认信息 | +| 员工端 | POST | `/portal/contract-confirm/:token` | 确认签署合同 | +| 管理端 | POST | `/employees/:id/generate-onboarding-qr` | 生成入职填报二维码 | +| 管理端 | POST | `/contracts/:id/generate-confirm-qr` | 生成合同确认二维码 | +| 管理端 | GET | `/payslip/confirm-status` | 工资条确认状态 | + +--- + +## 7. 数据库设计 + +### 7.1 Prisma Schema 概要 + +```prisma +// 核心表 +model Organization { + id String @id @default(cuid()) + name String + plan Plan @default(FREE) + maxEmployees Int @default(20) + city String? + createdAt DateTime @default(now()) + users User[] + employees Employee[] + contracts LaborContract[] + overtimeRecords OvertimeRecord[] + terminations TerminationRecord[] + riskItems RiskItem[] + auditLogs AuditLog[] +} + +model User { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id]) + phone String @unique + email String? + passwordHash String + name String + role Role @default(ADMIN) + createdAt DateTime @default(now()) + lastLoginAt DateTime? +} + +// 业务表 +model Employee { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id]) + name String + department String + hireDate DateTime + monthlySalary String // AES-256 加密存储 + status EmployeeStatus @default(ACTIVE) + gender String? + isPregnant Boolean @default(false) + isInMedicalPeriod Boolean @default(false) + isWorkInjured Boolean @default(false) + phone String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + contracts LaborContract[] + overtimeRecords OvertimeRecord[] + terminations TerminationRecord[] +} + +model LaborContract { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id]) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + 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 +} + +// 其余表参考 0-req.md 第5章数据模型 +``` + +### 7.2 枚举定义 + +```prisma +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 } +enum RiskLevel { HIGH MEDIUM LOW } +enum RiskStatus { PENDING RESOLVED IGNORED } +enum TerminationReason { NEGOTIATED FAULT NONFAULT LAYOFF EXPIRED } +enum RiskAssessment { SAFE WARNING DANGER } +enum OnboardingStatus { PENDING APPROVED REJECTED } +enum ContractConfirmStatus { UNCONFIRMED CONFIRMED EXPIRED } + +// 员工端扩展表 +model Payslip { + id String @id @default(cuid()) + orgId String + employeeId String + employee Employee @relation(fields: [employeeId], references: [id]) + month String // YYYY-MM + baseSalary Decimal // 基本工资 + overtimePay Decimal // 加班费合计 + weekdayOvertimePay Decimal + weekendOvertimePay Decimal + holidayOvertimePay Decimal + totalPay Decimal // 应发合计 + confirmedAt DateTime? // 员工确认时间 + confirmedIp String? // 确认 IP + createdAt DateTime @default(now()) + + @@unique([employeeId, month]) +} + +model OnboardingLink { + id String @id @default(cuid()) + orgId String + employeeId String? // 关联员工(审核通过后关联) + token String @unique // 一次性 token + status OnboardingStatus @default(PENDING) + phone String // 员工手机号 + expiresAt DateTime // 24h 有效 + submittedAt DateTime? // 员工提交时间 + submittedData Json? // 员工填报数据 + reviewedBy String? // 审核人 userId + reviewedAt DateTime? // 审核时间 + createdAt DateTime @default(now()) +} + +model ContractConfirmLink { + id String @id @default(cuid()) + orgId String + contractId String + token String @unique // 一次性 token + status ContractConfirmStatus @default(UNCONFIRMED) + employeePhone String // 员工手机号 + expiresAt DateTime // 7天有效 + confirmedAt DateTime? // 员工确认时间 + confirmedIp String? // 确认 IP + confirmedDevice String? // 设备信息 + createdAt DateTime @default(now()) +} +``` + +--- + +## 8. 前端架构 + +### 8.1 项目结构 + +``` +frontend/ +├── src/ +│ ├── main.tsx # 入口 +│ ├── App.tsx # 路由定义 +│ ├── components/ # 通用组件 +│ │ ├── layout/ +│ │ │ ├── TopNav.tsx # 顶部导航 +│ │ │ ├── MobileTabBar.tsx # 移动端底部导航 +│ │ │ └── PageContainer.tsx +│ │ ├── ui/ +│ │ │ ├── Button.tsx +│ │ │ ├── Card.tsx +│ │ │ ├── Signal.tsx # 信号灯组件 +│ │ │ ├── Modal.tsx +│ │ │ ├── Input.tsx +│ │ │ ├── Select.tsx +│ │ │ ├── DatePicker.tsx +│ │ │ └── EmptyState.tsx +│ │ └── shared/ +│ │ ├── RiskCard.tsx +│ │ ├── TodoList.tsx +│ │ └── ProgressBar.tsx +│ ├── pages/ +│ │ ├── auth/ +│ │ │ ├── Login.tsx +│ │ │ ├── Register.tsx +│ │ │ └── ForgotPassword.tsx +│ │ ├── Dashboard.tsx +│ │ ├── Contracts.tsx +│ │ ├── Money.tsx +│ │ ├── Termination.tsx +│ │ ├── AIAssistant.tsx +│ │ ├── Settings.tsx +│ │ └── portal/ +│ │ ├── PortalLogin.tsx # 员工端登录 +│ │ ├── Payslip.tsx # 工资条 +│ │ ├── MyContract.tsx # 我的合同 +│ │ ├── Onboarding.tsx # 入职填报 +│ │ └── ContractConfirm.tsx # 合同确认 +│ ├── hooks/ +│ │ ├── useAuth.ts +│ │ ├── useApi.ts +│ │ └── useRiskEngine.ts +│ ├── lib/ +│ │ ├── api.ts # Axios 实例 + 拦截器 +│ │ ├── calculator.ts # 纯前端计算逻辑 +│ │ ├── riskEngine.ts # 风险检测引擎 +│ │ └── utils.ts +│ ├── store/ +│ │ └── authStore.ts # Zustand 状态管理 +│ └── types/ +│ └── index.ts # TypeScript 类型定义 +├── package.json +├── vite.config.ts +├── tailwind.config.ts +└── tsconfig.json +``` + +### 8.2 状态管理 + +- **认证状态**: Zustand(user, token, isAuthenticated) +- **服务端数据**: TanStack Query(React Query)缓存 + 自动刷新 +- **表单状态**: React Hook Form + Zod 校验 +- **AI 对话**: 本地 useState 管理消息列表 + SSE 流式追加 + +### 8.3 路由守卫 + +```typescript +// ProtectedRoute:未登录 → 跳转 /login +// AdminRoute:非 admin → 跳转 / +// PublicRoute:已登录 → 跳转 / +``` + +--- + +## 9. 后端架构 + +### 9.1 项目结构 + +``` +backend/ +├── src/ +│ ├── index.ts # 入口 +│ ├── app.ts # Express 应用 +│ ├── routes/ +│ │ ├── auth.routes.ts +│ │ ├── employee.routes.ts +│ │ ├── contract.routes.ts +│ │ ├── overtime.routes.ts +│ │ ├── termination.routes.ts +│ │ ├── risk.routes.ts +│ │ ├── ai.routes.ts +│ │ ├── settings.routes.ts +│ │ └── portal.routes.ts # 员工端路由 +│ ├── middleware/ +│ │ ├── auth.ts # JWT 校验 +│ │ ├── orgFilter.ts # 多租户 orgId 注入 +│ │ ├── rateLimit.ts # 限流 +│ │ ├── errorHandler.ts # 统一错误处理 +│ │ └── auditLog.ts # 审计日志 +│ ├── services/ +│ │ ├── auth.service.ts +│ │ ├── employee.service.ts +│ │ ├── contract.service.ts +│ │ ├── risk.service.ts +│ │ ├── ai.service.ts # DashScope 调用 +│ │ ├── rag.service.ts # RAG 检索 +│ │ ├── portal.service.ts # 员工端服务 +│ │ └── qrcode.service.ts # 二维码生成 +│ ├── lib/ +│ │ ├── prisma.ts # Prisma 客户端 +│ │ ├── jwt.ts # JWT 工具 +│ │ ├── crypto.ts # AES-256 加解密 +│ │ └── dashscope.ts # 通义千问 SDK 封装 +│ ├── validators/ +│ │ ├── auth.validator.ts # Zod schema +│ │ ├── employee.validator.ts +│ │ └── ... +│ └── jobs/ +│ ├── riskScan.ts # 定时风险扫描 +│ └── aiPrediction.ts # 定时 AI 预测 +├── prisma/ +│ ├── schema.prisma +│ └── migrations/ +├── package.json +└── .env +``` + +### 9.2 环境变量 + +```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=... # AES-256 工资字段加密 + +# 存储 +SUPABASE_URL=... +SUPABASE_KEY=... + +# 二维码(前端生成,无需后端服务) +# 使用 qrcode.react 库在前端直接生成 + +# 部署 +PORT=3000 +CORS_ORIGIN=https://your-app.vercel.app +``` + +--- + +## 10. 验收标准 + +### 10.1 功能验收 + +| 模块 | 验收项 | 验收标准 | +|------|--------|---------| +| 注册 | 手机号注册 | 输入合法信息 → 创建成功 → 自动登录 | +| 登录 | 密码登录 | 正确密码 → 返回 Token;错误密码 → 提示错误 | +| 首页 | 数据聚合 | 登录后 → 显示员工数/风险数/待办/分布 | +| 合同 | 添加员工 | 4项必填 → 保存成功 → 列表显示 | +| 合同 | 信号灯状态 | 未签合同→🔴;即将到期→🟡;正常→🟢 | +| 合同 | 一键续签 | 点击续签→确认→状态变绿→风险消除 | +| 合同 | 纸质/电子 | 选择纸质→显示上传按钮;选择电子→显示编号/链接输入 | +| 钱的计算 | 加班费实时计算 | 输入数字→右侧结果实时更新 | +| 钱的计算 | 加班超时预警 | 总小时>36→黄色警告 | +| 解聘 | 5步向导 | 每步显示进度条→下一步→最终保存 | +| 解聘 | 禁止情形检查 | 选孕期员工→红色警告弹窗 | +| AI | 智能问答 | 输入问题→流式回答→附法律依据 | +| AI | 合同审查 | 粘贴合同→逐条标注→合规评分 | +| AI | 案例匹配 | 输入情况→相似案例→败诉概率 | +| 设置 | 用户管理 | admin可添加用户→分配角色 | +| 员工端 | 密码登录 | 输入手机号+密码→登录成功 | +| 员工端 | 验证码登录 | 输入手机号→收到验证码→登录成功 | +| 员工端 | 扫码进入 | HR 发二维码→员工扫码→打开员工端 | +| 员工端 | 工资条查看 | 选择月份→显示工资明细→确认已阅 | +| 员工端 | 合同查看 | 显示合同信息+扫描件+签署记录 | +| 员工端 | 入职填报 | 扫码→填写信息→提交→HR审核入库 | +| 员工端 | 合同确认 | 扫码→查看合同→勾选确认→记录时间IP | +| 管理端 | 生成填报二维码 | 添加员工→选生成二维码→显示二维码+链接→微信发送 | +| 管理端 | 生成确认二维码 | 合同详情→生成确认二维码→微信发送→员工确认后状态更新 | + +### 10.2 非功能验收 + +| 类别 | 验收标准 | +|------|---------| +| 性能 | 首屏加载 < 2s,API 响应 < 500ms,AI 首 token < 3s | +| 安全 | 密码 bcrypt 加密,工资 AES-256 加密,JWT 认证,orgId 隔离 | +| 响应式 | 桌面/平板/手机三端可用,导航自适应 | +| 兼容 | Chrome/Edge/Safari 最新版正常 | +| 数据隔离 | A 企业用户无法访问 B 企业数据 | +| 员工端隔离 | 员工只能查看自己的数据,不能查看他人 | +| 链接安全 | 入职/确认链接含一次性 token,过期失效 | + +--- + +## 11. 发布计划 + +### 11.1 v1.0 发布范围 + +| 阶段 | 内容 | 预估工期 | +|------|------|---------| +| P0 | 项目搭建 + 路由骨架 + Prisma Schema | 2天 | +| P1 | 认证体系(注册/登录/JWT/路由守卫) | 2天 | +| P2 | 首页风险总览 + 风险检测引擎 | 2天 | +| P3 | 合同管理(列表/添加/续签/纸质电子) | 3天 | +| P4 | 钱的计算(3 Tab 计算器) | 2天 | +| P5 | 解聘助手(5步向导 + 禁止检查) | 2天 | +| P6 | AI 合规顾问(问答/预测/审查/案例 + RAG) | 4天 | +| P7 | 员工端(验证码登录 + 工资条 + 合同 + 入职填报 + 合同确认) | 3天 | +| P8 | 系统设置 + 新手引导 + 空状态 | 1天 | +| P9 | 移动端适配 + 联调 | 2天 | +| P10 | 部署上线 + 验证 | 1天 | +| **合计** | | **~24天** | + +### 11.2 后续版本 + +| 版本 | 内容 | 预估 | +|------|------|------| +| v2.0 | 社保公积金模块 | +2周 | +| v3.0 | 人力成本分析 + 员工自助门户增强(考勤/请假) | +3周 | + +--- + +## 12. 风险与对策 + +| 风险 | 影响 | 对策 | +|------|------|------| +| 通义千问 API 不稳定 | AI 功能不可用 | 降级为规则引擎回答 + 重试机制 | +| 法律规则地区差异大 | 计算结果不准 | 提供城市选择 + 默认值 + 用户可修改 | +| RAG 知识库构建耗时 | P6 延期 | 先用 Prompt 内嵌法条,后续再建向量库 | +| 中小企业付费意愿低 | 商业化困难 | free 套餐足够基础使用,AI 功能促付费 | +| 数据安全合规要求 | 法律风险 | 数据加密 + 身份证号加密存储 + 隐私协议 | +| 员工端使用率低 | 功能闲置 | HR 主动生成二维码通过微信发给员工,降低使用门槛 | diff --git a/2-task.md b/2-task.md new file mode 100644 index 0000000..e656387 --- /dev/null +++ b/2-task.md @@ -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 Schema(2天) + +### 前端 + +- [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位+) + - 逻辑:创建 Organization(plan=free, maxEmployees=20)+ User(role=admin, bcrypt 加密) + - 返回:Access Token(2h)+ Refresh Token(7d) + - 限流:同一 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` Hook(Zustand store:user, 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) + - 提交后状态 → PENDING,HR 审核后 → 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 | +| 数据库 | PostgreSQL(Neon/Supabase)+ pgvector | +| 认证 | JWT(Access + Refresh)| +| 加密 | bcrypt(密码)+ AES-256(工资)| +| AI | 通义千问 Qwen(DashScope 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` 组件 diff --git a/20260723-优化-1.md b/20260723-优化-1.md new file mode 100644 index 0000000..60af68f --- /dev/null +++ b/20260723-优化-1.md @@ -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-MM(null=至今有效) + 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-MM(null=至今有效) + 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-MM(null=至今有效) + 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:离职/解聘填写社保公积金截止年月 + +### 前端 ResignModal(Roster.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 推送 | 全部 | diff --git a/20260723-优化-2.md b/20260723-优化-2.md new file mode 100644 index 0000000..d0a58b4 --- /dev/null +++ b/20260723-优化-2.md @@ -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/20;enterprise 无限 +- **涉及文件**: `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 语音输入 | 小 | diff --git a/20260723-优化-3.md b/20260723-优化-3.md new file mode 100644 index 0000000..75dc694 --- /dev/null +++ b/20260723-优化-3.md @@ -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 | 多城市社保配置 | 大 | \ No newline at end of file diff --git a/20260723-优化-4.md b/20260723-优化-4.md new file mode 100644 index 0000000..a7f9cc9 --- /dev/null +++ b/20260723-优化-4.md @@ -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 | 薪税数据导出 | 中 | ✅ 已完成 | \ No newline at end of file diff --git a/20260723-优化-5.md b/20260723-优化-5.md new file mode 100644 index 0000000..33a16d8 --- /dev/null +++ b/20260723-优化-5.md @@ -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 | 大数据集分页/流式导出 | 中 | \ No newline at end of file diff --git a/20260723-优化-6.md b/20260723-优化-6.md new file mode 100644 index 0000000..68517c4 --- /dev/null +++ b/20260723-优化-6.md @@ -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 | 案例匹配结果转待办 | 中 | \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..490abd0 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..1d1af59 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,3696 @@ +{ + "name": "hr-compliance-backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hr-compliance-backend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmmirror.com/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmmirror.com/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmmirror.com/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmmirror.com/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmmirror.com/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/morgan": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.48.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-6.48.0.tgz", + "integrity": "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmmirror.com/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..24e392b --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/manual_migrations/termination_workflow.sql b/backend/prisma/manual_migrations/termination_workflow.sql new file mode 100644 index 0000000..57360b8 --- /dev/null +++ b/backend/prisma/manual_migrations/termination_workflow.sql @@ -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"); diff --git a/backend/prisma/migrations/20260724011853_init/migration.sql b/backend/prisma/migrations/20260724011853_init/migration.sql new file mode 100644 index 0000000..76e3a2d --- /dev/null +++ b/backend/prisma/migrations/20260724011853_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..37102c2 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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-MM(null=当前有效) + 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-MM(null=当前有效) + 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-MM(null=至今有效) + 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-MM(null=至今有效) + 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-MM(null=至今有效) + 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") +} diff --git a/backend/prisma/seed-wufang.ts b/backend/prisma/seed-wufang.ts new file mode 100644 index 0000000..19ba91c --- /dev/null +++ b/backend/prisma/seed-wufang.ts @@ -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) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..1e373b2 --- /dev/null +++ b/backend/prisma/seed.ts @@ -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() + }) diff --git a/backend/scripts/migrate-records.ts b/backend/scripts/migrate-records.ts new file mode 100644 index 0000000..2ce3959 --- /dev/null +++ b/backend/scripts/migrate-records.ts @@ -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() + }) diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..530174e --- /dev/null +++ b/backend/src/app.ts @@ -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 diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..b97a967 --- /dev/null +++ b/backend/src/index.ts @@ -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}`) +}) diff --git a/backend/src/lib/crypto.ts b/backend/src/lib/crypto.ts new file mode 100644 index 0000000..fc62e59 --- /dev/null +++ b/backend/src/lib/crypto.ts @@ -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') +} diff --git a/backend/src/lib/jwt.ts b/backend/src/lib/jwt.ts new file mode 100644 index 0000000..2ca41c7 --- /dev/null +++ b/backend/src/lib/jwt.ts @@ -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 + } +} diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts new file mode 100644 index 0000000..4590932 --- /dev/null +++ b/backend/src/lib/prisma.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export default prisma diff --git a/backend/src/middleware/auditLog.ts b/backend/src/middleware/auditLog.ts new file mode 100644 index 0000000..8c003ba --- /dev/null +++ b/backend/src/middleware/auditLog.ts @@ -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, +) { + 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) + } +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 0000000..90cb6c1 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -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() +} diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..39ff101 --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -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: '服务器内部错误' }, + }) +} diff --git a/backend/src/middleware/rateLimit.ts b/backend/src/middleware/rateLimit.ts new file mode 100644 index 0000000..3cf1422 --- /dev/null +++ b/backend/src/middleware/rateLimit.ts @@ -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: '请求过于频繁,请稍后再试' } }, +}) diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts new file mode 100644 index 0000000..da3a4e5 --- /dev/null +++ b/backend/src/routes/ai.routes.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 diff --git a/backend/src/routes/attachment.routes.ts b/backend/src/routes/attachment.routes.ts new file mode 100644 index 0000000..138ff77 --- /dev/null +++ b/backend/src/routes/attachment.routes.ts @@ -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 diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts new file mode 100644 index 0000000..2108418 --- /dev/null +++ b/backend/src/routes/auth.routes.ts @@ -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() + +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 diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..45607c2 --- /dev/null +++ b/backend/src/routes/dashboard.routes.ts @@ -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 diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts new file mode 100644 index 0000000..b55414e --- /dev/null +++ b/backend/src/routes/employee.routes.ts @@ -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 diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts new file mode 100644 index 0000000..e44a680 --- /dev/null +++ b/backend/src/routes/export.routes.ts @@ -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 Promise> = { + 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 diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts new file mode 100644 index 0000000..6c26e53 --- /dev/null +++ b/backend/src/routes/import.routes.ts @@ -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 = { + '北京': { 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 diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts new file mode 100644 index 0000000..8ca1b8e --- /dev/null +++ b/backend/src/routes/notification.routes.ts @@ -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 diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts new file mode 100644 index 0000000..ec0534a --- /dev/null +++ b/backend/src/routes/payroll.routes.ts @@ -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 diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts new file mode 100644 index 0000000..f7e67a9 --- /dev/null +++ b/backend/src/routes/payroll2.routes.ts @@ -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 diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts new file mode 100644 index 0000000..e43db11 --- /dev/null +++ b/backend/src/routes/portal.routes.ts @@ -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() + +// 员工端认证中间件 +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 diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts new file mode 100644 index 0000000..d50f370 --- /dev/null +++ b/backend/src/routes/roster.routes.ts @@ -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 = { 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 = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } + const actionMap: Record = { 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 = { 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 = { 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 = { 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 diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts new file mode 100644 index 0000000..ef263b1 --- /dev/null +++ b/backend/src/routes/settings.routes.ts @@ -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 diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts new file mode 100644 index 0000000..a93dba9 --- /dev/null +++ b/backend/src/routes/social.routes.ts @@ -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() + const empPayslipMap = new Map() + 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() + 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 diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts new file mode 100644 index 0000000..6ba7777 --- /dev/null +++ b/backend/src/routes/termination.routes.ts @@ -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 diff --git a/backend/src/schemas/auth.schema.ts b/backend/src/schemas/auth.schema.ts new file mode 100644 index 0000000..20cdcf4 --- /dev/null +++ b/backend/src/schemas/auth.schema.ts @@ -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位'), +}) diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts new file mode 100644 index 0000000..bdb2183 --- /dev/null +++ b/backend/src/schemas/contract.schema.ts @@ -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), +}) diff --git a/backend/src/schemas/portal.schema.ts b/backend/src/schemas/portal.schema.ts new file mode 100644 index 0000000..6e4293c --- /dev/null +++ b/backend/src/schemas/portal.schema.ts @@ -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'), +}) diff --git a/backend/src/schemas/termination.schema.ts b/backend/src/schemas/termination.schema.ts new file mode 100644 index 0000000..64857e7 --- /dev/null +++ b/backend/src/schemas/termination.schema.ts @@ -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), +}) diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts new file mode 100644 index 0000000..1180f57 --- /dev/null +++ b/backend/src/services/ai.service.ts @@ -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 || '' +} diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts new file mode 100644 index 0000000..af1d129 --- /dev/null +++ b/backend/src/services/auth.service.ts @@ -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 } +} diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts new file mode 100644 index 0000000..55c8ac7 --- /dev/null +++ b/backend/src/services/contract.service.ts @@ -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 } +} diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts new file mode 100644 index 0000000..68c614c --- /dev/null +++ b/backend/src/services/payroll.service.ts @@ -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 { + 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() + 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 } +} diff --git a/backend/src/services/rag.service.ts b/backend/src/services/rag.service.ts new file mode 100644 index 0000000..22fec0f --- /dev/null +++ b/backend/src/services/rag.service.ts @@ -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 { + 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 { + 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 } +} diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts new file mode 100644 index 0000000..13288ff --- /dev/null +++ b/backend/src/services/risk.service.ts @@ -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() + 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, + } +} diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts new file mode 100644 index 0000000..ae68ce2 --- /dev/null +++ b/backend/src/services/termination.service.ts @@ -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 { + 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 { + 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), + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..56009d7 --- /dev/null +++ b/backend/tsconfig.json @@ -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" +} \ No newline at end of file diff --git a/docs/ui-ux-optimization-plan.md b/docs/ui-ux-optimization-plan.md new file mode 100644 index 0000000..32dbd41 --- /dev/null +++ b/docs/ui-ux-optimization-plan.md @@ -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 5:a11y 与细节打磨(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-sm;lg: 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-base;description 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 引入 Toast(sonner) + +**安装**:`npm install sonner` + +**修改文件清单**(37 处 alert/confirm): + +| 文件 | alert 数量 | confirm 数量 | 行号参考 | +|------|-----------|-------------|----------| +| `src/App.tsx` | — | — | 顶层添加 `` | +| `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 ( + <> + ... + + + ) +} +``` + +**各页面替换规则**: + +```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 ( + +

{message}

+
+ + +
+
+ ) +} +``` + +**替换清单**(所有 `confirm()` 调用): + +| 文件 | 行号 | 当前代码 | 替换为 | +|------|------|----------|--------| +| Money.tsx | L284 | `confirm('确认删除批次?')` | `` | +| Money.tsx | L493 | `confirm('确认归档?')` | `` | +| Money.tsx | L506 | `confirm('确认删除批次?')` | `` | +| Money.tsx | L1255 | `confirm('确认生成工资条?')` | `` | +| Roster.tsx | L400 | `confirm('确认撤回离职记录?')` | `` | + +**使用示例**: + +```tsx +const [confirmOpen, setConfirmOpen] = useState(false) + +// 触发 +onClick={() => setConfirmOpen(true)} + +// 渲染 + { 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) + 设置齿轮 + 用户头像 +
+ + + + + {/* 用户菜单保持 */} +
+``` + +**验收标准**:顶部导航 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' && } +``` + +**验收标准**: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 ( +
+ +
+ ) +} + +export default function App() { + return ( + }> + ... + + ) +} +``` + +**验收标准**:首屏仅加载 Dashboard chunk,其他页面按需加载,Network 面板可见独立 chunk。 + +--- + +### P3.2 大文件拆分 + +#### Roster.tsx(140KB → 拆分为 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.tsx(63KB → 拆分为 6 个文件) + +``` +src/pages/money/ +├── Money.tsx # 主页面:tab 切换 +├── BatchManager.tsx # 发薪批次 +├── TemplateManager.tsx # 薪酬模版 +├── OvertimeCalculator.tsx # 加班费计算 +├── PayslipManager.tsx # 工资条管理 +└── CompensationManager.tsx # 薪酬调整(从 Compensation.tsx 合入) +``` + +#### Termination.tsx(55KB → 拆分为 6 个文件) + +``` +src/pages/termination/ +├── Termination.tsx # 主页面:向导容器 +├── StepSelectEmployee.tsx # 步骤1:选择员工 +├── StepSelectReason.tsx # 步骤2:解聘方式 +├── StepCompliance.tsx # 步骤3:合规检查 +├── StepSettlement.tsx # 步骤4:费用结算 +└── StepConfirm.tsx # 步骤5:确认完成 +``` + +#### Settings.tsx(46KB → 拆分为 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 ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ) +} + +export function CardSkeleton() { + return ( +
+
+
+
+ ) +} + +export function DetailSkeleton() { + return ( +
+
+
+
+
+ ) +} +``` + +**替换清单**(13 处 "加载中..."): + +| 文件 | 行号 | 替换为 | +|------|------|--------| +| Dashboard.tsx | L112 | `` | +| Roster.tsx | L249, L702 | `` | +| Money.tsx | L239, L371, L719, L933, L1268 | `` | +| SocialInsurance.tsx | L314, L623 | `` | +| AIAssistant.tsx | L773 | `` | +| portal/Payslip.tsx | L131 | `` | +| portal/MyContract.tsx | L70 | `` | +| portal/ContractConfirm.tsx | L85 | `` | +| Contracts.tsx | L82 | `` | + +**验收标准**:`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' + +// 月度薪税趋势迷你折线图 + +

月度薪税趋势

+ + + + + + + + +
+ +// 风险分布环形图 + +

风险分布

+ + + + {riskData.map((entry, i) => )} + + + + +
+``` + +**验收标准**: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 ( +
+
+
+ {/* title + children 保持不变 */} +
+
+ ) +} +``` + +**验收标准**:弹窗有淡入+缩放动画,关闭有淡出动画,~200ms。 + +--- + +### P4.4 移动端表格响应式 + +**新增组件**:`src/components/ui/ResponsiveTable.tsx` + +```tsx +import { ReactNode } from 'react' +import clsx from 'clsx' + +interface Column { + key: string + label: string + render?: (row: T) => ReactNode + priority: 'high' | 'medium' | 'low' + className?: string +} + +interface ResponsiveTableProps { + columns: Column[] + data: T[] + rowKey: (row: T) => string + onRowClick?: (row: T) => void +} + +export default function ResponsiveTable({ columns, data, rowKey, onRowClick }: ResponsiveTableProps) { + return ( + <> + {/* 桌面端/平板:表格 */} + + + + {columns.map(col => ( + + ))} + + + + {data.map(row => ( + onRowClick?.(row)}> + {columns.map(col => ( + + ))} + + ))} + +
+ {col.label} +
+ {col.render ? col.render(row) : (row as any)[col.key]} +
+ + {/* 手机端:卡片列表 */} +
+ {data.map(row => { + const highCols = columns.filter(c => c.priority === 'high') + return ( +
onRowClick?.(row)}> + {highCols.map(col => ( +
+ {col.label} + + {col.render ? col.render(row) : (row as any)[col.key]} + +
+ ))} +
+ ) + })} +
+ + ) +} +``` + +**应用页面**:Roster、Money(工资条列表)、SocialInsurance(月度申报表) + +**验收标准**:iPhone SE 上列表为卡片模式,iPad 上为表格,桌面端完整表格。 + +--- + +## Phase 5:a11y 与细节打磨(1-2 天) + +### P5.1 div onClick → button + aria + +**修改文件**:`src/components/layout/TopNav.tsx` + +```tsx +// 添加 aria 属性 + + +
+ + ) +} +``` + +**替换清单**(所有 `confirm()` 调用): + +| 文件 | 行号 | 当前代码 | 替换为 | +|------|------|----------|--------| +| Money.tsx | L284 | `confirm('确认删除批次?')` | `` | +| Money.tsx | L493 | `confirm('确认归档?')` | `` | +| Money.tsx | L506 | `confirm('确认删除批次?')` | `` | +| Money.tsx | L1255 | `confirm('确认生成工资条?')` | `` | +| Roster.tsx | L400 | `confirm('确认撤回离职记录?')` | `` | + +**验收标准**:危险操作弹出 Dialog 而非浏览器原生 confirm,有明确文案说明后果。 + +#### P1.5 对比度修复 + +| 文件 | 当前 | 目标 | 说明 | +|------|------|------|------| +| 全局 `text-gray-400` | `text-gray-400` (#9CA3AF) | `text-gray-500` (#6B7280) | 对比度 2.5:1 → 4.6:1 | +| `src/components/ui/Pagination.tsx` | L61, L69 `text-gray-400` | `text-gray-500` | 翻页按钮 | +| `src/pages/Dashboard.tsx` | L229 `text-gray-400` | `text-gray-500` | "等人"文字 | +| `src/components/layout/TopNav.tsx` | L50 `text-gray-400` | `text-gray-500` | ChevronDown 图标 | + +**验收标准**:所有文字对比度 ≥ 4.5:1(WCAG AA),使用 axe DevTools 验证。 + +#### 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 +// 当前:仅用户下拉菜单 +// 目标:通知铃铛(badge) + 设置齿轮 + 用户头像 +
+ + + + + {/* 用户菜单保持 */} +
+``` + +#### P2.2 移动端底部导航精简为 4 + +**修改文件**:`src/components/layout/MobileTabBar.tsx` + +```tsx +// 当前 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 }, +] +``` + +#### P2.3 路由调整 + +**修改文件**:`src/App.tsx` + +| 变更 | 说明 | +|------|------| +| `/social` 路由保留但导航不直接暴露 | 作为 `/money` 的子 tab 或独立路由通过页面内跳转 | +| `/termination` 路由保留但导航不直接暴露 | 作为 `/roster` 内的功能入口(花名册 → 解聘操作) | +| Dashboard 移除薪税 tab | `activeTab` 类型从 `'overview' \| 'payroll' \| 'risk' \| 'task'` → `'overview' \| 'risk' \| 'task'` | + +**Dashboard.tsx 修改**: + +```tsx +// 移除 payroll tab +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 块 +``` + +#### P2.4 合并未挂载页面 + +| 操作 | 文件 | 说明 | +|------|------|------| +| Compensation → Money | `src/pages/Money.tsx` | 在 tabs 数组增加 `{ key: 'adjust', label: '薪酬调整' }`,渲染 `` | +| Contracts → Roster | `src/pages/Contracts.tsx` | Roster 详情已有合同 tab,删除独立 Contracts.tsx 或将其逻辑合并到 Roster 详情的合同 tab | + +**Money.tsx 修改**: + +```tsx +type Tab = 'batch' | 'template' | 'overtime' | 'payslip' | 'adjust' + +const tabs = [ + { key: 'batch', label: '发薪批次' }, + { key: 'template', label: '薪酬模版' }, + { key: 'overtime', label: '加班费计算' }, + { key: 'payslip', label: '工资条管理' }, + { key: 'adjust', label: '薪酬调整' }, // 新增 +] +// {tab === 'adjust' && } +``` + +**验收标准**:顶部导航 4 个 tab,移动端 4 个 tab,无重复入口,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')) +// ... 其他页面同理 + +function PageSkeleton() { + return ( +
+ +
+ ) +} + +export default function App() { + return ( + }> + ... + + ) +} +``` + +**验收标准**:首屏仅加载 Dashboard chunk,其他页面按需加载,Network 面板可见独立 chunk。 + +#### P3.2 大文件拆分 + +**Roster.tsx(140KB)拆分方案**: + +``` +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.tsx(63KB)拆分方案**: + +``` +src/pages/money/ +├── Money.tsx # 主页面:tab 切换 +├── BatchManager.tsx # 发薪批次 +├── TemplateManager.tsx # 薪酬模版 +├── OvertimeCalculator.tsx # 加班费计算 +├── PayslipManager.tsx # 工资条管理 +└── CompensationManager.tsx # 薪酬调整(从 Compensation.tsx 合入) +``` + +**Termination.tsx(55KB)拆分方案**: + +``` +src/pages/termination/ +├── Termination.tsx # 主页面:向导容器 +├── StepSelectEmployee.tsx # 步骤1:选择员工 +├── StepSelectReason.tsx # 步骤2:解聘方式 +├── StepCompliance.tsx # 步骤3:合规检查 +├── StepSettlement.tsx # 步骤4:费用结算 +└── StepConfirm.tsx # 步骤5:确认完成 +``` + +**Settings.tsx(46KB)拆分方案**: + +``` +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 ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ) +} + +export function CardSkeleton() { + return ( +
+
+
+
+ ) +} + +export function DetailSkeleton() { + return ( +
+
+
+
+
+ ) +} +``` + +**替换清单**(13 处 "加载中..."): + +| 文件 | 行号 | 替换为 | +|------|------|--------| +| Dashboard.tsx | L112 | `` | +| Roster.tsx | L249, L702 | `` | +| Money.tsx | L239, L371, L719, L933, L1268 | `` | +| SocialInsurance.tsx | L314, L623 | `` | +| AIAssistant.tsx | L773 | `` | +| portal/Payslip.tsx | L131 | `` | +| portal/MyContract.tsx | L70 | `` | +| portal/ContractConfirm.tsx | L85 | `` | +| Contracts.tsx | L82 | `` | + +**验收标准**:加载时显示骨架屏动画,无白屏或纯文字"加载中..."。 + +#### 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], + // ... +}) +``` + +**验收标准**:快速输入时不会每次按键触发 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' + +// 月度薪税趋势迷你折线图 + +

月度薪税趋势

+ + + + + + + + +
+ +// 风险分布环形图 + +

风险分布

+ + + + {riskData.map((entry, i) => )} + + + + +
+``` + +**验收标准**:Dashboard 概览页有折线图和环形图,图表响应式,tooltip 正常显示。 + +#### P4.3 Modal 过渡动画 + +**修改文件**:`src/components/ui/Modal.tsx` + +```tsx +// 添加 CSS transition +// 方案:利用 Tailwind 的 transition + opacity + scale + +// 遮罩层:opacity 0 → 100 +// 内容层:scale-95 opacity-0 → scale-100 opacity-100 + +// 新增 state 控制动画 +const [show, setShow] = useState(false) +useEffect(() => { + if (open) { + setShow(true) + } else { + const timer = setTimeout(() => setShow(false), 200) + return () => clearTimeout(timer) + } +}, [open]) + +if (!show && !open) return null + +return ( +
+
+
+ {/* 内容不变 */} +
+
+) +``` + +**验收标准**:弹窗有淡入+缩放动画,关闭有淡出动画,~200ms。 + +#### P4.4 移动端表格响应式 + +**新增组件**:`src/components/ui/ResponsiveTable.tsx` + +```tsx +interface Column { + key: string + label: string + render?: (row: T) => React.ReactNode + priority: 'high' | 'medium' | 'low' // 高优先级在手机端显示 + className?: string +} + +interface ResponsiveTableProps { + columns: Column[] + data: T[] + rowKey: (row: T) => string + onRowClick?: (row: T) => void +} + +// 桌面端:表格(显示所有列) +// 平板端:表格(隐藏 low priority 列,用 hidden md:table-cell) +// 手机端:卡片列表(仅显示 high priority 字段,竖排) +``` + +**应用页面**:Roster、Money(工资条列表)、SocialInsurance(月度申报表) + +**验收标准**:iPhone SE 上列表为卡片模式,iPad 上为紧凑表格,桌面端完整表格。 + +--- + +### Phase 5:a11y 与细节打磨(1-2 天) + +#### P5.1 div onClick → button + aria + +**修改文件**:`src/components/layout/TopNav.tsx` + +```tsx +// 当前(L46-50):div + onClick +
+ +
+
+
{current.icon}
+

{current.title}

+

{current.description}

+ + {/* 进度指示器 */} +
+ {steps.map((_, i) => ( +
+ ))} +
+ +
+ {step > 0 ? ( + + ) : } + +
+
+
+
+ ) +} diff --git a/frontend/src/components/layout/MobileTabBar.tsx b/frontend/src/components/layout/MobileTabBar.tsx new file mode 100644 index 0000000..260b2f9 --- /dev/null +++ b/frontend/src/components/layout/MobileTabBar.tsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/layout/PageContainer.tsx b/frontend/src/components/layout/PageContainer.tsx new file mode 100644 index 0000000..de89486 --- /dev/null +++ b/frontend/src/components/layout/PageContainer.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from 'react' +import clsx from 'clsx' + +export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx new file mode 100644 index 0000000..b844924 --- /dev/null +++ b/frontend/src/components/layout/TopNav.tsx @@ -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({ + queryKey: ['dashboard'], + queryFn: async () => { + const res = await api.get('/dashboard') as any + return res.data + }, + refetchInterval: 60000, + }) + const riskCount = dashboardData?.riskSummary?.pending || 0 + + return ( +
+
+ + + 用工合规助手 + + + + +
+ + + + +
+ + {menuOpen && ( + <> + +
+ + )} +
+
+
+ + ) +} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..18c47f1 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,29 @@ +import { ButtonHTMLAttributes } from 'react' +import clsx from 'clsx' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' + size?: 'sm' | 'md' | 'lg' +} + +export default function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) { + return ( + + ) +} diff --git a/frontend/src/components/ui/Card.tsx b/frontend/src/components/ui/Card.tsx new file mode 100644 index 0000000..76210c3 --- /dev/null +++ b/frontend/src/components/ui/Card.tsx @@ -0,0 +1,10 @@ +import { HTMLAttributes } from 'react' +import clsx from 'clsx' + +export default function Card({ className, children, ...props }: HTMLAttributes) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/ui/ConfirmDialog.tsx b/frontend/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..af2b4e2 --- /dev/null +++ b/frontend/src/components/ui/ConfirmDialog.tsx @@ -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 ( + +

{message}

+
+ + +
+
+ ) +} diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..8254ea8 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -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 ( +
+
+ {icon || } +
+

{title}

+ {description &&

{description}

} + {actionLabel && onAction && ( + + )} +
+ ) +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..575a452 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,38 @@ +import { InputHTMLAttributes, SelectHTMLAttributes, forwardRef } from 'react' +import clsx from 'clsx' + +export const Input = forwardRef>( + function Input({ className, ...props }, ref) { + return ( + + ) + } +) + +export const Select = forwardRef>( + function Select({ className, children, ...props }, ref) { + return ( + + ) + } +) + +export function Label({ children, className }: { children: React.ReactNode; className?: string }) { + return +} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 0000000..352bb2d --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -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 ( +
+
+
+ {title && ( +
+

{title}

+ +
+ )} +
{children}
+
+
+ ) +} diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx new file mode 100644 index 0000000..dbe9239 --- /dev/null +++ b/frontend/src/components/ui/Pagination.tsx @@ -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 ( +
+ {/* 左侧:条数信息 + 每页条数选择 */} +
+ 共 {total} 条 + {onPageSizeChange && ( + + )} + 第 {start}-{end} 条 +
+ + {/* 右侧:页码导航 */} +
+ + {pages.map((p, i) => + p === '...' ? ( + + ) : ( + + ), + )} + +
+
+ ) +} diff --git a/frontend/src/components/ui/Signal.tsx b/frontend/src/components/ui/Signal.tsx new file mode 100644 index 0000000..4224726 --- /dev/null +++ b/frontend/src/components/ui/Signal.tsx @@ -0,0 +1,27 @@ +import clsx from 'clsx' + +type Level = 'high' | 'medium' | 'low' | 'safe' + +const colors: Record = { + high: 'bg-danger', + medium: 'bg-warning', + low: 'bg-yellow-400', + safe: 'bg-safe', +} + +const labels: Record = { + high: '🔴', + medium: '🟡', + low: '🟡', + safe: '🟢', +} + +export default function Signal({ level, label }: { level: Level; label?: string }) { + return ( + + + {label && {label}} + {!label && {labels[level]}} + + ) +} diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 0000000..07adf85 --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,61 @@ +import clsx from 'clsx' + +interface SkeletonProps { + className?: string + lines?: number +} + +/** + * 骨架屏组件 + * 用于数据加载时的占位显示,减少布局闪烁 + */ +export function Skeleton({ className }: SkeletonProps) { + return
+} + +/** + * 多行文本骨架屏 + */ +export function SkeletonText({ lines = 3, className }: SkeletonProps) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ) +} + +/** + * 卡片骨架屏 + */ +export function SkeletonCard() { + return ( +
+ + + +
+ ) +} + +/** + * 页面级骨架屏 + */ +export function SkeletonPage() { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/hooks/useDebouncedValue.ts b/frontend/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..d44f0d3 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react' + +/** + * 防抖 Hook + * 延迟更新值,适用于搜索输入框等频繁触发的场景 + * @param value 原始值 + * @param delay 延迟毫秒数,默认 300ms + * @returns 防抖后的值 + */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + + return debouncedValue +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..4f79392 --- /dev/null +++ b/frontend/src/index.css @@ -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; } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..22c0c7e --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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 diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..418ec96 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + , +) diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx new file mode 100644 index 0000000..fea8979 --- /dev/null +++ b/frontend/src/pages/AIAssistant.tsx @@ -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('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 ( +
+

AI 合规顾问

+ +
+ {tabs.map((t) => { + const Icon = t.icon + return ( + + ) + })} +
+ + {tab === 'chat' && } + {tab === 'predict' && } + {tab === 'review' && } + {tab === 'case' && } + {tab === 'knowledge' && } +
+ ) +} + +function ChatTab() { + const queryClient = useQueryClient() + const [messages, setMessages] = useState([ + { 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(null) + const scrollRef = useRef(null) + const recognitionRef = useRef(null) + const saveTimerRef = useRef(null) + + const { data: conversations } = useQuery({ + 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 ( +
+ {/* 顶部操作栏 */} +
+ + + {conversations && conversations.length > 0 && ( + {conversations.length} 条历史 + )} +
+ + {/* 历史会话列表 */} + {showHistory && ( +
+ {conversations && conversations.length > 0 ? conversations.map((c: any) => ( +
+ loadConversation(c.id)}>{c.title} + {new Date(c.updatedAt).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} + +
+ )) :
暂无历史会话
} +
+ )} + +
+ {messages.map((msg, i) => ( +
+
+ {msg.content || (loading && i === messages.length - 1 ? '思考中...' : '')} +
+
+ ))} +
+ + {/* 快捷问题 */} + {messages.length <= 1 && ( +
+ {QUICK_QUESTIONS.map((q) => ( + + ))} +
+ )} + + {/* 输入框 */} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && send()} + placeholder="输入问题..." + disabled={loading} + /> + + +
+
+ ) +} + +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({ + 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 = {} + 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 ( + +
+ +

AI 风险预测

+
+ + {/* 筛选条件 */} +
+
+ + +
+
+ + +
+ {scope === 'department' && ( +
+ + +
+ )} + {scope === 'employee' && ( +
+ + +
+ )} +
+ + {loading ? ( +
+ 分析中... +
+ ) : ( +
{result}
+ )} +
+ +
+
+ ) +} + +function ReviewTab() { + const [contractText, setContractText] = useState('') + const [result, setResult] = useState(null) + const [loading, setLoading] = useState(false) + const [showSaveModal, setShowSaveModal] = useState(false) + const [saveEmployeeId, setSaveEmployeeId] = useState('') + + const { data: employees } = useQuery({ + 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 = { + 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 ( +
+ +
+ +

合同审查

+
+ +