From 42e0c650a4703d8a8ea5213ad405d5dd03aa22c2 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 30 Jul 2026 10:21:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B020260730=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=96=B9=E6=A1=88=E5=85=A8=E9=83=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI文件审查:.docx上传提取文本,支持多种文档类型 - 用工办理工作流:WorkProcess页面+后端API,支持入职/续签/终止等流程 - 企业自建文本库:Templates页面Tab切换,企业模板CRUD+渲染+下载Word - 考勤发布:Attendance发布/取消发布按钮,员工端MyAttendance页面 - 工资条发布:Money发布/定时发送按钮+弹窗,portal端publishStatus过滤 - 合同到期弹窗:Dashboard合同到期预警可点击打开弹窗,支持续签/终止操作 - Prisma schema新增WorkProcess/EnterpriseTemplate/AttendancePublish模型 - 前后端编译验证全部通过 --- 20260730-优化.md | 540 ++++++++++++++++++ backend/package-lock.json | 96 ++++ backend/package.json | 1 + backend/prisma/schema.prisma | 64 +++ backend/src/app.ts | 4 + backend/src/routes/ai.routes.ts | 39 ++ backend/src/routes/attendance.routes.ts | 73 +++ .../src/routes/enterprise-template.routes.ts | 152 +++++ backend/src/routes/payroll2.routes.ts | 95 +++ backend/src/routes/portal.routes.ts | 33 +- backend/src/routes/work-process.routes.ts | 267 +++++++++ backend/src/services/work-process.service.ts | 286 ++++++++++ docs/20260730-功能补齐方案.md | 404 +++++++++++++ docs/白话用工侠完整运行测试报告.md | 376 ++++++++++++ frontend/src/App.tsx | 4 + .../src/components/layout/PortalLayout.tsx | 3 +- frontend/src/components/layout/SidebarNav.tsx | 3 +- frontend/src/pages/AIAssistant.tsx | 64 ++- frontend/src/pages/Attendance.tsx | 46 +- frontend/src/pages/Dashboard.tsx | 150 ++++- frontend/src/pages/Money.tsx | 74 +++ frontend/src/pages/Templates.tsx | 302 +++++++++- frontend/src/pages/WorkProcess.tsx | 501 ++++++++++++++++ frontend/src/pages/portal/MyAttendance.tsx | 97 ++++ 24 files changed, 3639 insertions(+), 35 deletions(-) create mode 100644 20260730-优化.md create mode 100644 backend/src/routes/enterprise-template.routes.ts create mode 100644 backend/src/routes/work-process.routes.ts create mode 100644 backend/src/services/work-process.service.ts create mode 100644 docs/20260730-功能补齐方案.md create mode 100644 docs/白话用工侠完整运行测试报告.md create mode 100644 frontend/src/pages/WorkProcess.tsx create mode 100644 frontend/src/pages/portal/MyAttendance.tsx diff --git a/20260730-优化.md b/20260730-优化.md new file mode 100644 index 0000000..436b38c --- /dev/null +++ b/20260730-优化.md @@ -0,0 +1,540 @@ +# TurboHR 优化方案(20260730) + +> 日期:2026-07-30 +> 依据:白话用工侠完整运行测试报告对比分析 + 功能缺口梳理 +> 优先级:P0(紧急/核心)、P1(重要补全)、P2(增强优化) + +--- + +## 背景 + +将「白话用工侠完整运行测试报告」中列出的功能与本系统(TurboHR)实际代码逐一对比后,识别出以下功能缺口: + +### 已实现功能(14项) + +| 功能 | 对应文件 | +|------|---------| +| 登录与全局框架 | `Login.tsx`、`SidebarNav`、`TopNav` | +| 工作台(合规健康度/风险预警/待办) | `Dashboard.tsx` | +| AI 智能问答(流式/历史/语音/转律师) | `AIAssistant.tsx` ChatTab | +| AI 判赔预测(12类争议场景) | `AIAssistant.tsx` PredictTab | +| AI 合同审查(粘贴文本) | `AIAssistant.tsx` ReviewTab | +| AI 案例匹配 | `AIAssistant.tsx` CaseTab | +| 花名册(搜索/筛选/导入/导出/二维码邀请) | `Roster.tsx` | +| 考勤管理(记录/导入/统计/排班/休假) | `Attendance.tsx` | +| 合同管理(列表/查询/查看/下载/删除) | `Contracts.tsx` | +| 规章制度民主程序(四步流程) | `Policies.tsx` | +| 用工体检诊断(6维度评分) | `HealthCheck.tsx` | +| 文本模板库(87模板/下载Word/变量渲染) | `Templates.tsx` | +| 通知管理(发布/目标人群/定时) | `Notifications.tsx` | +| 计算器(五险一金/医疗期) | `SocialInsurance.tsx`、`MedicalPeriodCalculator.tsx` | + +### 部分实现功能(3项) + +| 功能 | 已有部分 | 缺失部分 | +|------|---------|---------| +| AI 文件审查 | 粘贴合同文本审查、结构化风险报告 | 不支持上传 doc/docx 文件,无文书类型选择 | +| 考勤管理 | 考勤记录导入、查看、统计 | 无"发布考勤表"功能 | +| 工资条管理 | 工资条生成、员工端查看 | 无"发布工资条"和"定时发送"功能 | + +### 未实现功能(5项) + +| 功能 | 说明 | +|------|------| +| 用工办理工作流(13类流程) | 最大的功能缺口,无统一办理工作流系统 | +| 背景调查 | 完全未实现(本次暂不纳入) | +| 视频中心 | 完全未实现(本次暂不纳入) | +| 企业自建文本库 | 无企业自建文本管理功能 | +| 合同到期处理弹窗 | 有到期提醒但无多选项处理弹窗(本次暂不纳入) | + +**本次优化聚焦4项功能**:用工办理工作流、企业自建文本库、考勤/工资条发布、AI文件审查上传。 + +--- + +## P0:AI文件审查上传功能 + +### 问题描述 + +现有 `AIAssistant.tsx` ReviewTab 仅支持粘贴合同文本进行审查,无法上传 doc/docx 文件。白话用工侠支持上传文件并选择文书类型(劳动合同/协商解除/劳务协议/实习协议/保密协议)。 + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `backend/src/routes/ai.routes.ts` | 新增 `POST /ai/review/upload` 接口 | +| `frontend/src/pages/AIAssistant.tsx` ReviewTab | 新增文件上传区 + 文书类型选择 | +| `backend/package.json` | 新增 `mammoth` 依赖 | + +### 实现方案 + +#### 后端:文件上传 + 文本提取 + +```typescript +// ai.routes.ts 新增 +import mammoth from 'mammoth' + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024 }, // 100MB + fileFilter: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + if (ext !== '.docx' && ext !== '.doc') { + return cb(new Error('仅支持 .doc 和 .docx 文件')) + } + cb(null, true) + }, +}) + +router.post('/review/upload', authMiddleware, upload.single('file'), async (req: AuthRequest, res, next) => { + try { + if (!req.file) return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传文件' } }) + const ext = path.extname(req.file.originalname).toLowerCase() + let text = '' + if (ext === '.docx') { + const result = await mammoth.extractRawText({ buffer: req.file.buffer }) + text = result.value + } else { + // .doc 旧格式:提示用户转换为 .docx + return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } }) + } + // 截断超长文本 + if (text.length > 50000) { + text = text.slice(0, 50000) + '\n\n[文本过长,已截断]' + } + res.json({ success: true, data: { text } }) + } catch (err) { next(err) } +}) +``` + +#### 前端:ReviewTab 改造 + +在现有粘贴文本输入框上方新增: + +``` +合同审查 Tab: +├── 文书类型选择(下拉:劳动合同/协商解除协议/劳务协议/实习协议/保密协议) +├── 文件上传区(拖拽或点击上传 .docx) +│ ├── 上传后调用 /ai/review/upload 提取文本 +│ └── 提取成功后自动填入下方输入框 +├── 文本输入框(现有,用户可编辑提取后的文本) +├── 开始审查按钮(现有) +└── 审查结果展示(现有) +``` + +#### 依赖安装 + +```bash +cd backend && npm install mammoth +``` + +### 安全考虑 + +- 文件大小限制:100MB(multer limits) +- 文件类型校验:仅 `.docx`,`.doc` 提示转换 +- 提取后不保存原文件,仅返回文本 +- 文本长度截断:超过 50000 字符时截断并提示 + +--- + +## P0:用工办理工作流系统(13类流程) + +### 问题描述 + +本系统有 `Termination.tsx`(离职管理)和 `Contracts.tsx`(合同管理),但缺少统一的用工办理工作流系统。白话用工侠提供13类办理流程:员工录用、员工入职、自定义合同签署、员工信息提交、员工转正、合同变更、合同续签、合同中止、开具收入证明、合同终止、合同解除、开具离职证明、灵活用工。 + +### 13类流程定义 + +| 编号 | 流程名称 | 类型代码 | 核心表单字段 | 关联模块 | +|------|---------|---------|------------|---------| +| 1 | 员工录用 | HIRE | 人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料 | 员工创建+合同起草 | +| 2 | 员工入职 | ONBOARD | 入职日期、岗位确认、合同签署方式、材料提交清单 | 员工状态→ACTIVE | +| 3 | 自定义合同签署 | CUSTOM_CONTRACT | 合同模板选择、变量填充、签署方、期限 | 合同管理 | +| 4 | 员工信息提交 | INFO_SUBMIT | 信息变更字段、证明材料 | 员工档案更新 | +| 5 | 员工转正 | CONFIRM | 转正日期、转正薪资、考核结果 | 员工状态+薪资变更 | +| 6 | 合同变更 | CHANGE | 变更类型、变更内容、生效日期 | 合同管理 | +| 7 | 合同续签 | RENEW | 续签次数、新期限、新薪资 | 合同管理 | +| 8 | 合同中止 | SUSPEND | 中止原因、中止期限、预计恢复日期 | 合同状态 | +| 9 | 开具收入证明 | INCOME_CERT | 用途、收入期间、接收方 | 文本模板渲染 | +| 10 | 合同终止 | TERMINATE | 终止原因、终止日期、经济补偿 | 合同状态+离职 | +| 11 | 合同解除 | RESCIND | 解除原因、解除方式、协商/单方 | 复用 Termination 模块 | +| 12 | 开具离职证明 | LEAVING_CERT | 离职日期、离职原因、接收方 | 文本模板渲染 | +| 13 | 灵活用工 | FLEXIBLE | 人员信息、用工类型、协议期限、计酬方式 | 合同管理(LABOR) | + +### 数据模型 + +```prisma +// 用工办理流程 +model WorkProcess { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE + title String + employeeId String? + employee Employee? @relation(fields: [employeeId], references: [id]) + status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED + formData Json // 表单数据 JSON + documents Json? // 生成的文书列表 [{name, content, type}] + approverId String? + approvedAt DateTime? + remark String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, type]) + @@index([orgId, createdBy]) +} +``` + +### 后端接口 + +``` +POST /api/v1/work-processes # 创建办理(含草稿) +GET /api/v1/work-processes # 列表查询(支持type/status筛选) +GET /api/v1/work-processes/:id # 详情 +PATCH /api/v1/work-processes/:id # 更新草稿 +POST /api/v1/work-processes/:id/submit # 提交办理 +POST /api/v1/work-processes/:id/approve # 审批通过 +POST /api/v1/work-processes/:id/reject # 驳回 +POST /api/v1/work-processes/:id/cancel # 撤销 +DELETE /api/v1/work-processes/:id # 删除草稿 +GET /api/v1/work-processes/:id/preview # 预览生成的文书 +``` + +### 前端架构 + +**新增页面**:`frontend/src/pages/WorkProcess.tsx` + +``` +页面结构: +├── 发起办理(13类流程卡片选择) +├── 办理记录(列表+筛选:类型/状态/日期) +├── 办理草稿(仅 DRAFT 状态) +└── 流程详情(步骤条+表单+预览+操作) +``` + +**流程详情组件**(通用 Wizard): +- 步骤条:填写信息 → 预览文书 → 存草稿/提交 +- 表单根据 `type` 动态渲染字段 +- 预览:调用文本模板渲染接口生成文书 +- 提交后根据流程类型执行对应业务逻辑 + +**复用已有模块**: +- 合同解除(#11)→ 复用 `Termination.tsx` 的 Wizard 逻辑 +- 合同续签(#7)→ 复用 `Contracts.tsx` 的合同管理逻辑 +- 收入证明(#9)/ 离职证明(#12)→ 复用 `Templates.tsx` 的模板渲染 +- 员工录用(#1)→ 复用 `roster/modals.tsx` 的添加员工逻辑 + +### 提交后业务联动 + +| 流程类型 | 提交后执行 | +|---------|-----------| +| HIRE | 创建员工记录 + 创建劳动合同 | +| ONBOARD | 更新员工状态为 ACTIVE + 记录入职日期 | +| CUSTOM_CONTRACT | 创建劳动合同 | +| INFO_SUBMIT | 更新员工档案字段 | +| CONFIRM | 更新试用期结束 + 调整薪资 | +| CHANGE | 更新合同字段 + 记录变更历史 | +| RENEW | 关闭旧合同 + 创建新合同 | +| SUSPEND | 合同状态改为 SUSPENDED | +| INCOME_CERT | 生成收入证明文书(不改变业务数据) | +| TERMINATE | 合同状态改为 TERMINATED + 触发离职流程 | +| RESCIND | 调用已有 Termination 逻辑 | +| LEAVING_CERT | 生成离职证明文书 | +| FLEXIBLE | 创建劳务协议合同 | + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `backend/prisma/schema.prisma` | 新增 `WorkProcess` 模型 | +| `backend/src/routes/work-process.routes.ts` | **新增**,办理 CRUD + 提交/审批/撤销 | +| `backend/src/services/work-process.service.ts` | **新增**,13类流程的业务联动逻辑 | +| `backend/src/index.ts` | 注册新路由 | +| `frontend/src/pages/WorkProcess.tsx` | **新增**,办理页面 | +| `frontend/src/App.tsx` | 注册路由 | +| `frontend/src/components/layout/SidebarNav.tsx` | 新增菜单项 | + +### 实施分阶段 + +1. **Phase 1**:数据模型 + 基础 CRUD + 列表/草稿页面 +2. **Phase 2**:员工录用、员工入职、合同续签、合同终止(4个高频流程) +3. **Phase 3**:剩余9类流程 + 文书预览生成 +4. **Phase 4**:审批流程 + 办理记录导出 + +--- + +## P1:企业自建文本库 + +### 问题描述 + +现有 `Templates.tsx` 仅提供系统预置模板(87个,只读),企业无法创建和管理自己的文本模板。白话用工侠有"企业文本库"功能,支持自建文本。 + +### 数据模型 + +```prisma +// 企业自建文本模板 +model EnterpriseTemplate { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String + category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER + description String? + content String @db.Text + variables String[] // 变量名列表 + status String @default("ACTIVE") // ACTIVE/ARCHIVED + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, category]) +} +``` + +### 后端接口 + +``` +# 现有模板路由:/api/v1/templates(系统预置,只读) +# 新增企业模板路由:/api/v1/enterprise-templates + +GET /api/v1/enterprise-templates # 列表(支持category/search) +POST /api/v1/enterprise-templates # 新建 +GET /api/v1/enterprise-templates/:id # 详情 +PUT /api/v1/enterprise-templates/:id # 更新 +DELETE /api/v1/enterprise-templates/:id # 删除 +POST /api/v1/enterprise-templates/:id/render # 渲染(复用现有 renderTemplate 逻辑) +GET /api/v1/enterprise-templates/:id/download # 下载Word +``` + +### 前端方案 + +**改造页面**:`frontend/src/pages/Templates.tsx` + +在现有模板库页面顶部新增 Tab 切换: +- **系统模板库**(现有功能不变) +- **企业文本库**(新增) + +企业文本库 Tab 内容: +``` +├── 查询栏(名称搜索 + 分类筛选) +├── 新建/编辑弹窗(名称、分类、变量配置、内容编辑) +├── 模板卡片列表(复用现有卡片样式) +├── 详情弹窗(变量填写 → 渲染 → 下载Word/复制) +└── 操作:编辑、复制、删除、归档 +``` + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `backend/prisma/schema.prisma` | 新增 `EnterpriseTemplate` 模型 | +| `backend/src/routes/enterprise-template.routes.ts` | **新增**,CRUD + 渲染 + 下载 | +| `backend/src/index.ts` | 注册新路由 | +| `frontend/src/pages/Templates.tsx` | 顶部新增 Tab 切换 + 企业文本库面板 | + +### 实现要点 + +- 变量提取:编辑内容时自动扫描 `{{变量名}}` 提取变量列表 +- 渲染逻辑:复用 `template.service.ts` 的 `renderTemplate` 函数 +- 内容编辑器:使用 textarea + 变量插入按钮(点击插入 `{{变量名}}`) +- 权限:ADMIN/HR 可增删改,VIEWER 只读 + +--- + +## P1:考勤发布流程 + +### 问题描述 + +现有 `Attendance.tsx` 有考勤记录和统计功能,但缺少"发布考勤表"功能。白话用工侠支持 HR 发布月度考勤表,员工在员工端查看确认。 + +### 数据模型 + +```prisma +// 考勤发布记录 +model AttendancePublish { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + month String // 2026-07 + title String // 如"2026年7月考勤表" + status String @default("PUBLISHED") // PUBLISHED/CANCELLED + publishDate DateTime @default(now()) + createdBy String + createdAt DateTime @default(now()) + + @@unique([orgId, month]) + @@index([orgId, month]) +} +``` + +### 后端接口 + +``` +POST /api/v1/attendance/publish # 发布考勤表 +GET /api/v1/attendance/publish-records # 发布记录列表 +POST /api/v1/attendance/publish/:id/cancel # 取消发布 +GET /api/v1/portal/attendance?month=2026-07 # 员工查看自己的月度考勤 +``` + +### 前端改造 + +**`Attendance.tsx`** — 在「考勤确认」Tab 工具栏新增: +- **「发布考勤表」按钮**:弹窗确认 → 选择月份 → 发布 +- **「发布记录」入口**:查看历史发布记录,可取消发布 + +**`portal/` 新增考勤查看页面** — `portal/MyAttendance.tsx`: +- 员工登录后查看已发布月份的考勤记录 +- 显示每日考勤状态、上下班时间 +- 支持月度切换 + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `backend/prisma/schema.prisma` | 新增 `AttendancePublish` 模型 | +| `backend/src/routes/attendance.routes.ts` | 新增发布/取消/记录接口 | +| `backend/src/routes/portal.routes.ts` | 新增员工端考勤查看接口 | +| `frontend/src/pages/Attendance.tsx` | 新增发布按钮和发布记录弹窗 | +| `frontend/src/pages/portal/MyAttendance.tsx` | **新增**,员工端考勤查看 | +| `frontend/src/App.tsx` | 注册员工端考勤路由 | +| `frontend/src/components/layout/PortalLayout.tsx` | 员工端导航新增考勤入口 | + +--- + +## P1:工资条发布与定时发送 + +### 问题描述 + +现有 `Money.tsx` 可生成工资条,员工端 `portal/Payslip.tsx` 可查看,但缺少"发布工资条"和"定时发送"功能。白话用工侠支持 HR 发布工资条后员工才能看到,并支持定时发送。 + +### 数据模型 + +复用已有 `Payslip` 模型,新增发布状态字段: + +```prisma +// 在 Payslip 模型新增字段 +model Payslip { + // ...已有字段 + publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED + publishedAt DateTime? + scheduledAt DateTime? // 定时发送时间 + // confirmedAt 已有 +} +``` + +### 后端接口 + +``` +POST /api/v1/payroll/batches/:batchId/publish # 发布工资条 +POST /api/v1/payroll/batches/:batchId/schedule # 定时发送 +GET /api/v1/payroll/schedule-records # 定时发送记录 +POST /api/v1/payroll/schedule/:id/cancel # 取消定时发送 +``` + +### 前端改造 + +**`Money.tsx`** — 在批次详情页新增: +- **「发布工资条」按钮**:将批次内所有工资条标记为 PUBLISHED +- **「定时发送」选项**:选择发送时间,到点自动发布 +- **「定时发送记录」入口**:查看定时发送列表,可取消 + +**`portal/Payslip.tsx`** — 调整查询逻辑: +- 发布前:员工端不显示该月工资条 +- 发布后:员工端显示工资条,可查看和确认 +- 定时发送:到点后状态从 SCHEDULED → PUBLISHED + +### 定时发送实现 + +- 使用 `node-cron` 或现有定时任务机制 +- 每分钟检查 `scheduledAt <= now && publishStatus = SCHEDULED` 的记录 +- 自动更新为 PUBLISHED 并发送通知 + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `backend/prisma/schema.prisma` | `Payslip` 模型新增 `publishStatus`/`publishedAt`/`scheduledAt` 字段 | +| `backend/src/routes/payroll.routes.ts` 或 `payroll2.routes.ts` | 新增发布/定时发送/取消接口 | +| `backend/src/index.ts` | 注册定时任务 | +| `frontend/src/pages/Money.tsx` | 批次详情页新增发布/定时发送按钮 | +| `frontend/src/pages/portal/Payslip.tsx` | 查询逻辑增加 publishStatus 过滤 | +| `backend/src/routes/portal.routes.ts` | 员工端 payslip 接口增加 publishStatus 过滤 | + +--- + +## P2:合同到期处理弹窗(增强) + +### 问题描述 + +现有 `Dashboard.tsx` 有合同到期提醒,但仅显示列表。白话用工侠支持到期合同弹窗处理:发送续签通知、已线下续签、终止合同、自定义签署,并可查看合同PDF。 + +### 前端改造 + +**`Dashboard.tsx`** — 合同到期待办项增加操作弹窗: + +``` +合同到期处理弹窗: +├── 员工信息 + 合同信息(类型/期限/到期日) +├── 合同PDF查看链接(如有附件) +└── 操作按钮: + ├── 发送续签通知 → 调用通知接口 + ├── 已线下续签 → 更新合同状态 + 创建新合同记录 + ├── 终止合同 → 跳转 Termination 模块 + └── 自定义签署 → 跳转 WorkProcess CUSTOM_CONTRACT +``` + +### 涉及文件 + +| 文件 | 改动 | +|------|------| +| `frontend/src/pages/Dashboard.tsx` | 合同到期待办增加操作弹窗 | +| `backend/src/routes/dashboard.routes.ts` | 如需新增批量操作接口 | + +--- + +## 实施计划 + +### 优先级排序 + +| 优先级 | 功能 | 预估工作量 | 建议时间 | +|--------|------|-----------|---------| +| P0 | AI文件审查上传 | 1-2天 | 立即 | +| P0 | 用工办理工作流 Phase 1-2 | 5-7天 | 本周 | +| P1 | 企业自建文本库 | 2-3天 | 下周 | +| P1 | 考勤发布 | 2天 | 下周 | +| P1 | 工资条发布与定时发送 | 2-3天 | 下周 | +| P0 | 用工办理工作流 Phase 3-4 | 5-7天 | 第三周 | +| P2 | 合同到期处理弹窗 | 1-2天 | 第三周 | + +### 分周计划 + +- **第1周**:AI文件审查上传 + 用工办理 Phase 1-2(数据模型 + CRUD + 4个高频流程) +- **第2周**:企业文本库 + 考勤发布 + 工资条发布 +- **第3周**:用工办理 Phase 3-4(剩余9类流程 + 审批) + 合同到期弹窗 + 联调测试 + +### 总工作量 + +约 18-26 个工作日 + +### 数据库迁移 + +所有新增模型和字段变更需要执行: +```bash +cd backend && npx prisma db push +``` + +--- + +## 风险与注意事项 + +1. **用工办理工作流**是最复杂的功能,建议先实现4个高频流程(录用/入职/续签/终止),验证架构后再扩展剩余9类 +2. **企业文本库**的变量提取逻辑需与系统模板保持一致,复用 `renderTemplate` 函数 +3. **工资条发布**涉及薪资敏感数据,需确保只有发布后员工端才能看到,发布前 `publishStatus = UNPUBLISHED` 的记录在员工端不可见 +4. **AI文件审查**的 .doc 旧格式支持有限,建议仅支持 .docx 并提示用户转换 +5. **定时发送**需要确保服务器进程持续运行(PM2 已有保障),定时任务需做幂等处理防止重复发布 +6. **用工办理工作流**的13类流程提交后业务联动逻辑较复杂,每类流程需独立编写 `executeWorkProcess(type, formData)` 逻辑 +7. **考勤发布**需考虑已取消发布的月份是否允许重新发布(`@@unique([orgId, month])` 约束需处理) +8. **合同到期弹窗**的"发送续签通知"需复用现有通知模块 `Notifications.tsx` 的逻辑 diff --git a/backend/package-lock.json b/backend/package-lock.json index 34650eb..5a95244 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", "morgan": "^1.10.0", "multer": "^2.2.0", "node-cron": "^3.0.3", @@ -944,6 +945,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", @@ -1097,6 +1107,15 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", @@ -1638,6 +1657,21 @@ "node": ">=0.3.1" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2576,6 +2610,17 @@ "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "license": "MIT" }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmmirror.com/make-error/-/make-error-1.3.6.tgz", @@ -2583,6 +2628,30 @@ "dev": true, "license": "ISC" }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2860,6 +2929,12 @@ } } }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", @@ -3297,6 +3372,12 @@ "source-map": "^0.6.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/ssf": { "version": "0.11.2", "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", @@ -3578,6 +3659,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", @@ -3730,6 +3817,15 @@ "node": ">=0.8" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 9b59cc4..189a12b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "express-rate-limit": "^7.4.0", "helmet": "^7.1.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.12.0", "morgan": "^1.10.0", "multer": "^2.2.0", "node-cron": "^3.0.3", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 80877b3..953e578 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -172,6 +172,9 @@ model Organization { leaveRecords LeaveRecord[] calendarEvents CalendarEvent[] consultations Consultation[] + workProcesses WorkProcess[] + enterpriseTemplates EnterpriseTemplate[] + attendancePublishes AttendancePublish[] } model User { @@ -252,6 +255,7 @@ model Employee { shiftAssignments ShiftAssignment[] leaveRecords LeaveRecord[] calendarEvents CalendarEvent[] + workProcesses WorkProcess[] @@unique([orgId, idCardHash]) } @@ -628,6 +632,8 @@ model Payslip { confirmedAt DateTime? confirmedIp String? publishedAt DateTime? // 工资条发布到员工端的时间 + publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED + scheduledAt DateTime? // 定时发送时间 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1171,3 +1177,61 @@ model Consultation { @@index([orgId, status]) @@index([orgId, type]) } + +// ========== 用工办理工作流 ========== +model WorkProcess { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE + title String + employeeId String? + employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull) + status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED + formData Json // 表单数据 JSON + documents Json? // 生成的文书列表 [{name, content, type}] + approverId String? + approvedAt DateTime? + remark String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, type]) + @@index([orgId, createdBy]) +} + +// ========== 企业自建文本模板 ========== +model EnterpriseTemplate { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String + category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER + description String? + content String @db.Text + variables String[] // 变量名列表 + status String @default("ACTIVE") // ACTIVE/ARCHIVED + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, category]) +} + +// ========== 考勤发布记录 ========== +model AttendancePublish { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + month String // YYYY-MM + title String // 如"2026年7月考勤表" + status String @default("PUBLISHED") // PUBLISHED/CANCELLED + publishDate DateTime @default(now()) + createdBy String + createdAt DateTime @default(now()) + + @@unique([orgId, month]) + @@index([orgId, month]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 27f8c0e..bf525cf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -58,6 +58,8 @@ import templateRoutes from './routes/template.routes' import auditRoutes from './routes/audit.routes' import calendarRoutes from './routes/calendar.routes' import platformRoutes from './routes/platform.routes' +import workProcessRoutes from './routes/work-process.routes' +import enterpriseTemplateRoutes from './routes/enterprise-template.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -80,6 +82,8 @@ app.use('/api/v1/templates', templateRoutes) app.use('/api/v1/audit', auditRoutes) app.use('/api/v1/calendar', calendarRoutes) app.use('/api/v1/platform', platformRoutes) +app.use('/api/v1/work-processes', workProcessRoutes) +app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes) app.use(errorHandler) diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 2ddac23..2369046 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -5,6 +5,9 @@ import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searc import prisma from '../lib/prisma' import { z } from 'zod' import { decrypt } from '../lib/crypto' +import multer from 'multer' +import path from 'path' +import mammoth from 'mammoth' const router = Router() @@ -947,6 +950,42 @@ ${expiringContracts.length > 0 ? expiringContracts.join('\n') : '无'}` } }) +// ========== AI 文件审查上传 ========== + +const reviewUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 100 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + if (ext !== '.docx' && ext !== '.doc') { + return cb(null, false) + } + cb(null, true) + }, +}) + +router.post('/review/upload', authMiddleware, reviewUpload.single('file'), async (req: AuthRequest, res, next) => { + try { + if (!req.file) { + return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请上传 .docx 文件' } }) + } + const ext = path.extname(req.file.originalname).toLowerCase() + let text = '' + if (ext === '.docx') { + const result = await mammoth.extractRawText({ buffer: req.file.buffer }) + text = result.value + } else { + return res.status(400).json({ success: false, error: { code: 'UNSUPPORTED', message: '暂不支持 .doc 格式,请将文件另存为 .docx 后上传' } }) + } + if (text.length > 50000) { + text = text.slice(0, 50000) + '\n\n[文本过长,已截断]' + } + res.json({ success: true, data: { text, fileName: req.file.originalname } }) + } catch (err) { + next(err) + } +}) + // ========== 人工咨询服务 ========== router.post('/consultation', authMiddleware, async (req: AuthRequest, res, next) => { diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index e6d6e1e..c4404f8 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -21,6 +21,7 @@ import { deleteLeaveRecord, } from '../services/attendance.service' import { createEvidence } from '../services/evidence.service' +import prisma from '../lib/prisma' const router = Router() @@ -238,4 +239,76 @@ router.delete('/leaves/:id', authMiddleware, async (req: AuthRequest, res: Respo } catch (err) { next(err) } }) +// ========== 考勤发布 ========== + +// 发布考勤表 +router.post('/publish', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { month, title } = req.body + if (!month) { + return res.status(400).json({ success: false, error: { code: 'MISSING_MONTH', message: '请选择月份' } }) + } + // 检查是否已发布且未取消 + const existing = await (prisma as any).attendancePublish.findFirst({ + where: { orgId: req.user!.orgId, month, status: 'PUBLISHED' }, + }) + if (existing) { + return res.status(400).json({ success: false, error: { code: 'ALREADY_PUBLISHED', message: `${month}月考勤表已发布` } }) + } + // 如果有已取消的记录,删除后重新创建 + const cancelled = await (prisma as any).attendancePublish.findFirst({ + where: { orgId: req.user!.orgId, month, status: 'CANCELLED' }, + }) + if (cancelled) { + await (prisma as any).attendancePublish.delete({ where: { id: cancelled.id } }) + } + const record = await (prisma as any).attendancePublish.create({ + data: { + orgId: req.user!.orgId, + month, + title: title || `${month}月考勤表`, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: record }) + } catch (err) { + next(err) + } +}) + +// 发布记录列表 +router.get('/publish-records', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const records = await (prisma as any).attendancePublish.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { + next(err) + } +}) + +// 取消发布 +router.post('/publish/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const record = await (prisma as any).attendancePublish.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!record) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '发布记录不存在' } }) + } + if (record.status !== 'PUBLISHED') { + return res.status(400).json({ success: false, error: { code: 'NOT_PUBLISHED', message: '仅已发布状态可取消' } }) + } + const updated = await (prisma as any).attendancePublish.update({ + where: { id: record.id }, + data: { status: 'CANCELLED' }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/enterprise-template.routes.ts b/backend/src/routes/enterprise-template.routes.ts new file mode 100644 index 0000000..4b4441a --- /dev/null +++ b/backend/src/routes/enterprise-template.routes.ts @@ -0,0 +1,152 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import prisma from '../lib/prisma' +import { renderTemplate } from '../services/template.service' + +const router = Router() + +// 提取变量名 +function extractVariables(content: string): string[] { + const matches = content.match(/\{\{(\w+)\}\}/g) || [] + return [...new Set(matches.map(m => m.replace(/\{\{|\}\}/g, '')))] +} + +// 列表 +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { category, search } = req.query + const where: any = { orgId: req.user!.orgId, status: 'ACTIVE' } + if (category) where.category = category + if (search) where.name = { contains: String(search) } + const items = await (prisma as any).enterpriseTemplate.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }) + res.json({ success: true, data: items }) + } catch (err) { + next(err) + } +}) + +// 新建 +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { name, category, description, content } = req.body + if (!name || !category || !content) { + return res.status(400).json({ success: false, error: { code: 'MISSING_FIELDS', message: '名称、分类、内容为必填' } }) + } + const variables = extractVariables(content) + const template = await (prisma as any).enterpriseTemplate.create({ + data: { + orgId: req.user!.orgId, + name, + category, + description: description || null, + content, + variables, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: template }) + } catch (err) { + next(err) + } +}) + +// 详情 +router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const template = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!template) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + res.json({ success: true, data: template }) + } catch (err) { + next(err) + } +}) + +// 更新 +router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const existing = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + const { name, category, description, content, status } = req.body + const variables = content ? extractVariables(content) : existing.variables + const updated = await (prisma as any).enterpriseTemplate.update({ + where: { id: req.params.id }, + data: { + ...(name !== undefined && { name }), + ...(category !== undefined && { category }), + ...(description !== undefined && { description }), + ...(content !== undefined && { content, variables }), + ...(status !== undefined && { status }), + }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 删除 +router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const existing = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + await (prisma as any).enterpriseTemplate.delete({ where: { id: req.params.id } }) + res.json({ success: true, data: { message: '已删除' } }) + } catch (err) { + next(err) + } +}) + +// 渲染 +router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const template = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!template) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + const { variables } = req.body as { variables: Record } + let content = template.content + for (const [key, value] of Object.entries(variables || {})) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value) + } + res.json({ success: true, data: { content } }) + } catch (err) { + next(err) + } +}) + +// 下载 Word +router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const template = await (prisma as any).enterpriseTemplate.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!template) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + const encoded = encodeURIComponent(template.name + '.doc') + res.setHeader('Content-Type', 'application/msword') + res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`) + res.send(template.content) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index b00847a..d71014e 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -870,4 +870,99 @@ router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response } }) +// ========== 工资条发布 ========== + +// 发布工资条(将批次内所有工资条标记为 PUBLISHED) +router.post('/batches/:batchId/publish', 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: '批次不存在' } }) + } + // 查找该批次关联的所有工资条(通过 BatchEntry 关联的 employeeId + month) + const entries = await prisma.batchEntry.findMany({ + where: { batchId, orgId }, + select: { employeeId: true }, + }) + const employeeIds = entries.map(e => e.employeeId) + if (employeeIds.length === 0) { + return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } }) + } + // 更新对应月份的工资条 + const result = await prisma.payslip.updateMany({ + where: { orgId, employeeId: { in: employeeIds }, month: batch.month }, + data: { publishStatus: 'PUBLISHED', publishedAt: new Date() }, + }) + res.json({ success: true, data: { published: result.count, month: batch.month } }) + } catch (err) { + next(err) + } +}) + +// 定时发送工资条 +router.post('/batches/:batchId/schedule', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { batchId } = req.params + const { scheduledAt } = req.body + if (!scheduledAt) { + return res.status(400).json({ success: false, error: { code: 'MISSING_DATE', message: '请选择发送时间' } }) + } + 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: '批次不存在' } }) + } + const entries = await prisma.batchEntry.findMany({ + where: { batchId, orgId }, + select: { employeeId: true }, + }) + const employeeIds = entries.map(e => e.employeeId) + if (employeeIds.length === 0) { + return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } }) + } + const result = await prisma.payslip.updateMany({ + where: { orgId, employeeId: { in: employeeIds }, month: batch.month }, + data: { publishStatus: 'SCHEDULED', scheduledAt: new Date(scheduledAt) }, + }) + res.json({ success: true, data: { scheduled: result.count, scheduledAt } }) + } catch (err) { + next(err) + } +}) + +// 定时发送记录 +router.get('/schedule-records', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const records = await prisma.payslip.findMany({ + where: { orgId: req.user!.orgId, publishStatus: 'SCHEDULED' }, + include: { employee: { select: { name: true, department: true } } }, + orderBy: { scheduledAt: 'asc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { + next(err) + } +}) + +// 取消定时发送 +router.post('/schedule/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const payslip = await prisma.payslip.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId, publishStatus: 'SCHEDULED' }, + }) + if (!payslip) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '定时发送记录不存在' } }) + } + const updated = await prisma.payslip.update({ + where: { id: payslip.id }, + data: { publishStatus: 'UNPUBLISHED', scheduledAt: null }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index c211434..b69b6d6 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -110,7 +110,7 @@ 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 }, + where: { employeeId: req.employee.id, orgId: req.employee.orgId, month, publishStatus: 'PUBLISHED' }, }) if (!payslip) { return res.json({ success: true, data: null }) @@ -125,7 +125,7 @@ router.get('/payslip', portalAuth, async (req: any, res, next) => { 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 }, + where: { employeeId: req.employee.id, orgId: req.employee.orgId, publishStatus: 'PUBLISHED' }, orderBy: { month: 'desc' }, take: 6, }) @@ -611,4 +611,33 @@ router.get('/auto-login', async (req, res, next) => { } }) +// 员工端查看自己的月度考勤 +router.get('/attendance', portalAuth, async (req: any, res, next) => { + try { + const month = req.query.month as string || new Date().toISOString().slice(0, 7) + // 检查该月份是否已发布 + const publish = await (prisma as any).attendancePublish.findFirst({ + where: { orgId: req.employee.orgId, month, status: 'PUBLISHED' }, + }) + if (!publish) { + return res.json({ success: true, data: { published: false, records: [] } }) + } + // 查询该月考勤记录 + const startDate = new Date(`${month}-01`) + const endDate = new Date(startDate) + endDate.setMonth(endDate.getMonth() + 1) + const records = await prisma.attendanceRecord.findMany({ + where: { + employeeId: req.employee.id, + orgId: req.employee.orgId, + date: { gte: startDate, lt: endDate }, + }, + orderBy: { date: 'asc' }, + }) + res.json({ success: true, data: { published: true, records, title: publish.title } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/work-process.routes.ts b/backend/src/routes/work-process.routes.ts new file mode 100644 index 0000000..6386b2b --- /dev/null +++ b/backend/src/routes/work-process.routes.ts @@ -0,0 +1,267 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import prisma from '../lib/prisma' +import { executeWorkProcess, generateDocument, PROCESS_TYPES, PROCESS_STATUS } from '../services/work-process.service' + +const router = Router() + +// 创建办理(含草稿) +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { type, title, employeeId, formData, status = 'DRAFT', remark } = req.body + if (!type || !PROCESS_TYPES[type]) { + return res.status(400).json({ success: false, error: { code: 'INVALID_TYPE', message: '无效的流程类型' } }) + } + const process = await (prisma as any).workProcess.create({ + data: { + orgId: req.user!.orgId, + type, + title: title || PROCESS_TYPES[type].label, + employeeId: employeeId || null, + formData: formData || {}, + status, + remark: remark || null, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: process }) + } catch (err) { + next(err) + } +}) + +// 列表查询 +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { type, status, page = '1', pageSize = '20' } = req.query + const where: any = { orgId: req.user!.orgId } + if (type) where.type = type + if (status) where.status = status + const total = await (prisma as any).workProcess.count({ where }) + const items = await (prisma as any).workProcess.findMany({ + where, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { createdAt: 'desc' }, + skip: (Number(page) - 1) * Number(pageSize), + take: Number(pageSize), + }) + res.json({ success: true, data: { items, total, page: Number(page), pageSize: Number(pageSize) } }) + } catch (err) { + next(err) + } +}) + +// 详情 +router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + include: { employee: { select: { id: true, name: true, department: true, status: true } } }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + res.json({ success: true, data: process }) + } catch (err) { + next(err) + } +}) + +// 更新草稿 +router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const existing = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!existing) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (existing.status !== 'DRAFT') { + return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可编辑' } }) + } + const { title, employeeId, formData, remark } = req.body + const updated = await (prisma as any).workProcess.update({ + where: { id: req.params.id }, + data: { + ...(title !== undefined && { title }), + ...(employeeId !== undefined && { employeeId: employeeId || null }), + ...(formData !== undefined && { formData }), + ...(remark !== undefined && { remark }), + }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 提交办理 +router.post('/:id/submit', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (process.status !== 'DRAFT') { + return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可提交' } }) + } + // 执行业务联动 + let execResult: any = {} + try { + execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id) + } catch (execErr: any) { + return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } }) + } + // 生成文书 + const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) + const doc = generateDocument(process.type, process.formData, org?.name || '') + const documents = doc.content ? [doc] : [] + const updated = await (prisma as any).workProcess.update({ + where: { id: process.id }, + data: { + status: 'COMPLETED', + documents: documents.length > 0 ? documents : null, + ...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }), + }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 审批通过 +router.post('/:id/approve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (process.status !== 'PENDING_APPROVAL') { + return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可审批' } }) + } + let execResult: any = {} + try { + execResult = await executeWorkProcess(process.id, process.type, process.formData, req.user!.orgId, req.user!.id) + } catch (execErr: any) { + return res.status(400).json({ success: false, error: { code: 'EXEC_FAILED', message: `执行失败:${execErr?.message || '未知错误'}` } }) + } + const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) + const doc = generateDocument(process.type, process.formData, org?.name || '') + const documents = doc.content ? [doc] : [] + const updated = await (prisma as any).workProcess.update({ + where: { id: process.id }, + data: { + status: 'COMPLETED', + approverId: req.user!.id, + approvedAt: new Date(), + documents: documents.length > 0 ? documents : null, + ...(execResult.employeeId && !process.employeeId && { employeeId: execResult.employeeId }), + }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 驳回 +router.post('/:id/reject', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (process.status !== 'PENDING_APPROVAL') { + return res.status(400).json({ success: false, error: { code: 'NOT_PENDING', message: '仅待审批状态可驳回' } }) + } + const updated = await (prisma as any).workProcess.update({ + where: { id: process.id }, + data: { + status: 'REJECTED', + approverId: req.user!.id, + approvedAt: new Date(), + remark: req.body.reason || '驳回', + }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 撤销 +router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (['COMPLETED', 'CANCELLED'].includes(process.status)) { + return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '已完成或已撤销的记录不可撤销' } }) + } + const updated = await (prisma as any).workProcess.update({ + where: { id: process.id }, + data: { status: 'CANCELLED' }, + }) + res.json({ success: true, data: updated }) + } catch (err) { + next(err) + } +}) + +// 删除草稿 +router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + if (process.status !== 'DRAFT') { + return res.status(400).json({ success: false, error: { code: 'NOT_DRAFT', message: '仅草稿状态可删除' } }) + } + await (prisma as any).workProcess.delete({ where: { id: process.id } }) + res.json({ success: true, data: { message: '已删除' } }) + } catch (err) { + next(err) + } +}) + +// 预览文书 +router.get('/:id/preview', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const process = await (prisma as any).workProcess.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!process) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '办理记录不存在' } }) + } + const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId } }) + const doc = generateDocument(process.type, process.formData, org?.name || '') + res.json({ success: true, data: doc }) + } catch (err) { + next(err) + } +}) + +// 获取流程类型列表 +router.get('/meta/types', authMiddleware, (_req: AuthRequest, res: Response) => { + res.json({ success: true, data: PROCESS_TYPES }) +}) + +// 获取状态列表 +router.get('/meta/statuses', authMiddleware, (_req: AuthRequest, res: Response) => { + res.json({ success: true, data: PROCESS_STATUS }) +}) + +export default router diff --git a/backend/src/services/work-process.service.ts b/backend/src/services/work-process.service.ts new file mode 100644 index 0000000..0cc9687 --- /dev/null +++ b/backend/src/services/work-process.service.ts @@ -0,0 +1,286 @@ +import prisma from '../lib/prisma' +import { encrypt } from '../lib/crypto' +import crypto from 'crypto' + +// 13类流程定义 +export const PROCESS_TYPES: Record = { + HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同', icon: 'user-plus' }, + ONBOARD: { label: '员工入职', description: '办理员工入职手续', icon: 'log-in' }, + CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署', icon: 'file-signature' }, + INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更', icon: 'edit' }, + CONFIRM: { label: '员工转正', description: '试用期员工转正', icon: 'check-circle' }, + CHANGE: { label: '合同变更', description: '变更合同内容', icon: 'refresh-cw' }, + RENEW: { label: '合同续签', description: '到期合同续签', icon: 'repeat' }, + SUSPEND: { label: '合同中止', description: '中止履行合同', icon: 'pause' }, + INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明', icon: 'file-text' }, + TERMINATE: { label: '合同终止', description: '合同到期终止', icon: 'x-circle' }, + RESCIND: { label: '合同解除', description: '协商或单方解除合同', icon: 'user-x' }, + LEAVING_CERT: { label: '开具离职证明', description: '为离职员工开具证明', icon: 'file-minus' }, + FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署', icon: 'briefcase' }, +} + +export const PROCESS_STATUS: Record = { + DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' }, + PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' }, + APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' }, + REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' }, + EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' }, + COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' }, + CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' }, +} + +// 提交后业务联动 +export async function executeWorkProcess(processId: string, type: string, formData: any, orgId: string, userId: string) { + switch (type) { + case 'HIRE': { + // 创建员工 + 合同 + const { name, department, hireDate, monthlySalary, phone, idCardNumber, gender, contractStartDate, contractEndDate, contractType = 'FIXED' } = formData + const idCardHash = idCardNumber ? crypto.createHash('sha256').update(idCardNumber).digest('hex') : null + const employee = await prisma.employee.create({ + data: { + orgId, + name, + department: department || '未分配', + hireDate: new Date(hireDate), + monthlySalary: encrypt(String(monthlySalary || 0)), + phone: phone || null, + idCardNumber: idCardNumber ? encrypt(idCardNumber) : null, + idCardHash, + gender: gender || null, + createdBy: userId, + }, + }) + if (contractStartDate) { + await prisma.laborContract.create({ + data: { + orgId, + employeeId: employee.id, + startDate: new Date(contractStartDate), + endDate: contractEndDate ? new Date(contractEndDate) : null, + contractType: contractType as any, + signMethod: 'PAPER', + contractYears: 3, + createdBy: userId, + }, + }) + } + return { employeeId: employee.id } + } + case 'ONBOARD': { + const { employeeId, hireDate } = formData + if (employeeId) { + await prisma.employee.update({ + where: { id: employeeId }, + data: { hireDate: new Date(hireDate), status: 'ACTIVE' }, + }) + } + return { employeeId } + } + case 'CONFIRM': { + const { employeeId, confirmDate, regularSalary } = formData + if (employeeId) { + if (regularSalary) { + await prisma.employee.update({ + where: { id: employeeId }, + data: { monthlySalary: encrypt(String(regularSalary)) }, + }) + } + } + return { employeeId } + } + case 'RENEW': { + const { employeeId, oldContractId, newStartDate, newEndDate, newSalary, contractType = 'FIXED', contractYears = 3 } = formData + if (oldContractId) { + const oldEndDate = new Date(newStartDate) + oldEndDate.setDate(oldEndDate.getDate() - 1) + await prisma.laborContract.update({ + where: { id: oldContractId }, + data: { endDate: oldEndDate }, + }) + } + if (employeeId) { + const oldContract = oldContractId ? await prisma.laborContract.findUnique({ where: { id: oldContractId } }) : null + const contract = await prisma.laborContract.create({ + data: { + orgId, + employeeId, + signDate: new Date(), + startDate: new Date(newStartDate), + endDate: newEndDate ? new Date(newEndDate) : null, + contractType: contractType as any, + signMethod: 'PAPER', + contractYears: Number(contractYears) || 3, + renewalCount: (oldContract?.renewalCount || 0) + 1, + createdBy: userId, + }, + }) + if (newSalary) { + await prisma.employee.update({ + where: { id: employeeId }, + data: { monthlySalary: encrypt(String(newSalary)) }, + }) + } + return { employeeId, newContractId: contract.id } + } + return { employeeId } + } + case 'TERMINATE': { + const { employeeId, contractId, terminateDate } = formData + if (contractId) { + await prisma.laborContract.update({ + where: { id: contractId }, + data: { endDate: new Date(terminateDate) }, + }) + } + if (employeeId) { + await prisma.employee.update({ + where: { id: employeeId }, + data: { status: 'RESIGNED' }, + }) + } + return { employeeId } + } + case 'RESCIND': { + const { employeeId, contractId, rescindDate } = formData + if (contractId) { + await prisma.laborContract.update({ + where: { id: contractId }, + data: { endDate: new Date(rescindDate) }, + }) + } + if (employeeId) { + await prisma.employee.update({ + where: { id: employeeId }, + data: { status: 'RESIGNED' }, + }) + } + return { employeeId } + } + case 'CHANGE': { + const { contractId, newEndDate } = formData + if (contractId && newEndDate) { + await prisma.laborContract.update({ + where: { id: contractId }, + data: { endDate: new Date(newEndDate) }, + }) + } + return { contractId } + } + case 'SUSPEND': { + const { contractId, suspendDate } = formData + if (contractId && suspendDate) { + await prisma.laborContract.update({ + where: { id: contractId }, + data: { endDate: new Date(suspendDate) }, + }) + } + return { contractId } + } + case 'CUSTOM_CONTRACT': { + const { employeeId, contractStartDate, contractEndDate, contractType = 'FIXED', signMethod = 'PAPER', contractYears = 3 } = formData + if (employeeId) { + const contract = await prisma.laborContract.create({ + data: { + orgId, + employeeId, + signDate: new Date(), + startDate: new Date(contractStartDate), + endDate: contractEndDate ? new Date(contractEndDate) : null, + contractType: contractType as any, + signMethod: signMethod as any, + contractYears: Number(contractYears) || 3, + createdBy: userId, + }, + }) + return { employeeId, newContractId: contract.id } + } + return {} + } + case 'FLEXIBLE': { + const { name, phone, idCardNumber, department, agreementStartDate, agreementEndDate, payMethod } = formData + const idCardHash = idCardNumber ? crypto.createHash('sha256').update(idCardNumber).digest('hex') : null + const employee = await prisma.employee.create({ + data: { + orgId, + name, + department: department || '灵活用工', + hireDate: new Date(agreementStartDate), + monthlySalary: encrypt('0'), + phone: phone || null, + idCardNumber: idCardNumber ? encrypt(idCardNumber) : null, + idCardHash, + createdBy: userId, + }, + }) + const contract = await prisma.laborContract.create({ + data: { + orgId, + employeeId: employee.id, + signDate: new Date(), + startDate: new Date(agreementStartDate), + endDate: agreementEndDate ? new Date(agreementEndDate) : null, + contractType: 'LABOR', + signMethod: 'PAPER', + contractYears: 1, + createdBy: userId, + }, + }) + return { employeeId: employee.id, newContractId: contract.id } + } + case 'INFO_SUBMIT': { + const { employeeId, ...updateFields } = formData + if (employeeId) { + const allowedFields: Record = {} + if (updateFields.department) allowedFields.department = updateFields.department + if (updateFields.phone) allowedFields.phone = updateFields.phone + if (updateFields.address) allowedFields.address = updateFields.address + if (updateFields.emergencyContact) allowedFields.emergencyContact = updateFields.emergencyContact + if (updateFields.emergencyPhone) allowedFields.emergencyPhone = updateFields.emergencyPhone + if (updateFields.bankAccount) allowedFields.bankAccount = encrypt(updateFields.bankAccount) + if (updateFields.bankName) allowedFields.bankName = updateFields.bankName + if (Object.keys(allowedFields).length > 0) { + await prisma.employee.update({ where: { id: employeeId }, data: allowedFields }) + } + } + return { employeeId } + } + case 'INCOME_CERT': + case 'LEAVING_CERT': { + // 这两类只生成文书,不改变业务数据 + return {} + } + default: + return {} + } +} + +// 生成文书预览 +export function generateDocument(type: string, formData: any, orgName: string): { name: string; content: string } { + const templates: Record string> = { + INCOME_CERT: (data, org) => `收入证明 + +兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。 + +该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。 + +本证明仅用于 ${data.purpose || '___'},不作其他用途。 + +特此证明。 + +${org} +${new Date().toLocaleDateString('zh-CN')}`, + LEAVING_CERT: (data, org) => `离职证明 + +兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。 + +该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。 + +特此证明。 + +${org} +${new Date().toLocaleDateString('zh-CN')}`, + } + const generator = templates[type] + if (!generator) return { name: '', content: '' } + return { name: `${PROCESS_TYPES[type]?.label || '文书'}.doc`, content: generator(formData, orgName) } +} diff --git a/docs/20260730-功能补齐方案.md b/docs/20260730-功能补齐方案.md new file mode 100644 index 0000000..47c1f31 --- /dev/null +++ b/docs/20260730-功能补齐方案.md @@ -0,0 +1,404 @@ +# 企业用工专家 — 功能补齐方案 + +> 日期:2026-07-30 +> 依据:白话用工侠完整运行测试报告对比分析 +> 优先级:🔴 高 / 🟡 中 + +--- + +## 一、用工办理工作流系统(13类流程)🔴 + +### 1.1 目标 + +实现统一的用工办理工作流系统,覆盖员工从录用到离职的全生命周期办理事项。 + +### 1.2 13类流程定义 + +| 编号 | 流程名称 | 核心表单字段 | 关联模块 | +|------|---------|------------|---------| +| 1 | 员工录用 | 人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料 | 员工创建+合同起草 | +| 2 | 员工入职 | 入职日期、岗位确认、合同签署方式、材料提交清单 | 员工状态→ACTIVE | +| 3 | 自定义合同签署 | 合同模板选择、变量填充、签署方、期限 | 合同管理 | +| 4 | 员工信息提交 | 信息变更字段、证明材料 | 员工档案更新 | +| 5 | 员工转正 | 转正日期、转正薪资、考核结果 | 员工状态+薪资变更 | +| 6 | 合同变更 | 变更类型、变更内容、生效日期 | 合同管理 | +| 7 | 合同续签 | 续签次数、新期限、新薪资 | 合同管理 | +| 8 | 合同中止 | 中止原因、中止期限、预计恢复日期 | 合同状态 | +| 9 | 开具收入证明 | 用途、收入期间、接收方 | 文本模板渲染 | +| 10 | 合同终止 | 终止原因、终止日期、经济补偿 | 合同状态+离职 | +| 11 | 合同解除 | 解除原因、解除方式、协商/单方 | 已有 Termination 模块复用 | +| 12 | 开具离职证明 | 离职日期、离职原因、接收方 | 文本模板渲染 | +| 13 | 灵活用工 | 人员信息、用工类型、协议期限、计酬方式 | 合同管理(LABOR) | + +### 1.3 数据模型设计 + +```prisma +// 用工办理流程 +model WorkProcess { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + type String // HIRE/ONBOARD/CUSTOM_CONTRACT/INFO_SUBMIT/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/TERMINATE/RESCIND/LEAVING_CERT/FLEXIBLE + title String + employeeId String? + employee Employee? @relation(fields: [employeeId], references: [id]) + status String @default("DRAFT") // DRAFT/PENDING_APPROVAL/APPROVED/REJECTED/EXECUTING/COMPLETED/CANCELLED + formData Json // 表单数据 JSON + documents Json? // 生成的文书列表 [{name, content, type}] + approverId String? + approvedAt DateTime? + remark String? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) + @@index([orgId, type]) + @@index([orgId, createdBy]) +} +``` + +### 1.4 前端架构 + +**新增页面**:`frontend/src/pages/WorkProcess.tsx` + +``` +页面结构: +├── 发起办理(13类流程卡片选择) +├── 办理记录(列表+筛选:类型/状态/日期) +├── 办理草稿(仅 DRAFT 状态) +└── 流程详情(步骤条+表单+预览+操作) +``` + +**流程详情组件**(通用 Wizard): +- 步骤条:填写信息 → 预览文书 → 存草稿/提交 +- 表单根据 `type` 动态渲染字段 +- 预览:调用文本模板渲染接口生成文书 +- 提交后根据流程类型执行对应业务逻辑 + +**复用已有模块**: +- 合同解除(#11)→ 复用 `Termination.tsx` 的 Wizard 逻辑 +- 合同续签(#7)→ 复用 `Contracts.tsx` 的合同管理逻辑 +- 收入证明(#9)/ 离职证明(#12)→ 复用 `Templates.tsx` 的模板渲染 +- 员工录用(#1)→ 复用 `roster/modals.tsx` 的添加员工逻辑 + +### 1.5 后端接口 + +``` +POST /api/v1/work-processes # 创建办理(含草稿) +GET /api/v1/work-processes # 列表查询(支持type/status筛选) +GET /api/v1/work-processes/:id # 详情 +PATCH /api/v1/work-processes/:id # 更新草稿 +POST /api/v1/work-processes/:id/submit # 提交办理 +POST /api/v1/work-processes/:id/approve # 审批通过 +POST /api/v1/work-processes/:id/reject # 驳回 +POST /api/v1/work-processes/:id/cancel # 撤销 +DELETE /api/v1/work-processes/:id # 删除草稿 +GET /api/v1/work-processes/:id/preview # 预览生成的文书 +``` + +### 1.6 提交后业务联动 + +| 流程类型 | 提交后执行 | +|---------|-----------| +| HIRE | 创建员工记录 + 创建劳动合同 | +| ONBOARD | 更新员工状态为 ACTIVE + 记录入职日期 | +| CUSTOM_CONTRACT | 创建劳动合同 | +| INFO_SUBMIT | 更新员工档案字段 | +| CONFIRM | 更新试用期结束 + 调整薪资 | +| CHANGE | 更新合同字段 + 记录变更历史 | +| RENEW | 关闭旧合同 + 创建新合同 | +| SUSPEND | 合同状态改为 SUSPENDED | +| INCOME_CERT | 生成收入证明文书(不改变业务数据) | +| TERMINATE | 合同状态改为 TERMINATED + 触发离职流程 | +| RESCIND | 调用已有 Termination 逻辑 | +| LEAVING_CERT | 生成离职证明文书 | +| FLEXIBLE | 创建劳务协议合同 | + +### 1.7 实现优先级 + +1. **Phase 1**:数据模型 + 基础 CRUD + 列表/草稿页面 +2. **Phase 2**:员工录用、员工入职、合同续签、合同终止(4个高频流程) +3. **Phase 3**:剩余9类流程 + 文书预览生成 +4. **Phase 4**:审批流程 + 办理记录导出 + +--- + +## 二、企业自建文本库 🟡 + +### 2.1 目标 + +允许企业创建、管理和复用自己的文本模板(合同、通知、协议等),与系统预置模板库并存。 + +### 2.2 数据模型设计 + +```prisma +// 企业自建文本模板 +model EnterpriseTemplate { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + name String + category String // CONTRACT/RULES/NOTICE/AGREEMENT/OTHER + description String? + content String @db.Text + variables String[] // 变量名列表 + status String @default("ACTIVE") // ACTIVE/ARCHIVED + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, category]) +} +``` + +### 2.3 前端方案 + +**改造页面**:`frontend/src/pages/Templates.tsx` + +在现有模板库页面顶部新增 Tab 切换: +- **系统模板库**(现有功能不变) +- **企业文本库**(新增) + +企业文本库 Tab 内容: +``` +├── 查询栏(名称搜索 + 分类筛选) +├── 新建/编辑弹窗(名称、分类、变量配置、内容编辑) +├── 模板卡片列表(复用现有卡片样式) +├── 详情弹窗(变量填写 → 渲染 → 下载Word/复制) +└── 操作:编辑、复制、删除、归档 +``` + +### 2.4 后端接口 + +``` +# 现有模板路由:/api/v1/templates(系统预置,只读) +# 新增企业模板路由:/api/v1/enterprise-templates + +GET /api/v1/enterprise-templates # 列表(支持category/search) +POST /api/v1/enterprise-templates # 新建 +GET /api/v1/enterprise-templates/:id # 详情 +PUT /api/v1/enterprise-templates/:id # 更新 +DELETE /api/v1/enterprise-templates/:id # 删除 +POST /api/v1/enterprise-templates/:id/render # 渲染(复用现有 renderTemplate 逻辑) +GET /api/v1/enterprise-templates/:id/download # 下载Word +``` + +### 2.5 实现要点 + +- 变量提取:编辑内容时自动扫描 `{{变量名}}` 提取变量列表 +- 渲染逻辑:复用 `template.service.ts` 的 `renderTemplate` 函数 +- 内容编辑器:使用 textarea + 变量插入按钮(点击插入 `{{变量名}}`) +- 权限:ADMIN/HR 可增删改,VIEWER 只读 + +--- + +## 三、考勤/工资条发布流程 🟡 + +### 3.1 考勤发布 + +#### 目标 +HR 在考勤管理页面将月度考勤数据"发布"给员工,员工在员工端查看并确认。 + +#### 数据模型 + +```prisma +// 考勤发布记录 +model AttendancePublish { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + month String // 2026-07 + title String // 如"2026年7月考勤表" + status String @default("PUBLISHED") // PUBLISHED/CANCELLED + publishDate DateTime @default(now()) + createdBy String + createdAt DateTime @default(now()) + + @@unique([orgId, month]) + @@index([orgId, month]) +} +``` + +#### 前端改造:`Attendance.tsx` + +在「考勤确认」Tab 工具栏新增: +- **「发布考勤表」按钮**:弹窗确认 → 选择月份 → 发布 +- **「发布记录」入口**:查看历史发布记录,可取消发布 + +#### 后端接口 + +``` +POST /api/v1/attendance/publish # 发布考勤表 +GET /api/v1/attendance/publish-records # 发布记录列表 +POST /api/v1/attendance/publish/:id/cancel # 取消发布 +``` + +#### 员工端:`portal/` 新增考勤查看页面 + +``` +GET /api/v1/portal/attendance?month=2026-07 # 员工查看自己的月度考勤 +``` + +### 3.2 工资条发布 + +#### 目标 +HR 在薪税管理中将批次工资条"发布"给员工,员工在员工端查看并确认。支持定时发送。 + +#### 数据模型 + +复用已有 `Payslip` 模型,新增发布状态字段: + +```prisma +// 在 Payslip 模型新增字段 +model Payslip { + // ...已有字段 + publishStatus String? // UNPUBLISHED/PUBLISHED/SCHEDULED + publishedAt DateTime? + scheduledAt DateTime? // 定时发送时间 + confirmedAt DateTime? // 员工确认时间(已有) +} +``` + +#### 前端改造:`Money.tsx` + +在批次详情页新增: +- **「发布工资条」按钮**:将批次内所有工资条标记为 PUBLISHED +- **「定时发送」选项**:选择发送时间,到点自动发布 +- **「定时发送记录」入口**:查看定时发送列表,可取消 + +#### 后端接口 + +``` +POST /api/v1/payroll/batches/:batchId/publish # 发布工资条 +POST /api/v1/payroll/batches/:batchId/schedule # 定时发送 +GET /api/v1/payroll/schedule-records # 定时发送记录 +POST /api/v1/payroll/schedule/:id/cancel # 取消定时发送 +``` + +#### 员工端:已有 `portal/Payslip.tsx` + +- 发布前:员工端不显示该月工资条 +- 发布后:员工端显示工资条,可查看和确认 +- 定时发送:到点后状态从 SCHEDULED → PUBLISHED + +#### 定时发送实现 + +- 使用 `node-cron` 或现有定时任务机制 +- 每分钟检查 `scheduledAt <= now && publishStatus = SCHEDULED` 的记录 +- 自动更新为 PUBLISHED 并发送通知 + +--- + +## 四、AI文件审查上传功能 🔴 + +### 4.1 目标 + +在现有合同审查功能基础上,支持上传 doc/docx 文件,自动提取文本后进行 AI 审查。 + +### 4.2 前端改造:`AIAssistant.tsx` ReviewTab + +在现有粘贴文本输入框上方新增文件上传区域: + +``` +合同审查 Tab 改造: +├── 文件上传区(新增) +│ ├── 文书类型选择(劳动合同/协商解除/劳务协议/实习协议/保密协议) +│ ├── 拖拽或点击上传 .doc/.docx 文件 +│ ├── 文件大小限制:100MB +│ └── 上传后自动提取文本并填入下方输入框 +├── 文本输入框(现有) +├── 开始审查按钮(现有) +└── 审查结果展示(现有) +``` + +### 4.3 后端改造 + +#### 文件上传接口 + +``` +POST /api/v1/ai/review/upload + - multipart/form-data + - 接收 doc/docx 文件 + - 提取纯文本 + - 返回 { text: "提取的文本内容" } +``` + +#### 文本提取方案 + +使用 `mammoth` 库提取 .docx 文本: + +```typescript +import mammoth from 'mammoth' + +// .docx 文件提取 +const result = await mammoth.extractRawText({ buffer: req.file.buffer }) +const text = result.value + +// .doc 文件(旧格式) +// 方案1:使用 antiword 命令行工具 +// 方案2:提示用户转换为 .docx +// 推荐:仅支持 .docx,.doc 提示转换 +``` + +#### 完整审查流程 + +```typescript +// 1. 上传文件 → 提取文本 +POST /ai/review/upload → { text } + +// 2. 前端将文本填入输入框(用户可编辑) + +// 3. 点击审查 → 调用现有 /ai/review 接口 +POST /ai/review { contractText } → 审查结果 +``` + +### 4.4 依赖安装 + +```bash +cd backend && npm install mammoth +``` + +### 4.5 安全考虑 + +- 文件大小限制:100MB(`multer` limits) +- 文件类型校验:仅 `.doc` 和 `.docx` +- 提取后不保存原文件,仅返回文本 +- 文本长度截断:超过 50000 字符时截断并提示 + +--- + +## 五、实施计划 + +### 5.1 优先级排序 + +| 优先级 | 功能 | 预估工作量 | 建议时间 | +|--------|------|-----------|---------| +| 🔴 高 | AI文件审查上传 | 1-2天 | 立即 | +| 🔴 高 | 用工办理工作流 Phase 1-2 | 5-7天 | 本周 | +| 🟡 中 | 企业自建文本库 | 2-3天 | 下周 | +| 🟡 中 | 考勤发布 | 2天 | 下周 | +| 🟡 中 | 工资条发布 | 2-3天 | 下周 | +| 🔴 高 | 用工办理工作流 Phase 3-4 | 5-7天 | 第三周 | + +### 5.2 总计 + +- **总工作量**:约 17-24 个工作日 +- **建议分3周完成**: + - 第1周:AI文件审查 + 用工办理Phase 1-2 + - 第2周:企业文本库 + 考勤发布 + 工资条发布 + - 第3周:用工办理Phase 3-4 + 联调测试 + +### 5.3 数据库迁移 + +所有新增模型需要执行 `npx prisma db push` 同步到数据库。 + +--- + +## 六、风险与注意事项 + +1. **用工办理工作流**是最复杂的功能,建议先实现4个高频流程(录用/入职/续签/终止),验证架构后再扩展 +2. **企业文本库**的变量提取逻辑需与系统模板保持一致,复用 `renderTemplate` 函数 +3. **工资条发布**涉及薪资敏感数据,需确保只有发布后员工端才能看到 +4. **AI文件审查**的 .doc 旧格式支持有限,建议仅支持 .docx 并提示用户转换 +5. **定时发送**需要确保服务器进程持续运行(PM2 已有保障) diff --git a/docs/白话用工侠完整运行测试报告.md b/docs/白话用工侠完整运行测试报告.md new file mode 100644 index 0000000..10a7dca --- /dev/null +++ b/docs/白话用工侠完整运行测试报告.md @@ -0,0 +1,376 @@ +# 白话用工侠完整运行测试报告 + +## 1. 报告结论 + +- 测试时间:2026-07-30 08:56—09:12(Asia/Shanghai) +- 测试地址:[https://console.bhygx.com/ent_app_dashboard](https://console.bhygx.com/ent_app_dashboard) +- 测试企业:唐山菲斯克人力资源服务有限公司 +- 测试账号:`139****9106`(报告不记录密码) +- 测试方式:使用真实浏览器登录,逐项进入菜单、列表、表单、筛选器、计算器、风险待办和视频播放页面,执行可逆操作并核对页面状态。 +- 总体结论:核心菜单和主要业务入口可以正常使用;员工、合同、模板、风险待办等数据可以加载。当前最需要优先处理的是 3 条严重超期合同待办、待办统计口径不一致、医疗期法律依据时效、背景调查额度异常显示,以及频繁出现的前端网络/脚本错误。 + +本次没有执行任何会正式改变业务数据的操作,包括:发布通知、发送 AI 问题、提交判赔、上传文件、发起背调、保存草稿、办理合同续签/终止、删除合同、提交民主程序、提交诊断问卷、导出人员或下载合同。 + +## 2. 核心业务数据概览 + +### 2.1 工作台合规数据 + +| 指标 | 当前值 | +|---|---:| +| 合规健康度 | 63 / 100,中等风险 | +| 风险点 | 3 | +| 首页待处理 | 4 项 | +| 合同管理评分 | 90 | +| 规章制度评分 | 60 | +| 考勤工时评分 | 80 | +| 员工档案评分 | 52 | +| 社保公积金评分 | 55 | +| 本年度价值贡献 | ¥0 | +| 民主中 / 公示中 / 废弃制度 | 0 / 0 / 0 | +| 用工体检 | 尚未提交问卷 | + +### 2.2 人员与合同 + +| 项目 | 当前值 | +|---|---:| +| 花名册总人数 | 25 | +| 在职 | 21 | +| 正式员工 | 21 | +| 试用期 | 0 | +| 待入职 | 0 | +| 离职 | 4 | +| 其他人员 | 4 | +| 试用期即将到期 | 0 | +| 合同总数 | 70 | +| 首页合同超期待办 | 3 条 | + +3 条待办合同分别已经超期约 364、397、411 天。为保护个人信息,本报告不写入员工姓名、手机号和合同文件链接。 + +### 2.3 内容与业务记录 + +| 模块 | 当前数据 | +|---|---| +| 考勤管理 | 暂无发布记录 | +| 工资条管理 | 暂无发布记录 | +| 办理记录 | 暂无记录 | +| 办理草稿 | 暂无草稿 | +| 企业文本库 | 暂无企业自建文本 | +| 用工文本模板库 | 87 个模板 | +| 视频中心 | 20 个课程模块;列表第一页展示 12 个 | +| 通知管理 | 暂无通知记录 | +| 民主程序 | 暂无民主/公示记录 | + +## 3. 完整菜单与功能测试结果 + +### 3.1 登录与全局框架 + +| 检查项 | 结果 | 说明 | +|---|---|---| +| 账号密码登录 | 通过 | 能从登录页正常进入企业工作台 | +| 企业切换 | 通过 | 当前只显示 1 家企业,并提供“全部企业”入口 | +| 左侧菜单 | 通过 | 菜单展开、收起和路由跳转正常 | +| 顶部通知 | 可见 | 页面右上角有通知角标 | +| 返回工作台 | 通过 | 测试结束后已恢复到工作台 | + +### 3.2 工作台 + +已验证: + +- 合规健康度及 5 个评分细项正常显示。 +- 高优先级风险预警会轮播显示不同员工的合同到期提醒。 +- 点击“立即处理”后,会在当前页面打开待办中心。 +- 待办中心包含“待办 / 已办”“全部 / 超期未处理 / 今日到期”筛选。 +- 进入具体合同待办后,可打开“发送续签通知”处理弹窗。 +- 弹窗支持:发送续签通知、已线下续签、终止合同、自定义签署。 +- 弹窗展示合同名称、起始日期、到期状态和合同 PDF 查看链接。 +- 本次只打开并关闭弹窗,没有选择续签次数,也没有点击提交。 + +### 3.3 白小侠 AI + +#### 智能问答 + +- 页面正常加载,包含新对话、最近会话、转专家、附件上传和问题输入框。 +- 输入测试文字后,发送按钮会启用;清空文字后恢复禁用。 +- 未发送测试问题,避免生成外部消息或消耗服务额度。 + +#### 文件审查 + +- 页面正常加载,可选择文书类型。 +- 已验证类型选择和上传入口联动。 +- 当前可见类型包括:劳动合同、协商解除劳动合同协议、劳务协议、实习协议、保密协议。 +- 文件限制提示:`doc/docx`,100MB 以内。 +- 未上传真实文件,未创建审查任务。 + +#### 判赔预测器 + +- 页面正常加载,提供 12 类争议场景。 +- 可填写员工/工龄、事实、地区、月薪、制度公示和证据留痕等信息。 +- 页面自带演示数据,并明确提示结果仅供决策参考。 +- 未点击“预测仲裁结果”,避免调用正式预测或消耗额度。 + +### 3.4 员工管理 + +#### 花名册 + +- 共 25 人,第一页正常展示 10 人。 +- 姓名搜索可用,但输入后需要按 Enter 才会触发查询。 +- 使用已存在的姓名测试后,结果正确收敛为 1 条;清空并按 Enter 后恢复 25 条。 +- 支持部门筛选、添加人员、导出、人事管理、更多操作。 +- “添加人员”弹窗可正常打开,包含: + - 添加已入职员工; + - 添加待入职人员; + - 在线创建; + - 二维码邀请; + - 批量导入。 +- 本次未新增、导入或导出人员。 + +#### 考勤管理 + +- 页面、姓名搜索、日期范围和“发布考勤表”入口正常。 +- 当前暂无考勤发布记录。 +- 页面说明仅支持平台标准模板。 +- 未上传或发布考勤表。 + +#### 工资条管理 + +- 页面、姓名搜索、日期范围、“定时发送记录”和“发布工资条”入口正常。 +- 当前暂无工资条发布记录。 +- 页面说明支持定时发送,且发送前可以取消。 +- 未上传或发布工资条。 + +### 3.5 用工办理 + +“发起办理”页面可正常进入,当前提供 13 类流程: + +1. 员工录用 +2. 员工入职 +3. 自定义合同签署 +4. 员工信息提交 +5. 员工转正 +6. 合同变更 +7. 合同续签 +8. 合同中止 +9. 开具收入证明 +10. 合同终止 +11. 合同解除 +12. 开具离职证明 +13. 灵活用工 + +已实际打开“员工录用”表单,验证以下区域可以加载:人员选择、入职时间、公司地址、部门、岗位、直属上级、联系方式、试用期薪酬、转正薪酬、携带材料、预览、流程图、存草稿和发布按钮。 + +办理记录和办理草稿页面的查询、重置和列表结构正常,目前均无记录。本次未保存草稿、未预览生成文书、未发布录用通知。 + +### 3.6 合同管理 + +- 合同列表共 70 条,分页正常,第一页展示 10 条。 +- 支持合同类型、合同状态、创建日期查询及重置。 +- 列表支持查看、下载和删除。 +- 同时存在履约中、已到期和无固定期限合同。 +- 本次未查看合同正文、未下载、未删除。 + +### 3.7 用工风控 + +#### 背景调查 + +- 工作台显示背景调查入口、人员选择框、免责声明和“发起背调”按钮。 +- 当前页面显示“剩余:-1次”,但按钮仍处于可点击状态。 +- 未勾选免责声明、未选择人员、未发起调查。 + +#### 规章制度民主程序 + +- 页面说明、人员范围、文件选择、标题、截止日期、说明、发布按钮均正常。 +- 说明框包含系统预置的 148 字通知模板。 +- 当前无民主记录和公示记录。 +- 未选择文件、未发布民主程序。 + +#### 用工体检诊断 + +- 页面可正常进入,列出劳动合同、规章制度、社保、背景调查、工时加班、竞业限制等诊断维度。 +- 点击“开始诊断”会离开平台,跳转到腾讯问卷 `wj.qq.com`。 +- 外部问卷能够打开,首题正常显示。 +- 外部 URL 携带 `openid` 查询参数;本报告不记录其具体值。 +- 未回答或提交问卷,随后返回平台。 + +### 3.8 知识中心 + +#### 企业文本库 + +- 查询、重置、新建入口正常。 +- 使用不存在的名称查询后正确显示空结果,重置后输入框清空。 +- 当前没有企业自建文本。 +- 未新建或上传文本。 + +#### 用工文本模板库 + +- 共 87 个模板,包含劳动关系、社保公积金、并购、跨境外派等类别。 +- 查询和重置入口正常。 +- 未下载或使用模板发起流程。 + +#### 视频中心 + +- 课程搜索、课程类型筛选、分页和课程卡片正常。 +- 使用不存在的课程名搜索后结果清空;清除关键词并再次搜索后恢复 12 个课程卡片。 +- 第一页有 12 个课程,详情页目录总计 20 个模块。 +- 已打开第一门课程,视频播放器能够加载并开始播放;随后已暂停。 +- 未持续播放、未切换全屏或画中画。 + +### 3.9 通知管理 + +- 通知标题搜索、发布通知和列表结构正常。 +- 当前暂无通知记录。 +- 发布表单可以正常打开,包含标题、正文、目标人群和定时发布。 +- 目标人群显示“全体人员(21人)”,与花名册在职人数一致。 +- 本次点击取消退出,没有发布通知。 + +### 3.10 常用计算器 + +#### 五险一金计算器 + +已实际运行一次无副作用计算: + +- 城市:唐山市 +- 社保基数:3920.55 +- 公积金基数:2200 +- 公积金比例:单位 5% + 个人 5% +- 个人缴纳:570.16 +- 企业缴纳:1388.34 +- 合计:1958.50 + +计算明细能够正常展开,养老、医疗、大病、失业、工伤、长期护理和公积金项目均有结果。 + +#### 医疗期计算器 + +- 上海 / 全国(非上海)模式切换正常。 +- 上海模式包含入职日期、计算截止日期和累计病休天数。 +- 全国模式包含入职日期、累计工龄满十年日期、连续/非连续病休、病休起止日期。 +- 未填写真实员工日期,未生成正式计算结果。 + +## 4. 发现的问题与风险 + +### P0:业务合规风险——3 条合同严重超期 + +待办中心有 3 条合同到期任务,分别超期约 364、397、411 天。系统本身已经将其标记为高优先级风险,但仍长期未处理。 + +建议:立即由 HR/法务逐人核实实际劳动关系、线下续签材料、是否已离职、是否应补签或归档。尤其要确认是否已经形成无固定期限劳动合同、事实劳动关系或违法终止风险。 + +### P1:已离职人员仍出现在续签待办中 + +3 条合同到期待办中,至少有 1 人在花名册里的员工状态为“离职”,但仍持续出现在“发送续签通知”办理入口。 + +可能原因:离职流程未闭环、合同终止状态未同步、历史待办未归档,或风控规则没有排除离职人员。 + +建议:核对员工状态、合同状态和待办状态的同步规则;离职完成后应关闭不再适用的续签待办,或明确标记为“历史遗留待核验”。 + +### P1:首页待处理数量与待办列表不一致 + +- 首页显示:4 项待处理。 +- 点击“立即处理”后的“全部”待办列表:3 条记录,分页只有 1 页。 + +建议:确认首页是否混合统计了其他类型任务;如果是,应在待办中心展示分类和总数。如果不是,则修复统计缓存或任务状态同步。 + +### P1:医疗期计算器展示的上海法规有效期已经过期 + +页面仍显示相关规定“效期延长至 2025年6月30日”,而测试日期为 2026-07-30。 + +风险:医疗期属于高敏感劳动合规事项,过期的有效期说明会削弱计算结果可信度,并可能导致错误的人事决定。 + +建议:立即核对法规是否再次延长、被替代或废止;更新法律依据、有效期和最后校验日期,并在计算结果旁显示政策版本。 + +### P1:背景调查额度显示为负数 + +页面显示“剩余:-1次”,同时“发起背调”按钮仍可点击。 + +可能原因:后端使用 `-1` 表示不限次数,但前端直接展示了内部哨兵值;也可能是额度扣减异常。 + +建议:如果 `-1` 表示不限次数,前端应显示“无限次”;如果不是,应禁止继续发起并修复额度计算。 + +### P2:员工风险分布统计口径不清晰 + +花名册总人数为 25,但初始风险分布显示“高风险 0、中风险 0、健康 10”,三项合计只有 10,恰好等于当前页条数。按姓名筛选到 1 人后,“健康”也变为 1。 + +建议:明确该统计是“当前页”“当前筛选结果”还是“全体员工”。如果是当前页,应在标题中明确说明;更推荐默认展示全量/筛选结果总计,而不是只统计分页可见数据。 + +### P2:无固定期限合同在花名册中显示为超长剩余天数 + +合同管理中,部分记录明确标记为“无固定期限合同”,到期日使用 `2099-12-31`。花名册则显示为“剩余约 2.68 万天”。 + +建议:花名册应识别无固定期限合同,直接显示“无固定期限”,不要展示由占位日期计算出的剩余天数,以免误导业务人员和风险评分。 + +### P2:前端错误日志频繁 + +本次走查期间捕获到站点脚本产生的错误: + +| 错误 | 次数 | +|---|---:| +| `Axios Network Error` | 39 | +| `Cannot read properties of undefined (reading 'clientHeight')` | 6 | +| 视频模块 `Cannot read properties of null (reading 'play')` | 1 | + +多数页面最终仍能显示,但这些错误可能造成偶发空白、数据加载延迟、视频自动播放失败或隐性接口失败。 + +建议:在浏览器 Network 面板和服务端日志中定位具体失败接口、HTTP 状态、CORS、超时和重试情况;为异步页面增加加载骨架、错误提示和重试按钮;修复空 DOM 引用。 + +### P2:用工体检向第三方页面传递标识参数 + +“开始诊断”会整页跳转到腾讯问卷,并在 URL 中携带 `openid` 参数。 + +建议:由产品、法务和安全团队确认该参数是否属于个人标识、是否已在隐私政策中说明、腾讯问卷的数据处理角色和留存周期;建议使用短期一次性令牌,避免直接暴露稳定标识,并考虑新标签页打开或增加返回平台入口。 + +### P3:部分页面首次进入存在短暂空白/延迟加载 + +员工花名册、合同管理、模板库、通知管理等页面在进入后的约 0.7 秒内可能只显示框架,等待约 1.5—1.8 秒后数据才完整出现。 + +建议:增加统一加载状态或骨架屏,避免用户误以为“暂无数据”;同时结合上述网络错误排查性能瓶颈。 + +## 5. 建议的处理优先级 + +### 立即处理(当天) + +1. 核实并处理 3 条超期合同。 +2. 核实已离职人员仍出现续签待办的原因。 +3. 对比首页 4 项与待办中心 3 项的统计明细。 +4. 确认医疗期计算器法律依据是否仍有效。 + +### 一周内处理 + +1. 修复背景调查 `-1次` 的展示或额度逻辑。 +2. 明确员工风险分布的统计口径。 +3. 将无固定期限合同显示为语义化状态,而不是 2099 年和数万天。 +4. 排查 Axios 网络错误和 `clientHeight` 空引用。 +5. 复核腾讯问卷 `openid` 参数的隐私与安全合规。 + +### 持续优化 + +1. 为延迟加载页面增加骨架屏和失败重试。 +2. 为关键统计增加“更新时间”和数据来源说明。 +3. 对合同、人员、待办三类状态建立每日自动一致性校验。 +4. 对法律计算器增加政策版本、适用地区、最后审核日期和免责声明。 + +## 6. 建议的回归测试用例 + +1. 离职员工完成离职后,续签待办是否自动关闭。 +2. 首页待办总数是否等于各分类待办之和。 +3. 背调额度为 0、正数、无限次时,文案和按钮状态是否正确。 +4. 无固定期限合同在合同列表和花名册中的显示是否一致。 +5. 花名册风险分布在分页、搜索、部门筛选前后是否符合定义。 +6. 医疗期政策更新后,上海和全国模式的计算结果是否经过法务复核。 +7. 慢网、接口失败、接口超时下是否显示加载/错误/重试状态。 +8. 视频详情页首次进入、自动播放被浏览器拦截、切换课程时是否仍有 `play` 空引用。 +9. 外部诊断问卷链接是否使用短期令牌,且返回平台流程正常。 + +## 7. 测试边界 + +以下项目因可能产生正式业务影响,本次只验证到入口或表单,不执行最终动作: + +- AI 问题发送、转专家、判赔预测; +- 文件审查上传; +- 添加/导入/导出员工; +- 发布考勤和工资条; +- 保存或发布各类用工办理流程; +- 合同下载、删除、续签、终止和自定义签署; +- 发起背景调查; +- 发布民主程序和通知; +- 提交腾讯诊断问卷; +- 使用模板正式发起流程。 + +如需继续做“可产生真实数据”的验收,建议先创建专用测试企业和测试员工,再按上述回归用例执行提交、短信、小程序待办、电子签署和撤销闭环测试。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2b02f77..909500e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,8 @@ const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCa const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) const CalendarPage = lazy(() => import('./pages/Calendar')) +const WorkProcess = lazy(() => import('./pages/WorkProcess')) +const MyAttendance = lazy(() => import('./pages/portal/MyAttendance')) // 平台管理端 const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin')) @@ -152,6 +154,7 @@ export default function App() { } /> } /> } /> + } /> {/* 平台管理端 */} }>} /> @@ -166,6 +169,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* 兜底 */} diff --git a/frontend/src/components/layout/PortalLayout.tsx b/frontend/src/components/layout/PortalLayout.tsx index ab917a6..a776e0b 100644 --- a/frontend/src/components/layout/PortalLayout.tsx +++ b/frontend/src/components/layout/PortalLayout.tsx @@ -4,12 +4,13 @@ */ import { Link, useLocation, useNavigate } from 'react-router-dom' -import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react' +import { DollarSign, FileText, ScrollText, LogOut, CalendarCheck } from 'lucide-react' import Logo from '../../components/ui/Logo' const tabItems = [ { path: '/portal/payslip', label: '工资条', icon: DollarSign }, { path: '/portal/contract', label: '我的合同', icon: FileText }, + { path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck }, { path: '/portal/policies', label: '规章制度', icon: ScrollText }, ] diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index fbb023e..7339703 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -13,7 +13,7 @@ import { Bot, BookMarked, Bell, ScrollText, Settings, ChevronDown, ChevronRight, - Building2, CalendarDays, + Building2, CalendarDays, ClipboardList, } from 'lucide-react' import Logo from '../ui/Logo' @@ -40,6 +40,7 @@ const navGroups: NavGroup[] = [ title: '员工管理', items: [ { path: '/roster', label: '花名册', icon: Users }, + { path: '/work-process', label: '用工办理', icon: ClipboardList }, { path: '/attendance', label: '考勤确认', icon: CalendarCheck }, { path: '/termination', label: '解聘补偿', icon: UserX }, ], diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 7b1129e..841a094 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1329,15 +1329,60 @@ function PredictTab() { ) } +const REVIEW_DOC_TYPES = [ + { value: 'labor_contract', label: '劳动合同' }, + { value: 'rescission', label: '协商解除协议' }, + { value: 'labor_service', label: '劳务协议' }, + { value: 'internship', label: '实习协议' }, + { value: 'nda', label: '保密协议' }, + { value: 'other', label: '其他' }, +] + function ReviewTab() { const [contractText, setContractText] = useState('') const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) + const [docType, setDocType] = useState('labor_contract') + const [fileName, setFileName] = useState('') const [showSaveModal, setShowSaveModal] = useState(false) const [saveEmployeeId, setSaveEmployeeId] = useState('') const [showHistory, setShowHistory] = useState(false) + const fileInputRef = useRef(null) const { history, saveMutation, deleteMutation, loadHistory } = useAIHistory('review') + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + const ext = file.name.toLowerCase().split('.').pop() + if (ext !== 'docx' && ext !== 'doc') { + toast.error('仅支持 .docx 格式文件') + return + } + if (file.size > 100 * 1024 * 1024) { + toast.error('文件大小不能超过 100MB') + return + } + setUploading(true) + try { + const formData = new FormData() + formData.append('file', file) + const res = await api.post('/ai/review/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) as any + if (res.data?.text) { + setContractText(res.data.text) + setFileName(file.name) + toast.success(`已提取文件内容(${res.data.text.length} 字)`) + } + } catch (err: any) { + toast.error(err?.response?.data?.error?.message || '文件上传失败') + } finally { + setUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + const { data: employees } = useQuery({ queryKey: ['roster-list'], queryFn: async () => { @@ -1410,8 +1455,23 @@ function ReviewTab() { deleteMutation.mutate(id)} /> )} -
- +
+ {/* 文件上传区 */} +
+ +
+ + + + {fileName && {fileName}} +
+
+ +