Files
TurboHR/20260727-优化-梳理.md
selfrelease 255af519d2 feat: Phase 1-3 优化全部完成
Phase 1 紧急修复(8项):
- 社保城市选择改为可输入
- 社保上下限拆分(三险/医保独立基数)
- 公积金试算结果展示修复
- 花名册合同保存修复(日期ISO格式)
- 薪酬批次创建失败修复(城市过滤+错误处理)
- 证据链查看修复
- 个税计算修复(blank_employees读取基本工资)
- 加班费倍率读取配置

Phase 2 功能完善(3项):
- 批量导入per-row异常捕获+导入按钮
- 单人发薪UI入口优化
- 解除协议模板补充(员工提出离职版)

Phase 3 后期规划(4项):
- 工资表导入功能(POST /import/payroll + 前端入口)
- 大病险/长护险附加险种(extraInsurances JSON + 计算适配)
- 专项附加扣除按月录入(SpecialDeductionRecord模型 + 前端Tab)
- 预置河北省社保政策(seed数据)
2026-07-27 18:55:08 +08:00

552 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TurboHR 20260727 优化需求梳理
> **来源**HR 用户测试反馈
> **日期**2026-07-27
> **测试地区**:河北省(重点)
> **梳理方式**:逐条对照源码分析根因,标注涉及文件和行号
---
## 一、需求分类总览
| # | 模块 | 优先级 | 类型 | 简述 | 根因已定位 |
|---|------|--------|------|------|-----------|
| 1 | 社保政策 | 🔴 高 | Bug+功能 | 仅北京/上海,无法添加其他城市,河北无法使用 | ✅ |
| 2 | 社保政策 | 🔴 高 | 功能 | 河北五险不同上下限,三险与医保需分开 | ✅ |
| 3 | 社保政策 | 🟡 中 | 功能 | 大病险、长护险各地市收费不同,需单独添加险种 | ✅ |
| 4 | 公积金 | 🔴 高 | Bug | 公积金模块无法测算数据,显示报错 | ✅ |
| 5 | 专项附加扣除 | 🟡 中 | 功能 | 无法关联自然人数据,需手动录入,是否可按月累计 | ✅ |
| 6 | 花名册-合同 | 🔴 高 | Bug | 花名册中添加劳动合同信息无法正常保存 | ✅ |
| 7 | 花名册-导入 | 🔴 高 | Bug+UI | 批量导入仅员工基本信息成功,合同/加班/考勤均失败 | ✅ |
| 8 | 花名册-UI | 🟡 中 | UI | 缺少明显的批量导入按钮入口 | ✅ |
| 9 | 薪酬管理 | 🔴 高 | Bug | 创建批次总提示创建失败 | ✅ |
| 10 | 薪酬管理 | 🔴 高 | 功能 | 无法单独添加一个人发薪,必须全员生成再删除 | ✅ |
| 11 | 薪酬管理 | 🟡 中 | 功能 | 缺少工资表导入功能 | ✅ |
| 12 | 个税计算 | 🔴 高 | Bug | 导入工资后显示无个税,未匹配自动算税 | ✅ |
| 13 | 证据链 | 🔴 高 | Bug | 证据链无法查看(手动录入的员工也无法查看) | ✅ |
| 14 | 加班费 | 🟡 中 | 功能 | 加班费倍率硬编码(1.5/2.0/3.0),需支持公司自定义标准 | ✅ |
| 15 | 考勤对接 | 🟢 低 | 功能 | 考勤制度是否可关联企微等外部系统 | — |
| 16 | 电子签 | 🟢 低 | 功能 | 合同签署是否可关联电子签平台 | — |
| 17 | 文本模板 | 🟡 中 | 功能 | 缺少「个人提出离职」的解除协议模板 | ✅ |
---
## 二、详细分析(含源码根因)
### 2.1 社保政策 — 多城市支持(#1, #2, #3)
**根因分析**
1. **城市选择为下拉固定列表,无法手动输入**
- `SocialInsurance.tsx:340-347` — 城市选择是 `<select>` 下拉,选项来自 `GET /social/config/cities` 返回的已有城市列表
- 如果数据库中只有北京/上海,用户无法选择其他城市
- **但**新建版本表单 `SocialInsurance.tsx:570` 中城市字段是 `<Input>` 文本框,可以手动输入
- **真正问题**:城市选择下拉限制了查看范围,用户在新建版本时可以输入「河北」,但切换城市查看时下拉没有「河北」选项
2. **社保上下限单一,无法区分三险与医保**
- `schema.prisma:389-390``SocialInsuranceConfig` 仅有 `baseMin` / `baseMax` 两个字段
- `payroll.service.ts:37-42``calcSocialInsurance()` 使用单一 `actualBase` 计算所有险种
- 河北省政策:养老/失业/工伤保险基数上下限 ≠ 医疗/生育保险基数上下限
3. **无大病险/长护险字段**
- `schema.prisma:376-401``SocialInsuranceConfig` 无大病险、长护险相关字段
- `payroll.service.ts:39-40` — 计算仅包含养老/医疗/失业/工伤/生育五险
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| Schema 扩展 | `prisma/schema.prisma:376-401` | 新增 `medicalBaseMin Float @default(0)` `medicalBaseMax Float @default(0)` `extraInsurances String? // JSON` |
| 社保计算适配 | `backend/src/services/payroll.service.ts:37-42` | 三险用 `baseMin/baseMax`,医保用 `medicalBaseMin/medicalBaseMax`(为 0 时 fallback 到 baseMin/baseMax |
| 前端城市选择 | `frontend/src/pages/SocialInsurance.tsx:340-347` | `<select>` 改为 `<input list>` + `<datalist>`,支持手动输入城市名 |
| 新建版本表单 | `frontend/src/pages/SocialInsurance.tsx:568-590` | 新增医保上下限输入框 + 附加险种配置区 |
| 预置河北数据 | seed 脚本或手动 | 添加河北省社保配置(养老/失业/工伤基数 3920~19602,医疗/生育基数 5360~26796 |
**河北省 2024 社保参考数据**
```
养老/失业/工伤:基数下限 3920,上限 19602
医疗/生育: 基数下限 5360,上限 26796
养老 企业16% 个人8%
失业 企业0.7% 个人0.3%
工伤 企业0.2~1.9%(按行业)
医疗 企业8% 个人2%
生育 企业1%(已并入医疗,河北单独列)
```
---
### 2.2 公积金测算报错(#4)
**根因分析**
- `social.routes.ts:557-606``/housing-calculate` 接口
- **核心问题在第 580-583 行**:当查不到公积金配置时,自动创建一条默认配置:
```ts
config = await prisma.housingFundConfig.create({
data: { orgId, effectiveFrom: ..., city: city || '北京', createdBy: ... },
})
```
创建的配置使用 schema 默认值(`housingOrg: 12, housingEmp: 12, baseMin: 6326, baseMax: 33891`),但 `city` 参数可能为 `undefined`
- **前端调用链**`SocialInsurance.tsx:611` — `calcHousingMutate()` 调用 `POST /social/housing-calculate`,传参 `{ base, month, city }`
- **可能原因**
1. `city` 未正确传递(`undefined`),创建的配置城市为「北京」而非用户期望的城市
2. 前端 `housingResult` 的 `items` 字段不存在(公积金返回的是 `housingOrg/housingEmp` 而非 `items` 数组),但前端试算结果展示复用了社保的 `r.items.map()` 逻辑,导致 `undefined.map()` 报错
- **前端结果展示 Bug**`SocialInsurance.tsx:625-677` — 试算结果展示区同时用于社保和公积金,使用 `r.items.map()` 渲染表格。但公积金计算接口返回 `{ housingOrg, housingEmp, total }`**没有 `items` 数组**,导致 `r.items` 为 `undefined` → `.map()` 抛出 TypeError
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 前端结果展示 | `frontend/src/pages/SocialInsurance.tsx:625-677` | 公积金试算结果单独渲染(显示企业/个人比例和金额),不复用社保的 items 表格 |
| 后端兜底优化 | `backend/src/routes/social.routes.ts:580-583` | 不自动创建默认配置,改为返回提示「该城市暂无公积金配置,请先创建」 |
---
### 2.3 专项附加扣除(#5
**根因分析**
- `payroll.service.ts:179` — 个税累计预扣计算:
```ts
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
```
- `Employee.specialDeduction` 是单一 Float 字段,表示每月专项附加扣除金额
- 个税计算时直接乘以月份序号作为累计扣除额
- **问题**:不支持按月不同金额(如某月子女教育扣除变更),且需手动在员工档案中录入
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 新建 Prisma 模型 | `prisma/schema.prisma` | `model SpecialDeductionRecord { id, orgId, employeeId, month, amount, type(子女教育/住房贷款/赡养老人/...), createdBy, createdAt }` |
| 个税计算适配 | `backend/src/services/payroll.service.ts:179` | 改为查询 `SpecialDeductionRecord` 按月累加,fallback 到 `employee.specialDeduction * 月份` |
| 前端录入入口 | `frontend/src/pages/roster/BasicInfo.tsx` | 在社保/公积金基数旁增加「专项附加扣除」按月录入区 |
---
### 2.4 花名册合同保存失败(#6)
**根因分析**
- **前端调用**`ContractInfo.tsx:19` — `api.post('/employees/contracts', { ...data, employeeId })`
- **后端路由**`employee.routes.ts:206-209` — `router.post('/contracts', ...)` → `addContractSchema.parse(req.body)` → `addContract()`
- **Schema 校验**`contract.schema.ts:54-64`
```ts
signDate: z.string().datetime().nullable(), // 必须是 ISO datetime 字符串
startDate: z.string().datetime(), // 必须是 ISO datetime 字符串
endDate: z.string().datetime().nullable(),
```
- **前端提交**`ContractInfo.tsx` 表单中日期用 `<Input type="date">`,值为 `YYYY-MM-DD` 格式(如 `2026-07-27`),**不是 ISO datetime 格式**`2026-07-27T00:00:00.000Z`
- **根因**Zod 校验 `z.string().datetime()` 要求 RFC 3339 格式,`YYYY-MM-DD` 不通过校验 → `ZodError` → 返回 400 → 前端显示「保存失败」
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| Schema 日期校验放宽 | `backend/src/schemas/contract.schema.ts:56-58` | 改为 `z.string().nullable()` + 在 `addContract()` 中用 `new Date()` 解析 |
| 或前端提交时转换 | `frontend/src/pages/roster/ContractInfo.tsx` | 提交前将日期转为 ISO 格式:`new Date(form.signDate).toISOString()` |
**推荐方案**:前端转换(改动最小,且 `Contracts.tsx` 新建员工时已用 `new Date(form.signDate).toISOString()` 转换,`ContractInfo.tsx` 遗漏了同样的转换)
---
### 2.5 批量导入问题(#7, #8
**根因分析**
- `import.routes.ts:214-390` — 多 Sheet 导入逻辑
- **Sheet 名称精确匹配**:代码中硬编码 Sheet 名称为中文(如「员工信息」「劳动合同」「加班记录」「考勤记录」),如果用户修改了 Sheet 名或模板格式不一致,则无法匹配
- **合同匹配逻辑**:先按 `idCardHash` 匹配,再按 `name` 匹配。如果员工信息 Sheet 和合同 Sheet 中的身份证号或姓名不一致(空格、别称),则匹配失败
- **错误信息未充分展示**:后端返回 `errors` 数组,但前端可能只显示了「成功 N 条」的汇总,未展示详细错误
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 花名册增加导入按钮 | `frontend/src/pages/Roster.tsx` 或 `Contracts.tsx` | 在列表页顶部增加「批量导入」按钮,点击后弹出导入向导 |
| 导入模板下载 | `frontend` | 调用 `GET /import/template` 下载标准模板 |
| 错误详情展示 | `frontend` | 导入结果弹窗中展示 `errors[]` 数组的每条错误(行号+原因) |
| Sheet 名称容错 | `backend/src/routes/import.routes.ts` | Sheet 名称匹配改为包含关键词即可(如包含「合同」即视为劳动合同 Sheet) |
---
### 2.6 薪酬管理创建失败(#9)
**根因分析**
- `payroll2.routes.ts:220-403` — 批次创建逻辑
- **关键链路**`createBatchSchema.parse(req.body)` → 查询员工 → 循环 `calcBatchEntry()` → 创建 `BatchEntry`
- **`calcBatchEntry()` 可能抛异常**`payroll.service.ts:109-127` — 查询 `socialInsuranceConfig` 和 `housingFundConfig` 时**不带 `city` 过滤**
```ts
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [...] },
orderBy: { effectiveFrom: 'desc' },
})
```
如果组织有多个城市的配置,可能取到错误城市的配置;如果无配置,`socialConfig` 为 `null`,社保为 0(不报错)
- **更可能的根因**`payroll.service.ts:329` — `Number(decrypt(emp.monthlySalary))` 如果 `monthlySalary` 加密格式异常,`decrypt` 抛出错误,虽然有 `catch` 回退到 `Number(emp.monthlySalary)`,但如果 `monthlySalary` 本身是加密后的非数字字符串,`Number()` 返回 `NaN`,后续计算 `NaN` 传播可能导致 Prisma 写入失败
- **另一个可能**`payroll2.routes.ts:346` — `calcBatchEntry()` 内部 `prisma.payslip.findMany()` 查询历史工资条,如果数据量大可能超时
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| calcBatchEntry 增加城市过滤 | `backend/src/services/payroll.service.ts:111-118` | 查询社保配置时加入 `city: employee.city` 过滤 |
| 错误处理增强 | `backend/src/routes/payroll2.routes.ts:346` | `calcBatchEntry()` 调用加 try-catch,单条失败跳过并记录,不阻塞整批 |
| 前端错误展示 | `frontend/src/pages/Money.tsx:111-120` | `onError` 时展示后端返回的具体错误信息 |
---
### 2.7 单人发薪(#10
**根因分析**
- **后端已有接口**`payroll2.routes.ts:489-547` — `POST /batches/:batchId/employees` 支持向批次添加员工
- **前端已有调用**`Money.tsx:735-739` — `addMutation` 调用 `api.post('/payroll2/batches/${batchId}/employees', { employeeIds })`
- **结论**:功能已存在,用户可能未找到入口。需检查前端 UI 是否暴露了「添加员工」按钮
**修复方案**
- 检查 `Money.tsx` 批次详情页中是否有「添加员工」按钮
- 如果按钮存在但隐藏,调整 UI 使其更明显
- 如果按钮不存在,在批次详情页增加「添加员工」操作
---
### 2.8 工资表导入(#11
**现状**:无工资表导入功能
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 后端导入接口 | `backend/src/routes/import.routes.ts` | 新增 `POST /import/payroll` 解析 Excel 工资表(员工姓名/身份证 + 基本工资/津贴/奖金/扣款) |
| 前端导入入口 | `frontend/src/pages/Money.tsx` | 批次详情页增加「导入工资表」按钮 |
---
### 2.9 个税计算问题(#12
**根因分析**
- `payroll.service.ts:160-183` — 累计预扣法个税计算逻辑完整
- **个税为 0 的正常情况**
- `totalPay < 5000` → `ytdTaxableIncome ≤ 0` → 个税 = 0
- `totalPay - 社保 - 公积金 - 5000*月份 - 专项附加扣除 ≤ 0` → 个税 = 0
- **个税为 0 的异常情况**
- 社保配置缺失 → `socialEmp = 0`(不会导致个税为 0,反而个税应更高)
- `specialDeduction` 为 0 → 减除费用仅 5000/月,如果工资 > 5000 应有个税
- **真正问题**:如果 `baseSalary = 0`(使用 `blank_all` 或 `blank_employees` 模式创建批次),`totalPay = 0` → 个税 = 0
- **用户反馈场景**:用户说「导入工资后显示无个税」,说明工资数据已导入但个税仍为 0
- 可能原因:导入工资数据后未触发 `calcBatchEntry()` 重新计算
- 或前端显示的个税字段映射有误
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 编辑条目时重算 | `backend/src/routes/payroll2.routes.ts` | `PUT /batches/:batchId/entries/:employeeId` 已调用 `calcBatchEntry()`,确认前端编辑后是否触发重算 |
| 前端字段映射 | `frontend/src/pages/Money.tsx` | 确认 `entry.tax` 字段正确显示 |
| 导入后自动重算 | `backend/src/routes/import.routes.ts` | 如果新增工资表导入,导入后自动调用 `calcBatchEntry()` |
---
### 2.10 证据链无法查看(#13
**根因分析**
- **后端 API 完整**`roster.routes.ts:240-494` — `GET /:id/evidence-chain` 返回 `{ employee, evidence[], risks[], summary }`
- **前端组件完整**`EvidenceChain.tsx:14-136` — 使用 `useQuery` 调用 `api.get('/roster/${employeeId}/evidence-chain')`,渲染证据列表和风险提醒
- **前端引用正确**`EmployeeProfile.tsx:96` — `{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}`
- **可能根因**
1. **Tab 未显示**`EmployeeProfile.tsx` 的 Tab 列表中是否有「证据链」Tab?需检查 `TAB_GROUPS` 定义
2. **API 路由前缀**:前端 `api.get('/roster/${employeeId}/evidence-chain')` → 实际请求路径需确认是否匹配后端路由挂载前缀
3. **新员工无数据**:手动录入的新员工如果没有合同/工资条/考勤等关联数据,`evidence[]` 数组可能为空,前端显示「无数据」
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 检查 Tab 定义 | `frontend/src/pages/roster/shared.ts` | 确认 `TAB_GROUPS` 中包含证据链 Tab |
| 空数据提示优化 | `frontend/src/pages/roster/EvidenceChain.tsx:24` | `if (!data || data.evidence?.length === 0)` 时显示「暂无证据记录,员工产生合同/工资/考勤等数据后自动生成」 |
| API 路径验证 | `frontend/src/lib/api.ts` | 确认 baseURL + `/roster/:id/evidence-chain` 是否匹配后端挂载路径 |
---
### 2.11 加班费自定义标准(#14)
**根因分析**
- **后端已有 OvertimeConfig 模型和 API**
- `schema.prisma:444-454` — `OvertimeConfig` 模型,含 `weekdayRate/weekendRate/holidayRate/monthlyDays/dailyHours`
- `payroll.routes.ts:334-370` — `GET /overtime/config` 和 `POST /overtime/config` 接口
- **但加班费计算仍用硬编码**`payroll.routes.ts:45-47` 和 `107-109`
```ts
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
const weekendPay = hourlyWage * 2.0 * data.weekendHours
const holidayPay = hourlyWage * 3.0 * data.holidayHours
```
**未读取 `OvertimeConfig` 中的倍率**,直接硬编码 1.5/2.0/3.0
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 加班费计算读取配置 | `backend/src/routes/payroll.routes.ts:44-47` | 先查 `OvertimeConfig`,用配置中的倍率替代硬编码 |
| 同上 | `backend/src/routes/payroll.routes.ts:107-109` | PUT 接口同样修复 |
| 前端增加配置入口 | `frontend/src/pages/Money.tsx` 或 `Settings.tsx` | 增加加班费倍率配置 UI |
---
### 2.12 考勤企微对接(#15
**现状**:无企微对接
**需求**:评估对接企业微信考勤数据的可行性
**方案**:后期规划,需企微 API 文档调研。企微提供考勤数据接口 `checkin/getcheckindata`,可定时拉取同步到系统
---
### 2.13 电子签对接(#16
**现状**:合同记录有 `signMethod` 字段(PAPER/ELECTRONIC),`ContractInfo.tsx` 表单已支持选择签署方式并填写电子合同编号/链接
**需求**:评估对接电子签平台(如法大大/上上签)的可行性
**方案**:后期规划,需第三方平台 API 调研。当前可先完善手动录入电子合同信息的流程
---
### 2.14 文本模板补充(#17
**根因分析**
- `template.service.ts:15-249` — 硬编码模板数组 `documentTemplates`
- 已有模板:固定期限劳动合同、无固定期限劳动合同、**协商解除劳动合同协议书**(`tpl_termination_agreement`)、员工手册公示通知、规章制度讨论通知、违纪处分通知书、试用期转正通知书、合同到期不续签通知书
- **缺少**:员工主动提出离职的解除协议书模板
**修复方案**
| 改动 | 文件 | 具体内容 |
|------|------|---------|
| 新增模板 | `backend/src/services/template.service.ts` | 在 `documentTemplates` 数组中新增 `tpl_voluntary_termination_agreement`(员工提出离职版解除协议) |
**模板内容要点**
- 乙方主动提出离职,甲方同意
- 无经济补偿金(员工主动辞职,法定无需支付)
- 工作交接条款
- 社保公积金截止月份
- 竞业限制延续条款(如有)
- 变量:`companyName, employeeName, idCard, resignDate, lastWorkDay, socialInsEndMonth, housingFundEndMonth, resignReason`
---
## 三、实施优先级建议
### Phase 1 — 紧急修复(阻塞客户使用)
| 序号 | 任务 | 根因 | 预估工作量 |
|------|------|------|-----------|
| 1 | 社保城市选择改为可输入 | 城市下拉限制了已有城市 | 0.5 天 |
| 2 | 社保上下限拆分(三险/医保) | Schema 单一 baseMin/baseMax | 1 天 |
| 3 | 预置河北省社保政策 | 无河北数据 | 0.5 天 |
| 4 | 公积金试算结果展示修复 | 公积金返回无 items 数组,复用社保表格渲染报错 | 0.5 天 |
| 5 | 花名册合同保存修复 | 前端提交 YYYY-MM-DD 未转 ISO datetime | 0.5 天 |
| 6 | 薪酬批次创建失败修复 | calcBatchEntry 缺城市过滤 + 错误处理不足 | 0.5 天 |
| 7 | 证据链查看修复 | 需确认 Tab 定义 + 空数据提示 | 0.5 天 |
| 8 | 个税计算排查 | 确认导入后是否触发重算 | 0.5 天 |
### Phase 2 — 功能完善
| 序号 | 任务 | 预估工作量 |
|------|------|-----------|
| 9 | 批量导入 Bug 修复 + 导入按钮 | 0.5 天 |
| 10 | 单人发薪 UI 入口优化 | 0.5 天 |
| 11 | 工资表导入功能 | 1 天 |
| 12 | 大病险/长护险单独险种模块 | 1 天 |
| 13 | 专项附加扣除按月录入 | 0.5 天 |
| 14 | 加班费倍率读取配置(后端已有模型,改计算逻辑) | 0.5 天 |
| 15 | 解除协议模板补充(员工提出离职版) | 0.5 天 |
### Phase 3 — 后期规划
| 序号 | 任务 | 说明 |
|------|------|------|
| 16 | 企微考勤对接 | 需 API 调研 |
| 17 | 电子签平台对接 | 需第三方平台选型 |
---
## 四、技术要点
### 4.1 社保上下限拆分方案
```prisma
// schema.prisma — SocialInsuranceConfig 新增字段
model SocialInsuranceConfig {
// ... 现有字段 ...
baseMin Float @default(6326) // 三险基数下限(养老/失业/工伤)
baseMax Float @default(33891) // 三险基数上限
medicalBaseMin Float @default(0) // 医保基数下限(0 = fallback 到 baseMin
medicalBaseMax Float @default(0) // 医保基数上限(0 = fallback 到 baseMax
extraInsurances String? // JSON: [{ name, type: 'fixed'|'rate', orgRate, empRate, orgAmount, empAmount }]
}
```
```ts
// payroll.service.ts — calcSocialInsurance 适配
export function calcSocialInsurance(base: number, config: any) {
const pensionBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const medicalBase = Math.min(
Math.max(base, config.medicalBaseMin || config.baseMin),
config.medicalBaseMax || config.baseMax
)
// 三险用 pensionBase,医保用 medicalBase
const socialEmp = pensionBase * (config.pensionEmp + config.unemploymentEmp) / 100
+ medicalBase * (config.medicalEmp) / 100
const socialOrg = pensionBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100
+ medicalBase * (config.medicalOrg + config.maternityOrg) / 100
return { actualBase: pensionBase, socialEmp, socialOrg }
}
```
### 4.2 合同日期格式修复
```ts
// ContractInfo.tsx — 提交前转换日期格式
const handleSubmit = () => {
const data = {
...formData,
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
}
addContractMutation.mutate(data)
}
```
### 4.3 公积金试算结果展示修复
```tsx
// SocialInsurance.tsx — 公积金试算结果单独渲染
{isHousing && r ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>缴费基数</span><span className="font-medium">¥{fmt(r.actualBase)}</span>
</div>
<div className="flex justify-between text-sm">
<span>企业缴纳 ({r.housingOrg ? '' : ''})</span>
<span className="text-danger">¥{fmt(r.housingOrg)}</span>
</div>
<div className="flex justify-between text-sm">
<span>个人缴纳</span>
<span className="text-warning">¥{fmt(r.housingEmp)}</span>
</div>
<div className="flex justify-between text-sm border-t pt-2 font-medium">
<span>合计</span><span className="text-primary">¥{fmt(r.total)}</span>
</div>
</div>
) : !isHousing && r ? (
// 社保试算结果保持原有 items 表格渲染
...
) : ...}
```
### 4.4 加班费倍率读取配置
```ts
// payroll.routes.ts — 保存加班费记录时读取 OvertimeConfig
router.post('/overtime', async (req, res, next) => {
const data = overtimeSchema.parse(req.body)
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
if (!config) config = { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 } as any
const hourlyWage = data.monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * data.weekdayHours
const weekendPay = hourlyWage * config.weekendRate * data.weekendHours
const holidayPay = hourlyWage * config.holidayRate * data.holidayHours
// ...
})
```
### 4.5 个税计算链路
```
创建/编辑批次条目 → calcBatchEntry()
→ 查社保配置(orgId + month,需加 city 过滤)
→ 查公积金配置(同上)
→ 累计预扣法:
ytdIncome = 历史工资条 totalPay 之和 + 本月 totalPay
ytdDeductions = 5000 * 月份 + ytdSocialEmp + ytdHousingEmp + ytdSpecialDeduction
ytdTaxableIncome = max(0, ytdIncome - ytdDeductions)
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
→ 返回 { tax, netPay, socialEmp, socialOrg, housingEmp, housingOrg }
```
**个税为 0 的条件**`ytdTaxableIncome ≤ 0`,即累计收入 ≤ 累计减除费用(5000×月份数 + 累计社保 + 累计公积金 + 累计专项附加扣除)
### 4.6 证据链 API 链路
```
前端 EvidenceChain.tsx
→ useQuery(['evidence-chain', employeeId])
→ api.get('/roster/${employeeId}/evidence-chain')
→ 后端 roster.routes.ts:240
→ prisma.employee.findFirst({ include: { contracts, payslips, overtimeRecords, ... } })
→ 组装 evidence[](劳动关系/薪酬发放/考勤记录/违纪处理/培训签收/绩效考核/解聘记录)
→ 风险检测 risks[](未签合同/合同到期/工资异常/无考勤记录等)
→ 返回 { employee, evidence, risks, summary: { total, signed, unsigned, riskCount } }
```
---
## 五、实施进度追踪(2026-07-27 更新)
### Phase 1 — 紧急修复(全部完成 ✅)
| 序号 | 任务 | 状态 | 修改文件 | 实施内容 |
|------|------|------|---------|---------|
| 1 | 社保城市选择改为可输入 | ✅ 已完成 | `SocialInsurance.tsx` | 城市下拉改为 `<input list>` + `<datalist>`,支持手动输入 |
| 2 | 社保上下限拆分(三险/医保) | ✅ 已完成 | `schema.prisma` `social.routes.ts` `payroll.service.ts` `SocialInsurance.tsx` | Schema 新增 `medicalBaseMin/medicalBaseMax``calcSocialInsurance()` 和 `calcSocialDetail()` 医保使用独立基数(为 0 时 fallback);试算接口适配;前端新建版本表单增加医保上下限输入;配置展示区显示医保上下限 |
| 3 | 公积金试算结果展示修复 | ✅ 已完成 | `SocialInsurance.tsx` | 公积金试算结果单独渲染(企业/个人缴纳金额),不再复用社保 `items.map()` |
| 4 | 花名册合同保存修复 | ✅ 已完成 | `ContractInfo.tsx` | 提交前将日期转为 ISO 格式 `new Date(form.signDate).toISOString()` |
| 5 | 薪酬批次创建失败修复 | ✅ 已完成 | `payroll.service.ts` `payroll2.routes.ts` | `calcBatchEntry` 查社保/公积金配置加 `city` 过滤;单员工计算 try-catch 不阻塞整批;返回 `failedEmployees` 详情 |
| 6 | 证据链查看修复 | ✅ 已完成 | `shared.ts` `EmployeeProfile.tsx` `EvidenceChain.tsx` | 添加 evidence Tab 到 `TAB_GROUPS``EmployeeProfile` 渲染 `EvidenceChain` 组件;空数据友好提示 |
| 7 | 个税计算排查修复 | ✅ 已完成 | `payroll2.routes.ts` | `blank_employees` 模式下从员工记录获取基本工资,避免 `baseSalary=0` 导致个税为 0 |
| 8 | 加班费倍率读取配置 | ✅ 已完成 | `payroll.routes.ts` | 从 `OvertimeConfig` 读取倍率,fallback 到 1.5/2.0/3.0 |
### Phase 2 — 功能完善(全部完成 ✅)
| 序号 | 任务 | 状态 | 修改文件 | 实施内容 |
|------|------|------|---------|---------|
| 9 | 批量导入 Bug 修复 + 导入按钮 | ✅ 已完成 | `import.routes.ts` `Roster.tsx` | 加班/违纪/考勤导入添加 per-row try-catchRoster 页面添加「批量导入」按钮入口 |
| 10 | 单人发薪 UI 入口优化 | ✅ 已完成 | `Roster.tsx` | 操作列添加「发薪」按钮(Wallet 图标),跳转薪税管理页面 |
| 11 | 解除协议模板补充 | ✅ 已完成 | `template.service.ts` | 新增 `tpl_termination_agreement_employee`(员工主动提出离职版),含离职原因/无补偿金/竞业限制等条款 |
### Phase 3 — 后期规划(部分完成)
| 序号 | 任务 | 状态 | 修改文件 | 实施内容 |
|------|------|------|---------|---------|
| 12 | 工资表导入功能 | ✅ 已完成 | `import.routes.ts` `Money.tsx` | 新增 `POST /import/payroll` 接口,解析 Excel 工资表批量更新批次条目(基本工资/加班费/津贴/扣款/奖金),自动重算税费;前端批次详情页添加「导入工资表」按钮 + 模板下载 + 结果展示 |
| 13 | 大病险/长护险单独险种模块 | ✅ 已完成 | `schema.prisma` `social.routes.ts` `payroll.service.ts` `SocialInsurance.tsx` | Schema 新增 `extraInsurances` JSON 字段;`calcSocialInsurance()` 和 `calcSocialDetail()` 支持附加险种计算(按养老基数/医保基数/固定金额三种方式);试算接口返回附加险种明细;前端新建版本表单增加附加险种动态配置区(添加/删除险种行) |
| 14 | 专项附加扣除按月录入 | ✅ 已完成 | `schema.prisma` `social.routes.ts` `SocialInsurance.tsx` | 新建 `SpecialDeductionRecord` 模型(子女教育/赡养老人/住房/继续教育/婴幼儿照护五项分项);后端 CRUD + 批量录入 API;前端新增「专项附加扣除」Tab,支持按月查看/编辑/新增,自动计算合计并同步员工便捷字段 |
| 15 | 预置河北省社保政策 | ✅ 已完成 | `seed.ts` | 添加河北省石家庄市社保配置(养老16/8、医疗8/2、失业0.7/0.3、工伤0.3、生育0.5,基数3920~19602+ 大病医疗(固定5元) + 长期护理险(0.1%);公积金配置(12%/12% |
### 待实施
| 序号 | 任务 | 说明 |
|------|------|------|
| 16 | 企微考勤对接 | 需 API 调研 |
| 17 | 电子签平台对接 | 需第三方平台选型 |
### 技术备注
- **Prisma migration**`medicalBaseMin`/`medicalBaseMax`、`extraInsurances`、`SpecialDeductionRecord` 已通过 `prisma db push` 同步到数据库
- **向后兼容**:医保独立上下限为 0 时自动 fallback 到 `baseMin/baseMax``extraInsurances` 为 null 时不影响现有计算
- **附加险种计算方式**`baseType: 'pension'` 按养老基数 × 比例,`baseType: 'medical'` 按医保基数 × 比例,`baseType: 'fixed'` 按固定金额
- **专项附加扣除**:录入后自动同步 `employee.specialDeduction` 便捷字段,个税计算时直接使用
- **解除协议模板**:原 `tpl_termination_agreement` 描述更新为「用人单位提出」,新增 `tpl_termination_agreement_employee` 为「员工主动提出离职」版