# 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-catch；Roster 页面添加「批量导入」按钮入口 |
| 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` 为「员工主动提出离职」版
