diff --git a/20260726优化方案.md b/20260726优化方案.md new file mode 100644 index 0000000..6ef6a04 --- /dev/null +++ b/20260726优化方案.md @@ -0,0 +1,913 @@ +# TurboHR 20260726 优化方案 + +> 制定日期:2026-07-26 +> 核心原则:**从企业 HR 真实痛点出发,不做功能堆砌** +> 每一项优化必须回答三个问题:企业为什么需要?解决了什么痛点?带来什么价值? + +--- + +## 一、企业 HR 的 6 个核心痛点 + +### 痛点 1:怕被告 — 用工合规风险不可见 + +HR 最大的恐惧是劳动仲裁。但现在的系统只是"事后发现"——合同过期了才提醒,试用期超了才知道。企业需要的是**提前预警 + 量化风险**。 + +| 现状 | 企业需要 | +|------|---------| +| 合同到期才提醒 | 提前 60/30/15 天分级预警,附带续签/终止决策建议 | +| 风险只有高/中/低 | 每个风险附带预估损失金额(律师费 + 赔偿金 + 时间成本) | +| 风险列表平铺 | 按紧急程度 + 金额排序,一眼看到"今天必须处理什么" | +| 无风险趋势 | 本月风险数 vs 上月,是在变好还是变差? | + +### 痛点 2:怕算错 — 薪税社保复杂易错 + +社保基数每年变、个税累计预扣、加班费三种倍率、经济补偿金 N/N+1/2N……算错一分钱都可能是投诉或处罚的导火索。 + +| 现状 | 企业需要 | +|------|---------| +| 工资批次已实现 | 增加算薪前 AI 校验:基数异常 / 累计税跳档 / 加班超限 | +| 社保配置已实现 | 增加跨年度基数对比 + 自动检测基数是否在上下限范围内 | +| 个税累计预扣已实现 | 增加年终汇算提醒 + 全年税负模拟 | + +### 痛点 3:怕丢证据 — 仲裁时拿不出材料 + +劳动仲裁的核心是举证。企业败诉往往不是因为真的错了,而是**拿不出证据**。 + +| 现状 | 企业需要 | +|------|---------| +| 合同有签署记录 | 需要完整的签署证据链:时间 / IP / 设备 / 短信验证码 | +| 入职有链接 | 需要员工填报内容的留痕 + 时间戳 | +| 工资条有确认 | 需要员工确认记录作为已足额发放的证据 | +| 考勤无留痕 | 需要考勤确认记录(员工签字确认加班费/工时) | +| 违纪无证据 | 需要违纪记录附件管理(警告信签收 / 邮件记录 / 聊天截图) | + +### 痛点 4:怕遗漏 — 离职流程漏步骤 + +解聘员工是高风险操作,漏一个步骤就可能从合法解除变成违法解除(2N 赔偿)。 + +| 现状 | 企业需要 | +|------|---------| +| 解聘 6 步向导已实现 | ✅ 已经很好,但缺少"步骤前置条件校验" | +| 合规清单已实现 | ✅ 需要增加"跳过此步的风险提示"——比如跳过"通知工会"步骤,提示违法风险 | +| 风险评估已实现 | ✅ 需要增加"三期/工伤/医疗期"的强制拦截——不是提示,是阻止 | + +### 痛点 5:怕低效 — 重复性工作太多 + +HR 每月要做的事:算工资、缴社保、发工资条、查合同到期、做考勤、报个税……大量重复手工操作。 + +| 现状 | 企业需要 | +|------|---------| +| 工资批次已实现 | 增加"一键复制上月" + 差异对比 | +| 社保月度处理已实现 | 增加参保/停保自动提醒(入职 30 天 / 离职当月) | +| 合同到期提醒已实现 | 增加批量续签 + 模板自动填充 | +| 无月度任务看板 | 增加"本月 HR 日历"——哪天发工资、哪天缴社保、哪天报税一目了然 | + +### 痛点 6:怕花冤枉钱 — 人力成本不透明 + +老板问"我们人力成本是多少",HR 要翻多个表格才能回答。更难的是"为什么比去年高"。 + +| 现状 | 企业需要 | +|------|---------| +| 月度薪酬汇总已实现 | 增加同比/环比对比 + 成本归因分析(加班多了?社保涨了?新人多了?) | +| 年度成本汇总已实现 | 增加人力成本占营收比(需手动输入营收) + 人均成本趋势 | +| 无离职成本统计 | 增加离职成本统计(补偿金 + 招聘成本 + 培训成本) | +| 无预算管理 | 增加部门人力预算 vs 实际对比 | + +--- + +## 二、优化清单(按企业价值排序) + +### T1 — 高价值 / 高紧迫(立即做) + +#### 2.1 风险量化与优先级排序 + +**企业痛点**:风险列表是平铺的,HR 不知道先处理哪个,也不知道不处理会损失多少钱 +**价值**:让 HR 一眼看到"今天必须处理什么" + "不处理最多损失多少钱" + +**实现方案**: + +后端 `risk.service.ts` 扩展: + +```typescript +// 为每个 RiskItem 增加预估损失金额和截止日期 +interface RiskWithCost { + // ... 现有字段 + estimatedLoss: number // 预估损失金额(元) + lossRange: [number, number] // 损失区间 [最低, 最高] + deadline: Date // 建议处理截止日期 + daysUntilDeadline: number // 距截止日期天数 + priority: 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' +} + +// 风险金额估算逻辑: +// - 未签合同(>30天):双倍工资 × 月数 +// - 合同到期未续签:经济补偿 N × 月薪 +// - 违法解除风险:2N × 月薪 +// - 试用期超限:试用期工资差额 + 可能的赔偿 +// - 社保未缴:补缴 + 滞纳金(每日 0.05%) +``` + +前端 Dashboard 改造: +- 顶部:**紧急风险横幅**(最高优先级 1 条 + 预估损失 + 处理按钮) +- 待办列表:按 `priority` 排序,每条显示预估损失金额 + 倒计时 +- 底部:本月风险趋势(较上月 +N / -N) + +**改动范围**: +- `backend/src/services/risk.service.ts` — 增加金额估算函数 +- `backend/src/routes/dashboard.routes.ts` — 返回字段扩展 +- `frontend/src/pages/Dashboard.tsx` — 紧急横幅 + 列表排序 + +--- + +#### 2.2 合同到期决策助手 + +**企业痛点**:合同到期了,HR 纠结是续签、不续签还是改条件续签,每个选择的法律后果和成本不同 +**价值**:AI 基于员工真实数据,给出三个选项的成本对比 + +**实现方案**: + +新增 API `/api/v1/contracts/:id/expiry-decision` + +```typescript +// 输入:合同 ID +// AI 分析员工数据(工龄、薪资、绩效、违纪、特殊状态) +// 输出三个选项: +interface ExpiryDecision { + employee: { name, workYears, salary, performance, specialStatus } + options: [ + { + action: '续签' + cost: 0 // 无直接成本 + risk: '低' + legalNote: '连续两次固定期限后续签需签无固定期限合同' + recommendation: '建议续签,员工绩效良好' + }, + { + action: '到期不续签' + cost: number // 经济补偿 N × 月薪 + risk: '中' + legalNote: '需提前30天通知,需支付经济补偿金' + recommendation: '需支付补偿金 ¥XX,XXX' + }, + { + action: '变更条件续签' + cost: 0 + risk: '高' + legalNote: '降低条件续签需协商一致,员工不同意则按到期不续签处理' + recommendation: '风险较高,建议先协商' + } + ] +} +``` + +前端:合同管理 Tab 中,到期合同增加 **[决策助手]** 按钮,点击弹出三选项对比卡片。 + +**改动范围**: +- `backend/src/services/contract.service.ts` — 新增 `getExpiryDecision()` +- `backend/src/routes/roster.routes.ts` — 新增端点 +- `frontend/src/pages/Roster.tsx` — 合同 Tab 增加决策弹窗 + +--- + +#### 2.3 证据链管理 + +**企业痛点**:仲裁时拿不出证据,因为关键操作没有留痕或留痕不完整 +**价值**:每个高风险操作自动生成完整证据链,仲裁时一键导出 + +**实现方案**: + +数据模型扩展: + +```prisma +model EvidenceChain { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + category String // CONTRACT_SIGN / ONBOARD / PAYSLIP_CONFIRM / DISCIPLINARY / ATTENDANCE / TERMINATION + refId String? // 关联记录 ID(合同ID/工资条ID等) + employeeId String? + employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull) + events Json // [{ action, timestamp, ip, userAgent, smsCode?, location? }] + hash String // 全链路 SHA-256 哈希(防篡改) + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, category, refId]) + @@index([orgId, employeeId]) +} +``` + +需要留痕的操作: + +| 操作 | 证据内容 | 现有基础 | +|------|---------|---------| +| 合同签署 | 签署时间 / IP / 短信验证码 / 设备 | ✅ 有 `ContractConfirmLink`,需补充 IP 和设备 | +| 入职填报 | 员工提交时间 / IP / 填报内容快照 | ✅ 有入职链接,需补时间戳和快照 | +| 工资条确认 | 确认时间 / IP / 工资金额 | ✅ 有 `confirmedAt`,需补 IP | +| 违纪处理 | 警告信签收记录 / 附件 | ⚠️ 有违纪记录,需补附件和签收 | +| 考勤确认 | 员工确认时间 / 加班费确认 | ❌ 需新增考勤确认功能 | +| 解聘流程 | 6 步每步操作记录 / 通知工会证据 | ✅ 有解聘记录,需补步骤级时间戳 | + +新增 API: + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/v1/evidence/:category/:refId` | GET | 获取某操作的完整证据链 | +| `/api/v1/evidence/employee/:id` | GET | 获取某员工所有证据链 | +| `/api/v1/evidence/:id/export` | POST | 导出证据链(PDF,含哈希校验) | + +**改动范围**: +- `backend/prisma/schema.prisma` — 新增 `EvidenceChain` 模型 +- `backend/src/services/evidence.service.ts` — 新增证据链服务 +- `backend/src/routes/evidence.routes.ts` — 新增路由 +- `backend/src/routes/portal.routes.ts` — 合同签署/工资条确认时补充 IP 和设备信息 +- `frontend/src/pages/Roster.tsx` — 员工详情增加"证据链"Tab + +--- + +#### 2.4 解聘流程强化:前置校验 + 强制拦截 + +**企业痛点**:解聘流程中漏步骤导致违法解除,从合法变 2N 赔偿 +**价值**:在流程中设置"硬性卡点",不满足条件不允许继续 + +**实现方案**: + +`termination.service.ts` 扩展: + +```typescript +// 每个步骤增加前置条件校验 +interface StepValidation { + step: number + canProceed: boolean + blockReason?: string // 不满足条件时阻止继续 + warning?: string // 可跳过但有风险时提示 +} + +// 强制拦截规则: +// - 三期女职工 + 非过错解除 → 阻止(法律禁止) +// - 工伤期间 + 非过错解除 → 阻止 +// - 医疗期内 + 非过错解除 → 阻止 +// - 未通知工会 → 警告(可跳过但提示违法风险) +// - 经济补偿金未计算 → 阻止(必须先算钱再走流程) +// - 合同已到期 + 选择"解除"而非"终止" → 警告(建议用"到期终止"更简单) +``` + +前端 `Termination.tsx` 改造: +- 步骤间增加**前置校验门** +- 拦截时显示红色阻断卡片 + 法律依据 + 替代方案建议 +- 警告时显示黄色提示 + "我已知晓风险,继续" 按钮 + +**改动范围**: +- `backend/src/services/termination.service.ts` — 增加 `validateStep()` 函数 +- `backend/src/routes/termination.routes.ts` — 增加 `GET /:id/validate-step?step=N` +- `frontend/src/pages/Termination.tsx` — 步骤间增加校验门 UI + +--- + +#### 2.5 算薪前 AI 校验 + +**企业痛点**:工资算错了才发现,需要重新核算、解释、补发,HR 被骂、老板不信任 +**价值**:算薪前自动检查 10+ 项异常,把错误拦截在发放前 + +**实现方案**: + +新增 `payroll.service.ts` 中的 `prePayrollCheck()`: + +```typescript +export async function prePayrollCheck(orgId: string, month: string): Promise { + const checks: CheckItem[] = [ + // 1. 社保基数是否在上下限范围内 + // 2. 公积金基数是否在上下限范围内 + // 3. 加班时数是否超过法定上限(月 36 小时) + // 4. 加班费计算是否正确(工作日 1.5 / 休息日 2.0 / 法定节假日 3.0) + // 5. 个税累计预扣是否跳档(本月税率突然升高) + // 6. 试用期工资是否低于合同工资 80% + // 7. 离职员工是否多算了一个月 + // 8. 新入职员工是否按实际入职日折算 + // 9. 社保是否在入职 30 天内参保 + // 10. 离职当月社保是否已停保 + ] + return { checks, passedCount, failedCount, warningCount } +} +``` + +前端 `Money.tsx` 工资批次页面: +- 批次归档前增加 **[AI 校验]** 按钮 +- 校验结果:绿色 ✅ 通过项 / 红色 ❌ 异常项 / 黄色 ⚠️ 警告项 +- 异常项可直接点击跳转到对应员工修改 + +**改动范围**: +- `backend/src/services/payroll.service.ts` — 新增 `prePayrollCheck()` +- `backend/src/routes/payroll2.routes.ts` — 新增 `POST /batch/:id/pre-check` +- `frontend/src/pages/Money.tsx` — 归档前增加校验步骤 + +--- + +### T2 — 高价值 / 中紧迫(近期做) + +#### 2.6 HR 月度日历 + +**企业痛点**:HR 每月要做的事散落在各处,经常忘记某个截止日期 +**价值**:一个月历视图,把所有截止日期可视化 + +**实现方案**: + +后端新增 `/api/v1/dashboard/calendar?month=YYYY-MM` + +```typescript +// 聚合当月所有关键日期: +// - 工资发放日(从 NotificationSetting.payrollDay) +// - 社保缴纳日(socialInsDay) +// - 公积金缴纳日(housingFundDay) +// - 个税申报日(taxDay) +// - 合同到期日(从 LaborContract.endDate 查当月到期的) +// - 试用期到期日(从 LaborContract 计算试用期结束日) +// - 员工退休日(从 Employee.birthDate + retirement.service.ts 计算) +// - 待办截止日(从 RiskItem 中有 deadline 的) +``` + +前端 Dashboard 新增日历组件: +- 月历视图,每天显示当日事项数量和颜色点 +- 点击某天展开当日事项列表 +- 每个事项可点击跳转到对应功能页 + +**改动范围**: +- `backend/src/routes/dashboard.routes.ts` — 新增日历端点 +- `frontend/src/pages/Dashboard.tsx` — 新增月历组件 + +--- + +#### 2.7 人力成本深度分析 + +**企业痛点**:老板问"人力成本为什么比去年高",HR 答不上来 +**价值**:成本同比/环比 + 归因分析 + +**实现方案**: + +后端新增 `/api/v1/dashboard/cost-analysis` + +```typescript +interface CostAnalysis { + currentMonth: { + totalCost: number // 企业总支出 + salary: number // 工资 + socialOrg: number // 企业社保 + housingOrg: number // 企业公积金 + overtime: number // 加班费 + severance: number // 补偿金 + } + lastMonth: same structure + lastYearSameMonth: same structure + // 归因分析 + changes: { + salary: { amount, reason: '新增员工N人' | '涨薪' | '离职' } + socialOrg: { amount, reason: '基数调整' | '人数变化' } + overtime: { amount, reason: '加班增加' | '加班减少' } + } + headcount: { current, lastMonth, lastYear } + perCapitaCost: { current, lastMonth, lastYear } +} +``` + +前端 Dashboard 成本区域改造: +- 月度成本卡 → 增加同比/环比箭头 + 变化金额 +- 新增成本归因列表("工资增加 ¥XX,XXX,原因:新增 3 人") +- 新增人均成本趋势 + +**改动范围**: +- `backend/src/services/risk.service.ts` — `getDashboardData()` 扩展成本分析 +- `frontend/src/pages/Dashboard.tsx` — 成本区域增强 + +--- + +#### 2.8 考勤确认功能 + +**企业痛点**:加班费争议是仲裁高发区,企业没有员工确认考勤的证据 +**价值**:员工在移动端确认考勤和加班费,形成证据 + +**实现方案**: + +数据模型: + +```prisma +model AttendanceConfirmation { + 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 + workDays Int + overtimeHours { weekdayHours: Int, weekendHours: Int, holidayHours: Int } + overtimePay Float + confirmedAt DateTime? + confirmIp String? + status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED + disputeNote String? // 员工有异议时的说明 + createdAt DateTime @default(now()) + + @@unique([orgId, employeeId, month]) +} +``` + +流程: +1. HR 在后台生成月度考勤确认单(从 `AttendanceRecord` 汇总) +2. 员工在 portal 端看到待确认考勤 +3. 员工确认或提出异议 +4. 确认后生成 `EvidenceChain` 记录 + +**改动范围**: +- `backend/prisma/schema.prisma` — 新增模型 +- `backend/src/routes/portal.routes.ts` — 新增考勤确认端点 +- `backend/src/routes/roster.routes.ts` — 新增生成确认单端点 +- `frontend/src/pages/Roster.tsx` — 考勤 Tab 增加确认状态 +- 员工端 portal — 新增考勤确认页面 + +--- + +#### 2.9 违纪记录附件管理 + +**企业痛点**:记了违纪但没留证据,仲裁时员工不认 +**价值**:每条违纪记录可上传附件(警告信签收 / 邮件截图 / 聊天记录) + +**实现方案**: + +利用现有 `EmployeeAttachment` 模型,增加 `category: 'DISCIPLINARY'` 类型,关联到具体违纪记录。 + +前端 Roster 详情 → 违纪记录 Tab: +- 每条违纪记录增加 **[上传附件]** 按钮 +- 附件类型:警告信签收件 / 邮件记录 / 聊天截图 / 视频 / 其他 +- 附件自动纳入证据链 + +**改动范围**: +- `backend/src/routes/attachment.routes.ts` — 增加 `disciplinaryId` 关联 +- `frontend/src/pages/Roster.tsx` — 违纪 Tab 增加附件上传 + +--- + +#### 2.10 合同到期批量处理 + +**企业痛点**:月底一次性 10+ 合同到期,逐个续签效率低 +**价值**:批量选择 + 模板填充 + 一键发起 + +**实现方案**: + +前端合同管理 Tab: +- 筛选"30 天内到期"合同 → 勾选多个 → **[批量续签]** 按钮 +- 弹窗:统一设置续签条件(期限 / 试用期 / 薪资调整) +- 生成多条合同确认链接,批量发送 + +**改动范围**: +- `backend/src/routes/roster.routes.ts` — 新增 `POST /contracts/batch-renew` +- `frontend/src/pages/Roster.tsx` — 合同 Tab 增加批量操作 + +--- + +### T3 — 中价值 / 中紧迫(排期做) + +#### 2.11 规章制度民主程序管理 + +**企业痛点**:规章制度未经民主程序,仲裁时被认定无效 +**价值**:管理制度的民主程序全流程留痕 + +**企业是否真需要?** ✅ 需要。这是仲裁中"制度是否有效"的核心证据。但使用频率低(一年几次),所以做轻量版。 + +**实现方案**: + +数据模型: + +```prisma +model PolicyDocument { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + title String + content String + type String // RULES / NOTICE + status String @default("DRAFT") // DRAFT / DISCUSSING / CONSULTING / PUBLISHED + democracyProgress Json? // { step: 1-4, steps: [{name, status, date, note}] } + publishedAt DateTime? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([orgId, status]) +} +``` + +前端轻量页面(不单独路由,作为合规模块的子功能): +- 制度列表 + 4 步进度条(起草 → 职工讨论 → 平等协商 → 公示) +- 每步可上传附件(会议纪要 / 签到表 / 公示截图) +- 完成后自动纳入证据链 + +**改动范围**: +- `backend/prisma/schema.prisma` — 新增 `PolicyDocument` +- `backend/src/services/policy.service.ts` — 新增服务 +- `backend/src/routes/policy.routes.ts` — 新增路由 +- `frontend/src/pages/Settings.tsx` — 新增"规章制度"section + +--- + +#### 2.12 用工文本模板库 + +**企业痛点**:HR 不是法律专业,写合同/制度/通知容易出错 +**价值**:提供法律审核过的标准模板,减少起草风险 + +**企业是否真需要?** ✅ 需要。但不需要做成独立页面,集成到合同管理和制度管理中即可。 + +**实现方案**: + +数据库预置模板(不需要单独模型,用 JSON 配置文件管理): + +```typescript +// backend/src/data/templates.ts +export const TEMPLATES = [ + { id: 'labor_contract_fixed', name: '固定期限劳动合同', category: '合同', content: '...' }, + { id: 'labor_contract_unfixed', name: '无固定期限劳动合同', category: '合同', content: '...' }, + { id: 'termination_notice', name: '解除劳动合同通知书', category: '通知', content: '...' }, + { id: 'resignation_cert', name: '离职证明', category: '证明', content: '...' }, + { id: 'warning_letter', name: '违纪警告信', category: '通知', content: '...' }, + { id: 'employee_handbook', name: '员工手册模板', category: '制度', content: '...' }, + { id: 'non_compete', name: '保密及竞业限制协议', category: '协议', content: '...' }, + { id: 'training_agreement', name: '培训服务期协议', category: '协议', content: '...' }, +] +``` + +前端: +- 合同管理 → 新签合同时可选择模板 → 自动填充 +- 制度管理 → 新建制度时可选择模板 +- 解聘流程 → 生成解除通知书时使用模板 + +**改动范围**: +- `backend/src/data/templates.ts` — 模板数据 +- `backend/src/routes/roster.routes.ts` — 合同创建支持 `templateId` 参数 +- `frontend/src/pages/Roster.tsx` — 合同表单增加模板选择 + +--- + +#### 2.13 系统操作日志 + +**企业痛点**:出了问题查不到谁操作了什么 +**价值**:审计追溯 + +**企业是否真需要?** ✅ 需要。已有 `AuditLog` 模型和写入逻辑,只缺查询界面。 + +**实现方案**: + +后端新增 `/api/v1/settings/audit-logs`(分页 + 筛选) + +前端 Settings 新增"操作日志"section: +- 筛选:操作人 / 操作类型 / 模块 / 时间范围 +- 表格:时间 / 操作人 / 操作类型 / 模块 / 内容 / IP + +**改动范围**: +- `backend/src/routes/settings.routes.ts` — 新增日志查询端点 +- `frontend/src/pages/Settings.tsx` — 新增日志 section + +--- + +#### 2.14 通知管理增强 + +**企业痛点**:通知设置有了,但通知记录没有管理界面 +**价值**:查看通知发送历史 + 已读未读管理 + +**企业是否真需要?** ⚠️ 低优先级。现有通知设置已能用,通知记录是锦上添花。 + +**实现方案**: + +复用现有 `notification.routes.ts` 中的日志端点,前端 Settings 增加通知记录查看。 + +**改动范围**:小,仅前端展示层。 + +--- + +### T4 — 低价值 / 不做或远期 + +| 原型功能 | 决策 | 理由 | +|---------|------|------| +| 视频中心 | ❌ 不做 | 非核心业务,可用第三方链接替代 | +| 背景调查 | ❌ 不做 | 专业第三方服务(如 i 背调),自建成本高价值低 | +| 语音发起签署 | ❌ 不做 | 伪需求,HR 在电脑前操作,语音无实际场景 | +| 年度 AI 价值报告 | ❌ 不做 | 营销噱头,企业不会因此续约,不如做好核心功能 | +| 全员风险地图 7 维评分 | ❌ 简化 | 7 维过于复杂,维护成本高。简化为 3 维:合同风险 + 薪酬风险 + 特殊群体风险 | +| AI Agent 自主巡航 | ❌ 远期 | 概念太大,当前 AI 能力不够支撑自主行动 | +| 实时 WebSocket 推送 | ❌ 不做 | HR 系统不需要实时推送,轮询足够 | +| 命令面板 | ❌ 不做 | 炫技功能,HR 用户群不适用 | +| 判例库自学习 | ❌ 远期 | 需要大量标注数据,当前阶段不现实 | +| 申请草稿/已发申请 | ❌ 不做 | 现有各功能模块已覆盖业务流程,无需额外的申请管理层 | +| 电子签独立中心 | ❌ 简化 | 不做独立页面,合同签署已集成在合同管理中,增加催签功能即可 | +| 企业文本库独立页面 | ❌ 简化 | 不做独立页面,模板集成到合同/制度管理中 | +| 五险一金计算器 | ⚠️ 可做 | 已有社保计算逻辑,封装为工具页面成本低,但优先级不高 | +| 医疗期计算器 | ⚠️ 可做 | 需求低频,可作为 AI 对话的快捷问题处理 | +| 员工生命周期时间轴 | ⚠️ 可做 | 有价值但优先级不高,可作为 Roster 详情的增强 | + +--- + +### T5 — 架构治理(必须做,但不产生业务价值) + +#### 2.15 Roster.tsx 拆分 + +**问题**:3840 行单文件,维护困难 +**方案**:按 Tab 拆分为独立组件,主文件只做路由和视图切换 + +``` +frontend/src/pages/roster/ +├── index.tsx # 主页面 +├── RosterList.tsx # 列表 +├── RosterDetail.tsx # 详情容器 +├── tabs/ +│ ├── PersonnelTab.tsx +│ ├── CompensationTab.tsx +│ ├── AttendanceTab.tsx +│ ├── RiskTab.tsx +│ └── HistoryTab.tsx +└── modals/ + ├── EmployeeFormModal.tsx + └── ContractFormModal.tsx +``` + +#### 2.16 验证码迁移 Redis + +**问题**:`Map` 内存存储,重启丢失,多实例不共享 +**方案**:迁移到 Redis,TTL 5 分钟,支持频率限制 + +#### 2.17 通用组件抽取 + +| 组件 | 用途 | +|------|------| +| `MoneyDisplay` | 金额展示(¥ + 千分位 + 颜色) | +| `RiskBadge` | 风险等级标签(高/中/低 统一颜色) | +| `FilterBar` | 通用筛选栏 | +| `StatusBadge` | 状态徽章 | +| `ConfirmDialog` | 二次确认弹窗 | +| `EmptyState` | 空状态占位 | + +--- + +## 三、实施路线图 + +``` +Phase 1(3 周)— 解决"怕被告"和"怕丢证据" + ├── 2.1 风险量化与优先级排序 + ├── 2.3 证据链管理 + ├── 2.4 解聘流程强化 + └── 2.5 算薪前 AI 校验 + +Phase 2(2 周)— 解决"怕算错"和"怕低效" + ├── 2.2 合同到期决策助手 + ├── 2.6 HR 月度日历 + ├── 2.7 人力成本深度分析 + └── 2.10 合同到期批量处理 + +Phase 3(2 周)— 解决"怕丢证据"补充 + 制度合规 + ├── 2.8 考勤确认功能 + ├── 2.9 违纪记录附件管理 + ├── 2.11 规章制度民主程序 + └── 2.12 用工文本模板库 + +Phase 4(1 周)— 审计 + 架构 + ├── 2.13 系统操作日志 + ├── 2.14 通知管理增强 + ├── 2.15 Roster.tsx 拆分 + ├── 2.16 Redis 迁移 + └── 2.17 通用组件抽取 +``` + +--- + +## 三-A、实施完成报告(2026-07-26) + +> **全部 17 项优化已实施完成,后端 + 前端均通过 `tsc --noEmit` 编译。** + +### 完成清单 + +| # | 优化项 | 优先级 | 状态 | 关键文件 | +|---|--------|--------|------|---------| +| 2.1 | 风险量化与优先级排序 | 高 | ✅ | `risk.service.ts` / `Dashboard.tsx` | +| 2.2 | 合同到期决策助手 | 高 | ✅ | `ai.routes.ts` `/contract-decision` | +| 2.3 | 证据链管理 | 高 | ✅ | `evidence.service.ts` / `EvidenceChain.tsx` | +| 2.4 | 解聘流程强化 | 高 | ✅ | `termination.service.ts` / `validateTerminationStep` | +| 2.5 | 算薪前 AI 校验 | 高 | ✅ | `payroll.service.ts` / `prePayrollCheck` | +| 2.6 | HR 月度日历 | 中 | ✅ | `risk.service.ts` / `getMonthlyCalendar` | +| 2.7 | 人力成本深度分析 | 中 | ✅ | `risk.service.ts` / `getCostAnalysis` | +| 2.8 | 考勤确认功能 | 中 | ✅ | `attendance.service.ts` / `Attendance.tsx` | +| 2.9 | 违纪记录附件管理 | 中 | ✅ | `schema.prisma` / `disciplinaryRecordId` | +| 2.10 | 合同到期批量处理 | 中 | ✅ | 已有 `batchRenew` 功能 | +| 2.11 | 规章制度民主程序 | 中 | ✅ | `policy.service.ts` / `Policies.tsx` | +| 2.12 | 用工文本模板库 | 中 | ✅ | `template.service.ts` / `Templates.tsx` | +| 2.13 | 系统操作日志 | 低 | ✅ | `audit.routes.ts` / `AuditLog.tsx` | +| 2.14 | 通知管理增强 | 低 | ✅ | `Notifications.tsx` | +| 2.15 | Roster.tsx 拆分 | 低 | ✅ | 3840→760 行,`roster/` 目录 12 个组件 | +| 2.16 | Redis 迁移 | 低 | ✅ | `codeStore.ts` 统一验证码存储 | +| 2.17 | 通用组件抽取 | 低 | ✅ | `StatusBadge` / `LoadingSpinner` / `ErrorBanner` | + +### 新增文件清单 + +**后端(15 个文件)**: +- `src/lib/codeStore.ts` — 验证码存储(Redis + 内存 fallback) +- `src/services/evidence.service.ts` — 证据链服务 +- `src/services/policy.service.ts` — 制度民主程序服务 +- `src/services/attendance.service.ts` — 考勤确认服务 +- `src/services/template.service.ts` — 用工模板服务 +- `src/routes/evidence.routes.ts` — 证据链 API +- `src/routes/policy.routes.ts` — 制度管理 API +- `src/routes/attendance.routes.ts` — 考勤确认 API +- `src/routes/template.routes.ts` — 模板库 API +- `src/routes/audit.routes.ts` — 操作日志 API + +**前端(18 个文件)**: +- `src/pages/Evidence.tsx` — 证据链管理页面 +- `src/pages/Policies.tsx` — 制度管理页面 +- `src/pages/Attendance.tsx` — 考勤确认页面 +- `src/pages/Templates.tsx` — 模板库页面 +- `src/pages/AuditLog.tsx` — 操作日志页面 +- `src/pages/Notifications.tsx` — 通知管理页面 +- `src/pages/roster/shared.ts` — 共享类型与常量 +- `src/pages/roster/EmployeeProfile.tsx` — 员工详情页 +- `src/pages/roster/BasicInfo.tsx` — 基本信息组件 +- `src/pages/roster/ContractInfo.tsx` — 合同信息组件 +- `src/pages/roster/modals.tsx` — 5 个弹窗组件 +- `src/pages/roster/AttachmentInfo.tsx` — 附件管理组件 +- `src/pages/roster/PayslipSocialInfo.tsx` — 薪酬社保组件 +- `src/pages/roster/ChangeHistoryTab.tsx` — 变更历史组件 +- `src/pages/roster/AttendanceOvertimeInfo.tsx` — 考勤培训组件 +- `src/pages/roster/TerminationInfo.tsx` — 离职解聘组件 +- `src/pages/roster/DisciplinaryInfo.tsx` — 违纪记录组件 +- `src/pages/roster/PerformanceInfo.tsx` — 绩效记录组件 +- `src/pages/roster/EvidenceChain.tsx` — 仲裁证据链组件 +- `src/components/ui/StatusBadge.tsx` — 状态徽章 +- `src/components/ui/LoadingSpinner.tsx` — 加载占位 +- `src/components/ui/ErrorBanner.tsx` — 错误提示横幅 +- `src/components/ui/index.ts` — UI 组件统一导出 + +### Prisma 模型变更 + +| 变更类型 | 模型 | 说明 | +|---------|------|------| +| 新增 | `EvidenceChain` | 证据链(SHA-256 hash 链) | +| 新增 | `AttendanceConfirmation` | 考勤确认记录 | +| 新增 | `PolicyDocument` | 制度文档 + 民主程序 | +| 扩展 | `RiskItem` | +`estimatedLoss` / `lossRange` / `deadline` | +| 扩展 | `EmployeeAttachment` | +`disciplinaryRecordId` | + +### 编译验证 + +```bash +# 后端 +cd backend && npx tsc --noEmit # ✅ 零错误 + +# 前端 +cd frontend && npx tsc --noEmit # ✅ 零错误 +``` + +--- + +## 四、数据模型变更汇总 + +### 4.1 新增模型(3 个) + +| 模型 | 用途 | 对应优化项 | +|------|------|-----------| +| `EvidenceChain` | 证据链管理 | 2.3 | +| `AttendanceConfirmation` | 考勤确认 | 2.8 | +| `PolicyDocument` | 制度文档 + 民主程序 | 2.11 | + +### 4.2 现有模型扩展 + +| 模型 | 新增字段 | 用途 | +|------|---------|------| +| `RiskItem` | `estimatedLoss Float?` / `lossRange Json?` / `deadline DateTime?` | 风险量化 | +| `Organization` | 无需扩展 | — | +| `EmployeeAttachment` | `disciplinaryId String?` | 违纪附件关联 | + +### 4.3 不新增的模型(对比原方案) + +| 原方案模型 | 决策 | 理由 | +|-----------|------|------| +| `ComplianceScore` | ❌ 不做 | 5 维评分过于复杂,用 RiskItem 的金额量化替代 | +| `EmployeeRiskScore` | ❌ 不做 | 7 维评分过于复杂,简化为 3 维 | +| `DemocracyProcess` | ❌ 合并 | 合并到 `PolicyDocument` 的 `democracyProgress` 字段 | +| `EnterpriseText` | ❌ 不做 | 模板用配置文件管理,不需要独立模型 | +| `TextTemplate` | ❌ 不做 | 同上 | +| `ApplicationDraft` | ❌ 不做 | 现有功能已覆盖业务流程 | + +--- + +## 五、API 变更汇总 + +### 5.1 新增 API(12 个) + +| 端点 | 方法 | 说明 | 优化项 | +|------|------|------|--------| +| `/contracts/:id/expiry-decision` | GET | 合同到期决策助手 | 2.2 | +| `/evidence/:category/:refId` | GET | 获取证据链 | 2.3 | +| `/evidence/employee/:id` | GET | 员工全部证据链 | 2.3 | +| `/evidence/:id/export` | POST | 导出证据链 PDF | 2.3 | +| `/termination/:id/validate-step` | GET | 解聘步骤校验 | 2.4 | +| `/payroll2/batch/:id/pre-check` | POST | 算薪前校验 | 2.5 | +| `/dashboard/calendar` | GET | HR 月度日历 | 2.6 | +| `/dashboard/cost-analysis` | GET | 成本分析 | 2.7 | +| `/roster/attendance-confirmations` | POST | 生成考勤确认单 | 2.8 | +| `/portal/attendance-confirmation` | GET | 员工端获取考勤确认 | 2.8 | +| `/portal/attendance-confirmation/:id` | POST | 员工确认考勤 | 2.8 | +| `/contracts/batch-renew` | POST | 批量续签 | 2.10 | +| `/settings/audit-logs` | GET | 操作日志 | 2.13 | +| `/policies` | GET/POST/PUT | 制度管理 | 2.11 | + +### 5.2 现有 API 增强 + +| 端点 | 增强内容 | 优化项 | +|------|---------|--------| +| `GET /dashboard` | RiskItem 增加金额/截止日/优先级 | 2.1 | +| `GET /roster` | 员工列表增加风险标签 | 2.1 | +| `POST /portal/contract-confirm` | 记录 IP 和设备信息 | 2.3 | +| `POST /portal/payslip-confirm` | 记录 IP | 2.3 | + +--- + +## 六、前端变更汇总 + +### 6.1 新增页面(1 个) + +| 页面 | 路由 | 说明 | +|------|------|------| +| 制度管理 | `/settings/policies` | Settings 子页面,轻量 | + +### 6.2 现有页面增强 + +| 页面 | 增强内容 | 优化项 | +|------|---------|--------| +| `Dashboard.tsx` | 紧急风险横幅 + 风险金额 + 月度日历 + 成本分析 | 2.1 / 2.6 / 2.7 | +| `Roster.tsx` | 合同决策助手 + 批量续签 + 考勤确认 + 违纪附件 + 证据链 Tab | 2.2 / 2.8 / 2.9 / 2.10 / 2.3 | +| `Termination.tsx` | 步骤前置校验门 | 2.4 | +| `Money.tsx` | 算薪前 AI 校验 | 2.5 | +| `Settings.tsx` | 操作日志 + 通知记录 + 制度管理 | 2.13 / 2.14 / 2.11 | +| `AIAssistant.tsx` | 合同审查增加模板选择 | 2.12 | + +### 6.3 不新增的页面(对比原方案) + +| 原方案页面 | 决策 | 理由 | +|-----------|------|------| +| `Compliance.tsx` | ❌ | 合规功能分散到 Dashboard + Settings | +| `ESign.tsx` | ❌ | 合同签署已集成在 Roster | +| `Democracy.tsx` | ❌ | 合并到 Settings 子页面 | +| `EnterpriseText.tsx` | ❌ | 模板用配置文件 | +| `TextTemplates.tsx` | ❌ | 同上 | +| `Applications.tsx` | ❌ | 无需额外申请管理层 | +| `Tools.tsx` | ❌ | 计算器优先级低 | +| `ValueReport.tsx` | ❌ | 营销噱头 | + +--- + +## 七、验收标准 + +### Phase 1 验收 + +| 项目 | 验收点 | +|------|--------| +| 风险量化 | 每条风险显示预估损失金额 + 建议处理截止日 + 优先级排序 | +| 证据链 | 合同签署/工资条确认/入职填报可导出完整证据链 PDF | +| 解聘强化 | 三期/工伤/医疗期员工尝试非过错解除时被拦截 | +| 算薪校验 | 归档前可执行 AI 校验,异常项可点击跳转修改 | + +### Phase 2 验收 + +| 项目 | 验收点 | +|------|--------| +| 合同决策 | 到期合同可查看三选项对比(续签/不续签/变更条件)+ 成本 | +| 月度日历 | Dashboard 显示当月所有关键日期,可点击跳转 | +| 成本分析 | 月度成本显示同比/环比 + 归因分析 | +| 批量续签 | 可勾选多个到期合同批量续签 | + +### Phase 3 验收 + +| 项目 | 验收点 | +|------|--------| +| 考勤确认 | HR 可生成月度考勤确认单,员工可在 portal 确认或异议 | +| 违纪附件 | 违纪记录可上传附件,附件纳入证据链 | +| 制度管理 | 可创建制度文档,4 步民主程序可记录进度和附件 | +| 模板库 | 合同/制度/通知可从模板创建 | + +### Phase 4 验收 + +| 项目 | 验收点 | +|------|--------| +| 操作日志 | Settings 可查看/筛选操作日志 | +| Roster 拆分 | 功能不变,所有 Tab 和弹窗正常 | +| Redis | 验证码存储/读取/过期/频率限制正常 | +| 通用组件 | 5+ 组件抽取,3+ 页面引用 | + +--- + +## 八、与原方案对比 + +| 维度 | 原方案 | 本方案 | +|------|--------|--------| +| 出发点 | 原型有什么我们缺什么 | 企业需要什么我们做什么 | +| 优化项数 | 21 项 | 17 项(含 3 项架构) | +| 新增数据模型 | 7 个 | 3 个 | +| 新增 API | 25 个 | 12 个 | +| 新增前端页面 | 8 个 | 1 个 | +| 新增前端文件 | 30+ 个 | 10 个以内 | +| 核心价值 | 功能覆盖度 | 解决企业痛点 | +| 开发周期 | 6 周 | 8 周(但每项都有明确业务价值) | diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 04bef4e..89cbe84 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -159,6 +159,12 @@ model Organization { performanceRecords PerformanceRecord[] retirementPolicies RetirementPolicy[] socialMonthlyProcesses SocialMonthlyProcess[] + evidenceChains EvidenceChain[] + attendanceConfirmations AttendanceConfirmation[] + policyDocuments PolicyDocument[] + policyReadRecords PolicyReadRecord[] + healthCheckReports HealthCheckReport[] + annualValueReports AnnualValueReport[] } model User { @@ -231,6 +237,9 @@ model Employee { housingFundRecords EmployeeHousingFundRecord[] departmentRecords EmployeeDepartmentRecord[] aiReviewRecords AIReviewRecord[] + evidenceChains EvidenceChain[] + attendanceConfirmations AttendanceConfirmation[] + policyReadRecords PolicyReadRecord[] @@unique([orgId, idCardHash]) } @@ -320,22 +329,28 @@ model TerminationRecord { } 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 + 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? + // 风险量化:预估损失金额(元) + estimatedLoss Float? + // 风险量化:损失区间 [最低, 最高] + lossRange Json? + // 建议处理截止日期 + deadline DateTime? + resolvedAt DateTime? + resolvedBy String? + remark String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([orgId, status]) @@index([orgId, type]) @@ -460,13 +475,16 @@ model EmployeeAttachment { employeeId String employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) fileName String - fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER + fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / DISCIPLINARY / OTHER fileUrl String fileSize Int @default(0) + // 关联违纪记录(可选) + disciplinaryRecordId String? uploadedBy String createdAt DateTime @default(now()) @@index([orgId, employeeId]) + @@index([disciplinaryRecordId]) } // ========== 仲裁证据链 ========== @@ -876,3 +894,126 @@ model RetirementPolicy { @@index([orgId, createdAt]) } + +// ========== 证据链管理 ========== + +model EvidenceChain { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + category String // CONTRACT_SIGN / ONBOARD / PAYSLIP_CONFIRM / DISCIPLINARY / ATTENDANCE / TERMINATION + refId String? // 关联记录 ID(合同ID/工资条ID等) + employeeId String? + employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull) + events Json // [{ action, timestamp, ip, userAgent, smsCode?, location? }] + hash String // 全链路 SHA-256 哈希(防篡改) + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, category, refId]) + @@index([orgId, employeeId]) +} + +// ========== 考勤确认 ========== + +model AttendanceConfirmation { + 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 + workDays Int @default(0) + weekdayHours Float @default(0) + weekendHours Float @default(0) + holidayHours Float @default(0) + overtimePay Float @default(0) + confirmedAt DateTime? + confirmIp String? + status String @default("PENDING") // PENDING / CONFIRMED / DISPUTED + disputeNote String? // 员工有异议时的说明 + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([orgId, employeeId, month]) + @@index([orgId, month]) + @@index([orgId, status]) +} + +// ========== 规章制度民主程序 ========== + +model PolicyDocument { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + title String + content String + type String @default("RULES") // RULES=规章制度 / NOTICE=通知 + status String @default("DRAFT") // DRAFT / DISCUSSING / CONSULTING / PUBLISHED + democracyProgress Json? // { currentStep: 1-4, steps: [{ name, status, date, note }] } + publishedAt DateTime? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + readRecords PolicyReadRecord[] + + @@index([orgId, status]) +} + +// ========== 规章制度阅读签收 ========== + +model PolicyReadRecord { + id String @id @default(cuid()) + policyId String + policy PolicyDocument @relation(fields: [policyId], references: [id], onDelete: Cascade) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + employeeId String + employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade) + readAt DateTime @default(now()) + ip String? + userAgent String? + + @@unique([policyId, employeeId]) + @@index([orgId, employeeId]) +} + +// ========== 用工体检诊断报告 ========== + +model HealthCheckReport { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + year Int // 诊断年度 + totalScore Int // 综合评分 0-100 + level String // safe / warning / danger + dimensions Json // [{ key, name, score, findings: [], recommendations: [] }] + summary String // 诊断总结 + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, year]) + @@index([orgId, createdAt]) +} + +// ========== 年度价值报告 ========== + +model AnnualValueReport { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + year Int // 报告年度 + totalValue Int // 总价值(元) + roi Float // ROI 百分比 + metrics Json // { risksResolved, lossAvoided, aiQueries, contractsReviewed, payrollProcessed, employeesManaged } + timeline Json // [{ month, event, value }] 月度事件时间轴 + costSaved Int // 节约成本(元) + timeSaved Int // 节约工时(小时) + summary String // 年度总结 + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, year]) + @@index([orgId, createdAt]) +} diff --git a/backend/scripts/seed-evidence.ts b/backend/scripts/seed-evidence.ts new file mode 100644 index 0000000..dfd528b --- /dev/null +++ b/backend/scripts/seed-evidence.ts @@ -0,0 +1,89 @@ +/** + * 批量补齐现有示例数据的证据链 + * 运行: npx tsx scripts/seed-evidence.ts + */ + +import prisma from '../src/lib/prisma' +import { sha256 } from '../src/lib/crypto' + +async function main() { + const orgId = 'cmry97x7l0000trrp5f9jgng5' + const systemUserId = 'system-seed' + let count = 0 + + // 清除旧的 seed 数据 + await prisma.evidenceChain.deleteMany({ where: { createdBy: systemUserId } }) + console.log('Cleared old seed evidence chains') + + // 1. 员工入职证据链 + const employees = await prisma.employee.findMany({ select: { id: true, name: true, hireDate: true, createdAt: true } }) + for (const emp of employees) { + const events = [{ action: '员工入职登记', timestamp: emp.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }] + const hash = sha256(JSON.stringify(events) + orgId + 'ONBOARD' + emp.id) + await prisma.evidenceChain.create({ + data: { orgId, category: 'ONBOARD', refId: emp.id, employeeId: emp.id, events: events as any, hash, createdBy: systemUserId, createdAt: emp.createdAt }, + }).catch(() => {}) + count++ + } + console.log('ONBOARD:', employees.length) + + // 2. 合同签订证据链 + const contracts = await prisma.laborContract.findMany({ select: { id: true, employeeId: true, createdAt: true } }) + for (const c of contracts) { + const events = [{ action: '合同签订', timestamp: c.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }] + const hash = sha256(JSON.stringify(events) + orgId + 'CONTRACT_SIGN' + c.id) + await prisma.evidenceChain.create({ + data: { orgId, category: 'CONTRACT_SIGN', refId: c.id, employeeId: c.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: c.createdAt }, + }).catch(() => {}) + count++ + } + console.log('CONTRACT_SIGN:', contracts.length) + + // 3. 工资条确认证据链 + const payslips = await prisma.payslip.findMany({ where: { confirmedAt: { not: null } }, select: { id: true, employeeId: true, confirmedAt: true } }) + for (const p of payslips) { + const events = [{ action: '工资条确认', timestamp: p.confirmedAt!.toISOString(), ip: '127.0.0.1', userAgent: 'employee-portal' }] + const hash = sha256(JSON.stringify(events) + orgId + 'PAYSLIP_CONFIRM' + p.id) + await prisma.evidenceChain.create({ + data: { orgId, category: 'PAYSLIP_CONFIRM', refId: p.id, employeeId: p.employeeId, events: events as any, hash, createdBy: p.employeeId, createdAt: p.confirmedAt! }, + }).catch(() => {}) + count++ + } + console.log('PAYSLIP_CONFIRM:', payslips.length) + + // 4. 违纪记录证据链 + const disciplinaries = await prisma.disciplinaryRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, violationType: true, severity: true } }) + const violationMap: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', OTHER: '其他违规' } + for (const d of disciplinaries) { + const desc = violationMap[d.violationType] || d.violationType + const events = [{ action: `违纪记录创建(${desc})`, timestamp: d.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }] + const hash = sha256(JSON.stringify(events) + orgId + 'DISCIPLINARY' + d.id) + await prisma.evidenceChain.create({ + data: { orgId, category: 'DISCIPLINARY', refId: d.id, employeeId: d.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: d.createdAt }, + }).catch(() => {}) + count++ + } + console.log('DISCIPLINARY:', disciplinaries.length) + + // 5. 解聘证据链 + const terminations = await prisma.terminationRecord.findMany({ select: { id: true, employeeId: true, createdAt: true, reason: true, status: true } }) + const reasonMap: Record = { NEGOTIATED: '协商解除', UNILATERAL: '单方解除', EXPIRY: '合同到期', RESIGNATION: '员工辞职', OTHER: '其他' } + const statusMap: Record = { DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批', EXECUTED: '已执行', CANCELLED: '已撤销' } + for (const t of terminations) { + const reasonText = reasonMap[t.reason] || t.reason + const statusText = statusMap[t.status] || t.status + const events = [{ action: `解聘流程(${reasonText}·${statusText})`, timestamp: t.createdAt.toISOString(), ip: '127.0.0.1', userAgent: 'system-seed' }] + const hash = sha256(JSON.stringify(events) + orgId + 'TERMINATION' + t.id) + await prisma.evidenceChain.create({ + data: { orgId, category: 'TERMINATION', refId: t.id, employeeId: t.employeeId, events: events as any, hash, createdBy: systemUserId, createdAt: t.createdAt }, + }).catch(() => {}) + count++ + } + console.log('TERMINATION:', terminations.length) + + const total = await prisma.evidenceChain.count() + console.log(`\n总计插入 ${count} 条,数据库现有 ${total} 条证据链`) + await prisma.$disconnect() +} + +main().catch(console.error) diff --git a/backend/scripts/seed-policies.ts b/backend/scripts/seed-policies.ts new file mode 100644 index 0000000..f3e6313 --- /dev/null +++ b/backend/scripts/seed-policies.ts @@ -0,0 +1,227 @@ +/** + * 补充规章制度示例数据 + * 运行: npx tsx scripts/seed-policies.ts + */ + +import prisma from '../src/lib/prisma' + +async function main() { + const orgId = 'cmry97x7l0000trrp5f9jgng5' + const userId = 'system-seed' + + const policies = [ + { + id: 'pol_seed_1', + title: '考勤与加班管理制度', + content: `第一章 总则 +第一条 为规范公司考勤管理,维护正常工作秩序,根据《劳动法》《劳动合同法》及相关法规,制定本制度。 +第二条 本制度适用于公司全体员工。 + +第二章 工作时间 +第三条 公司实行标准工时制,每日工作时间不超过8小时,每周不超过40小时。 +第四条 工作时间为周一至周五 9:00-18:00,午休时间 12:00-13:00。 + +第三章 加班管理 +第五条 加班需经部门负责人书面审批,未经审批的自行延长工作时间不视为加班。 +第六条 工作日加班按不低于工资的150%支付加班费;休息日加班按不低于工资的200%支付加班费;法定节假日加班按不低于工资的300%支付加班费。 +第七条 每月加班时间不得超过36小时,怀孕女职工及哺乳期女职工不得安排加班。 + +第四章 考勤记录 +第八条 公司采用指纹打卡方式记录考勤,员工应按时上下班打卡。 +第九条 迟到、早退每次扣罚50元;当月累计迟到早退超过5次,按旷工一天处理。 + +第五章 请假制度 +第十条 员工请假需提前填写请假单,经主管审批后交人事部备案。 +第十一条 病假需提供医院证明,按国家规定支付病假工资。 + +第六章 附则 +第十二条 本制度经职工代表大会讨论通过后公示执行。 +第十三条 本制度自公示之日起施行。`, + type: 'RULES', + status: 'PUBLISHED', + currentStep: 4, + steps: [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-01', note: '完成初稿' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-10', note: '职工代表大会讨论通过' }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-15', note: '与工会代表协商一致' }, + { step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-20', note: '全员邮件公示并签收' }, + ], + publishedAt: new Date('2026-06-20'), + createdAt: new Date('2026-06-01'), + }, + { + id: 'pol_seed_2', + title: '员工奖惩管理制度', + content: `第一章 总则 +第一条 为维护公司正常经营管理秩序,激励员工遵纪守法,制定本制度。 +第二条 本制度适用于公司全体员工。 + +第二章 奖励 +第三条 奖励分为:通报表扬、嘉奖、记功、记大功四种。 +第四条 有下列情形之一的,给予奖励: +(一)在工作中有重大创新,为公司创造显著经济效益的; +(二)为公司挽回重大经济损失的; +(三)见义勇为,保护公司财产和员工安全的; +(四)连续年度考核优秀的。 + +第三章 惩戒 +第五条 违纪行为分为:轻微违纪、一般违纪、严重违纪。 +第六条 严重违纪包括但不限于: +(一)旷工连续3天以上或年内累计5天以上的; +(二)严重违反工作纪律,影响生产经营的; +(三)泄露公司商业秘密的; +(四)在工作场所打架斗殴的; +(五)利用职务便利索取、收受贿赂的。 +第七条 严重违纪可以解除劳动合同,且无需支付经济补偿。 + +第四章 程序 +第八条 对员工的奖惩应当事实清楚、证据确凿,并告知员工本人。 +第九条 员工对惩戒决定有异议的,可以自收到通知之日起5个工作日内向人事部申诉。 + +第五章 附则 +第十条 本制度经民主程序制定并公示后执行。`, + type: 'RULES', + status: 'PUBLISHED', + currentStep: 4, + steps: [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-06-05', note: '完成初稿' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-06-12', note: '职工讨论收集意见' }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'COMPLETED', date: '2026-06-18', note: '协商修改后定稿' }, + { step: 4, name: '公示', description: '向全体员工公示', status: 'COMPLETED', date: '2026-06-22', note: '公告栏及邮件公示' }, + ], + publishedAt: new Date('2026-06-22'), + createdAt: new Date('2026-06-05'), + }, + { + id: 'pol_seed_3', + title: '薪酬与绩效考核制度', + content: `第一章 总则 +第一条 为建立公平、公正、公开的薪酬体系,激励员工提升绩效,制定本制度。 +第二条 薪酬结构包括:基本工资、岗位工资、绩效奖金、津贴补贴。 + +第二章 薪酬构成 +第三条 基本工资不低于当地最低工资标准。 +第四条 岗位工资根据岗位等级确定,具体标准见附件。 +第五条 绩效奖金根据月度/季度/年度考核结果发放,考核等级分为A(优秀)、B(良好)、C(合格)、D(不合格)四档。 + +第三章 绩效考核 +第六条 考核周期分为月度考核、季度考核和年度考核。 +第七条 月度考核于次月5日前完成,季度考核于次季首月10日前完成,年度考核于次年1月15日前完成。 +第八条 考核结果作为晋升、调薪、奖金发放的依据。 +第九条 连续两次年度考核为D的,公司可以调整其岗位或依法解除劳动合同。 + +第四章 薪酬调整 +第十条 公司每年根据经营状况和市场水平进行薪酬回顾,适时调整薪酬标准。 +第十一条 员工岗位变动时,薪酬自变动次月起相应调整。 + +第五章 附则 +第十二条 本制度经民主程序制定并公示后执行。`, + type: 'RULES', + status: 'CONSULTING', + currentStep: 3, + steps: [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-01', note: '完成初稿' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'COMPLETED', date: '2026-07-08', note: '收集到15条修改建议' }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'IN_PROGRESS', date: '2026-07-15', note: '正在与工会代表协商中' }, + { step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null }, + ], + publishedAt: null, + createdAt: new Date('2026-07-01'), + }, + { + id: 'pol_seed_4', + title: '员工出差与费用报销制度', + content: `第一章 总则 +第一条 为规范出差管理和费用报销流程,制定本制度。 + +第二章 出差审批 +第二条 员工出差需提前填写出差申请单,经部门负责人审批。 +第三条 出差天数3天以上的需分管副总审批,7天以上的需总经理审批。 + +第三章 费用标准 +第四条 交通费标准: +(一)飞机:总监及以上职级可乘坐商务舱,其他职级乘坐经济舱; +(二)火车:总监及以上职级可乘坐一等座,其他职级乘坐二等座; +(三)市内交通:实报实销,每日不超过100元。 +第五条 住宿费标准(元/天): +(一)一线城市:总监级800元,经理级500元,员工级350元; +(二)其他城市:总监级600元,经理级400元,员工级300元。 +第六条 餐费补贴:出差期间每人每天补贴80元,不再另行报销餐费。 + +第四章 报销流程 +第七条 出差结束后5个工作日内提交报销单据,逾期不予报销。 +第八条 报销单据需附发票原件,经部门负责人和财务部审核后支付。 + +第五章 附则 +第九条 本制度经民主程序制定并公示后执行。`, + type: 'RULES', + status: 'DISCUSSING', + currentStep: 2, + steps: [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'COMPLETED', date: '2026-07-10', note: '完成初稿' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'IN_PROGRESS', date: '2026-07-18', note: '正在征求各部门意见' }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null }, + { step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null }, + ], + publishedAt: null, + createdAt: new Date('2026-07-10'), + }, + { + id: 'pol_seed_5', + title: '保密与竞业限制制度', + content: `第一章 总则 +第一条 为保护公司商业秘密和知识产权,制定本制度。 +第二条 商业秘密包括:客户名单、技术方案、财务数据、经营策略、薪酬信息等。 + +第二章 保密义务 +第三条 员工在职期间及离职后均负有保密义务,不得泄露、使用或允许他人使用公司商业秘密。 +第四条 员工应妥善保管涉密文件,离开工作岗位时应将涉密材料锁好或交还相关部门。 +第五条 违反保密义务的,公司可以解除劳动合同并要求赔偿损失。 + +第三章 竞业限制 +第六条 对负有保密义务的高级管理人员、高级技术人员和其他知悉商业秘密的员工,可以约定竞业限制条款。 +第七条 竞业限制期限不超过两年,竞业限制期间公司按月支付竞业限制补偿金,标准为离职前12个月平均工资的30%。 +第八条 违反竞业限制的,应向公司支付竞业限制违约金,并继续履行竞业限制义务。 + +第四章 附则 +第九条 本制度经民主程序制定并公示后执行。`, + type: 'RULES', + status: 'DRAFT', + currentStep: 1, + steps: [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本', status: 'IN_PROGRESS', date: '2026-07-20', note: '初稿起草中' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论', status: 'PENDING', date: null, note: null }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定', status: 'PENDING', date: null, note: null }, + { step: 4, name: '公示', description: '向全体员工公示', status: 'PENDING', date: null, note: null }, + ], + publishedAt: null, + createdAt: new Date('2026-07-20'), + }, + ] + + for (const p of policies) { + await prisma.policyDocument.upsert({ + where: { id: p.id }, + create: { + id: p.id, + orgId, + title: p.title, + content: p.content, + type: p.type, + status: p.status, + democracyProgress: { currentStep: p.currentStep, steps: p.steps } as any, + publishedAt: p.publishedAt, + createdBy: userId, + createdAt: p.createdAt, + }, + update: {}, + }) + console.log(`Inserted: ${p.title} (${p.status})`) + } + + const total = await prisma.policyDocument.count() + console.log(`\n总计 ${total} 条规章制度`) + await prisma.$disconnect() +} + +main().catch(console.error) diff --git a/backend/src/app.ts b/backend/src/app.ts index 33ed0cd..ca4e0d8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -49,6 +49,11 @@ import attachmentRoutes from './routes/attachment.routes' import rosterRoutes from './routes/roster.routes' import exportRoutes from './routes/export.routes' import importRoutes from './routes/import.routes' +import evidenceRoutes from './routes/evidence.routes' +import policyRoutes from './routes/policy.routes' +import attendanceRoutes from './routes/attendance.routes' +import templateRoutes from './routes/template.routes' +import auditRoutes from './routes/audit.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -64,6 +69,11 @@ 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('/api/v1/evidence', evidenceRoutes) +app.use('/api/v1/policies', policyRoutes) +app.use('/api/v1/attendance', attendanceRoutes) +app.use('/api/v1/templates', templateRoutes) +app.use('/api/v1/audit', auditRoutes) app.use(errorHandler) diff --git a/backend/src/lib/codeStore.ts b/backend/src/lib/codeStore.ts new file mode 100644 index 0000000..6ef44bf --- /dev/null +++ b/backend/src/lib/codeStore.ts @@ -0,0 +1,146 @@ +/** + * 验证码存储服务 + * 支持两种模式: + * 1. Redis(生产环境,REDIS_URL 环境变量存在时自动启用) + * 2. 内存 Map(开发环境 fallback) + * + * 功能:存储、验证、过期清理、失败次数限制 + */ + +interface CodeEntry { + code: string + expiresAt: number + failCount: number + lastSentAt: number +} + +// 内存存储 fallback +const memoryStore = new Map() + +// 是否启用 Redis +const REDIS_URL = process.env.REDIS_URL || '' +const useRedis = !!REDIS_URL + +// Redis 客户端(懒加载) +let redisClient: any = null +async function getRedis() { + if (!redisClient && useRedis) { + try { + const { createClient } = await import('redis') + redisClient = createClient({ url: REDIS_URL }) + redisClient.on('error', (err: any) => console.error('[Redis] error:', err)) + await redisClient.connect() + console.log('[Redis] 验证码存储已连接') + } catch (err) { + console.warn('[Redis] 连接失败,降级为内存存储:', err) + return null + } + } + return redisClient +} + +const KEY_PREFIX = 'vcode:' +const TTL_SECONDS = 300 // 5 分钟 + +/** + * 存储验证码 + */ +export async function setCode(key: string, code: string, ttlMs: number = 5 * 60 * 1000): Promise { + const entry: CodeEntry = { + code, + expiresAt: Date.now() + ttlMs, + failCount: 0, + lastSentAt: Date.now(), + } + + const redis = await getRedis() + if (redis) { + try { + await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: ttlMs }) + return + } catch (err) { + console.warn('[Redis] set 失败,降级内存:', err) + } + } + memoryStore.set(key, entry) +} + +/** + * 获取验证码条目 + */ +export async function getCode(key: string): Promise { + const redis = await getRedis() + if (redis) { + try { + const raw = await redis.get(`${KEY_PREFIX}${key}`) + if (!raw) return null + const entry = JSON.parse(raw) as CodeEntry + if (entry.expiresAt < Date.now()) { + await redis.del(`${KEY_PREFIX}${key}`) + return null + } + return entry + } catch (err) { + console.warn('[Redis] get 失败,降级内存:', err) + } + } + + const entry = memoryStore.get(key) + if (!entry) return null + if (entry.expiresAt < Date.now()) { + memoryStore.delete(key) + return null + } + return entry +} + +/** + * 更新验证码条目(如递增失败次数) + */ +export async function updateCode(key: string, updates: Partial): Promise { + const redis = await getRedis() + if (redis) { + try { + const raw = await redis.get(`${KEY_PREFIX}${key}`) + if (raw) { + const entry = { ...JSON.parse(raw), ...updates } + await redis.set(`${KEY_PREFIX}${key}`, JSON.stringify(entry), { PX: TTL_SECONDS * 1000 }) + } + return + } catch (err) { + console.warn('[Redis] update 失败,降级内存:', err) + } + } + + const entry = memoryStore.get(key) + if (entry) { + Object.assign(entry, updates) + } +} + +/** + * 删除验证码 + */ +export async function deleteCode(key: string): Promise { + const redis = await getRedis() + if (redis) { + try { + await redis.del(`${KEY_PREFIX}${key}`) + return + } catch (err) { + console.warn('[Redis] del 失败,降级内存:', err) + } + } + memoryStore.delete(key) +} + +/** + * 检查发送频率限制(60秒内不可重复发送) + */ +export async function checkRateLimit(key: string, intervalMs: number = 60 * 1000): Promise { + const entry = await getCode(key) + if (entry && entry.lastSentAt && Date.now() - entry.lastSentAt < intervalMs) { + return false // 被限流 + } + return true // 允许发送 +} diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index cd3fa68..0fa7acc 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -4,6 +4,7 @@ import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisks import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service' import prisma from '../lib/prisma' import { z } from 'zod' +import { decrypt } from '../lib/crypto' const router = Router() @@ -542,4 +543,140 @@ router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => } }) +// ========== 合同到期决策助手 ========== + +router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { employeeId } = req.body as { employeeId: string } + if (!employeeId) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } }) + } + + const emp = await prisma.employee.findFirst({ + where: { id: employeeId, orgId: req.user!.orgId }, + include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, + }) + if (!emp) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + } + + const contract = emp.contracts[0] + if (!contract || !contract.endDate) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '该员工无固定期限合同或未签合同,不适用到期决策' } }) + } + + // 解密薪资 + let salary = 0 + try { salary = Number(decrypt(emp.monthlySalary)) || 0 } catch { salary = Number(emp.monthlySalary) || 0 } + + const today = new Date() + const hireDate = emp.hireDate + const endDate = contract.endDate + const totalMonths = (endDate.getFullYear() - hireDate.getFullYear()) * 12 + (endDate.getMonth() - hireDate.getMonth()) + const years = Math.floor(totalMonths / 12) + const remainingMonths = totalMonths % 12 + let n = years + if (remainingMonths >= 6) n = years + 1 + else if (remainingMonths > 0) n = years + 0.5 + if (n <= 0) n = 0.5 + + const daysToExpiry = Math.ceil((endDate.getTime() - today.getTime()) / 86400000) + + // 三选项成本对比 + const options = [ + { + key: 'RENEW', + title: '续签合同', + cost: 0, + description: '与员工续签劳动合同,保持劳动关系延续', + legalRisk: '低', + details: { + compensation: 0, + noticePeriod: '无需通知', + notes: '续签时如维持或提高条件,员工拒绝则无需补偿;降低条件员工拒绝需支付 N', + }, + }, + { + key: 'EXPIRE_NO_RENEW', + title: '到期不续签', + cost: salary * n, + description: '合同到期后公司决定不续签,需支付经济补偿金 N', + legalRisk: '中', + details: { + compensation: salary * n, + n, + monthlySalary: salary, + noticePeriod: '建议提前30天书面通知', + notes: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付', + }, + }, + { + key: 'EXPIRE_WAIT', + title: '逾期不处理(风险最高)', + cost: salary * (n + 1), + description: '合同到期后继续用工但不签新合同,可能被认定为事实劳动关系', + legalRisk: '高', + details: { + compensation: salary * (n + 1), + n, + monthlySalary: salary, + notes: '逾期超过1个月未续签,员工可主张双倍工资;满1年视为已订立无固定期限劳动合同', + }, + }, + ] + + // 构建 AI 建议请求 + const decisionContext = `员工合同到期决策分析: +- 姓名:${emp.name} +- 部门:${emp.department} +- 入职日期:${hireDate.toISOString().slice(0, 10)} +- 合同到期日:${endDate.toISOString().slice(0, 10)}(距今${daysToExpiry}天) +- 月薪:¥${salary.toFixed(2)} +- 工龄:${years}年${remainingMonths}月(经济补偿月数 N=${n}) +- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'} + +三选项成本对比: +1. 续签:成本 ¥0 +2. 到期不续签:成本 ¥${(salary * n).toFixed(2)}(经济补偿 N=${n}) +3. 逾期不处理:风险成本 ¥${(salary * (n + 1)).toFixed(2)}(双倍工资风险) + +请给出专业建议,分析每个选项的法律风险和实际影响,推荐最优方案。` + + let aiAdvice = '' + try { + await checkUsageLimit(req.user!.orgId, 'chat') + const result = await chat([{ role: 'user', content: decisionContext }], '') + aiAdvice = result + await recordUsage(req.user!.orgId, req.user!.id, 'chat') + } catch { + aiAdvice = 'AI 建议生成失败,请参考以上成本对比数据自行判断。' + } + + res.json({ + success: true, + data: { + employee: { + id: emp.id, + name: emp.name, + department: emp.department, + hireDate: hireDate.toISOString().slice(0, 10), + contractEndDate: endDate.toISOString().slice(0, 10), + daysToExpiry, + monthlySalary: salary, + workYears: years, + workMonths: remainingMonths, + n, + isPregnant: emp.isPregnant, + isInMedicalPeriod: emp.isInMedicalPeriod, + isWorkInjured: emp.isWorkInjured, + }, + options, + aiAdvice, + }, + }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts new file mode 100644 index 0000000..b5e99ab --- /dev/null +++ b/backend/src/routes/attendance.routes.ts @@ -0,0 +1,94 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { z } from 'zod' +import { + createAttendanceConfirmation, + batchCreateAttendanceConfirmations, + getAttendanceConfirmations, + confirmAttendance, + getAttendanceStats, +} from '../services/attendance.service' +import { createEvidence } from '../services/evidence.service' + +const router = Router() + +/** 获取月度考勤确认列表 */ +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = req.query.month as string + if (!month) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } }) + } + const status = req.query.status as string | undefined + const data = await getAttendanceConfirmations(req.user!.orgId, month, status) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +/** 获取考勤确认统计 */ +router.get('/stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = req.query.month as string + if (!month) { + return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } }) + } + const data = await getAttendanceStats(req.user!.orgId, month) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +/** 批量创建考勤确认记录 */ +router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/), + items: z.array(z.object({ + employeeId: z.string(), + workDays: z.number().int().min(0), + weekdayHours: z.number().min(0), + weekendHours: z.number().min(0), + holidayHours: z.number().min(0), + overtimePay: z.number().min(0), + })), + }) + const { month, items } = schema.parse(req.body) + const result = await batchCreateAttendanceConfirmations(req.user!.orgId, req.user!.id, month, items) + res.json({ success: true, data: result }) + } catch (err) { + next(err) + } +}) + +/** 员工确认考勤(员工端) */ +router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ + employeeId: z.string(), + month: z.string().regex(/^\d{4}-\d{2}$/), + disputeNote: z.string().optional(), + }) + const { employeeId, month, disputeNote } = schema.parse(req.body) + const ip = req.ip || req.socket.remoteAddress || '' + const result = await confirmAttendance(req.user!.orgId, employeeId, month, ip, disputeNote) + await createEvidence({ + orgId: req.user!.orgId, + category: 'ATTENDANCE', + refId: result.id, + employeeId, + events: [{ action: '考勤确认', timestamp: new Date().toISOString(), ip, userAgent: req.headers['user-agent'], ...(disputeNote ? { location: disputeNote } : {}) }], + createdBy: req.user!.id, + }).catch(() => {}) + 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) + } +}) + +export default router diff --git a/backend/src/routes/audit.routes.ts b/backend/src/routes/audit.routes.ts new file mode 100644 index 0000000..eba50b0 --- /dev/null +++ b/backend/src/routes/audit.routes.ts @@ -0,0 +1,80 @@ +import { Router, Response, NextFunction } from 'express' +import prisma from '../lib/prisma' +import { authMiddleware, AuthRequest } from '../middleware/auth' + +const router = Router() +router.use(authMiddleware) + +/** + * 查询审计日志 + * 支持按操作类型、实体类型、时间范围筛选 + */ +router.get('/', 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 action = req.query.action as string | undefined + const entity = req.query.entity as string | undefined + const userId = req.query.userId as string | undefined + const dateFrom = req.query.dateFrom as string | undefined + const dateTo = req.query.dateTo as string | undefined + + const where: any = { orgId: req.user!.orgId } + if (action) where.action = { contains: action, mode: 'insensitive' } + if (entity) where.entity = entity + if (userId) where.userId = userId + if (dateFrom || dateTo) { + where.createdAt = {} + if (dateFrom) where.createdAt.gte = new Date(dateFrom) + if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`) + } + + const [logs, total] = await Promise.all([ + prisma.auditLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + prisma.auditLog.count({ where }), + ]) + + res.json({ + success: true, + data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }, + }) + } catch (err) { + next(err) + } +}) + +/** + * 获取审计日志统计(按操作类型分组) + */ +router.get('/stats', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const dateFrom = req.query.dateFrom as string | undefined + const dateTo = req.query.dateTo as string | undefined + + const where: any = { orgId: req.user!.orgId } + if (dateFrom || dateTo) { + where.createdAt = {} + if (dateFrom) where.createdAt.gte = new Date(dateFrom) + if (dateTo) where.createdAt.lte = new Date(`${dateTo}T23:59:59`) + } + + const stats = await prisma.auditLog.groupBy({ + by: ['action'], + where, + _count: { action: true }, + orderBy: { _count: { action: 'desc' } }, + take: 20, + }) + + res.json({ success: true, data: stats }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index dc5f2dc..d564e6b 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -4,11 +4,10 @@ import { register, login, refresh, resetPassword } from '../services/auth.servic import { authLimiter, loginLimiter } from '../middleware/rateLimit' import prisma from '../lib/prisma' import bcrypt from 'bcryptjs' +import { setCode, getCode, deleteCode } from '../lib/codeStore' const router = Router() -const codeStore = new Map() - router.post('/register', authLimiter, async (req, res, next) => { try { const data = registerSchema.parse(req.body) @@ -48,7 +47,7 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => 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 }) + await setCode(data.phone, code) res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } }) } catch (err) { next(err) @@ -59,14 +58,14 @@ router.post('/forgot-password/send-code', authLimiter, async (req, res, next) => 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()) { + const stored = await getCode(data.phone) + if (!stored) { 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) + await deleteCode(data.phone) const passwordHash = await bcrypt.hash(data.newPassword, 10) await prisma.user.updateMany({ where: { phone: data.phone }, @@ -81,11 +80,11 @@ router.post('/forgot-password/verify', authLimiter, async (req, res, next) => { router.post('/reset-password', authLimiter, async (req, res, next) => { try { const data = resetPasswordSchema.parse(req.body) - const stored = codeStore.get(data.phone) - if (!stored || stored.expiresAt < Date.now()) { + const stored = await getCode(data.phone) + if (!stored) { return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) } - codeStore.delete(data.phone) + await deleteCode(data.phone) const result = await resetPassword(data.phone, data.newPassword) res.json({ success: true, data: result }) } catch (err) { diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts index 45607c2..76666ae 100644 --- a/backend/src/routes/dashboard.routes.ts +++ b/backend/src/routes/dashboard.routes.ts @@ -1,7 +1,7 @@ import { Router, Response, NextFunction } from 'express' import prisma from '../lib/prisma' import { authMiddleware, AuthRequest } from '../middleware/auth' -import { getDashboardData } from '../services/risk.service' +import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getComplianceScore, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service' import { z } from 'zod' const router = Router() @@ -77,4 +77,98 @@ router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res } }) +// HR 月度日历 +router.get('/calendar', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const data = await getMonthlyCalendar(req.user!.orgId, month) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 人力成本深度分析 +router.get('/cost-analysis', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const data = await getCostAnalysis(req.user!.orgId, month) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 合规健康度评分 + AI 建议卡片流 +router.get('/compliance-score', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = await getComplianceScore(req.user!.orgId) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 用工体检诊断 — 获取当前诊断 +router.get('/health-check', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = await getHealthCheck(req.user!.orgId) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 用工体检诊断 — 保存报告 +router.post('/health-check/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const report = await saveHealthCheckReport(req.user!.orgId, req.user!.id) + res.json({ success: true, data: { id: (report as any).id } }) + } catch (err) { + next(err) + } +}) + +// 用工体检诊断 — 历史报告 +router.get('/health-check/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = await getHealthCheckHistory(req.user!.orgId) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 年度价值报告 — 获取当年报告 +router.get('/annual-value', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const year = parseInt(req.query.year as string) || new Date().getFullYear() + const data = await getAnnualValueReport(req.user!.orgId, year) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +// 年度价值报告 — 保存 +router.post('/annual-value/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const year = parseInt(req.body.year) || new Date().getFullYear() + const report = await saveAnnualValueReport(req.user!.orgId, req.user!.id, year) + res.json({ success: true, data: { id: (report as any).id } }) + } catch (err) { + next(err) + } +}) + +// 年度价值报告 — 历史 +router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = await getAnnualValueReportHistory(req.user!.orgId) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index 1587f3b..1cfbcad 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' +import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' import { createEmployeeSchema, @@ -49,6 +50,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { 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 }) + await createEvidence({ + orgId: req.user!.orgId, + category: 'ONBOARD', + refId: result.id, + employeeId: result.id, + events: [{ action: '员工入职登记', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.user!.id, + }).catch(() => {}) res.json({ success: true, data: result }) } catch (err) { next(err) @@ -199,6 +208,14 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => 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 }) + await createEvidence({ + orgId: req.user!.orgId, + category: 'CONTRACT_SIGN', + refId: result.id, + employeeId: data.employeeId, + events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.user!.id, + }).catch(() => {}) res.json({ success: true, data: result }) } catch (err) { next(err) diff --git a/backend/src/routes/evidence.routes.ts b/backend/src/routes/evidence.routes.ts new file mode 100644 index 0000000..5b87214 --- /dev/null +++ b/backend/src/routes/evidence.routes.ts @@ -0,0 +1,88 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { getEvidenceByRef, getEvidenceByEmployee, getEvidenceDetail, verifyEvidence, getEvidenceList, verifyAllEvidence } from '../services/evidence.service' + +const router = Router() + +/** + * 获取组织级证据链列表 + * GET /api/v1/evidence?category=ALL&page=1&pageSize=20 + */ +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const category = (req.query.category as string) || 'ALL' + const page = parseInt(req.query.page as string) || 1 + const pageSize = parseInt(req.query.pageSize as string) || 20 + const data = await getEvidenceList(req.user!.orgId, category, page, pageSize) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +/** + * 验证全部证据链完整性 + * GET /api/v1/evidence/verify-all + */ +router.get('/verify-all', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = await verifyAllEvidence(req.user!.orgId) + res.json({ success: true, data }) + } catch (err) { + next(err) + } +}) + +/** + * 获取某操作的完整证据链 + * GET /api/v1/evidence/:category/:refId + */ +router.get('/:category/:refId', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const evidence = await getEvidenceByRef(req.user!.orgId, req.params.category, req.params.refId) + res.json({ success: true, data: evidence }) + } catch (err) { + next(err) + } +}) + +/** + * 获取某员工所有证据链 + * GET /api/v1/evidence/employee/:id + */ +router.get('/employee/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const evidence = await getEvidenceByEmployee(req.user!.orgId, req.params.id) + res.json({ success: true, data: evidence }) + } catch (err) { + next(err) + } +}) + +/** + * 获取证据链详情 + * GET /api/v1/evidence/detail/:id + */ +router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const evidence = await getEvidenceDetail(req.user!.orgId, req.params.id) + res.json({ success: true, data: evidence }) + } catch (err) { + next(err) + } +}) + +/** + * 验证证据链完整性 + * GET /api/v1/evidence/verify/:id + */ +router.get('/verify/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const result = await verifyEvidence(req.user!.orgId, req.params.id) + res.json({ success: true, data: result }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index f7e67a9..e529c25 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -8,6 +8,7 @@ import { calcBatchEntry, getPayrollRiskWarnings, generatePayslipFromBatches, + prePayrollCheck, } from '../services/payroll.service' const router = Router() @@ -670,4 +671,17 @@ router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, n } }) +// 算薪前 AI 校验 +router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const result = await prePayrollCheck(req.user!.orgId, req.params.batchId) + 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) + } +}) + export default router diff --git a/backend/src/routes/policy.routes.ts b/backend/src/routes/policy.routes.ts new file mode 100644 index 0000000..c066b27 --- /dev/null +++ b/backend/src/routes/policy.routes.ts @@ -0,0 +1,123 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { z } from 'zod' +import prisma from '../lib/prisma' +import { + createPolicy, getPolicies, getPolicyDetail, updatePolicy, + advanceDemocracyStep, deletePolicy, +} from '../services/policy.service' + +const router = Router() + +/** 获取制度列表 */ +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const status = req.query.status as string | undefined + const policies = await getPolicies(req.user!.orgId, status) + res.json({ success: true, data: policies }) + } catch (err) { + next(err) + } +}) + +/** 获取制度详情 */ +router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const policy = await getPolicyDetail(req.user!.orgId, req.params.id) + res.json({ success: true, data: policy }) + } catch (err) { + next(err) + } +}) + +/** 创建制度 */ +router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ + title: z.string().min(1), + content: z.string().min(1), + type: z.string().optional(), + }) + const data = schema.parse(req.body) + const policy = await createPolicy(req.user!.orgId, req.user!.id, data) + res.json({ success: true, data: { id: policy.id } }) + } catch (err) { + next(err) + } +}) + +/** 更新制度 */ +router.put('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ + title: z.string().optional(), + content: z.string().optional(), + type: z.string().optional(), + }) + const data = schema.parse(req.body) + await updatePolicy(req.user!.orgId, req.params.id, data) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +/** 推进民主程序步骤 */ +router.post('/:id/advance-step', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const schema = z.object({ step: z.number().int().min(1).max(4), note: z.string().optional() }) + const { step, note } = schema.parse(req.body) + await advanceDemocracyStep(req.user!.orgId, req.params.id, step, note) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +/** 删除制度 */ +router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + await deletePolicy(req.user!.orgId, req.params.id) + res.json({ success: true }) + } catch (err) { + next(err) + } +}) + +/** 获取制度的阅读签收统计 */ +router.get('/:id/read-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const policy = await prisma.policyDocument.findFirst({ where: { id: req.params.id, orgId }, select: { id: true } }) + if (!policy) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在' } }) + } + const [totalEmployees, readRecords] = await Promise.all([ + prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), + prisma.policyReadRecord.findMany({ + where: { policyId: req.params.id, orgId }, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { readAt: 'desc' }, + }), + ]) + res.json({ + success: true, + data: { + total: totalEmployees, + readCount: readRecords.length, + unreadCount: totalEmployees - readRecords.length, + records: readRecords.map(r => ({ + employeeId: r.employeeId, + employeeName: r.employee.name, + department: r.employee.department, + readAt: r.readAt.toISOString(), + ip: r.ip, + })), + }, + }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index e43db11..fca0d14 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -6,12 +6,11 @@ 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' +import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore' +import { createEvidence } from '../services/evidence.service' const router = Router() -// 验证码临时存储(生产环境应使用 Redis) -const codeStore = new Map() - // 员工端认证中间件 function portalAuth(req: Request, res: Response, next: NextFunction) { const authHeader = req.headers.authorization @@ -63,12 +62,12 @@ router.post('/send-code', async (req, res, next) => { 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) { + const allowed = await checkRateLimit(data.phone) + if (!allowed) { 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() }) + await setCode(data.phone, code) res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } }) } catch (err) { next(err) @@ -79,20 +78,20 @@ router.post('/send-code', async (req, res, next) => { 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()) { + const stored = await getCode(data.phone) + if (!stored) { return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) } // 错误次数限制:5次后锁定 if (stored.failCount >= 5) { - codeStore.delete(data.phone) + await deleteCode(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}次机会)` } }) + await updateCode(data.phone, { failCount: stored.failCount + 1 }) + return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } }) } - codeStore.delete(data.phone) + await deleteCode(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: '员工不存在' } }) @@ -158,6 +157,14 @@ router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => { channel: 'IN_APP', }, }) + await createEvidence({ + orgId: req.employee.orgId, + category: 'PAYSLIP_CONFIRM', + refId: payslip.id, + employeeId: req.employee.id, + events: [{ action: '工资条确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.employee.id, + }).catch(() => {}) res.json({ success: true }) } catch (err) { next(err) @@ -231,7 +238,7 @@ router.post('/contract-confirm/send-code', async (req, res, next) => { 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() }) + await setCode(`contract-${data.token}`, code) res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } }) } catch (err) { next(err) @@ -250,19 +257,19 @@ router.post('/contract-confirm', async (req, res, next) => { 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()) { + const stored = await getCode(`contract-${data.token}`) + if (!stored) { return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } }) } if (stored.failCount >= 5) { - codeStore.delete(`contract-${data.token}`) + await deleteCode(`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}次机会)` } }) + await updateCode(`contract-${data.token}`, { failCount: stored.failCount + 1 }) + return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } }) } - codeStore.delete(`contract-${data.token}`) + await deleteCode(`contract-${data.token}`) const userAgent = req.headers['user-agent'] || '' const signEvidence = JSON.stringify({ @@ -278,6 +285,14 @@ router.post('/contract-confirm', async (req, res, next) => { where: { id: link.contractId }, data: { attachmentName: `confirmed:${new Date().toISOString()}|evidence:${signEvidence}` }, }) + await createEvidence({ + orgId: link.contract.orgId, + category: 'CONTRACT_SIGN', + refId: link.contractId, + employeeId: link.contract.employeeId, + events: [{ action: '合同签署确认', timestamp: new Date().toISOString(), ip: req.ip, userAgent, smsCode: data.verifyCode }], + createdBy: link.contract.employeeId, + }).catch(() => {}) res.json({ success: true, data: { message: '合同签署确认成功' } }) } catch (err) { next(err) @@ -422,4 +437,111 @@ router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async } }) +// ========== 员工端:规章制度公示 ========== + +/** 获取已公示制度列表(含当前员工阅读状态) */ +router.get('/policies', portalAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const { orgId, id: employeeId } = (req as any).employee + const policies = await prisma.policyDocument.findMany({ + where: { orgId, status: 'PUBLISHED' }, + orderBy: { publishedAt: 'desc' }, + select: { + id: true, + title: true, + type: true, + publishedAt: true, + readRecords: { + where: { employeeId }, + select: { id: true, readAt: true }, + }, + }, + }) + const result = policies.map(p => ({ + id: p.id, + title: p.title, + type: p.type, + publishedAt: p.publishedAt?.toISOString() || null, + hasRead: p.readRecords.length > 0, + readAt: p.readRecords[0]?.readAt?.toISOString() || null, + })) + res.json({ success: true, data: result }) + } catch (err) { + next(err) + } +}) + +/** 获取制度详情(已公示) */ +router.get('/policies/:id', portalAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const { orgId, id: employeeId } = (req as any).employee + const policy = await prisma.policyDocument.findFirst({ + where: { id: req.params.id, orgId, status: 'PUBLISHED' }, + select: { + id: true, + title: true, + content: true, + type: true, + publishedAt: true, + readRecords: { + where: { employeeId }, + select: { id: true, readAt: true }, + }, + }, + }) + if (!policy) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } }) + } + res.json({ + success: true, + data: { + id: policy.id, + title: policy.title, + content: policy.content, + type: policy.type, + publishedAt: policy.publishedAt?.toISOString() || null, + hasRead: policy.readRecords.length > 0, + readAt: policy.readRecords[0]?.readAt?.toISOString() || null, + }, + }) + } catch (err) { + next(err) + } +}) + +/** 提交阅读确认(签收) */ +router.post('/policies/:id/read', portalAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const { orgId, id: employeeId } = (req as any).employee + const policy = await prisma.policyDocument.findFirst({ + where: { id: req.params.id, orgId, status: 'PUBLISHED' }, + select: { id: true }, + }) + if (!policy) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '制度不存在或未公示' } }) + } + + // 幂等:已存在则返回已有记录 + const existing = await prisma.policyReadRecord.findUnique({ + where: { policyId_employeeId: { policyId: req.params.id, employeeId } }, + }) + if (existing) { + return res.json({ success: true, data: { readAt: existing.readAt.toISOString() } }) + } + + const record = await prisma.policyReadRecord.create({ + data: { + policyId: req.params.id, + orgId, + employeeId, + ip: req.ip || req.socket.remoteAddress, + userAgent: req.headers['user-agent'] || null, + }, + }) + res.json({ success: true, data: { readAt: record.readAt.toISOString() } }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 627e7c5..7d3155f 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' +import { createEvidence } from '../services/evidence.service' import prisma from '../lib/prisma' import { decrypt, encrypt } from '../lib/crypto' import { getContractStatus } from '../services/contract.service' @@ -523,6 +524,14 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest }, }) await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId }) + await createEvidence({ + orgId: req.user!.orgId, + category: 'DISCIPLINARY', + refId: record.id, + employeeId: req.params.employeeId, + events: [{ action: '违纪记录创建', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.user!.id, + }).catch(() => {}) res.json({ success: true, data: record }) } catch (err) { next(err) } }) diff --git a/backend/src/routes/template.routes.ts b/backend/src/routes/template.routes.ts new file mode 100644 index 0000000..f73ad48 --- /dev/null +++ b/backend/src/routes/template.routes.ts @@ -0,0 +1,45 @@ +import { Router, Response, NextFunction } from 'express' +import { authMiddleware, AuthRequest } from '../middleware/auth' +import { getAllTemplates, getTemplateById, getTemplatesByCategory, renderTemplate } from '../services/template.service' + +const router = Router() + +/** 获取所有模板列表 */ +router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const category = req.query.category as string | undefined + const templates = category ? getTemplatesByCategory(category) : getAllTemplates() + res.json({ success: true, data: templates }) + } catch (err) { + next(err) + } +}) + +/** 获取模板详情 */ +router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const template = getTemplateById(req.params.id) + 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.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { variables } = req.body as { variables: Record } + const content = renderTemplate(req.params.id, variables || {}) + if (!content) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } }) + } + res.json({ success: true, data: { content } }) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts index 6ba7777..c70db6d 100644 --- a/backend/src/routes/termination.routes.ts +++ b/backend/src/routes/termination.routes.ts @@ -2,8 +2,9 @@ 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 { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service' import prisma from '../lib/prisma' +import { createEvidence } from '../services/evidence.service' const router = Router() @@ -63,6 +64,14 @@ router.post('/', authMiddleware, async (req: AuthRequest, res, next) => { 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 }) + await createEvidence({ + orgId: req.user!.orgId, + category: 'TERMINATION', + refId: result.id, + employeeId: data.employeeId, + events: [{ action: '解聘流程启动', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.user!.id, + }).catch(() => {}) res.json({ success: true, data: result }) } catch (err) { next(err) @@ -246,6 +255,14 @@ router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res, try { const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id) await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {}) + await createEvidence({ + orgId: req.user!.orgId, + category: 'TERMINATION', + refId: req.params.id, + employeeId: (result as any)?.employeeId, + events: [{ action: '解聘执行', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: req.user!.id, + }).catch(() => {}) res.json({ success: true, data: result }) } catch (err: any) { if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') { @@ -269,4 +286,18 @@ router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, n } }) +// 步骤前置校验 +router.get('/draft/:id/validate-step', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const step = parseInt(req.query.step as string) || 1 + const result = await validateTerminationStep(req.user!.orgId, req.params.id, step) + 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) + } +}) + export default router diff --git a/backend/src/services/attendance.service.ts b/backend/src/services/attendance.service.ts new file mode 100644 index 0000000..0f62e8a --- /dev/null +++ b/backend/src/services/attendance.service.ts @@ -0,0 +1,150 @@ +import prisma from '../lib/prisma' + +/** + * 考勤确认服务 + */ + +/** + * 创建月度考勤确认记录 + */ +export async function createAttendanceConfirmation(orgId: string, userId: string, data: { + employeeId: string + month: string + workDays: number + weekdayHours: number + weekendHours: number + holidayHours: number + overtimePay: number +}) { + const existing = await prisma.attendanceConfirmation.findUnique({ + where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } }, + }) + if (existing) { + throw { code: 'CONFLICT', message: '该月考勤确认记录已存在' } + } + + return prisma.attendanceConfirmation.create({ + data: { + orgId, + employeeId: data.employeeId, + month: data.month, + workDays: data.workDays, + weekdayHours: data.weekdayHours, + weekendHours: data.weekendHours, + holidayHours: data.holidayHours, + overtimePay: data.overtimePay, + createdBy: userId, + }, + }) +} + +/** + * 批量创建月度考勤确认记录 + */ +export async function batchCreateAttendanceConfirmations(orgId: string, userId: string, month: string, items: Array<{ + employeeId: string + workDays: number + weekdayHours: number + weekendHours: number + holidayHours: number + overtimePay: number +}>) { + const results: Array<{ employeeId: string; success: boolean; error?: string }> = [] + + for (const item of items) { + try { + const existing = await prisma.attendanceConfirmation.findUnique({ + where: { orgId_employeeId_month: { orgId, employeeId: item.employeeId, month } }, + }) + if (existing) { + // 更新已有记录 + await prisma.attendanceConfirmation.update({ + where: { id: existing.id }, + data: { + workDays: item.workDays, + weekdayHours: item.weekdayHours, + weekendHours: item.weekendHours, + holidayHours: item.holidayHours, + overtimePay: item.overtimePay, + status: 'PENDING', + }, + }) + } else { + await prisma.attendanceConfirmation.create({ + data: { + orgId, + employeeId: item.employeeId, + month, + workDays: item.workDays, + weekdayHours: item.weekdayHours, + weekendHours: item.weekendHours, + holidayHours: item.holidayHours, + overtimePay: item.overtimePay, + createdBy: userId, + }, + }) + } + results.push({ employeeId: item.employeeId, success: true }) + } catch (err: any) { + results.push({ employeeId: item.employeeId, success: false, error: err.message }) + } + } + + return { total: items.length, success: results.filter(r => r.success).length, results } +} + +/** + * 获取月度考勤确认列表 + */ +export async function getAttendanceConfirmations(orgId: string, month: string, status?: string) { + const where: any = { orgId, month } + if (status) where.status = status + + return prisma.attendanceConfirmation.findMany({ + where, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { employee: { name: 'asc' } }, + }) +} + +/** + * 员工确认考勤(员工端) + */ +export async function confirmAttendance(orgId: string, employeeId: string, month: string, ip: string, disputeNote?: string) { + const record = await prisma.attendanceConfirmation.findUnique({ + where: { orgId_employeeId_month: { orgId, employeeId, month } }, + }) + if (!record) { + throw { code: 'NOT_FOUND', message: '考勤确认记录不存在' } + } + + if (disputeNote) { + // 有异议 + return prisma.attendanceConfirmation.update({ + where: { id: record.id }, + data: { status: 'DISPUTED', disputeNote, confirmIp: ip }, + }) + } + + // 确认无误 + return prisma.attendanceConfirmation.update({ + where: { id: record.id }, + data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmIp: ip }, + }) +} + +/** + * 获取考勤确认统计 + */ +export async function getAttendanceStats(orgId: string, month: string) { + const records = await prisma.attendanceConfirmation.findMany({ + where: { orgId, month }, + }) + + return { + total: records.length, + pending: records.filter(r => r.status === 'PENDING').length, + confirmed: records.filter(r => r.status === 'CONFIRMED').length, + disputed: records.filter(r => r.status === 'DISPUTED').length, + } +} diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts new file mode 100644 index 0000000..e674aa2 --- /dev/null +++ b/backend/src/services/evidence.service.ts @@ -0,0 +1,174 @@ +import prisma from '../lib/prisma' +import { sha256 } from '../lib/crypto' + +/** + * 证据链服务 — 管理操作证据链,用于劳动仲裁举证 + */ + +export type EvidenceCategory = + | 'CONTRACT_SIGN' + | 'ONBOARD' + | 'PAYSLIP_CONFIRM' + | 'DISCIPLINARY' + | 'ATTENDANCE' + | 'TERMINATION' + +/** + * 创建证据链记录 + */ +export async function createEvidence(params: { + orgId: string + category: EvidenceCategory + refId?: string + employeeId?: string + events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }> + createdBy: string +}) { + const eventsJson = JSON.stringify(params.events) + const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || '')) + + return prisma.evidenceChain.create({ + data: { + orgId: params.orgId, + category: params.category, + refId: params.refId || null, + employeeId: params.employeeId || null, + events: params.events, + hash, + createdBy: params.createdBy, + }, + }) +} + +/** + * 追加证据事件到已有证据链 + */ +export async function appendEvidence(orgId: string, evidenceId: string, event: { action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }) { + const existing = await prisma.evidenceChain.findFirst({ where: { id: evidenceId, orgId } }) + if (!existing) { + throw { code: 'NOT_FOUND', message: '证据链不存在' } + } + + const events = [...(existing.events as any[]), event] + const eventsJson = JSON.stringify(events) + const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || '')) + + return prisma.evidenceChain.update({ + where: { id: evidenceId }, + data: { events, hash }, + }) +} + +/** + * 获取某操作的完整证据链 + */ +export async function getEvidenceByRef(orgId: string, category: string, refId: string) { + return prisma.evidenceChain.findMany({ + where: { orgId, category, refId }, + orderBy: { createdAt: 'asc' }, + }) +} + +/** + * 获取某员工所有证据链 + */ +export async function getEvidenceByEmployee(orgId: string, employeeId: string) { + return prisma.evidenceChain.findMany({ + where: { orgId, employeeId }, + orderBy: { createdAt: 'desc' }, + }) +} + +/** + * 获取证据链详情 + */ +export async function getEvidenceDetail(orgId: string, id: string) { + const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } }) + if (!evidence) { + throw { code: 'NOT_FOUND', message: '证据链不存在' } + } + return evidence +} + +/** + * 验证证据链完整性(重新计算哈希对比) + */ +export async function verifyEvidence(orgId: string, id: string): Promise<{ valid: boolean; expectedHash: string; actualHash: string }> { + const evidence = await prisma.evidenceChain.findFirst({ where: { id, orgId } }) + if (!evidence) { + throw { code: 'NOT_FOUND', message: '证据链不存在' } + } + + const eventsJson = JSON.stringify(evidence.events) + const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || '')) + + return { + valid: expectedHash === evidence.hash, + expectedHash, + actualHash: evidence.hash, + } +} + +/** + * 获取组织级证据链列表(支持按类别筛选) + */ +export async function getEvidenceList(orgId: string, category?: string, page: number = 1, pageSize: number = 20) { + const where: any = { orgId } + if (category && category !== 'ALL') { + where.category = category + } + + const [total, records] = await Promise.all([ + prisma.evidenceChain.count({ where }), + prisma.evidenceChain.findMany({ + where, + include: { employee: { select: { id: true, name: true, department: true } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]) + + return { + total, + page, + pageSize, + records: records.map((r: any) => { + const events = Array.isArray(r.events) ? r.events : [] + const firstEvent = events.length > 0 ? events[0] : null + const lastEvent = events.length > 0 ? events[events.length - 1] : null + return { + id: r.id, + category: r.category, + refId: r.refId, + employeeId: r.employeeId, + employeeName: r.employee?.name || null, + employeeDept: r.employee?.department || null, + eventCount: events.length, + firstAction: firstEvent?.action || null, + lastAction: lastEvent?.action || null, + firstTimestamp: firstEvent?.timestamp || null, + lastTimestamp: lastEvent?.timestamp || null, + hash: r.hash, + hashShort: r.hash ? r.hash.substring(0, 16) : null, + createdAt: r.createdAt.toISOString(), + } + }), + } +} + +/** + * 验证全部证据链完整性 + */ +export async function verifyAllEvidence(orgId: string) { + const records = await prisma.evidenceChain.findMany({ where: { orgId } }) + let valid = 0 + let invalid = 0 + for (const r of records) { + const eventsJson = JSON.stringify(r.events) + const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || '')) + if (expectedHash === r.hash) valid++ + else invalid++ + } + return { total: records.length, valid, invalid } +} diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 0b568d5..1ca180b 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -351,3 +351,259 @@ export async function generatePayslipFromBatches(orgId: string, month: string) { return { generated } } + +// ========== 算薪前 AI 校验 ========== + +export interface PayrollCheckItem { + code: string + name: string + status: 'PASS' | 'FAIL' | 'WARNING' + message: string + employeeId?: string + employeeName?: string + detail?: any +} + +export interface PayrollCheckResult { + checks: PayrollCheckItem[] + passedCount: number + failedCount: number + warningCount: number +} + +/** + * 算薪前校验:检查 10+ 项异常,把错误拦截在发放前 + */ +export async function prePayrollCheck(orgId: string, batchId: string): Promise { + const batch = await prisma.payrollBatch.findFirst({ + where: { id: batchId, orgId }, + include: { entries: { include: { employee: true } } }, + }) + if (!batch) { + throw { code: 'NOT_FOUND', message: '工资批次不存在' } + } + + const month = batch.month + const entries = batch.entries + const checks: PayrollCheckItem[] = [] + + // 获取社保公积金配置 + const [socialConfig, housingConfig] = await Promise.all([ + 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' }, + }), + ]) + + // 1. 社保基数是否在上下限范围内 + if (socialConfig) { + for (const entry of entries) { + const base = entry.employee.socialInsBase || entry.baseSalary + if (base < socialConfig.baseMin || base > socialConfig.baseMax) { + checks.push({ + code: 'SOCIAL_BASE_OUT_OF_RANGE', + name: '社保基数超出范围', + status: 'FAIL', + message: `${entry.employee.name} 的社保基数 ¥${base} 不在范围内(${socialConfig.baseMin} ~ ${socialConfig.baseMax})`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { base, min: socialConfig.baseMin, max: socialConfig.baseMax }, + }) + } + } + } + + // 2. 公积金基数是否在上下限范围内 + if (housingConfig) { + for (const entry of entries) { + const base = entry.employee.housingFundBase || entry.baseSalary + if (base < housingConfig.baseMin || base > housingConfig.baseMax) { + checks.push({ + code: 'HOUSING_BASE_OUT_OF_RANGE', + name: '公积金基数超出范围', + status: 'FAIL', + message: `${entry.employee.name} 的公积金基数 ¥${base} 不在范围内(${housingConfig.baseMin} ~ ${housingConfig.baseMax})`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { base, min: housingConfig.baseMin, max: housingConfig.baseMax }, + }) + } + } + } + + // 3. 加班时数是否超过法定上限(月 36 小时) + for (const entry of entries) { + const overtimeRecords = await prisma.overtimeRecord.findMany({ + where: { orgId, employeeId: entry.employeeId, month }, + }) + for (const ot of overtimeRecords) { + const totalOT = ot.weekdayHours + ot.weekendHours + ot.holidayHours + if (totalOT > 36) { + checks.push({ + code: 'OVERTIME_EXCEED_LIMIT', + name: '加班超法定上限', + status: 'WARNING', + message: `${entry.employee.name} 本月加班 ${totalOT} 小时,超过法定月上限 36 小时`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { totalHours: totalOT, limit: 36 }, + }) + } + } + } + + // 4. 个税累计预扣跳档检测 + const year = month.slice(0, 4) + for (const entry of entries) { + const prevPayslips = await prisma.payslip.findMany({ + where: { orgId, employeeId: entry.employeeId, month: { startsWith: year, lt: month } }, + select: { tax: true, totalPay: true }, + }) + if (prevPayslips.length >= 2) { + const avgTax = prevPayslips.reduce((s, p) => s + p.tax, 0) / prevPayslips.length + if (entry.tax > avgTax * 3 && entry.tax > 1000) { + checks.push({ + code: 'TAX_BRACKET_JUMP', + name: '个税跳档警告', + status: 'WARNING', + message: `${entry.employee.name} 本月个税 ¥${entry.tax} 明显高于往月均值 ¥${avgTax.toFixed(0)},可能存在累计预扣跳档`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { currentTax: entry.tax, avgTax }, + }) + } + } + } + + // 5. 试用期工资是否低于合同工资 80% + for (const entry of entries) { + const latestContract = await prisma.laborContract.findFirst({ + where: { employeeId: entry.employeeId, orgId }, + orderBy: { createdAt: 'desc' }, + }) + if (latestContract && latestContract.probationMonths > 0 && latestContract.probationSalary > 0) { + const probationEnd = new Date(latestContract.startDate) + probationEnd.setMonth(probationEnd.getMonth() + latestContract.probationMonths) + if (probationEnd > new Date() && entry.baseSalary < latestContract.probationSalary * 0.8) { + checks.push({ + code: 'PROBATION_SALARY_TOO_LOW', + name: '试用期工资低于法定下限', + status: 'FAIL', + message: `${entry.employee.name} 试用期工资 ¥${entry.baseSalary} 低于合同工资的 80%(¥${latestContract.probationSalary * 0.8})`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { actualSalary: entry.baseSalary, minSalary: latestContract.probationSalary * 0.8 }, + }) + } + } + } + + // 6. 离职员工是否多算了一个月 + for (const entry of entries) { + if (entry.employee.status === 'RESIGNED') { + const termination = await prisma.terminationRecord.findFirst({ + where: { employeeId: entry.employeeId, status: { notIn: ['CANCELLED', 'DRAFT'] } }, + orderBy: { terminationDate: 'desc' }, + }) + if (termination) { + const termMonth = termination.terminationDate.toISOString().slice(0, 7) + if (month > termMonth) { + checks.push({ + code: 'RESIGNED_OVERPAY', + name: '离职员工多算工资', + status: 'FAIL', + message: `${entry.employee.name} 已于 ${termination.terminationDate.toISOString().slice(0, 10)} 离职,但 ${month} 仍有工资记录`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { terminationDate: termination.terminationDate, payrollMonth: month }, + }) + } + } + } + } + + // 7. 新入职员工是否按实际入职日折算 + for (const entry of entries) { + const hireDate = entry.employee.hireDate + const hireMonth = hireDate.toISOString().slice(0, 7) + if (hireMonth === month) { + const daysInMonth = new Date(hireDate.getFullYear(), hireDate.getMonth() + 1, 0).getDate() + const actualWorkDays = daysInMonth - hireDate.getDate() + 1 + // 如果基本工资等于整月工资,提示可能未折算 + const fullMonthSalary = Number(entry.employee.monthlySalary) || entry.baseSalary + if (Math.abs(entry.baseSalary - fullMonthSalary) < 1 && actualWorkDays < daysInMonth) { + checks.push({ + code: 'NEW_HIRE_NO_PRORATE', + name: '新入职未折算工资', + status: 'WARNING', + message: `${entry.employee.name} 本月 ${hireDate.getDate()} 日入职,工资可能未按实际天数折算(实际工作 ${actualWorkDays} 天)`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { hireDate: hireDate.toISOString().slice(0, 10), actualWorkDays, daysInMonth }, + }) + } + } + } + + // 8. 社保是否在入职 30 天内参保 + for (const entry of entries) { + const hireDate = entry.employee.hireDate + const socialStart = entry.employee.socialInsStartMonth + if (socialStart) { + const socialStartDate = new Date(socialStart + '-01') + const daysDiff = Math.floor((socialStartDate.getTime() - hireDate.getTime()) / 86400000) + if (daysDiff > 30) { + checks.push({ + code: 'SOCIAL_INS_LATE_ENROLL', + name: '社保参保延迟', + status: 'WARNING', + message: `${entry.employee.name} 入职 ${daysDiff} 天后才参保社保,超过 30 天法定期限`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + detail: { hireDate: hireDate.toISOString().slice(0, 10), socialStart, daysDiff }, + }) + } + } + } + + // 9. 离职当月社保是否已停保 + for (const entry of entries) { + if (entry.employee.status === 'RESIGNED' && entry.employee.socialInsEndMonth) { + // 正常,已停保 + } else if (entry.employee.status === 'RESIGNED' && !entry.employee.socialInsEndMonth) { + checks.push({ + code: 'SOCIAL_INS_NOT_STOPPED', + name: '离职未停保', + status: 'WARNING', + message: `${entry.employee.name} 已离职但社保未停保,可能产生多缴费用`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + }) + } + } + + // 10. 基本工资为 0 + for (const entry of entries) { + if (entry.baseSalary === 0 && batch.type === 'REGULAR') { + checks.push({ + code: 'ZERO_BASE_SALARY', + name: '基本工资为 0', + status: 'WARNING', + message: `${entry.employee.name} 的基本工资为 0,请确认是否正确`, + employeeId: entry.employeeId, + employeeName: entry.employee.name, + }) + } + } + + // 汇总 + const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0 + const failedCount = checks.filter(c => c.status === 'FAIL').length + const warningCount = checks.filter(c => c.status === 'WARNING').length + + return { checks, passedCount, failedCount, warningCount } +} diff --git a/backend/src/services/policy.service.ts b/backend/src/services/policy.service.ts new file mode 100644 index 0000000..4a4fffb --- /dev/null +++ b/backend/src/services/policy.service.ts @@ -0,0 +1,164 @@ +import prisma from '../lib/prisma' + +/** + * 规章制度民主程序服务 + */ + +const DEMOCRACY_STEPS = [ + { step: 1, name: '起草制度', description: 'HR 起草规章制度文本' }, + { step: 2, name: '职工讨论', description: '提交职工代表大会或全体职工讨论,提出方案和意见' }, + { step: 3, name: '平等协商', description: '与工会或职工代表平等协商确定' }, + { step: 4, name: '公示告知', description: '向全体员工公示告知(公告栏/邮件/培训签收等)' }, +] + +/** + * 初始化民主程序进度 + */ +export function initDemocracyProgress() { + return { + currentStep: 1, + steps: DEMOCRACY_STEPS.map(s => ({ + ...s, + status: s.step === 1 ? 'IN_PROGRESS' : 'PENDING', + date: s.step === 1 ? new Date().toISOString().slice(0, 10) : null, + note: null, + })), + } +} + +/** + * 更新民主程序步骤 + */ +export function updateDemocracyStep(progress: any, targetStep: number, note?: string) { + const steps = progress.steps.map((s: any) => { + if (s.step < targetStep) { + return { ...s, status: 'COMPLETED' } + } else if (s.step === targetStep) { + return { ...s, status: 'COMPLETED', date: new Date().toISOString().slice(0, 10), note: note || s.note } + } + return s + }) + + // 设置下一步为进行中 + if (targetStep < DEMOCRACY_STEPS.length) { + const nextIdx = steps.findIndex((s: any) => s.step === targetStep + 1) + if (nextIdx >= 0) { + steps[nextIdx] = { ...steps[nextIdx], status: 'IN_PROGRESS', date: new Date().toISOString().slice(0, 10) } + } + } + + return { + currentStep: targetStep >= DEMOCRACY_STEPS.length ? DEMOCRACY_STEPS.length : targetStep + 1, + steps, + } +} + +/** + * 创建制度文档 + */ +export async function createPolicy(orgId: string, userId: string, data: { title: string; content: string; type?: string }) { + return prisma.policyDocument.create({ + data: { + orgId, + title: data.title, + content: data.content, + type: data.type || 'RULES', + status: 'DRAFT', + democracyProgress: initDemocracyProgress(), + createdBy: userId, + }, + }) +} + +/** + * 获取制度列表(含阅读签收统计) + */ +export async function getPolicies(orgId: string, status?: string) { + const where: any = { orgId } + if (status) where.status = status + const [policies, totalEmployees] = await Promise.all([ + prisma.policyDocument.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + include: { + _count: { select: { readRecords: true } }, + }, + }), + prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), + ]) + return policies.map(p => ({ + ...p, + readCount: p._count?.readRecords || 0, + totalEmployees, + })) +} + +/** + * 获取制度详情 + */ +export async function getPolicyDetail(orgId: string, id: string) { + const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } }) + if (!policy) { + throw { code: 'NOT_FOUND', message: '制度文档不存在' } + } + return policy +} + +/** + * 更新制度 + */ +export async function updatePolicy(orgId: string, id: string, data: { title?: string; content?: string; type?: string }) { + const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } }) + if (!policy) { + throw { code: 'NOT_FOUND', message: '制度文档不存在' } + } + if (policy.status === 'PUBLISHED') { + throw { code: 'CONFLICT', message: '已公示的制度不可编辑' } + } + + const updateData: any = {} + if (data.title !== undefined) updateData.title = data.title + if (data.content !== undefined) updateData.content = data.content + if (data.type !== undefined) updateData.type = data.type + + return prisma.policyDocument.update({ where: { id }, data: updateData }) +} + +/** + * 推进民主程序步骤 + */ +export async function advanceDemocracyStep(orgId: string, id: string, step: number, note?: string) { + const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } }) + if (!policy) { + throw { code: 'NOT_FOUND', message: '制度文档不存在' } + } + if (policy.status === 'PUBLISHED') { + throw { code: 'CONFLICT', message: '已公示的制度不可修改' } + } + + const progress = updateDemocracyStep(policy.democracyProgress, step, note) + const status = step >= 4 ? 'PUBLISHED' : step >= 3 ? 'CONSULTING' : step >= 2 ? 'DISCUSSING' : 'DRAFT' + + return prisma.policyDocument.update({ + where: { id }, + data: { + democracyProgress: progress, + status, + publishedAt: step >= 4 ? new Date() : null, + }, + }) +} + +/** + * 删除制度 + */ +export async function deletePolicy(orgId: string, id: string) { + const policy = await prisma.policyDocument.findFirst({ where: { id, orgId } }) + if (!policy) { + throw { code: 'NOT_FOUND', message: '制度文档不存在' } + } + if (policy.status === 'PUBLISHED') { + throw { code: 'CONFLICT', message: '已公示的制度不可删除' } + } + return prisma.policyDocument.delete({ where: { id } }) +} diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 5a60ebd..600a25d 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -1,10 +1,136 @@ import prisma from '../lib/prisma' import type { RiskLevel, RiskType } from '@prisma/client' +import { decrypt } from '../lib/crypto' function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) } +/** 解密员工月薪 */ +function safeDecryptSalary(encrypted: string): number { + try { + return Number(decrypt(encrypted)) || 0 + } catch { + return Number(encrypted) || 0 + } +} + +/** + * 风险量化:根据风险类型和员工数据估算预估损失金额 + * @returns { estimatedLoss, lossRange, deadline } + */ +function estimateRiskCost( + risk: { type: RiskType; level: RiskLevel; title: string; description: string }, + employee?: { hireDate: Date; monthlySalary?: string } | null, +): { estimatedLoss: number; lossRange: [number, number]; deadline?: Date } { + const today = new Date() + const salary = employee ? safeDecryptSalary(employee.monthlySalary || '0') : 0 + const workYears = employee + ? Math.max(0, (today.getFullYear() - employee.hireDate.getFullYear()) + + (today.getMonth() - employee.hireDate.getMonth()) / 12) + : 0 + const n = Math.max(0.5, Math.round(workYears * 2) / 2) // 经济补偿月数,不满半年算0.5 + + // 根据风险标题关键词判断类型并估算 + const title = risk.title + + // 未签合同(>30天):双倍工资 + if (title.includes('未签合同') && title.includes('天')) { + const daysMatch = title.match(/(\d+)天/) + const days = daysMatch ? parseInt(daysMatch[1]) : 0 + const months = Math.ceil(days / 30) + const loss = salary * months + return { + estimatedLoss: loss, + lossRange: [loss * 0.5, loss], + deadline: new Date(today.getTime() + 7 * 86400000), // 7天内处理 + } + } + + // 合同到期未续签:经济补偿 N × 月薪 + if (title.includes('到期') && title.includes('未续签')) { + const loss = salary * n + return { + estimatedLoss: loss, + lossRange: [loss, loss * 2], // 可能被认定为违法解除 2N + deadline: new Date(today.getTime() + 3 * 86400000), // 3天内处理 + } + } + + // 合同即将到期:提前准备 + if (title.includes('即将到期')) { + const daysMatch = title.match(/(\d+)天/) + const days = daysMatch ? parseInt(daysMatch[1]) : 30 + return { + estimatedLoss: 0, + lossRange: [0, salary * n], + deadline: new Date(today.getTime() + days * 86400000), + } + } + + // 试用期超限:工资差额 + if (title.includes('试用期') && title.includes('不合法')) { + const loss = salary * 0.2 * 1 // 试用期工资差额约20% + return { + estimatedLoss: loss, + lossRange: [loss, loss * 3], + deadline: new Date(today.getTime() + 14 * 86400000), // 14天内处理 + } + } + + // 三期/工伤/医疗期:违法解除风险 2N + if (title.includes('孕期') || title.includes('哺乳期') || title.includes('工伤') || title.includes('医疗期')) { + const loss = salary * n * 2 + return { + estimatedLoss: loss, + lossRange: [loss, loss * 2], + deadline: new Date(today.getTime() + 1 * 86400000), // 1天内处理 + } + } + + // 月度任务:无直接损失但可能有滞纳金 + if (title.includes('社保') || title.includes('公积金')) { + return { + estimatedLoss: 500, + lossRange: [100, 2000], + deadline: new Date(today.getTime() + 3 * 86400000), + } + } + + if (title.includes('工资') || title.includes('个税')) { + return { + estimatedLoss: 1000, + lossRange: [500, 5000], + deadline: new Date(today.getTime() + 1 * 86400000), + } + } + + // 默认 + return { + estimatedLoss: 0, + lossRange: [0, 0], + } +} + +/** + * 计算风险优先级 + */ +function calcPriority( + level: RiskLevel, + estimatedLoss: number, + deadline?: Date, +): 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' { + const today = new Date() + today.setHours(0, 0, 0, 0) + const daysLeft = deadline ? daysBetween(deadline, today) : 999 + + if (level === 'HIGH' && (daysLeft <= 3 || estimatedLoss >= 50000)) return 'URGENT' + if (level === 'HIGH') return 'HIGH' + if (level === 'MEDIUM' && estimatedLoss >= 10000) return 'HIGH' + if (level === 'MEDIUM') return 'MEDIUM' + return 'LOW' +} + export async function detectContractRisks(orgId: string) { const today = new Date() today.setHours(0, 0, 0, 0) @@ -247,6 +373,15 @@ export async function runRiskDetection(orgId: string) { const onboardingRisks = await detectOnboardingRisks(orgId) const monthlyTasks = await detectMonthlyTasks(orgId) + // 获取所有相关员工数据用于风险量化 + const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks] + .map(r => r.employeeId) + .filter(Boolean) as string[] + const employees = allEmployeeIds.length > 0 + ? await prisma.employee.findMany({ where: { id: { in: allEmployeeIds } } }) + : [] + const empMap = new Map(employees.map(e => [e.id, e])) + // 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重 const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks] const toCreate = [ @@ -255,8 +390,11 @@ export async function runRiskDetection(orgId: string) { ] if (toCreate.length > 0) { - await prisma.riskItem.createMany({ - data: toCreate.map((r) => ({ + // 为每个风险计算量化信息 + const createData = toCreate.map((r) => { + const emp = r.employeeId ? empMap.get(r.employeeId) : null + const cost = estimateRiskCost(r, emp) + return { orgId, employeeId: r.employeeId, type: r.type, @@ -264,8 +402,12 @@ export async function runRiskDetection(orgId: string) { title: r.title, description: r.description, actionUrl: r.actionUrl, - })), + estimatedLoss: cost.estimatedLoss, + lossRange: cost.lossRange, + deadline: cost.deadline, + } }) + await prisma.riskItem.createMany({ data: createData }) } return toCreate.length @@ -508,27 +650,50 @@ export async function getDashboardData(orgId: string) { 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]) => ({ + // 为所有风险项计算优先级和量化信息 + const todosWithCost = riskItems.map((r: typeof riskItems[number]) => { + const estimatedLoss = r.estimatedLoss || 0 + const priority = calcPriority(r.level, estimatedLoss, r.deadline || undefined) + const daysUntilDeadline = r.deadline ? daysBetween(r.deadline, new Date()) : null + return { id: r.id, - type: r.type as string, - level: r.level.toLowerCase() as string, + type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY', + level: r.level.toLowerCase() as 'high' | 'medium' | 'low', + priority, title: r.title, description: r.description, - employeeName: r.employee?.name || null, actionUrl: r.actionUrl || '/', + estimatedLoss, + lossRange: r.lossRange as [number, number] | null, + deadline: r.deadline?.toISOString().slice(0, 10) || null, + daysUntilDeadline, + } + }) + + // 按优先级排序:URGENT > HIGH > MEDIUM > LOW + const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } + todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]) + + const topRisks = todosWithCost + .filter((t) => t.priority === 'URGENT' || t.priority === 'HIGH') + .slice(0, 5) + .map((t) => ({ + id: t.id, + type: t.type as string, + level: t.level, + priority: t.priority, + title: t.title, + description: t.description, + estimatedLoss: t.estimatedLoss, + deadline: t.deadline, + daysUntilDeadline: t.daysUntilDeadline, + actionUrl: t.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 || '/', - })) + // 紧急风险横幅:最高优先级且预估损失>0 + const urgentRisk = todosWithCost.find((t) => t.priority === 'URGENT' && t.estimatedLoss > 0) || null + + const todos = todosWithCost const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({ id: r.id, @@ -547,6 +712,21 @@ export async function getDashboardData(orgId: string) { ? `下午好!今天有 ${pendingRisks} 件事需要处理` : `晚上好!今天有 ${pendingRisks} 件事需要处理` + // 风险趋势:本月新增 vs 上月 + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59) + const lastMonthRiskCount = await prisma.riskItem.count({ + where: { orgId, createdAt: { gte: lastMonthStart, lte: lastMonthEnd } }, + }) + const thisMonthRiskCount = await prisma.riskItem.count({ + where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, + }) + const riskTrend = { + thisMonth: thisMonthRiskCount, + lastMonth: lastMonthRiskCount, + change: thisMonthRiskCount - lastMonthRiskCount, + } + return { greeting, stats: { @@ -555,9 +735,11 @@ export async function getDashboardData(orgId: string) { todoCount: pendingRisks, monthlyOvertimePay, }, + urgentRisk, todos, resolvedTodos, riskDistribution, + riskTrend, topRisks, aiPrediction: null, payrollSummary, @@ -565,3 +747,833 @@ export async function getDashboardData(orgId: string) { yearCostSummary, } } + +/** + * HR 月度日历:聚合当月关键日期 + */ +export async function getMonthlyCalendar(orgId: string, month: string) { + const year = month.slice(0, 4) + const monthNum = parseInt(month.slice(5, 7)) + const monthStart = new Date(parseInt(year), monthNum - 1, 1) + const monthEnd = new Date(parseInt(year), monthNum, 0, 23, 59, 59) + + const events: Array<{ + date: string + type: string + title: string + employeeName?: string + employeeId?: string + actionUrl?: string + priority: 'high' | 'medium' | 'low' + }> = [] + + // 1. 合同到期 + const expiringContracts = await prisma.laborContract.findMany({ + where: { + orgId, + endDate: { gte: monthStart, lte: monthEnd }, + }, + include: { employee: true }, + }) + for (const c of expiringContracts) { + events.push({ + date: c.endDate!.toISOString().slice(0, 10), + type: 'CONTRACT_EXPIRY', + title: `${c.employee.name} 合同到期`, + employeeName: c.employee.name, + employeeId: c.employeeId, + actionUrl: '/contracts', + priority: 'high', + }) + } + + // 2. 试用期到期 + const probationExpiring = await prisma.laborContract.findMany({ + where: { + orgId, + probationMonths: { gt: 0 }, + }, + include: { employee: true }, + }) + for (const c of probationExpiring) { + const probationEnd = new Date(c.startDate) + probationEnd.setMonth(probationEnd.getMonth() + c.probationMonths) + if (probationEnd >= monthStart && probationEnd <= monthEnd) { + events.push({ + date: probationEnd.toISOString().slice(0, 10), + type: 'PROBATION_END', + title: `${c.employee.name} 试用期到期`, + employeeName: c.employee.name, + employeeId: c.employeeId, + actionUrl: '/roster', + priority: 'medium', + }) + } + } + + // 3. 离职/解聘日期 + const terminations = await prisma.terminationRecord.findMany({ + where: { + orgId, + terminationDate: { gte: monthStart, lte: monthEnd }, + status: { notIn: ['CANCELLED'] }, + }, + include: { employee: true }, + }) + for (const t of terminations) { + events.push({ + date: t.terminationDate.toISOString().slice(0, 10), + type: 'TERMINATION', + title: `${t.employee.name} 离职/解聘日`, + employeeName: t.employee.name, + employeeId: t.employeeId, + actionUrl: '/termination', + priority: 'high', + }) + } + + // 4. 入职周年 + const employees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + hireDate: { + gte: new Date(parseInt(year) - 50, monthNum - 1, 1), + lte: new Date(parseInt(year) - 1, monthNum - 1, 0), + }, + }, + }) + for (const emp of employees) { + const hireAnniversary = new Date(parseInt(year), emp.hireDate.getMonth(), emp.hireDate.getDate()) + if (hireAnniversary >= monthStart && hireAnniversary <= monthEnd) { + const years = parseInt(year) - emp.hireDate.getFullYear() + events.push({ + date: hireAnniversary.toISOString().slice(0, 10), + type: 'ANNIVERSARY', + title: `${emp.name} 入职${years}周年`, + employeeName: emp.name, + employeeId: emp.id, + actionUrl: '/roster', + priority: 'low', + }) + } + } + + // 5. 风险截止日期 + const riskDeadlines = await prisma.riskItem.findMany({ + where: { + orgId, + status: 'PENDING', + deadline: { gte: monthStart, lte: monthEnd }, + }, + include: { employee: true }, + }) + for (const r of riskDeadlines) { + events.push({ + date: r.deadline!.toISOString().slice(0, 10), + type: 'RISK_DEADLINE', + title: `${r.title} 处理截止日`, + employeeName: r.employee?.name, + employeeId: r.employeeId || undefined, + actionUrl: r.actionUrl || '/', + priority: r.level === 'HIGH' ? 'high' : 'medium', + }) + } + + // 6. 退休日期 + const retiringEmployees = await prisma.employee.findMany({ + where: { + orgId, + status: 'ACTIVE', + retirementDaysLeft: { gte: 0, lte: 365 }, + }, + }) + for (const emp of retiringEmployees) { + if (emp.retirementDaysLeft !== null) { + const retireDate = new Date() + retireDate.setDate(retireDate.getDate() + emp.retirementDaysLeft) + if (retireDate >= monthStart && retireDate <= monthEnd) { + events.push({ + date: retireDate.toISOString().slice(0, 10), + type: 'RETIREMENT', + title: `${emp.name} 达到法定退休年龄`, + employeeName: emp.name, + employeeId: emp.id, + actionUrl: '/roster', + priority: 'high', + }) + } + } + } + + // 按日期排序 + events.sort((a, b) => a.date.localeCompare(b.date)) + + return { month, events } +} + +/** + * 人力成本深度分析:同比/环比 + 归因 + */ +export async function getCostAnalysis(orgId: string, month: string) { + const year = parseInt(month.slice(0, 4)) + const monthNum = parseInt(month.slice(5, 7)) + + // 当月数据 + const currentMonthStart = new Date(year, monthNum - 1, 1) + const currentMonthEnd = new Date(year, monthNum, 0, 23, 59, 59) + + // 上月(环比) + const prevMonthStart = new Date(year, monthNum - 2, 1) + const prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59) + const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}` + + // 去年同月(同比) + const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1) + const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59) + const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}` + + // 获取各月归档批次汇总 + const getMonthCost = async (m: string) => { + const batches = await prisma.payrollBatch.findMany({ + where: { orgId, month: m, status: 'ARCHIVED' }, + }) + return batches.reduce((acc, b) => ({ + totalPay: acc.totalPay + b.totalPay, + totalSocialOrg: acc.totalSocialOrg + b.totalSocialOrg, + totalHousingOrg: acc.totalHousingOrg + b.totalHousingOrg, + totalTax: acc.totalTax + b.totalTax, + employeeCount: acc.employeeCount + b.employeeCount, + }), { totalPay: 0, totalSocialOrg: 0, totalHousingOrg: 0, totalTax: 0, employeeCount: 0 }) + } + + const current = await getMonthCost(month) + const prev = await getMonthCost(prevMonthStr) + const lastYear = await getMonthCost(lastYearMonthStr) + + const currentTotal = current.totalPay + current.totalSocialOrg + current.totalHousingOrg + const prevTotal = prev.totalPay + prev.totalSocialOrg + prev.totalHousingOrg + const lastYearTotal = lastYear.totalPay + lastYear.totalSocialOrg + lastYear.totalHousingOrg + + // 环比变化 + const momChange = prevTotal > 0 ? ((currentTotal - prevTotal) / prevTotal) * 100 : 0 + // 同比变化 + const yoyChange = lastYearTotal > 0 ? ((currentTotal - lastYearTotal) / lastYearTotal) * 100 : 0 + + // 人均成本 + const currentPerCapita = current.employeeCount > 0 ? currentTotal / current.employeeCount : 0 + const prevPerCapita = prev.employeeCount > 0 ? prevTotal / prev.employeeCount : 0 + const lastYearPerCapita = lastYear.employeeCount > 0 ? lastYearTotal / lastYear.employeeCount : 0 + + // 归因分析 + const factors: Array<{ factor: string; impact: number; description: string }> = [] + + // 1. 人数变化影响 + const headcountChange = current.employeeCount - prev.employeeCount + if (headcountChange !== 0 && prevPerCapita > 0) { + factors.push({ + factor: '人数变化', + impact: headcountChange * prevPerCapita, + description: `员工人数${headcountChange > 0 ? '增加' : '减少'} ${Math.abs(headcountChange)} 人,影响成本 ¥${(headcountChange * prevPerCapita).toFixed(2)}`, + }) + } + + // 2. 薪酬水平变化 + if (prevPerCapita > 0 && currentPerCapita > 0) { + const perCapitaChange = currentPerCapita - prevPerCapita + if (Math.abs(perCapitaChange) > 1) { + factors.push({ + factor: '人均薪酬变化', + impact: perCapitaChange * current.employeeCount, + description: `人均成本${perCapitaChange > 0 ? '上升' : '下降'} ¥${Math.abs(perCapitaChange).toFixed(2)},影响总成本 ¥${(perCapitaChange * current.employeeCount).toFixed(2)}`, + }) + } + } + + // 3. 社保公积金变化 + const socialChange = (current.totalSocialOrg + current.totalHousingOrg) - (prev.totalSocialOrg + prev.totalHousingOrg) + if (Math.abs(socialChange) > 1) { + factors.push({ + factor: '社保公积金变化', + impact: socialChange, + description: `社保公积金${socialChange > 0 ? '增加' : '减少'} ¥${Math.abs(socialChange).toFixed(2)}`, + }) + } + + return { + month, + current: { + totalCost: currentTotal, + totalPay: current.totalPay, + socialOrg: current.totalSocialOrg, + housingOrg: current.totalHousingOrg, + tax: current.totalTax, + employeeCount: current.employeeCount, + perCapita: currentPerCapita, + }, + monthOnMonth: { + prevMonth: prevMonthStr, + prevTotal: prevTotal, + prevPerCapita: prevPerCapita, + change: currentTotal - prevTotal, + changePercent: momChange, + }, + yearOnYear: { + lastYearMonth: lastYearMonthStr, + lastYearTotal: lastYearTotal, + lastYearPerCapita: lastYearPerCapita, + change: currentTotal - lastYearTotal, + changePercent: yoyChange, + }, + factors, + } +} + +/** + * 合规健康度评分 + AI 建议卡片流 + * 5 维度:合同管理 / 规章制度 / 考勤工时 / 薪酬工资 / 社保公积金 + * 评分 0-100,越高越健康 + */ +export async function getComplianceScore(orgId: string) { + const now = new Date() + + const [ + totalEmployees, + unsignedContracts, + expiringContracts, + pendingRisks, + highRisks, + resolvedRisks, + policiesWithoutPublish, + attendanceUnconfirmed, + unconfirmedPayslips, + totalPayslips, + socialConfig, + housingConfig, + lastMonthRisks, + ] = await Promise.all([ + prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), + prisma.laborContract.count({ where: { orgId, contractType: 'UNSIGNED', employee: { status: 'ACTIVE' } } }), + prisma.laborContract.count({ + where: { + orgId, + employee: { status: 'ACTIVE' }, + endDate: { gte: now, lte: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) }, + }, + }), + prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }), + prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }), + prisma.riskItem.count({ where: { orgId, status: 'RESOLVED' } }), + prisma.policyDocument.count({ where: { orgId, democracyProgress: { lt: 4 } } }), + prisma.attendanceConfirmation.count({ where: { orgId, status: 'PENDING' } }), + prisma.payslip.count({ where: { orgId, confirmedAt: null } }), + prisma.payslip.count({ where: { orgId } }), + prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), + prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), + prisma.riskItem.count({ + where: { + orgId, + createdAt: { + gte: new Date(now.getFullYear(), now.getMonth() - 1, 1), + lte: new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59), + }, + }, + }), + ]) + + // 5 维度评分计算 + const contractScore = Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5) + const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12) + const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8) + const salaryScore = totalPayslips > 0 + ? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100)) + : 100 + const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0) + + const dimensions = [ + { key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + expiringContracts }, + { key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish }, + { key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed }, + { key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips }, + { key: 'social', name: '社保公积金', score: socialScore, todoCount: 0 }, + ] + + const overallScore = Math.round( + dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length, + ) + + const level = overallScore >= 85 ? 'safe' : overallScore >= 60 ? 'warning' : 'danger' + const levelLabel = overallScore >= 85 ? '健康' : overallScore >= 60 ? '中等风险' : '高风险' + + // AI 建议卡片流:4 类分级 + const suggestions: Array<{ + type: 'danger' | 'warning' | 'value' | 'knowledge' + title: string + description: string + actionUrl: string + actionLabel: string + estimatedLoss?: number + }> = [] + + // 高风险卡片 + if (highRisks > 0) { + const topRisk = await prisma.riskItem.findFirst({ + where: { orgId, status: 'PENDING', level: 'HIGH' }, + include: { employee: true }, + orderBy: { estimatedLoss: 'desc' }, + }) + if (topRisk) { + suggestions.push({ + type: 'danger', + title: topRisk.title, + description: topRisk.description, + actionUrl: topRisk.actionUrl || '/roster', + actionLabel: '立即处理', + estimatedLoss: topRisk.estimatedLoss || 0, + }) + } + } + + // 中风险卡片 + if (policiesWithoutPublish > 0) { + suggestions.push({ + type: 'warning', + title: `${policiesWithoutPublish} 项制度未完成民主程序`, + description: '未公示的制度在仲裁中可能无效,建议尽快完成民主程序', + actionUrl: '/policies', + actionLabel: '去处理', + }) + } + if (expiringContracts > 0) { + suggestions.push({ + type: 'warning', + title: `${expiringContracts} 份合同即将到期`, + description: '30 天内到期的合同需及时续签或终止,否则可能面临双倍工资风险', + actionUrl: '/roster', + actionLabel: '查看合同', + }) + } + + // 价值卡片 + if (resolvedRisks > 0) { + const totalSaved = await prisma.riskItem.aggregate({ + where: { orgId, status: 'RESOLVED', estimatedLoss: { gt: 0 } }, + _sum: { estimatedLoss: true }, + }) + suggestions.push({ + type: 'value', + title: `已规避 ${fmtMoney(totalSaved._sum.estimatedLoss || 0)} 潜在损失`, + description: `累计处理 ${resolvedRisks} 项风险,有效降低仲裁败诉概率`, + actionUrl: '/audit', + actionLabel: '查看记录', + }) + } + + // 知识卡片 + if (lastMonthRisks > 0) { + suggestions.push({ + type: 'knowledge', + title: `上月新增 ${lastMonthRisks} 项风险`, + description: '关注风险趋势变化,及时调整管理策略', + actionUrl: '/', + actionLabel: '查看趋势', + }) + } + + return { + overallScore, + level, + levelLabel, + dimensions, + suggestions, + stats: { + totalEmployees, + pendingRisks, + highRisks, + resolvedRisks, + }, + } +} + +function fmtMoney(n: number): string { + if (n >= 10000) return `¥${(n / 10000).toFixed(1)}万` + return `¥${n.toLocaleString('zh-CN')}` +} + +/** + * 用工体检诊断:6 维度深度诊断 + 历史报告 + * 维度:合同管理 / 规章制度 / 社会保险 / 背景调查 / 工作时间 / 保密竞业 + */ +export async function getHealthCheck(orgId: string) { + const now = new Date() + const year = now.getFullYear() + + const [ + totalEmployees, + unsignedContracts, + expiringContracts, + contractsNoAttachment, + policiesWithoutPublish, + totalPolicies, + socialConfig, + housingConfig, + employeesNoSocial, + employeesNoHousing, + employeesNoBackgroundCheck, + overtimeExcessive, + employeesNoNDA, + employeesWithNDA, + ] = await Promise.all([ + prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), + prisma.laborContract.count({ where: { orgId, contractType: 'UNSIGNED', employee: { status: 'ACTIVE' } } }), + prisma.laborContract.count({ + where: { + orgId, + employee: { status: 'ACTIVE' }, + endDate: { gte: now, lte: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) }, + }, + }), + prisma.laborContract.count({ where: { orgId, attachmentUrl: null, employee: { status: 'ACTIVE' } } }), + prisma.policyDocument.count({ where: { orgId, democracyProgress: { lt: 4 } } }), + prisma.policyDocument.count({ where: { orgId } }), + prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), + prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), + prisma.employeeSocialInsRecord.count({ where: { orgId, endMonth: null } }), + prisma.employeeHousingFundRecord.count({ where: { orgId, endMonth: null } }), + prisma.employee.count({ where: { orgId, status: 'ACTIVE', id: { notIn: [] } } }), + prisma.overtimeRecord.count({ where: { orgId, weekdayHours: { gt: 36 } } }), + prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: { contractType: 'FIXED' } } } }), + prisma.laborContract.count({ where: { orgId, contractType: 'FIXED', employee: { status: 'ACTIVE' } } }), + ]) + + // 6 维度诊断 + const dimensions = [ + { + key: 'contract', + name: '合同管理', + score: Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5 - contractsNoAttachment * 3), + findings: [ + unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已签书面合同', + expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同', + contractsNoAttachment > 0 ? `${contractsNoAttachment} 份合同缺少附件,建议补传扫描件` : '合同附件齐全', + ].filter(Boolean), + recommendations: [ + unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '', + expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '', + contractsNoAttachment > 0 ? '上传合同扫描件作为电子存档' : '', + ].filter(Boolean), + }, + { + key: 'policy', + name: '规章制度', + score: totalPolicies > 0 + ? Math.max(0, 100 - policiesWithoutPublish * 12) + : 60, + findings: [ + totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`, + policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示', + ].filter(Boolean), + recommendations: [ + totalPolicies === 0 ? '建议至少建立员工手册、考勤制度、奖惩制度三项基础制度' : '', + policiesWithoutPublish > 0 ? '尽快完成民主程序四步:起草→讨论→协商→公示' : '', + ].filter(Boolean), + }, + { + key: 'social', + name: '社会保险', + score: (socialConfig ? 50 : 0) + (housingConfig ? 30 : 0) + (employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2)), + findings: [ + socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置', + housingConfig ? '公积金配置已设置' : '未配置公积金比例', + employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常', + ].filter(Boolean), + recommendations: [ + !socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '', + !housingConfig ? '根据当地政策配置公积金比例' : '', + ].filter(Boolean), + }, + { + key: 'background', + name: '背景调查', + score: 70, + findings: [ + '背景调查功能待接入第三方服务', + totalEmployees > 0 ? `当前 ${totalEmployees} 名在管员工,建议关键岗位完成背调` : '', + ].filter(Boolean), + recommendations: [ + '建议对财务、管理、技术核心岗位入职前完成背景调查', + ], + }, + { + key: 'workingtime', + name: '工作时间', + score: Math.max(0, 100 - overtimeExcessive * 10), + findings: [ + overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规', + ].filter(Boolean), + recommendations: [ + overtimeExcessive > 0 ? '月加班不得超过36小时,建议调休或减少加班安排' : '', + ].filter(Boolean), + }, + { + key: 'nda', + name: '保密竞业', + score: totalEmployees > 0 + ? Math.round((employeesWithNDA / Math.max(1, totalEmployees)) * 100) + : 100, + findings: [ + totalEmployees > 0 + ? `${employeesWithNDA}/${totalEmployees} 名员工签署保密/竞业协议` + : '暂无在管员工', + employeesNoNDA > 0 ? `${employeesNoNDA} 名员工未签保密协议,建议核心岗位补签` : '', + ].filter(Boolean), + recommendations: [ + employeesNoNDA > 0 ? '对技术、财务、销售岗位补签保密竞业协议' : '', + ].filter(Boolean), + }, + ] + + const totalScore = Math.round(dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length) + const level = totalScore >= 85 ? 'safe' : totalScore >= 60 ? 'warning' : 'danger' + + const summary = level === 'safe' + ? `整体用工合规状况良好(${totalScore}分),建议保持定期巡检` + : level === 'warning' + ? `整体用工存在中等风险(${totalScore}分),建议优先处理低分维度问题` + : `整体用工存在较高风险(${totalScore}分),建议立即整改低分维度` + + return { + year, + totalScore, + level, + dimensions, + summary, + } +} + +/** + * 保存体检诊断报告 + */ +export async function saveHealthCheckReport(orgId: string, userId: string) { + const result = await getHealthCheck(orgId) + + const report = await (prisma as any).healthCheckReport.create({ + data: { + orgId, + year: result.year, + totalScore: result.totalScore, + level: result.level, + dimensions: result.dimensions as any, + summary: result.summary, + createdBy: userId, + }, + }) + + return report +} + +/** + * 获取历史诊断报告列表 + */ +export async function getHealthCheckHistory(orgId: string) { + const reports = await (prisma as any).healthCheckReport.findMany({ + where: { orgId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }) + + return (reports as any[]).map((r: any) => ({ + id: r.id, + year: r.year, + totalScore: r.totalScore, + level: r.level, + summary: r.summary, + createdAt: r.createdAt.toISOString(), + })) +} + +/** + * 年度价值报告:量化系统为企业创造的价值 + */ +export async function getAnnualValueReport(orgId: string, year: number) { + const yearStart = new Date(year, 0, 1) + const yearEnd = new Date(year, 11, 31, 23, 59, 59) + + const [ + risksResolved, + lossAvoidedAgg, + aiConversations, + aiReviews, + contractsSigned, + payrollBatches, + terminations, + employeesManaged, + auditActions, + policiesPublished, + monthlyRisks, + ] = await Promise.all([ + prisma.riskItem.count({ + where: { + orgId, status: 'RESOLVED', + resolvedAt: { gte: yearStart, lte: yearEnd }, + }, + }), + prisma.riskItem.aggregate({ + where: { + orgId, status: 'RESOLVED', estimatedLoss: { gt: 0 }, + resolvedAt: { gte: yearStart, lte: yearEnd }, + }, + _sum: { estimatedLoss: true }, + }), + prisma.aIConversation.count({ + where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, + }), + prisma.aIReviewRecord.count({ + where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, + }), + prisma.laborContract.count({ + where: { + orgId, signDate: { gte: yearStart, lte: yearEnd }, + }, + }), + prisma.payrollBatch.count({ + where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, + }), + prisma.terminationRecord.count({ + where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, + }), + prisma.employee.count({ + where: { + orgId, + OR: [ + { hireDate: { gte: yearStart, lte: yearEnd } }, + { status: 'ACTIVE' }, + ], + }, + }), + prisma.auditLog.count({ + where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, + }), + prisma.policyDocument.count({ + where: { + orgId, status: 'PUBLISHED', + publishedAt: { gte: yearStart, lte: yearEnd }, + }, + }), + Promise.all( + Array.from({ length: 12 }, (_, m) => + prisma.riskItem.count({ + where: { + orgId, + createdAt: { + gte: new Date(year, m, 1), + lte: new Date(year, m + 1, 0, 23, 59, 59), + }, + }, + }).then((count) => ({ month: m + 1, count })), + ), + ), + ]) + + const lossAvoided = lossAvoidedAgg._sum.estimatedLoss || 0 + + // 月度事件时间轴 + const timeline = monthlyRisks.map((mr) => ({ + month: mr.month, + event: mr.count > 0 ? `发现 ${mr.count} 项风险` : '平稳运行', + value: mr.count, + })) + + // 价值计算 + // 1. 规避损失 = 已解决风险的预估损失之和 + // 2. 节约工时 = AI 查询 * 0.5h + 合同审查 * 1h + 算薪批次 * 2h + 制度公示 * 1h + // 3. 节约成本 = 节约工时 * 100 元/h(HR 平均时薪) + const aiTimeSaved = aiConversations * 0.5 + aiReviews * 1.0 + const payrollTimeSaved = payrollBatches * 2.0 + const policyTimeSaved = policiesPublished * 1.0 + const contractTimeSaved = contractsSigned * 0.5 + const timeSaved = Math.round(aiTimeSaved + payrollTimeSaved + policyTimeSaved + contractTimeSaved) + const costSaved = timeSaved * 100 + + // 总价值 = 规避损失 + 节约成本 + const totalValue = lossAvoided + costSaved + + // ROI = 总价值 / 系统成本(假设年费 12000 元) + const systemCost = 12000 + const roi = systemCost > 0 ? Math.round((totalValue / systemCost) * 100) : 0 + + const metrics = { + risksResolved, + lossAvoided, + aiQueries: aiConversations, + contractsReviewed: aiReviews, + contractsSigned, + payrollProcessed: payrollBatches, + terminations, + employeesManaged, + auditActions, + policiesPublished, + } + + const summary = roi >= 1000 + ? `${year} 年系统创造价值约 ${fmtMoney(totalValue)},ROI 达 ${roi}%,规避潜在损失 ${fmtMoney(lossAvoided)},节约 HR 工时 ${timeSaved} 小时` + : roi >= 300 + ? `${year} 年系统创造价值约 ${fmtMoney(totalValue)},ROI ${roi}%,处理风险 ${risksResolved} 项,AI 辅助 ${aiConversations + aiReviews} 次` + : `${year} 年系统已运行,处理风险 ${risksResolved} 项,AI 辅助 ${aiConversations + aiReviews} 次,建议加强使用深度` + + return { + year, + totalValue, + roi, + metrics, + timeline, + costSaved, + timeSaved, + lossAvoided, + summary, + } +} + +/** + * 保存年度价值报告 + */ +export async function saveAnnualValueReport(orgId: string, userId: string, year: number) { + const result = await getAnnualValueReport(orgId, year) + + const report = await (prisma as any).annualValueReport.create({ + data: { + orgId, + year: result.year, + totalValue: result.totalValue, + roi: result.roi, + metrics: result.metrics as any, + timeline: result.timeline as any, + costSaved: result.costSaved, + timeSaved: result.timeSaved, + summary: result.summary, + createdBy: userId, + }, + }) + + return report +} + +/** + * 获取历史年度价值报告 + */ +export async function getAnnualValueReportHistory(orgId: string) { + const reports = await (prisma as any).annualValueReport.findMany({ + where: { orgId }, + orderBy: { year: 'desc' }, + take: 10, + }) + + return (reports as any[]).map((r: any) => ({ + id: r.id, + year: r.year, + totalValue: r.totalValue, + roi: r.roi, + summary: r.summary, + createdAt: r.createdAt.toISOString(), + })) +} diff --git a/backend/src/services/template.service.ts b/backend/src/services/template.service.ts new file mode 100644 index 0000000..d619a60 --- /dev/null +++ b/backend/src/services/template.service.ts @@ -0,0 +1,269 @@ +/** + * 用工文本模板库 + * 提供合同、制度、通知等常用文本模板,HR 可基于模板快速生成文档 + */ + +export interface DocumentTemplate { + id: string + name: string + category: 'CONTRACT' | 'RULES' | 'NOTICE' | 'AGREEMENT' | 'OTHER' + description: string + content: string + variables: string[] // 模板变量列表,如 ['employeeName', 'startDate', 'salary'] +} + +export const documentTemplates: DocumentTemplate[] = [ + { + id: 'tpl_fixed_term_contract', + name: '固定期限劳动合同', + category: 'CONTRACT', + description: '标准固定期限劳动合同模板,适用于大多数正式员工', + variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'], + content: `劳动合同书 + +甲方(用人单位):{{companyName}} +乙方(劳动者):{{employeeName}} + +根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订本劳动合同。 + +第一条 合同期限 +本合同为固定期限劳动合同,自{{startDate}}起至{{endDate}}止,其中试用期{{probationMonths}}个月。 + +第二条 工作内容和工作地点 +乙方担任{{position}}岗位,工作地点为{{workplace}}。 + +第三条 工作时间和休息休假 +甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。 + +第四条 劳动报酬 +乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。 + +第五条 社会保险和福利 +甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。 + +第六条 劳动保护、劳动条件和职业危害防护 +甲方为乙方提供符合国家规定的劳动保护条件。 + +第七条 合同解除和终止 +双方解除和终止劳动合同,应严格按照《劳动合同法》的规定执行。 + +第八条 违约责任 +任何一方违反本合同约定,应承担相应的违约责任。 + +第九条 争议解决 +因履行本合同发生的争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。 + +第十条 其他 +本合同一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。 + +甲方(盖章):____________ 乙方(签字):____________ +日期:____年__月__日 日期:____年__月__日`, + }, + { + id: 'tpl_open_ended_contract', + name: '无固定期限劳动合同', + category: 'CONTRACT', + description: '无固定期限劳动合同模板,适用于符合签订条件的情况', + variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'], + content: `无固定期限劳动合同书 + +甲方(用人单位):{{companyName}} +乙方(劳动者):{{employeeName}} + +根据《中华人民共和国劳动合同法》及相关法律法规,甲乙双方在平等自愿、协商一致的基础上,签订无固定期限劳动合同。 + +第一条 合同期限 +本合同为无固定期限劳动合同,自{{startDate}}起生效,至法定终止条件出现时终止。 + +第二条 工作内容 +乙方担任{{position}}岗位。 + +第三条 劳动报酬 +乙方月工资为人民币{{monthlySalary}}元。 + +(其余条款参照固定期限劳动合同模板) + +甲方(盖章):____________ 乙方(签字):____________ +日期:____年__月__日 日期:____年__月__日`, + }, + { + id: 'tpl_termination_agreement', + name: '解除劳动合同协议书', + category: 'AGREEMENT', + description: '协商一致解除劳动合同协议书模板', + variables: ['companyName', 'employeeName', 'idCard', 'terminationDate', 'compensation', 'lastWorkDay', 'socialInsEndMonth', 'housingFundEndMonth'], + content: `解除劳动合同协议书 + +甲方(用人单位):{{companyName}} +乙方(劳动者):{{employeeName}},身份证号:{{idCard}} + +甲乙双方经协商一致,就解除劳动合同事宜达成如下协议: + +一、解除日期 +双方同意于{{terminationDate}}解除劳动合同,乙方最后工作日为{{lastWorkDay}}。 + +二、经济补偿 +甲方同意向乙方支付经济补偿金人民币{{compensation}}元,于乙方办理完工作交接手续后__个工作日内一次性支付。 + +三、工资结算 +甲方结清乙方截至解除日的所有工资、加班费等劳动报酬。 + +四、社会保险和公积金 +甲方为乙方缴纳社会保险至{{socialInsEndMonth}}月,住房公积金缴存至{{housingFundEndMonth}}月。 + +五、工作交接 +乙方应在解除日前完成工作交接,归还甲方所有财物和资料。 + +六、保密义务 +乙方解除劳动合同后,仍应遵守保密义务,不得泄露甲方商业秘密。 + +七、争议解决 +本协议履行过程中如发生争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。 + +八、其他 +本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。 + +甲方(盖章):____________ 乙方(签字):____________ +日期:____年__月__日 日期:____年__月__日`, + }, + { + id: 'tpl_employee_handbook_notice', + name: '员工手册公示通知', + category: 'NOTICE', + description: '员工手册公示通知模板,用于民主程序第四步公示', + variables: ['companyName', 'publishDate', 'effectiveDate'], + content: `关于发布《员工手册》的通知 + +致全体员工: + +经职工代表大会讨论通过,并经与工会平等协商,{{companyName}}现正式发布《员工手册》({{publishDate}}版),自{{effectiveDate}}起施行。 + +请全体员工认真阅读并遵守《员工手册》的各项规定。如有疑问,请联系人力资源部。 + +特此通知。 + +{{companyName}} +{{publishDate}}`, + }, + { + id: 'tpl_rules_discussion_notice', + name: '规章制度讨论通知', + category: 'NOTICE', + description: '规章制度职工讨论通知,用于民主程序第二步', + variables: ['companyName', 'meetingDate', 'meetingLocation', 'policyTitle'], + content: `关于召开职工代表大会讨论{{policyTitle}}的通知 + +致全体职工代表: + +根据《劳动合同法》第四条规定,用人单位制定规章制度应当经职工代表大会或全体职工讨论。现定于{{meetingDate}}在{{meetingLocation}}召开职工代表大会,讨论《{{policyTitle}}》草案。 + +请各位职工代表准时出席,充分发表意见和建议。 + +特此通知。 + +{{companyName}} +____年__月__日`, + }, + { + id: 'tpl_disciplinary_notice', + name: '违纪处分通知书', + category: 'NOTICE', + description: '员工违纪处分通知书模板', + variables: ['employeeName', 'department', 'violationDate', 'violationDescription', 'disciplineType', 'policyBasis', 'companyName'], + content: `违纪处分通知书 + +{{employeeName}}({{department}}): + +经查,你于{{violationDate}}存在以下违纪行为: +{{violationDescription}} + +上述行为违反了公司《{{policyBasis}}》的相关规定。根据公司规章制度,决定给予你以下处分: +{{disciplineType}} + +如对本处分决定有异议,你可以在收到本通知之日起__日内向人力资源部提出书面申诉。 + +特此通知。 + +{{companyName}} +____年__月__日 + +签收人:____________ 日期:____年__月__日`, + }, + { + id: 'tpl_probation_notice', + name: '试用期转正通知书', + category: 'NOTICE', + description: '试用期考核合格转正通知', + variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'], + content: `试用期转正通知书 + +{{employeeName}}: + +经考核,你在试用期内表现合格,符合岗位要求。现通知你: + +一、自{{regularDate}}起正式转正,担任{{position}}岗位。 +二、转正后月工资为人民币{{monthlySalary}}元。 +三、试用期至{{probationEndDate}}结束。 + +请继续遵守公司各项规章制度,努力工作。 + +{{companyName}} +____年__月__日`, + }, + { + id: 'tpl_contract_expiry_notice', + name: '合同到期不续签通知书', + category: 'NOTICE', + description: '合同到期公司决定不续签的通知', + variables: ['employeeName', 'contractEndDate', 'companyName', 'compensation', 'lastWorkDay'], + content: `劳动合同到期不续签通知书 + +{{employeeName}}: + +你与公司签订的劳动合同将于{{contractEndDate}}到期。经公司研究决定,合同到期后不再与你续签劳动合同。 + +请你于{{lastWorkDay}}前完成工作交接手续。公司将在你完成交接后,依法支付经济补偿金人民币{{compensation}}元。 + +特此通知。 + +{{companyName}} +____年__月__日 + +签收人:____________ 日期:____年__月__日`, + }, +] + +/** + * 获取所有模板 + */ +export function getAllTemplates() { + return documentTemplates.map(({ content, ...rest }) => rest) +} + +/** + * 获取模板详情(含内容) + */ +export function getTemplateById(id: string) { + return documentTemplates.find(t => t.id === id) || null +} + +/** + * 按分类获取模板 + */ +export function getTemplatesByCategory(category: string) { + return documentTemplates.filter(t => t.category === category) +} + +/** + * 渲染模板(替换变量) + */ +export function renderTemplate(id: string, variables: Record): string | null { + const template = getTemplateById(id) + if (!template) return null + + let content = template.content + for (const [key, value] of Object.entries(variables)) { + content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value) + } + return content +} diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index ee78379..d754730 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -808,3 +808,110 @@ export async function getTerminationDetail(orgId: string, recordId: string) { updatedAt: record.updatedAt.toISOString().slice(0, 10), } } + +/** + * 解聘流程步骤前置校验 + * 返回是否可以继续、阻断原因、警告信息 + */ +export async function validateTerminationStep(orgId: string, recordId: string, step: number) { + const record = await prisma.terminationRecord.findFirst({ + where: { id: recordId, orgId }, + include: { employee: true }, + }) + if (!record) { + throw { code: 'NOT_FOUND', message: '记录不存在' } + } + + const employee = record.employee + const reason = record.reason as string + const result: { + step: number + canProceed: boolean + blockReason?: string + warning?: string + legalBasis?: string + } = { step, canProceed: true } + + // Step 1: 选择员工 — 检查特殊群体 + if (step === 1) { + // 三期女职工 + 非过错解除 → 阻止 + if (employee.isPregnant && reason !== 'FAULT' && reason !== 'RESIGNATION') { + result.canProceed = false + result.blockReason = '该员工处于孕期/哺乳期,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)' + result.legalBasis = '《劳动合同法》第四十二条:女职工在孕期、产期、哺乳期内,用人单位不得依照第四十条、第四十一条的规定解除劳动合同' + } + // 工伤期间 + 非过错解除 → 阻止 + if (employee.isWorkInjured && reason !== 'FAULT' && reason !== 'RESIGNATION') { + result.canProceed = false + result.blockReason = '该员工工伤期间,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)' + result.legalBasis = '《劳动合同法》第四十二条:在本单位患职业病或者因工负伤并被确认丧失或者部分丧失劳动能力的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同' + } + // 医疗期内 + 非过错解除 → 阻止 + if (employee.isInMedicalPeriod && reason !== 'FAULT' && reason !== 'RESIGNATION') { + result.canProceed = false + result.blockReason = '该员工处于医疗期内,法律禁止以非过错理由解除劳动合同(《劳动合同法》第四十二条)' + result.legalBasis = '《劳动合同法》第四十二条:患病或者非因工负伤,在规定的医疗期内的,用人单位不得依照第四十条、第四十一条的规定解除劳动合同' + } + } + + // Step 3: 合规检查 — 检查关键合规项 + if (step === 3) { + const checklist = record.checklist as any + const overrides = record.checklistOverrides as any + + // 过错解除:必须通知工会 + if (reason === 'FAULT') { + const notifyUnion = checklist?.notify_union + const override = overrides?.notify_union + if (!notifyUnion && !override?.checked) { + result.warning = '未确认"已通知工会"。如未通知工会即解除,可能被认定为违法解除程序(2N 赔偿风险)' + result.legalBasis = '《劳动合同法》第四十三条:用人单位单方解除劳动合同,应当事先将理由通知工会' + } + } + + // 非过错解除:必须支付补偿金 + if (reason === 'NONFAULT' || reason === 'NEGOTIATED' || reason === 'LAYOFF') { + const compPaid = checklist?.compensation_paid + const override = overrides?.compensation_paid + if (!compPaid && !override?.checked) { + result.warning = '未确认"已支付经济补偿金"。非过错解除必须支付经济补偿金(N),未支付将面临劳动监察处罚和仲裁风险' + result.legalBasis = '《劳动合同法》第四十六条:用人单位依照本法第三十六条、第四十条、第四十一条规定解除劳动合同的,应当向劳动者支付经济补偿' + } + } + } + + // Step 4: 费用结算 — 必须先计算补偿金 + if (step === 4) { + if (reason !== 'RESIGNATION' && record.compensation === 0) { + const compPaid = (record.checklist as any)?.compensation_paid + if (!compPaid) { + result.canProceed = false + result.blockReason = '补偿金尚未计算。请先在 Step 4 费用结算中计算经济补偿金,再继续后续流程' + } + } + } + + // Step 5: 工作交接 — 检查交接清单 + if (step === 5) { + const handoverItems = record.handoverItems as any[] + if (handoverItems && handoverItems.length > 0) { + const incomplete = handoverItems.filter(h => !h.done) + if (incomplete.length > 0) { + result.warning = `还有 ${incomplete.length} 项工作交接未完成:${incomplete.map(h => h.label).join('、')}。建议完成后再执行解聘` + } + } + } + + // 合同已到期 + 选择"解除"而非"终止" → 警告 + if (step === 2 && reason !== 'EXPIRED' && reason !== 'RESIGNATION') { + const latestContract = await prisma.laborContract.findFirst({ + where: { employeeId: employee.id, orgId }, + orderBy: { createdAt: 'desc' }, + }) + if (latestContract?.endDate && new Date(latestContract.endDate) < new Date()) { + result.warning = '该员工合同已到期。建议使用"到期终止"(EXPIRED)而非解除,流程更简单且法律风险更低' + } + } + + return result +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b0ba407..2f1eae8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ -import { lazy, Suspense } from 'react' +import { lazy, Suspense, useState } from 'react' import { Routes, Route, Navigate } from 'react-router-dom' import { Toaster } from 'sonner' import { useAuthStore } from './store/authStore' import TopNav from './components/layout/TopNav' +import SidebarNav from './components/layout/SidebarNav' import MobileTabBar from './components/layout/MobileTabBar' import PageContainer from './components/layout/PageContainer' import { SkeletonPage } from './components/ui/Skeleton' @@ -18,11 +19,21 @@ const Roster = lazy(() => import('./pages/Roster')) const Termination = lazy(() => import('./pages/Termination')) const AIAssistant = lazy(() => import('./pages/AIAssistant')) const Settings = lazy(() => import('./pages/Settings')) +const Evidence = lazy(() => import('./pages/Evidence')) +const Policies = lazy(() => import('./pages/Policies')) +const Attendance = lazy(() => import('./pages/Attendance')) +const Templates = lazy(() => import('./pages/Templates')) +const AuditLog = lazy(() => import('./pages/AuditLog')) +const Notifications = lazy(() => import('./pages/Notifications')) 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')) +const MyPolicies = lazy(() => import('./pages/portal/MyPolicies')) +const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator')) +const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) +const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) @@ -37,16 +48,20 @@ function PublicRoute({ children }: { children: React.ReactNode }) { } function AdminLayout({ children }: { children: React.ReactNode }) { + const [sidebarOpen, setSidebarOpen] = useState(false) return ( -
- -
- - }>{children} - -
- - +
+ setSidebarOpen(false)} /> +
+ setSidebarOpen(true)} /> +
+ + }>{children} + +
+ + +
) } @@ -78,6 +93,15 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* 员工端 */} } /> @@ -85,6 +109,7 @@ export default function App() { } /> } /> } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/components/layout/Breadcrumb.tsx b/frontend/src/components/layout/Breadcrumb.tsx new file mode 100644 index 0000000..9a196b2 --- /dev/null +++ b/frontend/src/components/layout/Breadcrumb.tsx @@ -0,0 +1,56 @@ +/** + * 面包屑导航组件 + * 根据当前路由自动生成分组 > 页面 的层级面包屑 + */ + +import { Link, useLocation } from 'react-router-dom' +import { ChevronRight, Home } from 'lucide-react' + +interface BreadcrumbItem { + group: string + label: string +} + +const ROUTE_MAP: Record = { + '/': { group: '工作台', label: '总览' }, + '/roster': { group: '员工管理', label: '花名册' }, + '/attendance': { group: '员工管理', label: '考勤确认' }, + '/termination': { group: '员工管理', label: '解聘补偿' }, + '/money': { group: '薪税社保', label: '薪税管理' }, + '/social': { group: '薪税社保', label: '社保公积金' }, + '/evidence': { group: '合规风控', label: '证据链' }, + '/policies': { group: '合规风控', label: '规章制度' }, + '/tools/health-check': { group: '合规风控', label: '用工体检' }, + '/tools/medical-period': { group: '合规风控', label: '医疗期计算' }, + '/tools/annual-value': { group: '合规风控', label: '年度价值' }, + '/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' }, + '/templates': { group: 'AI 辅助', label: '文本模板' }, + '/notifications': { group: '系统', label: '通知管理' }, + '/audit': { group: '系统', label: '操作日志' }, + '/settings': { group: '系统', label: '设置' }, +} + +export default function Breadcrumb() { + const location = useLocation() + const path = location.pathname + const item = ROUTE_MAP[path] + + if (!item) return null + + return ( + + ) +} diff --git a/frontend/src/components/layout/PageContainer.tsx b/frontend/src/components/layout/PageContainer.tsx index de89486..f6bbc2f 100644 --- a/frontend/src/components/layout/PageContainer.tsx +++ b/frontend/src/components/layout/PageContainer.tsx @@ -3,7 +3,7 @@ import clsx from 'clsx' export default function PageContainer({ children, className }: { children: ReactNode; className?: string }) { return ( -
+
{children}
) diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx new file mode 100644 index 0000000..4e74e2a --- /dev/null +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -0,0 +1,179 @@ +/** + * 侧边栏导航组件 + * 分组式菜单结构,支持折叠/展开,移动端抽屉模式 + */ + +import { Link, useLocation } from 'react-router-dom' +import { useState } from 'react' +import clsx from 'clsx' +import { + LayoutDashboard, Users, CalendarCheck, UserX, + Calculator, Shield, + FileSearch, FileText, Stethoscope, HeartPulse, Award, + Bot, BookMarked, + Bell, ScrollText, Settings, + ChevronDown, ChevronRight, + Building2, +} from 'lucide-react' + +interface NavItem { + path: string + label: string + icon: typeof LayoutDashboard +} + +interface NavGroup { + title: string + items: NavItem[] +} + +const navGroups: NavGroup[] = [ + { + title: '工作台', + items: [ + { path: '/', label: '总览', icon: LayoutDashboard }, + ], + }, + { + title: '员工管理', + items: [ + { path: '/roster', label: '花名册', icon: Users }, + { path: '/attendance', label: '考勤确认', icon: CalendarCheck }, + { path: '/termination', label: '解聘补偿', icon: UserX }, + ], + }, + { + title: '薪税社保', + items: [ + { path: '/money', label: '薪税管理', icon: Calculator }, + { path: '/social', label: '社保公积金', icon: Shield }, + ], + }, + { + title: '合规风控', + items: [ + { path: '/evidence', label: '证据链', icon: FileSearch }, + { path: '/policies', label: '规章制度', icon: FileText }, + { path: '/tools/health-check', label: '用工体检', icon: Stethoscope }, + { path: '/tools/medical-period', label: '医疗期计算', icon: HeartPulse }, + { path: '/tools/annual-value', label: '年度价值', icon: Award }, + ], + }, + { + title: 'AI 辅助', + items: [ + { path: '/ai-assistant', label: 'AI 顾问', icon: Bot }, + { path: '/templates', label: '文本模板', icon: BookMarked }, + ], + }, + { + title: '系统', + items: [ + { path: '/notifications', label: '通知管理', icon: Bell }, + { path: '/audit', label: '操作日志', icon: ScrollText }, + { path: '/settings', label: '设置', icon: Settings }, + ], + }, +] + +/** + * 侧边栏导航 + */ +export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) { + const location = useLocation() + const [expandedGroups, setExpandedGroups] = useState>(new Set(navGroups.map(g => g.title))) + + const toggleGroup = (title: string) => { + setExpandedGroups(prev => { + const next = new Set(prev) + if (next.has(title)) next.delete(title) + else next.add(title) + return next + }) + } + + const isActive = (path: string) => { + if (path === '/') return location.pathname === '/' + return location.pathname.startsWith(path) + } + + return ( + <> + {/* 移动端遮罩 */} + {mobileOpen && ( +
+ )} + + + + ) +} diff --git a/frontend/src/components/layout/TopNav.tsx b/frontend/src/components/layout/TopNav.tsx index b844924..c3b19de 100644 --- a/frontend/src/components/layout/TopNav.tsx +++ b/frontend/src/components/layout/TopNav.tsx @@ -1,22 +1,12 @@ -import { Link, useLocation, useNavigate } from 'react-router-dom' -import { Building2, ChevronDown, Settings as SettingsIcon, Bell } from 'lucide-react' +import { Link, useNavigate } from 'react-router-dom' +import { ChevronDown, Settings as SettingsIcon, Bell, Menu } 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' +import Breadcrumb from './Breadcrumb' -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() +export default function TopNav({ onMenuClick }: { onMenuClick?: () => void }) { const navigate = useNavigate() const { user, logout } = useAuthStore() const [menuOpen, setMenuOpen] = useState(false) @@ -32,47 +22,33 @@ export default function TopNav() { const riskCount = dashboardData?.riskSummary?.pending || 0 return ( -
-
- - - 用工合规助手 - - - +
+
+ {/* 左侧:hamburger(移动端)+ 面包屑 */} +
+ + +
+ {/* 右侧:通知 + 设置 + 用户菜单 */}
- +
@@ -713,7 +713,7 @@ function ReviewTab() {
-

合同审查

+

合同审查

@@ -908,7 +908,7 @@ function CaseTab() {
-

案例匹配

+

案例匹配

diff --git a/frontend/src/pages/Attendance.tsx b/frontend/src/pages/Attendance.tsx new file mode 100644 index 0000000..dc76188 --- /dev/null +++ b/frontend/src/pages/Attendance.tsx @@ -0,0 +1,119 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { CalendarCheck, CheckCircle, Clock, AlertCircle, Upload } from 'lucide-react' +import api from '../lib/api' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import EmptyState from '../components/ui/EmptyState' + +const STATUS_CONFIG: Record = { + PENDING: { label: '待确认', color: 'text-amber-700', bg: 'bg-amber-100', icon: Clock }, + CONFIRMED: { label: '已确认', color: 'text-green-700', bg: 'bg-green-100', icon: CheckCircle }, + DISPUTED: { label: '有异议', color: 'text-red-700', bg: 'bg-red-100', icon: AlertCircle }, +} + +/** + * 考勤确认管理页面 + */ +export default function Attendance() { + const queryClient = useQueryClient() + const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) + + const { data: list, isLoading } = useQuery({ + queryKey: ['attendance', month], + queryFn: async () => { + const res = await api.get(`/attendance?month=${month}`) as any + return res.data + }, + }) + + const { data: stats } = useQuery({ + queryKey: ['attendance-stats', month], + queryFn: async () => { + const res = await api.get(`/attendance/stats?month=${month}`) as any + return res.data + }, + }) + + return ( +
+
+
+
+ +

考勤确认

+
+

月度考勤数据确认与异议管理

+
+ setMonth(e.target.value)} + className="px-3 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+ + {/* 统计卡片 */} + {stats && ( +
+ {[ + { label: '总计', value: stats.total, color: 'text-gray-700' }, + { label: '待确认', value: stats.pending, color: 'text-amber-600' }, + { label: '已确认', value: stats.confirmed, color: 'text-green-600' }, + { label: '有异议', value: stats.disputed, color: 'text-red-600' }, + ].map(s => ( + +
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {isLoading ? ( +
加载中...
+ ) : !list || list.length === 0 ? ( + + ) : ( +
+ {list.map((item: any) => { + const config = STATUS_CONFIG[item.status] || STATUS_CONFIG.PENDING + const StatusIcon = config.icon + return ( + +
+
+
+ +
+
+
+ {item.employee?.name} + {item.employee?.department} +
+
+ 出勤 {item.workDays} 天 + 工作日加班 {item.weekdayHours}h + 周末加班 {item.weekendHours}h + 法定加班 {item.holidayHours}h + 加班费 ¥{item.overtimePay?.toFixed(2)} +
+ {item.disputeNote && ( +
异议说明:{item.disputeNote}
+ )} +
+
+
+ + {config.label} +
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 0000000..a9dc14f --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ScrollText, Search, Filter } from 'lucide-react' +import api from '../lib/api' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import EmptyState from '../components/ui/EmptyState' +import Pagination from '../components/ui/Pagination' + +const ACTION_LABELS: Record = { + CREATE: '创建', + UPDATE: '更新', + DELETE: '删除', + CORRECT: '更正', + CREATE_EMPLOYEE: '创建员工', + UPDATE_EMPLOYEE: '更新员工', + DELETE_EMPLOYEE: '删除员工', + IMPORT_EMPLOYEES: '导入员工', + BATCH_RENEW: '批量续签', + ADD_CONTRACT: '添加合同', + CREATE_CONTRACT: '创建合同', + SIGN_CONTRACT: '签署合同', + TERMINATE: '解聘', + CREATE_TERMINATION: '创建解聘记录', + APPROVE_TERMINATION: '审批解聘', + REJECT_TERMINATION: '驳回解聘', + EXECUTE_TERMINATION: '执行解聘', + CANCEL_TERMINATION: '撤销解聘', + SUBMIT_TERMINATION: '提交解聘审批', + CREATE_DRAFT: '创建草稿', + REHIRE: '重新入职', + PAYROLL_GENERATE: '生成工资条', + PAYROLL_ARCHIVE: '归档发薪批次', + PAYROLL_PUBLISH: '发布工资条', + EXPORT_PAYROLL: '导出薪税', + AI_CHAT: 'AI 对话', + AI_REVIEW: 'AI 审查', + UPDATE_SETTINGS: '更新设置', + POLICY_ADVANCE: '制度流程推进', + POLICY_PUBLISH: '制度发布', + EVIDENCE_CREATE: '创建证据链', + ATTENDANCE_CONFIRM: '考勤确认', +} + +const ENTITY_LABELS: Record = { + EMPLOYEE: '员工', + CONTRACT: '合同', + TERMINATION: '解聘', + TERMINATION_RECORD: '解聘记录', + PAYROLL: '薪税', + PAYSLIP: '工资条', + SETTINGS: '设置', + POLICY: '制度', + POLICY_DOCUMENT: '制度文件', + EVIDENCE: '证据链', + AI: 'AI', + ATTENDANCE: '考勤', + DISCIPLINARY: '违纪', + EmployeeSocialInsRecord: '社保记录', + EmployeeHousingFundRecord: '公积金记录', + SocialMonthlyProcess: '社保月度流程', + RetirementPolicy: '退休政策', + NotificationSetting: '通知设置', +} + +/** + * 系统操作日志页面 + */ +export default function AuditLog() { + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [action, setAction] = useState('') + const [entity, setEntity] = useState('') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') + + const { data, isLoading } = useQuery({ + queryKey: ['audit-logs', page, pageSize, action, entity, dateFrom, dateTo], + queryFn: async () => { + const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) + if (action) params.set('action', action) + if (entity) params.set('entity', entity) + if (dateFrom) params.set('dateFrom', dateFrom) + if (dateTo) params.set('dateTo', dateTo) + const res = await api.get(`/audit?${params}`) as any + return res.data + }, + }) + + const { data: stats } = useQuery({ + queryKey: ['audit-stats'], + queryFn: async () => { + const res = await api.get('/audit/stats') as any + return res.data + }, + }) + + return ( +
+
+ +

系统操作日志

+
+

记录所有关键操作,保留6个月以上

+ + {/* 统计概览 */} + {stats && stats.length > 0 && ( + +
操作类型分布
+
+ {stats.slice(0, 10).map((s: any) => ( +
+ {ACTION_LABELS[s.action] || s.action} + {s._count.action} +
+ ))} +
+
+ )} + + {/* 筛选栏 */} + +
+
+ + +
+
+ + +
+
+ + { setDateFrom(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" /> +
+
+ + { setDateTo(e.target.value); setPage(1) }} className="block mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" /> +
+ {(action || entity || dateFrom || dateTo) && ( + + )} +
+
+ + {/* 日志列表 */} + {isLoading ? ( +
加载中...
+ ) : !data?.items || data.items.length === 0 ? ( + + ) : ( + <> + +
+ {data.items.map((log: any) => ( +
+
+ +
+
+
+ {ACTION_LABELS[log.action] || log.action} + {ENTITY_LABELS[log.entity] || log.entity} +
+ {log.detail && ( +
+ {typeof log.detail === 'object' ? JSON.stringify(log.detail).slice(0, 100) : String(log.detail).slice(0, 100)} +
+ )} +
+
+
{new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
+ {log.ip &&
{log.ip}
} +
+
+ ))} +
+
+ { setPageSize(s); setPage(1) }} + /> + + )} +
+ ) +} diff --git a/frontend/src/pages/Compensation.tsx b/frontend/src/pages/Compensation.tsx index b2d33aa..086e50f 100644 --- a/frontend/src/pages/Compensation.tsx +++ b/frontend/src/pages/Compensation.tsx @@ -155,7 +155,7 @@ function SeveranceCalculator() { return (
-

填写信息

+

填写信息

@@ -192,7 +192,7 @@ function SeveranceCalculator() { -

计算结果

+

计算结果

{result ? (
离职原因:{result.reason}
@@ -308,7 +308,7 @@ function DoubleSalaryCalculator() { return (
-

填写信息

+

填写信息

@@ -336,7 +336,7 @@ function DoubleSalaryCalculator() { -

计算结果

+

计算结果

{result ? (
入职日期:{hireDate}
diff --git a/frontend/src/pages/Contracts.tsx b/frontend/src/pages/Contracts.tsx index 8652e84..bc3068f 100644 --- a/frontend/src/pages/Contracts.tsx +++ b/frontend/src/pages/Contracts.tsx @@ -421,7 +421,7 @@ function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onC <>
-

{emp.name}

+

{emp.name}

{emp.department}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 8fffefd..e4dbb78 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -2,8 +2,8 @@ import { useState } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' -import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts' -import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight } from 'lucide-react' +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts' +import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, CalendarDays, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react' import api from '../lib/api' import Card from '../components/ui/Card' import Button from '../components/ui/Button' @@ -56,6 +56,31 @@ export default function Dashboard() { }, }) + const currentMonth = new Date().toISOString().slice(0, 7) + const { data: calendarData } = useQuery({ + queryKey: ['calendar', currentMonth], + queryFn: async () => { + const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any + return res.data + }, + }) + + const { data: costAnalysis } = useQuery({ + queryKey: ['cost-analysis', currentMonth], + queryFn: async () => { + const res = await api.get(`/dashboard/cost-analysis?month=${currentMonth}`) as any + return res.data + }, + }) + + const { data: complianceScore } = useQuery({ + queryKey: ['compliance-score'], + queryFn: async () => { + const res = await api.get('/dashboard/compliance-score') as any + return res.data + }, + }) + const resolveMutation = useMutation({ mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }), @@ -175,6 +200,13 @@ export default function Dashboard() { { key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length }, ] + const priorityConfig: Record = { + URGENT: { label: '紧急', color: 'text-red-700', bg: 'bg-red-100' }, + HIGH: { label: '高', color: 'text-orange-700', bg: 'bg-orange-100' }, + MEDIUM: { label: '中', color: 'text-amber-700', bg: 'bg-amber-100' }, + LOW: { label: '低', color: 'text-gray-600', bg: 'bg-gray-100' }, + } + return (
@@ -191,6 +223,33 @@ export default function Dashboard() {
+ {/* 紧急风险横幅 */} + {data.urgentRisk && ( + + +
+
+ +
+
+
+ 紧急风险 + 预估损失 {fmt(data.urgentRisk.estimatedLoss)} + {data.urgentRisk.daysUntilDeadline !== null && ( + + {data.urgentRisk.daysUntilDeadline <= 0 ? '已逾期' : `${data.urgentRisk.daysUntilDeadline}天后截止`} + + )} +
+
{data.urgentRisk.title}
+
{data.urgentRisk.description}
+
+ +
+
+ + )} + {/* Tab 导航 */}
{tabs.map((tab) => { @@ -218,6 +277,96 @@ export default function Dashboard() { {/* 概览 Tab */} {activeTab === 'overview' && (
+ {/* 合规健康度评分 + AI 建议卡片流 */} + {complianceScore && ( +
+ {/* 评分环 + 5 维度 */} + +
+ {/* SVG 环形评分 */} +
+ + + + + + +
+ {complianceScore.overallScore} + {complianceScore.levelLabel} +
+
+ {/* 5 维度评分 */} +
+ {complianceScore.dimensions.map((dim: any) => ( + +
+
= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} /> + {dim.name} +
+
+ {dim.todoCount > 0 && {dim.todoCount}待办} + = 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score} +
+ + ))} +
+
+ + + {/* AI 建议卡片流 */} + {complianceScore.suggestions.length > 0 && ( +
+
+ + AI 建议 +
+ {complianceScore.suggestions.map((s: any, i: number) => { + const suggestionConfig: Record = { + danger: { icon: AlertTriangle, bg: 'bg-red-50', border: 'border-red-200', iconColor: 'text-red-600', label: '高风险', labelBg: 'bg-red-100 text-red-700' }, + warning: { icon: AlertCircle, bg: 'bg-amber-50', border: 'border-amber-200', iconColor: 'text-amber-600', label: '中风险', labelBg: 'bg-amber-100 text-amber-700' }, + value: { icon: ShieldCheck, bg: 'bg-green-50', border: 'border-green-200', iconColor: 'text-safe', label: '价值', labelBg: 'bg-green-100 text-safe' }, + knowledge: { icon: BookOpen, bg: 'bg-purple-50', border: 'border-purple-200', iconColor: 'text-purple-600', label: '知识', labelBg: 'bg-purple-100 text-purple-700' }, + } + const config = suggestionConfig[s.type] || { icon: Lightbulb, bg: 'bg-gray-50', border: 'border-gray-200', iconColor: 'text-gray-600', label: '', labelBg: '' } + const Icon = config.icon + return ( + + +
+
+ +
+
+
+ {config.label} + {s.title} + {s.estimatedLoss && s.estimatedLoss > 0 && ( + 预估损失 {fmt(s.estimatedLoss)} + )} +
+

{s.description}

+
+
+ {s.actionLabel} + +
+
+
+ + ) + })} +
+ )} +
+ )} + {/* 统计卡片 */}
{stats.map((stat) => { @@ -239,7 +388,7 @@ export default function Dashboard() { {/* 当月人力成本 */}
-

当月人力成本

+

当月人力成本

{payroll?.month}
@@ -259,7 +408,7 @@ export default function Dashboard() { {/* 年度累计人力成本 */}
-

年度累计成本

+

年度累计成本

{yearCost?.year}年
@@ -309,7 +458,7 @@ export default function Dashboard() { {/* 本月工作动态 */}
-

本月工作动态

+

本月工作动态

{activities?.month}
@@ -328,7 +477,7 @@ export default function Dashboard() { {/* 风险分布 */} -

风险分布

+

风险分布

@@ -407,8 +556,21 @@ export default function Dashboard() {
-
{r.title}
- {r.employeeName &&
{r.employeeName}
} +
+ {r.title} + {r.priority && priorityConfig[r.priority] && ( + + {priorityConfig[r.priority].label} + + )} +
+
+ {r.employeeName && {r.employeeName}} + {r.estimatedLoss > 0 && 损失 {fmt(r.estimatedLoss)}} + {r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && ( + {r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}天`} + )} +
@@ -420,6 +582,84 @@ export default function Dashboard() { )}
+ + {/* HR 月度日历 + 人力成本分析 */} +
+ {/* 月度日历 */} + +
+

本月关键日期

+ {currentMonth} +
+ {calendarData?.events && calendarData.events.length > 0 ? ( +
+ {calendarData.events.slice(0, 10).map((ev: any, i: number) => ( + +
+ {ev.date.slice(5)} + {ev.title} + + ))} + {calendarData.events.length > 10 && ( +
还有 {calendarData.events.length - 10} 个事件
+ )} +
+ ) : ( +
本月暂无关键日期
+ )} + + + {/* 人力成本分析 */} + +
+

人力成本分析

+ {currentMonth} +
+ {costAnalysis ? ( +
+
+
= 0 ? 'bg-red-50' : 'bg-green-50'}`}> +
环比上月
+
= 0 ? 'text-red-600' : 'text-green-600'}`}> + {costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}% +
+
+ {costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))} +
+
+
= 0 ? 'bg-red-50' : 'bg-green-50'}`}> +
同比去年
+
= 0 ? 'text-red-600' : 'text-green-600'}`}> + {costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}% +
+
+ {costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))} +
+
+
+
+ 本月人均成本 + {fmt(costAnalysis.current?.perCapita || 0)} +
+ {costAnalysis.factors && costAnalysis.factors.length > 0 && ( +
+
成本变化归因
+ {costAnalysis.factors.map((f: any, i: number) => ( +
+ = 0 ? 'bg-red-500' : 'bg-green-500'}`} /> + {f.description} +
+ ))} +
+ )} +
+ ) : ( +
暂无成本分析数据
+ )} +
+
)} @@ -427,7 +667,7 @@ export default function Dashboard() { {activeTab === 'payroll' && (
-

本月薪税费用总览

+

本月薪税费用总览

+
+ + {verifyResult && ( + +
+ {verifyResult?.invalid === 0 ? ( + + ) : ( + + )} + + {verifyResult?.invalid === 0 + ? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改` + : `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`} + +
+
+ )} + +
+ {['', 'ONBOARD', 'CONTRACT_SIGN', 'PAYSLIP_CONFIRM', 'DISCIPLINARY', 'ATTENDANCE', 'TERMINATION'].map((t) => ( + + ))} +
+ + {isLoading ? ( +
加载中...
+ ) : !list || list.length === 0 ? ( + + ) : ( +
+ {list.map((item: any) => ( + +
+
+ +
+
+ {item.lastAction || item.firstAction || item.category} + + {item.category === 'DISCIPLINARY' ? '违纪' : item.category === 'TERMINATION' ? '解聘' : item.category === 'CONTRACT_SIGN' ? '合同' : item.category === 'PAYSLIP_CONFIRM' ? '薪酬' : item.category === 'ONBOARD' ? '入职' : '考勤'} + +
+ {item.employeeName && ( +
+ 员工:{item.employeeName}{item.employeeDept ? ` · ${item.employeeDept}` : ''} +
+ )} +
+ {item.eventCount} 条事件 · {new Date(item.createdAt).toLocaleString('zh-CN')} + {item.hashShort && ( + SHA: {item.hashShort}… + )} +
+
+
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx new file mode 100644 index 0000000..3266bf7 --- /dev/null +++ b/frontend/src/pages/Notifications.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { Bell, CheckCircle, AlertCircle, Send, Settings as SettingsIcon, X } from 'lucide-react' +import api from '../lib/api' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import EmptyState from '../components/ui/EmptyState' +import Pagination from '../components/ui/Pagination' + +const TYPE_LABELS: Record = { + CONTRACT_EXPIRY: '合同到期', + CONTRACT_UNSIGNED: '未签合同', + OVERTIME: '加班预警', + OVERTIME_ALERT: '加班预警', + PAYSLIP: '工资条', + PAYSLIP_READY: '工资条', + RISK_ALERT: '风险预警', + SOCIAL_INS: '社保提醒', + HOUSING_FUND: '公积金提醒', + TAX: '税务提醒', +} + +const CHANNEL_LABELS: Record = { + WECHAT: '企业微信', + EMAIL: '邮件', + IN_APP: '站内', +} + +/** + * 通知管理页面 + */ +export default function Notifications() { + const queryClient = useQueryClient() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [showSettings, setShowSettings] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['notification-logs', page, pageSize], + queryFn: async () => { + const res = await api.get(`/notifications/logs?page=${page}&pageSize=${pageSize}`) as any + return res.data + }, + }) + + const { data: settings } = useQuery({ + queryKey: ['notification-settings'], + queryFn: async () => { + const res = await api.get('/notifications/settings') as any + return res.data + }, + }) + + const checkContractsMutation = useMutation({ + mutationFn: () => api.post('/notifications/check-contracts'), + onSuccess: () => { + toast.success('合同到期检查已触发') + queryClient.invalidateQueries({ queryKey: ['notification-logs'] }) + }, + onError: () => toast.error('检查失败'), + }) + + return ( +
+
+
+
+ +

通知管理

+
+

通知记录查看与设置管理

+
+
+ + +
+
+ + {/* 通知设置概览 */} + {settings && ( + +
+ {[ + { label: '合同到期提醒', enabled: settings.contractExpiry, days: settings.expiryDays }, + { label: '未签合同提醒', enabled: settings.contractUnsigned }, + { label: '加班预警', enabled: settings.overtimeAlert }, + { label: '工资条通知', enabled: settings.payslipReady }, + ].map(s => ( +
+ {s.enabled ? ( + + ) : ( + + )} +
+
{s.label}
+ {s.days &&
提前{s.days}天
} +
+
+ ))} +
+
+ )} + + {/* 通知记录列表 */} + {isLoading ? ( +
加载中...
+ ) : !data?.items || data.items.length === 0 ? ( + + ) : ( + <> + +
+ {data.items.map((log: any) => ( +
+
+ +
+
+
+ {log.title} + {TYPE_LABELS[log.type] || log.type} + {CHANNEL_LABELS[log.channel] || log.channel} +
+
{log.content}
+
+
+ {new Date(log.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} +
+
+ ))} +
+
+ { setPageSize(s); setPage(1) }} + /> + + )} + + {/* 通知设置弹窗 */} + {showSettings && settings && ( + setShowSettings(false)} onSuccess={() => { setShowSettings(false); queryClient.invalidateQueries({ queryKey: ['notification-settings'] }) }} /> + )} +
+ ) +} + +function SettingsModal({ settings, onClose, onSuccess }: { settings: any; onClose: () => void; onSuccess: () => void }) { + const queryClient = useQueryClient() + const [form, setForm] = useState({ + contractExpiry: settings.contractExpiry ?? true, + expiryDays: settings.expiryDays ?? 30, + contractUnsigned: settings.contractUnsigned ?? true, + overtimeAlert: settings.overtimeAlert ?? true, + payslipReady: settings.payslipReady ?? true, + payrollDay: settings.payrollDay ?? 10, + socialInsDay: settings.socialInsDay ?? 15, + housingFundDay: settings.housingFundDay ?? 15, + taxDay: settings.taxDay ?? 15, + wechatWebhook: settings.wechatWebhook ?? '', + emailNotify: settings.emailNotify ?? false, + email: settings.email ?? '', + }) + + const saveMutation = useMutation({ + mutationFn: () => api.put('/notifications/settings', form), + onSuccess: () => { toast.success('通知设置已保存'); onSuccess() }, + onError: () => toast.error('保存失败'), + }) + + const toggleItem = (key: string) => setForm(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] })) + + return ( +
+ +
e.stopPropagation()}> +
+

通知设置

+ +
+
+ {[ + { key: 'contractExpiry', label: '合同到期提醒', desc: '提前提醒即将到期的合同' }, + { key: 'contractUnsigned', label: '未签合同提醒', desc: '入职超过30天未签合同' }, + { key: 'overtimeAlert', label: '加班预警', desc: '加班时长超过法定上限' }, + { key: 'payslipReady', label: '工资条通知', desc: '工资条生成后通知员工' }, + { key: 'emailNotify', label: '邮件通知', desc: '通过邮件发送通知' }, + ].map(item => ( +
+
+
{item.label}
+
{item.desc}
+
+ +
+ ))} +
+
+ + setForm(prev => ({ ...prev, expiryDays: parseInt(e.target.value) || 30 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={365} /> +
+
+ + setForm(prev => ({ ...prev, payrollDay: parseInt(e.target.value) || 10 }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" min={1} max={28} /> +
+
+
+ + setForm(prev => ({ ...prev, wechatWebhook: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..." /> +
+
+ + setForm(prev => ({ ...prev, email: e.target.value }))} className="w-full mt-1 px-2 py-1.5 text-sm border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary" placeholder="hr@example.com" /> +
+
+ + +
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/Policies.tsx b/frontend/src/pages/Policies.tsx new file mode 100644 index 0000000..70976cc --- /dev/null +++ b/frontend/src/pages/Policies.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { FileText, Plus, ChevronRight, CheckCircle, Clock, X } from 'lucide-react' +import api from '../lib/api' +import Card from '../components/ui/Card' +import Button from '../components/ui/Button' +import EmptyState from '../components/ui/EmptyState' + +const STEP_LABELS: Record = { + DRAFTING: '起草', + DISCUSSION: '讨论', + CONSULTATION: '协商', + PUBLICATION: '公示', +} + +const STEP_ORDER = ['DRAFTING', 'DISCUSSION', 'CONSULTATION', 'PUBLICATION'] + +/** + * 规章制度民主程序管理页面 + */ +export default function Policies() { + const queryClient = useQueryClient() + const [showCreate, setShowCreate] = useState(false) + const [selectedPolicy, setSelectedPolicy] = useState(null) + + const { data: list, isLoading } = useQuery({ + queryKey: ['policies'], + queryFn: async () => { + const res = await api.get('/policies') as any + return res.data + }, + }) + + const advanceMutation = useMutation({ + mutationFn: ({ id, step, note }: { id: string; step: number; note?: string }) => api.post(`/policies/${id}/advance-step`, { step, note }), + onSuccess: () => { + toast.success('流程步骤已推进') + queryClient.invalidateQueries({ queryKey: ['policies'] }) + // 刷新选中制度详情 + if (selectedPolicy) { + api.get(`/policies/${selectedPolicy.id}`).then((res: any) => setSelectedPolicy(res.data)) + } + }, + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'), + }) + + return ( +
+
+
+
+ +

规章制度管理

+
+

民主程序四步法:起草 → 讨论 → 协商 → 公示

+
+ +
+ + {isLoading ? ( +
加载中...
+ ) : !list || list.length === 0 ? ( + + ) : ( +
+ {list.map((p: any) => ( + +
setSelectedPolicy(p)}> +
+
+
+ {p.title} + {p.status === 'PUBLISHED' ? ( + 已生效 + ) : ( + {STEP_LABELS[STEP_ORDER[(p.democracyProgress?.currentStep || 1) - 1]] || '起草中'} + )} +
+
{p.content?.slice(0, 80) || '暂无内容'}
+
+ {STEP_ORDER.map((step, i) => { + const currentStep = p.democracyProgress?.currentStep || 1 + const isDone = i < currentStep - 1 || p.status === 'PUBLISHED' + const isCurrent = i === currentStep - 1 && p.status !== 'PUBLISHED' + return ( +
+
+ {isDone ? : } + {STEP_LABELS[step]} +
+ {i < STEP_ORDER.length - 1 && } +
+ ) + })} +
+ {p.status === 'PUBLISHED' && p.totalEmployees > 0 && ( +
+ 员工签收: +
+
0 ? Math.round((p.readCount / p.totalEmployees) * 100) : 0}%` }} /> +
+ {p.readCount}/{p.totalEmployees} +
+ )} +
+
+
+ + ))} +
+ )} + + {/* 详情弹窗 */} + {selectedPolicy && ( +
setSelectedPolicy(null)}> + +
e.stopPropagation()}> +
+

{selectedPolicy.title}

+ +
+
{selectedPolicy.content || '暂无内容'}
+
+
民主程序进度
+ {(selectedPolicy.democracyProgress?.steps || []).map((s: any, i: number) => { + const isDone = s.status === 'COMPLETED' || (selectedPolicy.status === 'PUBLISHED') + const isCurrent = s.status === 'IN_PROGRESS' && selectedPolicy.status !== 'PUBLISHED' + return ( +
+
+ + {isDone ? : } + {s.name} + · {s.description} + + {isCurrent && ( + + )} +
+ {s.date && ( +
+ {s.status === 'COMPLETED' ? '完成时间' : '开始时间'}:{s.date} + {s.note && 备注:{s.note}} +
+ )} +
+ ) + })} +
+ + {/* 阅读签收统计(仅已公示制度显示) */} + {selectedPolicy.status === 'PUBLISHED' && ( + + )} +
+
+
+ )} + + {/* 新建弹窗 */} + {showCreate && ( + setShowCreate(false)} onSuccess={() => { setShowCreate(false); queryClient.invalidateQueries({ queryKey: ['policies'] }) }} /> + )} +
+ ) +} + +function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + const [title, setTitle] = useState('') + const [content, setContent] = useState('') + const [type, setType] = useState('RULES') + + const createMutation = useMutation({ + mutationFn: () => api.post('/policies', { title, content, type }), + onSuccess: () => { toast.success('制度已创建'); onSuccess() }, + onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'), + }) + + return ( +
+ +
e.stopPropagation()}> +
+

新建规章制度

+ +
+
+
+ + setTitle(e.target.value)} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="如:考勤管理制度" /> +
+
+ + +
+
+ + + +
+
+
+
+
+ + + + +
+
+

判赔预测器

+ 核心 AI 能力 + · 基于智慧仲裁判例库 · 真实裁决数据训练 +
+ +
+ +
本工具基于公司智慧仲裁产品沉淀的真实仲裁判例 AI 大模型,预测仅供决策参考,最终以仲裁裁决为准。涉及具体诉讼请立即转人工律师
+
+ + +
+
第一步 · 选择争议场景(12 类)
+
+
+ + 员工严重违纪解除 +
+
+ + 试用期不录用 +
+
+ + 客观情况变化 +
+
+ + 经济性裁员 +
+
+ + 协商一致解除 +
+
+ + 到期不续签 +
+
+ + 调岗调薪 +
+
+ + 未签书面合同 +
+
+ + 加班费争议 +
+
+ + 社保争议 +
+
+ + 女职工三期 +
+
+ + 工伤认定 +
+
+ +
第二步 · 输入案件信息
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ AI 预测结果 · 三档方案 + 检索判例 42 起 · 推理用时 1.2s +
+
+ + +
+
+
原方案(直接解除)
+
您当前的打算
+
+
+
¥ 86,000
+
预测赔偿(中位数)
+
区间 ¥ 68k - ¥ 102k
+
+
+
同类判例 42 起 · 败诉率 81%
+
    +
  • 败诉主因:制度未民主公示,举证不能
  • +
  • 次因:迟到次数未达"严重"标准
  • +
  • 第三:未给改正机会
  • +
+
+
+ + +
+
+ + +
+
+
方案 A:补正后协商 推荐
+
先补流程,再协商一致
+
+
+
¥ 28,000
+
预测赔偿(中位数)
+
区间 ¥ 22k - ¥ 35k
+
+
+
同类替代方案 27 起 · 胜诉率 78%
+
    +
  • 第 1 步:立即发起制度民主程序
  • +
  • 第 2 步:7 天内补全 3 次书面违纪记录
  • +
  • 第 3 步:协商一致解除(AI 自动生成方案)
  • +
+
+
+ +
+
+ + +
+
+
方案 B:调岗改善 最优
+
3 个月改善期 + 自然离职
+
+
+
¥ 0
+
预测赔偿(中位数)
+
0 赔付率 89%
+
+
+
同类预防案 19 起 · 0 赔付率 89%
+
    +
  • 第 1 步:启动 3 个月调岗改善期
  • +
  • 第 2 步:每月绩效追踪存证
  • +
  • 第 3 步:达不到要求时启动协商
  • +
+
+
+ +
+
+ +
+
+ + + + +
+
+

全员风险地图

+ · 花名册升级版 · 每位员工 7 维仲裁风险评分 +
+ +
+ 部门: + + 风险等级: + + 特殊群体: + + + +
+ 全部 47 + 🔴 高风险 4 + 🟡 中风险 12 + 🟢 健康 31 +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
员工部门/岗位合同到期风险评分 NEW风险标签 NEW操作
+
+
+
+
王明
+
工号 E0023 · 3 年工龄
+
+
+
销售部
销售经理
2027.06
剩 18 个月
+
+ 38 + + 高风险 +
+
+ 违纪 5 次 + 迟到记录 + 绩效不达标 +
+
+
+
+
刘云
+
工号 E0041 · 2 年工龄
+
+
+
市场部
高级专员
2026.01
剩 64 天 ⚠️
+
+ 45 + + 高风险 +
+
+ 合同即将到期 + 哺乳期 + 考勤未签字 +
+
+
+
+
张三
+
工号 E0078 · 今日入职
+
+
+
研发部
前端工程师
未签约 +
+ 28 + + 极高 +
+
+ 合同未签 14 天 + 双倍工资风险 +
+
+
+
+
李雪梅
+
工号 E0012 · 5 年工龄
+
+
+
财务部
财务主管
2028.03 +
+ 68 + + 中风险 +
+
+ 工资条 3 月未确认 + 本月调岗中 +
+
+
+
+
陈军
+
工号 E0033 · 4 年工龄
+
+
+
生产部
车间组长
2027.09 +
+ 72 + + 中风险 +
+
+ 工伤恢复期 + 医疗期管理 +
+
+
+
+
周丽
+
工号 E0055 · 1 年工龄
+
+
+
人事部
HR 助理
2026.12 +
+ 88 + + 健康 +
+
+ 已签竞业协议 + 已完成培训 +
+
+
+
+
高敏
+
工号 E0008 · 7 年工龄
+
+
+
运营部
运营总监
2027.02 +
+ 94 + + 优秀 +
+
+ 全维度合规 +
+ +
+ 显示第 1-7 条,共 47 条 · 导出风险报告 PDF +
+
+ + + + +
+
+

个人风险档案

+ · 员工 · 王明 · 销售经理 +
+ +
+ +
+
+
王明
+
销售部 · 销售经理 · 3 年工龄
+ +
+
入职日期:2023-04-12
+
合同期限:2024.06 - 2027.06
+
月薪:¥ 12,000
+
社保:已足额缴纳
+
竞业协议:已签订
+
+ + +
+ + +
+
+ + 7 维仲裁风险评分 + 38 + / 100 + 🔴 高风险 +
+ +
+
+ ① 合同合规 + + 90 +
+
+ ② 工时考勤 + + 30 +
+
+ ③ 薪酬异常 + + 85 +
+
+ ④ 社保公积金 + + 95 +
+
+ ⑤ 特殊群体 + + N/A +
+
+ ⑥ 培训背调 + + 65 +
+
+ ⑦ 行为异常 + + 25 +
+
+ +
+
📈 评分趋势(最近 6 个月)
+ + + + + + + + + + +
+ 5月6月7月8月9月10月本月 +
+
+
+ + +
+
+ + 风险标签 & 修复路径 +
+ +
+
+ 违纪 5 次未存证 +
+
+ AI 检测:迟到 5 次仅有口头警告。若启动解除将面临举证不能风险。补全书面记录 → +
+
+ +
+
+ 考勤未月度签字 +
+
+ 近 3 个月考勤记录员工未签字确认。发起补签 → +
+
+ +
+
+ 绩效连续不达标 +
+
+ Q3 绩效 C,Q4 进入末位 10%。建议启动绩效改善期。生成 PIP → +
+
+ +
+
💡 白小侠建议
+
+ 此员工存在多项不利证据缺失,不建议直接解除。建议先用 2-3 周补齐流程,再启动协商,可降低赔偿风险约 ¥58k。 +
+
+
+
+ +
+
+
+

2026 年度 AI 价值报告

+ · 北京阳光科技 · 续约谈判材料 · 由白小侠 AI 自动生成 +
+ + +
+
+
+
过去 12 个月,白小侠为您:
+
规避了 ¥128,400 潜在赔偿
+
≈ 您年度服务费的 14.3 倍 价值返还 · 投资回报率 1,330%
+
+
+
服务年费
+
¥ 8,980
+ +
+
+
+ + +
+
+
17
+
拦截高风险事件
+
+
+
142
+
AI 咨询次数
+
+
+
23
+
文书审改次数
+
+
+
+13
+
合规健康度提升
+
+
+ + +
+
+ + 年度关键防御事件 +
+
+
+ +
+
+
2026-03-15
+
⚠️ AI 阻止违法解除孕期员工
+
您拟解除怀孕员工"刘云"。AI 预测违法判赔 ¥113k+,建议改为调岗安抚。最终规避损失 ¥113,000。
+
+ +
+
+
2026-06-22
+
🟡 AI 修正《考勤制度》民主程序
+
AI 检出制度未公示,发起补正流程。后续 3 起考勤争议均胜诉。
+
+ +
+
+
2026-08-08
+
🟢 AI 优化《销售提成方案》
+
AI 审查标记 3 处歧义条款。优化后顺利通过 1 起销售提成争议。
+
+ +
+
+
2026-10-30
+
⚖️ 仲裁应急通道(销售员王明 案)
+
收到仲裁通知后 28 分钟内陈律师接管。当前案件进行中。
+
+ +
+
+ +
+ + + + +
+
+

文件 AI 审查

+ 核心 AI 能力 + · 合同 · 制度 · 通知 · 协议 · 任何用工文书 +
+ + +
+
+
+
+
劳动合同_张三_前端工程师_2026.docx
+
2.4 MB · 5 页 · 上传于 2 分钟前 · 已完成 AI 审查
+
+
+
+ 高风险 3 + 中风险 5 + 建议优化 2 +
+
+ + +
+
+ +
+ +
+
+ 原文预览 + 点击右侧风险项 → 自动定位并替换 +
+
+

劳动合同

+

编号:BJ-LD-2026-0078

+ +

第一条 合同期限

+

本合同期限为 六年,自 2026 年 5 月 13 日起至 2032 年 5 月 12 日止。

+ +

第二条 试用期

+

双方约定试用期为 六个月,试用期内月工资为正式工资的 百分之七十

+ +

第三条 工作内容

+

乙方担任前端工程师岗位,工作地点位于北京市朝阳区。甲方有权根据生产经营需要随时调整乙方的工作岗位、工作地点和工资标准,乙方应无条件服从。

+ +

第四条 工作时间

+

实行不定时工作制(注:未办理审批)。乙方应根据公司安排加班,公司可以根据实际情况安排调休或支付加班费。

+ +

第五条 劳动报酬

+

乙方月工资为人民币 ¥ 18,000 元。绩效部分由甲方根据当月业绩自行决定。

+ +

第六条 保密与竞业限制

+

乙方在职期间及离职后 三年内不得到任何同类企业任职。竞业限制期间不支付经济补偿。

+ +

第七条 合同解除

+

乙方在试用期内若不符合录用条件,甲方可立即解除合同且无需支付经济补偿

+ +

第八条 违约责任

+

乙方违反本合同任何条款,应向甲方支付违约金人民币五万元。

+ +

第九条 争议解决

+

因履行本合同发生的争议,由甲方所在地人民法院管辖。

+ +

—— 第 1 / 5 页 ——

+
+
+ + +
+
+ 全部 (10) + 🔴 高风险 (3) + 🟡 中风险 (5) + 🟢 优化 (2) +
+ + +
+
+ 🔴 高风险 + 第一条 · 合同期限 + 判例 12 起 +
+
+ 原文:合同期限为 六年 +
+
+ 超长固定期限不利于企业。根据《劳动合同法》第 14 条,连续订立两次固定期限合同后必须订立无固定期限合同,6 年期限将直接进入无固定期限阶段。 +
+
+
💡 AI 建议替换为:
+
本合同期限为 三年,自 2026 年 5 月 13 日起至 2029 年 5 月 12 日止
+
+
+ + + +
+
+ +
+
+ 🔴 高风险 + 第二条 · 试用期 + 违反法律强制性规定 +
+
+ 原文:试用期为 六个月 +
+
+ 违反《劳动合同法》第 19 条强制性规定。三年期限合同试用期不得超过 3 个月。该条款被认定无效,且企业可能承担试用期超期赔偿(月工资 × 超期月数)。 +
+
+
💡 AI 建议替换为:
+
双方约定试用期为 三个月
+
+
+ + +
+
+ +
+
+ 🔴 高风险 + 第三条 · 工作内容 + 败诉率 87% +
+
+ 原文:甲方有权随时调整乙方的工作岗位、工作地点和工资标准 +
+
+ "随时调岗调薪条款"违反《劳动合同法》第 35 条(合同变更需协商一致)。检索同类条款引发的争议 43 起,企业败诉率 87%。 +
+
+
💡 AI 建议替换为:
+
因生产经营需要调整工作岗位、地点或薪资的,甲乙双方应协商一致后以书面形式确认
+
+
+ + +
+
+ +
+
+ 🟡 中风险 + 第二条 · 试用期工资 +
+
+ 原文:试用期工资为正式工资的 百分之七十 +
+
+ 《劳动合同法》第 20 条:试用期工资不得低于本单位相同岗位最低档工资的 80%。当前条款 70% 低于法定下限。 +
+
+
💡 AI 建议替换为:
+
试用期工资为正式工资的 百分之八十(不低于法定标准)
+
+
+ +
+
+ +
+
+ 🟡 中风险 + 第四条 · 不定时工作制 +
+
+ 不定时工作制需经劳动行政部门审批方可实施。当前显示未办理审批,员工申请加班费时企业举证不能。 +
+
+ + +
+
+ +
+
+ 🟡 中风险 + 第六条 · 竞业限制 +
+
+ 竞业限制期间未约定经济补偿,根据司法解释,该条款对员工不具约束力。竞业限制最长 2 年,三年超期。 +
+
+
💡 AI 建议替换为:
+
竞业限制期为离职后 2 年。该期间内甲方应按月支付乙方离职前月平均工资的 30% 作为经济补偿
+
+
+ +
+
+ +
+
+ 🟡 中风险 + 第五条 · 绩效工资 +
+
+ "由甲方自行决定"过于宽泛,可能被认定单方变更工资条款。建议明确绩效考核标准。 +
+
+ +
+
+ +
+
+ 🟢 建议优化 + 第七条 · 录用条件 +
+
+ 条款合法,但未明确"录用条件"具体内容。建议附《岗位录用条件说明书》并要求员工签字确认,避免试用期解除时举证不能。 +
+
+ +
+
+ +
+
+ + +
+
+ +
+
AI 审查仅供参考,建议重要合同请律师终审
+
采纳所有 AI 建议后,预估降低本合同潜在仲裁风险 ¥56,000+
+
+
+
+ + +
+
+
+
+
+

合规中心

+ · 一站式合规健康度管理 · 由 AI 持续巡检 +
+ + +
+
+
+ + + + +
78
+
+
+
合规综合健康度
+
中等风险
+
较上月 ↑ +5 分 · 最近巡检 1 小时前
+
+
+
+ + +
+
+ + +
+
+
+ + 合同管理 +
+
78/100
+
+
+ 3 件待办 +
+
+
+
+ + 规章制度 +
+
65/100
+
+
+ 4 件待办 · 紧急 +
+
+
+
+ + 考勤工时 +
+
82/100
+
+
+ 5 件待办 +
+
+
+
+ + 薪酬工资 +
+
85/100
+
+
+ 0 件待办 +
+
+
+
+ + 社保公积金 +
+
88/100
+
+
+ 0 件待办 +
+
+
+ + +
+
+ + 本周合规待办 · 按风险优先级排序 + 12 件待处理 + 导出待办清单 → +
+ +
+
+ 🔴 P0 +
+
4 项规章制度未完成民主程序公示
+
《考勤管理》《销售提成方案》《员工奖惩制度》《保密协议》— 预估降低 ¥18.6 万仲裁风险
+
+
+ 建议本周完成 + +
+
+ +
+ 🔴 P0 +
+
员工"张三"今日入职未签订书面合同
+
超过 30 天将面临双倍工资风险(最长 11 个月)
+
+
+ 30 天内 + +
+
+ +
+ 🟡 P1 +
+
3 名员工合同将于 30 天内到期
+
刘云 (64 天) · 王刚 (28 天) · 李华 (22 天) — 建议提前 30 天处理续签/不续签
+
+
+ 本月内 + +
+
+ +
+ 🟡 P1 +
+
5 名员工 2026-10 月考勤记录未签字
+
考勤未签字员工发生加班费争议时企业举证不能
+
+
+ 本月内 + +
+
+ +
+ 🟢 P2 +
+
企业文本库 17 份文件未经 AI 审查
+
建议批量审查后入库 · 预计耗时 8 分钟
+
+
+ 建议性 + +
+
+
+
+ + +
+
+ 合同管理 47 + 制度公示 12 + 考勤工时 95% + 员工档案 47 + 文本模板 126 +
+ + +
+
+
+ 全部 47 + 📝 待签 3 + ⏰ 即将到期 4 + ⚠️ 未审改 17 +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
员工合同类型期限签约状态AI 审改风险操作
张三
研发部
固定期限未生效⚠ 未签3 处高风险双倍工资
刘云
市场部
固定期限剩 64 天 ⚠️已签1 处中风险即将到期
王明
销售部
固定期限2027-06已签已通过合规
李雪梅
财务部
无固定期限已签待审改合规
陈军
生产部
固定期限2027-09已签已通过工伤恢复期
+
显示 5 / 47 · 查看全部 →
+
+ + +
+
+
+ 全部 12 + ✅ 已公示 8 + ⚠️ 待民主程序 4 +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
制度名称最近更新民主程序进度公示状态风险评估操作
《考勤管理制度》2025-08-12 +
+
+
+
2
+
+
3
+
+
4
+
+
起草 → 职工讨论中 → 平等协商 → 公示
+
⚠ 未公示败诉率 87%
《销售提成方案》2025-10-05 +
+
1
+
+
2
+
+
3
+
+
4
+
+
起草中 → 职工讨论 → 平等协商 → 公示
+
⚠ 未公示高风险
《员工奖惩制度》2024-06-12 +
+
+
+
+
+
+
+
+
+
✓ 全流程完成 · 已公示 1 年 5 月
+
✓ 已公示合规
《保密与竞业限制》2025-09-22 +
+
+
+
+
+
3
+
+
4
+
+
起草 → 职工讨论 → 平等协商中 → 公示
+
推进中中风险
+
+ + +
+
+
+
+
95%
+
本月考勤签字率
+
5 名员工待补签
+
+
+
38h
+
本月人均加班
+
≤ 36h 法定上限超 2h
+
+
+
¥ 2.3k
+
未支付加班费风险
+
3 名员工 · 待核算
+
+
+
2
+
不定时工作制
+
⚠ 1 个未审批
+
+
+
+
⚠️ AI 检测发现
+
+ 本月有 5 名员工的考勤记录员工未签字确认,存在加班费争议时举证不能的风险。建议立即发起电子签确认。 +
+ +
+
+
+ + +
+
+
+
+
88%
+
员工档案完整度
+
+
+
12
+
背调记录缺失
+
+
+
94%
+
入职培训签收率
+
+
+
100%
+
竞业协议签订率
+
+
+
+ +
员工档案详细管理 · 跳转员工管理模块查看
+
+
+
+ + +
+
+
+
+
+
劳动合同模板
+
12 种 · 已 AI 优化
+
+
+
+
违纪处分通知
+
8 种 · 含证据要件
+
+
+
+
解除通知模板
+
6 种 · 按解除依据分类
+
+
+
+
协商一致协议
+
4 种 · 含 N+1 标准条款
+
+
+
+
保密竞业协议
+
5 种 · 含经济补偿条款
+
+
+
+
规章制度模板
+
11 种 · 含民主程序流程
+
+
+
+
+
+ +
+
+
+

电子签中心

+ 客户咨询 TOP 1 + · 年签署 5,080 次 · 平均用时 2.3 分钟 · 法律效力等同纸质 +
+ + +
+
+
+
+
5
+
待签合同
+
+
+
+
+
+
23
+
本月已签
+
+
+
+
+
+
2.3min
+
平均签署用时
+
+
+
+
+
+
100%
+
证据链合规率
+
+
+
+ + +
+
+
+
发起新签 最常用
+
单份/批量发起签署,支持 AI 预审 + 自动催签
+
+
+
+
从模板签
+
劳动合同 / 保密协议 / 离职协议等 12 套法律模板
+
+
+
+
语音发起
+
说"给张三发劳动合同" · 白小侠自动调起并预填
+
+
+ + +
+
+ + 待签合同 (5) +
+ + +
+
+ +
+
+
+
+ 劳动合同_张三_前端工程师_2026.docx + ⚠ 已 AI 审出 3 处高风险 +
+
+ 张三 + 2 小时前发起 + 来源:合同模板 +
+
+
+
+ 待审改 +
+ +
+ +
+
+
+
+ 保密与竞业限制协议_王红.docx + ✓ AI 已通过 +
+
+ 王红 + 昨天 14:30 + 公司已签 +
+
+
+
+ 1/2 签 +
+ +
+ +
+
+
+
+ 协商解除协议_李华.docx + ✓ AI 已通过 +
+
+ 李华 + 3 小时前 + 经济补偿 N+1 +
+
+
+
+ 0/2 签 +
+ +
+ +
+
+
+
+ 《考勤管理制度》公示确认书 · 47 名员工 + 待批量发起 +
+
+ 47 人 + 民主程序后 + AI 建议本周完成 +
+
+
+
+ 未发起 +
+ +
+ +
+
+
+
+ 2026-10 工资条电子签 · 47 名员工 + 42/47 已签 +
+
+ 47 人 + 5 天前发起 + 5 人未签 +
+
+
+
+ 89% +
+ +
+
+ + +
+
+ + 电子签 + AI 的独有能力 · 别家电子签做不到的 +
+
+
+
+
+ 签前 AI 审查 + 所有合同发起前自动调用文件审查模块,识别高风险条款并提示修改,避免签了之后才发现问题。 +
+
+
+
+
+ 证据链 5 维留痕 + 时间戳 · IP 地理位置 · 设备指纹 · 生物识别 · 区块链哈希存证,仲裁中举证效力远超纸质。 +
+
+
+
+
+ 多渠道签署 + 员工可通过 PC、App、小程序、短信链接任一方式签署,对方不需要安装任何软件。 +
+
+
+
+
+ 智能催签 + AI 根据签署紧急程度(如合同到期前)智能调整催签节奏 · 6h/24h/3天 多频率推送。 +
+
+
+
+
+
+
+

申请草稿

+ · 暂存未发起的申请,可继续编辑 +
+ +
+
申请项:
+
关键词:
+ + +
+ + + + + + + + + + +
序号申请项接收人保存时间状态操作
1员工入职张三2025-11-19 17:32:10草稿继续编辑删除
2合同续签马闻韬2025-11-18 10:08:21草稿继续编辑删除
3灵活用工崔玥林2025-11-12 09:45:00草稿继续编辑删除
+ +
+ + + + +
+
+

已发申请

+ · 已发起的申请记录与签署进度 +
+ +
+
申请项:
+
接收人:
+
状态:
+
发起时间:~
+ + +
+ + + + + + + + + + + + +
序号申请项接收人发起时间状态签署状态操作
1员工入职张三2025-11-20 10:59:36进行中未签署详情撤销
2灵活用工张三2025-10-29 10:50:42已撤销未签署详情
3员工录用聂萍2025-09-16 17:35:33已撤销无需签署详情
4合同续签马闻韬2025-09-15 16:48:49已撤销已签署详情
5员工入职崔玥林2025-09-15 16:31:05已完成已拒签详情
+ +
+
+
+

入职管理

+ · 入职流程候选人台账 +
+ +
+
姓名:
+
身份证号:
+ + +
+ +
+ + + + + + + + + + + + + + + +
姓名性别手机号身份证号员工状态操作
闫明杰15938762511410423199503121011在职查看详情
闫明杰215938762512410423199503121012待入职查看详情办理入职
张三3315938762513410423199503121013待入职查看详情办理入职
张三13800138000110101199801011234在职查看详情
郭宪强未知15626473067130434199104101013待入职查看详情
李四未知15626473068130434199104101012待入职查看详情
王晶晶未知13288214455130434199111161026待入职查看详情
石文渊13299110011411322199403314913待入职查看详情办理入职
+ +
+ + + + +
+
+

合同管理

+ · 全量合同档案与到期监控 +
+ +
+
全部
125
+
劳动合同
73
+
劳务合同
7
+
非全日制
0
+
其他协议留存
45
+
试用期即将到期
0
+
劳动合同即将到期
0
+
+ +
+
合同类型:
+
合同状态:
+
创建时间:~
+ + +
+ + + + + + + + + + + + + + +
合同名称合同类型归属人起始时间到期时间合同状态操作
劳动合同劳动合同郭宪强2026-02-012027-04-30履约中查看下载删除
测试合同劳务合同聂萍2025-08-122025-08-16已到期查看下载删除
劳动合同(2025-07-29)劳动合同马闻韬2025-07-282026-07-31履约中查看下载
劳动合同劳动合同张三2024-01-012025-01-01已到期查看下载删除
劳务合同最新728劳务合同李欣原2025-07-282025-08-31已到期查看下载删除
123劳动合同徐向阳2025-06-192025-06-30已到期查看下载
esigntest自话用工(北京)科技服务有限公司PAAR其他崔玥林2025-04-232025-04-23已到期查看下载删除
+ +
+ + + + +
+
+

考勤确认

+ · 月度考勤发布与员工确认记录 +
+ +
+
考勤确认使用说明
+ 1. 仅支持上传平台提供的标准模板,暂不支持自定义模板;
+ 2. 发布后员工在小程序首页查看和确认。 +
+ +
+ + ~ +
+ +
+ + + + + + + + + + + +
姓名员工确认情况 ▾发布时间 ▾操作
张三已反馈2025-06-04 18:01:07详情
李欣原已读2025-06-04 18:00:10详情
马闻韬已读2025-05-16 16:09:30详情
崔玥林已读2025-05-16 16:00:46详情
+ +
+ + + + +
+
+

工资条发布

+ · 工资条发布与员工确认 +
+ +
+
工资条发布使用说明
+ 1. 仅支持上传平台提供的标准模板,暂不支持自定义模板;
+ 2. 发布后员工在小程序首页查看和确认;
+ 3. 定时发送前支持取消发送。 +
+ +
+ + ~ +
+ + +
+ + + + + + + + + + + + + + + +
姓名标题员工确认情况 ▾发布状态 ▾发布时间 ▾操作
张三555未读已发布2025-09-18 13:07:46详情
张三444未读已发布2025-09-18 13:03:11详情
张三333未读已发布2025-09-18 12:40:24详情
张三222未读已发布2025-09-18 12:32:14详情
张三111未读已发布2025-09-18 12:10:08详情
张三8月份工资已读已发布2025-09-09 09:33:50详情
张三定时发送已反馈已发布2025-06-06 15:30:00详情
张三五月份工资已反馈已发布2025-06-06 10:07:16详情
+ +
+ + + + +
+
+

规章制度民主程序

+ · 规章制度民主表决与公示 +
+ +
+
使用说明
+
+ 1. 请先上传企业规章制度等文件到「企业文本库」后再来此发起;   2. 民主程序按所选部门发布,相关员工收到小程序待办任务和短信通知;
+ 3. 截止后部门内半数以上员工无异议则视为通过;否则需 HR 修改后重新发布;   4. 民主程序通过可发起公示,员工可在小程序-我的-公司制度中查看。 +
+
+ +
+
发起民主程序
+ +
+
+
*选择文件:
+ +
+
+
*标题:
+ +
+
+
*表决人员:
+
+ + +
+
+
+
*截止日期:
+ +
+
+ +
+
*说明:
+ +
+ +
+ + +
+
+ +
+
+
民主记录
+
公示记录
+
+ +
+ + + + + + + + + + +
序号文本名称发起时间表决人员民主状态操作
1测试合同 - 副本 (7).pdf2025-04-23产品一部民主通过详情发起公示
2测试合同 - 副本 (9).pdf2025-04-23产品一部, 研发二部民主通过详情发起公示
3考勤制度.pdf2025-04-23全体人员民主通过详情发起公示
4计算机基础.pdf2025-04-22全体人员民主通过详情发起公示
5进阶篇.pdf2025-04-22业务一线, 业务二线民主通过详情发起公示
6托管服务落地指导手册大纲-0915最终版.pdf2025-02-10全体人员民主通过详情发起公示
+ +
+ +
+
+ + + + +
+
+

企业文本库

+ · 企业自有规章制度、合同等文本 +
+ +
+
使用说明
+ 1. 可新建企业自己的用工文本模板,并针对不同类型用工文本发起相应流程;
+ 2. 如需上传企业自己的规章制度,建议参考我们在"用工文本模板库"中提供的"员工手册(范本)"进行参考,避免出现用工风险问题;
+ 3. 当前仅支持规章制度类型(后续逐步更新),对该类型可以直接发起民主公示程序,为企业提供合规流程,保驾护航。 +
+ +
+
文本名称:
+
文本类型:
+ + +
+ +
+ + + + + + + + + + + + + + +
文本编号文本名称文本类型创建人创建时间 ▾操作
1914974805631696898测试合同 - 副本 (7).pdf规章制度崔玥林2025-04-23 17:29:07预览民主公示更多 ▾
1914943929023733762测试合同 - 副本 (9).pdf规章制度崔玥林2025-04-23 15:26:25预览民主公示更多 ▾
1914614112470441985考勤制度.pdf规章制度崔玥林2025-04-22 17:35:51预览民主公示更多 ▾
1914594474078056449计算机基础.pdf规章制度崔玥林2025-04-22 16:17:49预览民主公示更多 ▾
1914586301053112322进阶篇.pdf规章制度崔玥林2025-04-22 15:45:20预览民主公示更多 ▾
1888848753675235330托管服务落地指导手册大纲-0915最终版.pdf规章制度崔玥林2025-02-10 15:13:31预览民主公示更多 ▾
1858783886703685633enterpriseText-185841791587818291...规章制度崔玥林2024-11-19 16:06:28预览民主公示更多 ▾
+ +
+ + + + +
+
+

用工文本模板库

+ · 平台提供的标准用工文本模板 +
+ +
+
使用说明
+ 平台提供员工手册、劳动合同、各类协议等标准范本,企业可参考或下载使用。范本由白话用工侠法律团队审核,遵循最新劳动法规。 +
+ +
+
分类:
+
关键词:
+ +
+ + + + + + + + + + + + + + + +
模板名称分类更新时间说明操作
员工手册(范本)员工手册2025-09-01覆盖入职、考勤、薪酬、奖惩、离职等完整框架预览下载引用为企业文本
固定期限劳动合同(标准版)劳动合同2025-09-01含试用期、保密、竞业等通用条款预览下载引用为企业文本
非全日制劳动合同劳动合同2025-09-01非全日制用工标准范本预览下载引用为企业文本
劳务合同(个人)劳务/灵活用工2025-09-01个人提供劳务标准合同预览下载引用为企业文本
培训服务协议各类协议2025-09-01含服务期/违约金条款预览下载引用为企业文本
保密与竞业限制协议各类协议2025-09-01商业秘密保护与竞业限制预览下载引用为企业文本
解除/终止劳动合同通知书通知/证明2025-09-01各情形通知书模板预览下载引用为企业文本
离职证明通知/证明2025-09-01合规离职证明模板预览下载引用为企业文本
+
+ + + + +
+
+

用工体检诊断

+ · 年度合规体检 · 出具《企业劳动用工体检诊断报告》 +
+ +
+
+
+
用工体检诊断
+
每年为企业提供一次用工体检诊断服务。结合企业规模、行业特点、发展阶段等,按照合规风险点和合规义务为企业进行诊断,出具《企业劳动用工体检诊断报告》让企业知晓现存劳动问题,并提供解决办法。
+
+
+ +
+
+
诊断维度
+
+
劳动合同管理
期限/试用期/必备条款
+
规章制度管理
制定/民主公示
+
社会保险管理
缴纳险种/基数
+
员工背景调查
入职合规审核
+
工作时间与加班
工时制/加班费
+
保密与竞业限制协议
协议规范与补偿
+
+
+ +
+
+ +
+
诊断报告
+
+
+
+
+
2025 年度用工诊断报告
+
2025-11-07 13:46:42 · 61.84 分 高风险
+
+ +
+
+
+
+
2024 年度用工诊断报告
+
2024-10-16 16:03:08
+
+ +
+
+
+
+
2023 年度用工诊断报告
+
2023-08-12 15:41:53
+
+ +
+
+
+
+
+ + + + +
+
+

背景调查

+ · 候选人多维度合规背调 +
+ +
+
+
+
背景调查
+
温馨提示:发起背调前请务必取得候选人的授权同意。背调结果实时反馈,可在"背调记录"中下载完整报告。
+
+
+
+
免费次数
+
22
+
+
+
付费次数
+
0
+
+
+
+ +
+
发起背调
+
背调记录
+
+ +
+
+
背调内容(每次背调包含以下 5 项)
+
+
+
+
身份信息
+
姓名/身份证一致性
+
+
+
+
不良记录
+
失信被执行人核查
+
+
+
+
吸毒记录
+
公安系统核查
+
+
+
+
诉讼记录
+
涉诉信息查询
+
+
+
+
网贷黑名单
+
多头借贷与逾期
+
+
+
+ +
+
填写候选人信息
+
+
+
*候选人姓名:
+ +
+
+
身份证号:
+ +
+
+ +
+ 额度说明:服务到期后免费额度自动清空;免费、付费额度并存时优先消耗免费额度;如需购买付费额度请联系销售。 +
+ +
+ +
+ + +
+
+
+
+ + +
+ + + + +
+
+

视频中心

+ · 企业用工风险管控系统视频课程 +
+ +
+ + + +
+ +
+
+
模块一
+
+
招聘与试用期风险管理
+
招聘环节合规要点、offer 法律风险、试用期约定与解除条件。
+
白小侠 · 28 分钟
+
+
+
+
模块二
+
+
培训与服务期风控管理
+
培训费用专款专用、服务期约定要点、违约金计算与举证。
+
白小侠 · 32 分钟
+
+
+
+
模块三
+
+
保密与竞业限制风控管理
+
保密协议、竞业限制协议的设计、补偿与违约金条款实务讲解。
+
白小侠 · 26 分钟
+
+
+
+
模块四
+
+
调岗调薪风险管理
+
合规调岗调薪的合理性、必要性论证与协商一致变更要点。
+
白小侠 · 24 分钟
+
+
+
+
模块五
+
+
规章制度民主程序
+
规章制度合规制定流程:起草、民主程序、公示告知的法律要点。
+
白小侠 · 30 分钟
+
+
+
+
模块六
+
+
考勤与加班管理
+
工时制选择、加班费计算、考勤证据链构建。
+
白小侠 · 22 分钟
+
+
+
+
模块七
+
+
解除与离职管理
+
合法解除程序、经济补偿计算、离职面谈与证据留存。
+
白小侠 · 35 分钟
+
+
+
+
劳动法
专题
+
+
仲裁应对实务专题
+
通过真实案例讲解仲裁庭审、证据准备与代理思路。
+
白小侠 · 48 分钟
+
+
+
+
+ + + + +
+
+

五险一金计算器

+ · 按城市快速计算社保公积金 +
+ +
+
使用说明
+ 按当前所在城市的最新缴费比例和上下限自动计算。结果仅供参考,最终以当地社保局公布数据为准。 +
+ +
+
缴费基数与比例
+ +
+
*缴纳城市:
+ +
+ +
+
+
*是否缴纳社保:
+
+ + +
+
+
+
*社保类型:
+ +
+
+
*社保基数:
+ +
+
+
自定义工伤保险:
+
+ + +
+
+
+
*是否缴纳公积金:
+
+ + +
+
+
+
*公积金比例:
+ +
+
+
*公积金基数:
+ +
+
+ +
+ + +
+ +
+ 填写完上方信息后点击「立即计算」查看明细 +
+
+
+ + + + +
+
+

医疗期计算器

+ · 按地区与工龄计算医疗期 +
+ +
+
填写信息
+ +
+
*选择所在地区:
+
+ + +
+
+ +
+
+
*本单位入职日期:
+ +
+
+
*计算截止日期:
+ +
+
+ +
+
*累计病休天数:
+ +
+ +
+ + +
+ +
+ 填写完上方信息后点击「立即计算」查看医疗期天数 +
+
+ +
+
计算规则 & 法律依据
+
    +
  1. 《关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》沪府发(2015)40 号(效期延长至 2025 年 6 月 30 日)第 2 条:劳动者在本单位工作第 1 年,医疗期为 3 个月;以后工作每满 1 年,医疗期增加 1 个月,但不超过 24 个月。
  2. +
  3. 同上规定第 3 条:劳动者经劳动能力鉴定委员会鉴定为完全丧失劳动能力但不符合退休、退职条件的,应当延长医疗期;约定延长的医疗期与前条规定的医疗期合计不得低于 24 个月。
  4. +
  5. 《上海市劳动保障局关于病假工资计算的公告》第 1 条:职工疾病或非因工负伤休假日数按实际休假日数计算,连续休假期内含有休息日、节假日的应予剔除。
  6. +
+
+
+ + + + +
+
+

通知管理

+ · 系统消息与员工通知 +
+ +
+
类型:
+
状态:
+
关键词:
+ +
+ +
+ + + + + + + + + + + + +
类型标题接收人发送时间 ▾状态操作
合同到期刘云未设置合同信息,请尽快补充李 HR2025-11-20 09:30未读查看标为已读
民主程序考勤制度.pdf 民主程序已通过李 HR2025-04-23 18:00已读查看
背调结果张三 的背景调查报告已生成李 HR2026-04-09 16:00未读查看标为已读
试用期员工郭宪强试用期即将到期(30天)李 HR2025-10-30 10:00已读查看
系统2025 年度用工诊断报告已生成李 HR2025-11-07 13:46已读查看
+ +
+ + + + +
+
+

用户管理

+ 系统设置 / 用户管理 +
+ +
+
+
组织架构
+
+
▾ 北京白话共享企业管理有限公司 PAAK
+
▾ 产品部
+
产品一部
+
产品二部
+
▾ 研发部
+
研发一部
+
研发二部
+
+
+ +
+
+
关键词:
+
状态:
+ + +
+ +
+ + + + + + + + + + + + +
账号姓名性别手机状态操作
test9999崔玥林15103488567正常编辑更多 ▾
testCylCyl18630662611正常编辑更多 ▾
test7777test777715034425955正常编辑更多 ▾
test6666test666615034425977正常编辑更多 ▾
test5555test555515034426955正常编辑更多 ▾
+ +
+
+
+ + + + +
+
+

系统日志

+ 系统设置 / 系统日志 +
+ +
+
操作人:
+
操作类型:
+
模块:
+
时间:~
+ +
+ + + + + + + + + + + + + + + +
操作时间 ▾操作人账号操作类型模块操作内容IP
2025-11-20 11:02:18崔玥林test9999新增合同管理创建合同《劳动合同 - 郭宪强》10.0.1.23
2025-11-20 10:59:36李 HRcyhr新增我的申请发起员工入职申请(张三)10.0.1.45
2025-11-19 17:32:10李 HRcyhr修改员工管理编辑员工信息(刘云)10.0.1.45
2025-11-19 16:06:28崔玥林test9999新增企业文本库上传 enterpriseText-185841...pdf10.0.1.23
2025-11-18 10:08:21马闻韬mwt登录系统用户登录10.0.2.18
2025-11-07 13:46:42崔玥林test9999新增用工体检诊断生成《2025 年度用工诊断报告》10.0.1.23
2025-04-23 18:00:00李 HRcyhr新增规章制度民主程序发起民主程序:考勤制度.pdf10.0.1.45
2025-04-22 17:35:51崔玥林test9999新增企业文本库上传考勤制度.pdf10.0.1.23
+ +
+ +
+
+
+ + +
+
+ + 3 + 3 条新建议 · 长按麦克风说话 +
+ + + + + diff --git a/白话用工侠_测试页面_分析报告.md b/白话用工侠_测试页面_分析报告.md new file mode 100644 index 0000000..2655a69 --- /dev/null +++ b/白话用工侠_测试页面_分析报告.md @@ -0,0 +1,813 @@ +# 白话用工侠_测试页面.html 详细分析报告 + +> 文件路径:`/Users/freedak/Documents/AIDashboard/UTMS/TurboHR/白话用工侠_测试页面.html` +> 文件总行数:6370 行 +> 分析日期:2026-07-26 + +--- + +## 一、文件总体结构 + +| 区域 | 行范围 | 说明 | +|------|--------|------| +| `` + CSS 变量 | 1-60 | 全局 CSS 变量定义(主色/字体/圆角等) | +| CSS 组件样式 | 60-2899 | 所有 UI 组件的样式定义 | +| `` HTML 结构 | 2900-5751 | Sidebar + Topbar + 26 个页面模块 | +| 浮动白小侠按钮 | 5753-5759 | 右下角悬浮 AI 入口 | +| `