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数据)
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
# 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` 为「员工主动提出离职」版
|
||||
@@ -165,6 +165,7 @@ model Organization {
|
||||
policyReadRecords PolicyReadRecord[]
|
||||
healthCheckReports HealthCheckReport[]
|
||||
annualValueReports AnnualValueReport[]
|
||||
specialDeductionRecords SpecialDeductionRecord[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -240,6 +241,7 @@ model Employee {
|
||||
evidenceChains EvidenceChain[]
|
||||
attendanceConfirmations AttendanceConfirmation[]
|
||||
policyReadRecords PolicyReadRecord[]
|
||||
specialDeductionRecords SpecialDeductionRecord[]
|
||||
|
||||
@@unique([orgId, idCardHash])
|
||||
}
|
||||
@@ -386,8 +388,11 @@ model SocialInsuranceConfig {
|
||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
|
||||
baseMin Float @default(6326) // 社保缴费基数下限
|
||||
baseMax Float @default(33891) // 社保缴费基数上限
|
||||
baseMin Float @default(6326) // 社保缴费基数下限(养老/失业/工伤)
|
||||
baseMax Float @default(33891) // 社保缴费基数上限(养老/失业/工伤)
|
||||
medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin)
|
||||
medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax)
|
||||
extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }]
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||
@@ -1017,3 +1022,26 @@ model AnnualValueReport {
|
||||
@@index([orgId, year])
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
// 专项附加扣除按月记录
|
||||
model SpecialDeductionRecord {
|
||||
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 月份
|
||||
amount Float @default(0) // 专项附加扣除总额
|
||||
children Float @default(0) // 子女教育
|
||||
elderly Float @default(0) // 赡养老人
|
||||
housing Float @default(0) // 住房贷款利息/住房租金
|
||||
education Float @default(0) // 继续教育
|
||||
infant Float @default(0) // 3岁以下婴幼儿照护
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([employeeId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
|
||||
@@ -132,6 +132,48 @@ async function main() {
|
||||
})
|
||||
console.log('公积金配置已创建')
|
||||
|
||||
// 4.6 创建河北省社保配置(2025年度标准)
|
||||
await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
city: '石家庄',
|
||||
pensionOrg: 16,
|
||||
pensionEmp: 8,
|
||||
medicalOrg: 8,
|
||||
medicalEmp: 2,
|
||||
unemploymentOrg: 0.7,
|
||||
unemploymentEmp: 0.3,
|
||||
injuryOrg: 0.3,
|
||||
maternityOrg: 0.5,
|
||||
baseMin: 3920,
|
||||
baseMax: 19602,
|
||||
medicalBaseMin: 3920,
|
||||
medicalBaseMax: 19602,
|
||||
extraInsurances: [
|
||||
{ name: '大病医疗', baseType: 'fixed', fixedAmount: 5, empFixedAmount: 0, orgRate: 0, empRate: 0 },
|
||||
{ name: '长期护理险', baseType: 'pension', orgRate: 0.1, empRate: 0.1 },
|
||||
],
|
||||
effectiveFrom: '2025-07',
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
console.log('河北省社保配置已创建')
|
||||
|
||||
// 4.7 创建河北省公积金配置
|
||||
await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
city: '石家庄',
|
||||
housingOrg: 12,
|
||||
housingEmp: 12,
|
||||
baseMin: 3920,
|
||||
baseMax: 19602,
|
||||
effectiveFrom: '2025-07',
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
console.log('河北省公积金配置已创建')
|
||||
|
||||
// 5. 创建通知设置
|
||||
await prisma.notificationSetting.create({
|
||||
data: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router, Response } from 'express'
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
@@ -6,6 +6,7 @@ import { requireAdmin } from '../middleware/rbac'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services/retirement.service'
|
||||
import { calcBatchEntry } from '../services/payroll.service'
|
||||
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
@@ -327,19 +328,21 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const month = dateToMonth(date)
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const hours = num(r['加班时长'])
|
||||
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
|
||||
result.overtime++
|
||||
try {
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
|
||||
const month = dateToMonth(date)
|
||||
const otType = val(r['加班类型']) || '工作日加班'
|
||||
const hours = num(r['加班时长'])
|
||||
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
|
||||
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
|
||||
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
|
||||
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
|
||||
result.overtime++
|
||||
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,16 +354,18 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
|
||||
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
|
||||
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
|
||||
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
|
||||
result.disciplinary++
|
||||
try {
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) { result.errors.push(`违纪第${i + 2}行:日期格式错误`); continue }
|
||||
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
|
||||
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
|
||||
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
|
||||
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
|
||||
result.disciplinary++
|
||||
} catch (e: any) { result.errors.push(`违纪第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,14 +377,16 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) continue
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
|
||||
result.attendance++
|
||||
try {
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const date = parseDate(r['日期'])
|
||||
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
|
||||
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
|
||||
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
|
||||
result.attendance++
|
||||
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,4 +617,104 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
// 工资表导入 — 批量更新批次条目的薪酬输入项
|
||||
router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ success: false, message: '请上传文件' })
|
||||
const orgId = req.user!.orgId
|
||||
const userId = req.user!.id
|
||||
const batchId = req.body.batchId as string
|
||||
if (!batchId) return res.status(400).json({ success: false, message: '缺少批次ID' })
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可导入' })
|
||||
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]]
|
||||
if (!sheet) return res.status(400).json({ success: false, message: 'Excel 文件无有效 Sheet' })
|
||||
|
||||
const rows = XLSX.utils.sheet_to_json(sheet)
|
||||
const result = { total: rows.length, updated: 0, errors: [] as string[] }
|
||||
|
||||
// 构建员工查找索引
|
||||
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
|
||||
const empByName = new Map(employees.map(e => [e.name, e.id]))
|
||||
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash!, e.id]))
|
||||
|
||||
// 查询现有批次条目
|
||||
const entries = await prisma.batchEntry.findMany({ where: { batchId }, select: { id: true, employeeId: true } })
|
||||
const entryByEmp = new Map(entries.map(e => [e.employeeId, e.id]))
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(r['身份证号'])
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
|
||||
if (!empId) { result.errors.push(`第${i + 2}行:找不到员工「${val(r['姓名'])}」`); continue }
|
||||
const entryId = entryByEmp.get(empId)
|
||||
if (!entryId) { result.errors.push(`第${i + 2}行:员工「${val(r['姓名'])}」不在本批次中`); continue }
|
||||
|
||||
const inputs = {
|
||||
baseSalary: num(r['基本工资']) || 0,
|
||||
overtimePay: num(r['加班费']) || 0,
|
||||
allowance: num(r['津贴']) || 0,
|
||||
deduction: num(r['扣款']) || 0,
|
||||
bonus: num(r['奖金']) || 0,
|
||||
}
|
||||
|
||||
// 重新计算税费
|
||||
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.totalPay,
|
||||
totalNetPay: acc.totalNetPay + e.netPay,
|
||||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||||
totalTax: acc.totalTax + e.tax,
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资表导入模板下载
|
||||
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
const data = [
|
||||
{ '姓名': '张三', '身份证号': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
|
||||
{ '姓名': '李四', '身份证号': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="payroll-import-template.xlsx"')
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -41,10 +41,11 @@ router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeSchema.parse(req.body)
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||||
const hourlyWage = data.monthlyWage / otConfig.monthlyDays / otConfig.dailyHours
|
||||
const weekdayPay = hourlyWage * otConfig.weekdayRate * data.weekdayHours
|
||||
const weekendPay = hourlyWage * otConfig.weekendRate * data.weekendHours
|
||||
const holidayPay = hourlyWage * otConfig.holidayRate * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
@@ -103,10 +104,11 @@ router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFu
|
||||
const weekendHours = data.weekendHours ?? existing.weekendHours
|
||||
const holidayHours = data.holidayHours ?? existing.holidayHours
|
||||
|
||||
const hourlyWage = monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * holidayHours
|
||||
const otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||||
const hourlyWage = monthlyWage / otConfig.monthlyDays / otConfig.dailyHours
|
||||
const weekdayPay = hourlyWage * otConfig.weekdayRate * weekdayHours
|
||||
const weekendPay = hourlyWage * otConfig.weekendRate * weekendHours
|
||||
const holidayPay = hourlyWage * otConfig.holidayRate * holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.update({
|
||||
|
||||
@@ -297,6 +297,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
|
||||
// 创建批次条目
|
||||
const entries: any[] = []
|
||||
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||||
for (const emp of employees) {
|
||||
let baseSalary = 0
|
||||
let overtimePay = 0
|
||||
@@ -334,6 +335,10 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
}
|
||||
// blank_employees 和 blank_all: 所有金额默认 0
|
||||
// blank_employees 模式下尝试从员工记录获取基本工资
|
||||
if (mode === 'blank_employees' && emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
|
||||
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
|
||||
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
|
||||
@@ -343,7 +348,15 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
// 计算社保、个税等
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
|
||||
let calcResult: any
|
||||
try {
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
} catch (calcErr: any) {
|
||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
|
||||
}
|
||||
|
||||
// 风险提示
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
|
||||
@@ -396,7 +409,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: updatedBatch })
|
||||
res.json({ success: true, data: updatedBatch, failedEmployees: failedEmployees.length > 0 ? failedEmployees : undefined })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ const socialConfigFields = {
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
medicalBaseMin: z.number().optional(),
|
||||
medicalBaseMax: z.number().optional(),
|
||||
extraInsurances: z.any().optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
@@ -408,23 +411,49 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const medicalOrg = medicalBase * config.medicalOrg / 100
|
||||
const medicalEmp = medicalBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
const maternityOrg = medicalBase * config.maternityOrg / 100
|
||||
let totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
let totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
|
||||
// 附加险种
|
||||
const extraItems: any[] = []
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
for (const ins of config.extraInsurances) {
|
||||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||||
const orgAmt = ins.fixedAmount
|
||||
const empAmt = ins.empFixedAmount || 0
|
||||
totalOrg += orgAmt
|
||||
totalEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: orgAmt, empAmount: empAmt })
|
||||
} else {
|
||||
const orgAmt = insBase * (ins.orgRate || 0) / 100
|
||||
const empAmt = insBase * (ins.empRate || 0) / 100
|
||||
totalOrg += orgAmt
|
||||
totalEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: orgAmt, empAmount: empAmt })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
medicalBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
@@ -435,6 +464,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
...extraItems,
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
@@ -790,16 +820,30 @@ router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res
|
||||
/** 根据基数和社保配置计算各项企业/个人缴费明细 */
|
||||
function calcSocialDetail(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||
const items = [
|
||||
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: actualBase * config.medicalOrg / 100, empAmount: actualBase * config.medicalEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100, empAmount: medicalBase * config.medicalEmp / 100 },
|
||||
{ name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 },
|
||||
{ name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
|
||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: actualBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
]
|
||||
// 附加险种
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
for (const ins of config.extraInsurances) {
|
||||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||||
items.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: ins.fixedAmount, empAmount: ins.empFixedAmount || 0 })
|
||||
} else {
|
||||
items.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: insBase * (ins.orgRate || 0) / 100, empAmount: insBase * (ins.empRate || 0) / 100 })
|
||||
}
|
||||
}
|
||||
}
|
||||
const totalOrg = items.reduce((s, i) => s + i.orgAmount, 0)
|
||||
const totalEmp = items.reduce((s, i) => s + i.empAmount, 0)
|
||||
return { actualBase, items, totalOrg, totalEmp }
|
||||
return { actualBase, medicalBase, items, totalOrg, totalEmp }
|
||||
}
|
||||
|
||||
/** 根据基数和公积金配置计算企业/个人缴费明细 */
|
||||
@@ -1274,4 +1318,139 @@ router.put('/records/housing/:id/correct', async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 专项附加扣除按月录入 ==========
|
||||
|
||||
// 查询员工某月专项附加扣除
|
||||
router.get('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month } = req.query
|
||||
if (!employeeId || !month) return res.status(400).json({ success: false, message: '缺少 employeeId 或 month' })
|
||||
const record = await prisma.specialDeductionRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: employeeId as string, month: month as string } },
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量查询员工某月专项附加扣除
|
||||
router.get('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.query
|
||||
if (!month) return res.status(400).json({ success: false, message: '缺少 month' })
|
||||
const records = await prisma.specialDeductionRecord.findMany({
|
||||
where: { orgId: req.user!.orgId, month: month as string },
|
||||
include: { employee: { select: { name: true, department: true } } },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建/更新专项附加扣除
|
||||
const upsertDeductionSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
amount: z.number().min(0).optional(),
|
||||
children: z.number().min(0).optional(),
|
||||
elderly: z.number().min(0).optional(),
|
||||
housing: z.number().min(0).optional(),
|
||||
education: z.number().min(0).optional(),
|
||||
infant: z.number().min(0).optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = upsertDeductionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
const amount = data.amount ?? (data.children || 0) + (data.elderly || 0) + (data.housing || 0) + (data.education || 0) + (data.infant || 0)
|
||||
|
||||
const record = await prisma.specialDeductionRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
amount,
|
||||
children: data.children || 0,
|
||||
elderly: data.elderly || 0,
|
||||
housing: data.housing || 0,
|
||||
education: data.education || 0,
|
||||
infant: data.infant || 0,
|
||||
remark: data.remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
amount,
|
||||
children: data.children || 0,
|
||||
elderly: data.elderly || 0,
|
||||
housing: data.housing || 0,
|
||||
education: data.education || 0,
|
||||
infant: data.infant || 0,
|
||||
remark: data.remark,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步员工便捷字段
|
||||
await prisma.employee.update({ where: { id: data.employeeId }, data: { specialDeduction: amount } })
|
||||
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量录入专项附加扣除
|
||||
router.post('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, items } = req.body as { month: string; items: any[] }
|
||||
if (!month || !items || !Array.isArray(items)) return res.status(400).json({ success: false, message: '缺少 month 或 items' })
|
||||
const orgId = req.user!.orgId
|
||||
const result = { total: items.length, updated: 0, errors: [] as string[] }
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
try {
|
||||
const amount = item.amount ?? (item.children || 0) + (item.elderly || 0) + (item.housing || 0) + (item.education || 0) + (item.infant || 0)
|
||||
await prisma.specialDeductionRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: item.employeeId, month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
month,
|
||||
amount,
|
||||
children: item.children || 0,
|
||||
elderly: item.elderly || 0,
|
||||
housing: item.housing || 0,
|
||||
education: item.education || 0,
|
||||
infant: item.infant || 0,
|
||||
remark: item.remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
amount,
|
||||
children: item.children || 0,
|
||||
elderly: item.elderly || 0,
|
||||
housing: item.housing || 0,
|
||||
education: item.education || 0,
|
||||
infant: item.infant || 0,
|
||||
remark: item.remark,
|
||||
},
|
||||
})
|
||||
await prisma.employee.update({ where: { id: item.employeeId }, data: { specialDeduction: amount } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 1}行:${e?.message || '录入失败'}`)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -35,10 +35,35 @@ export async function getTemplate(orgId: string) {
|
||||
// ========== 社保计算 ==========
|
||||
|
||||
export function calcSocialInsurance(base: number, config: any) {
|
||||
// 养老/失业/工伤保险基数
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
|
||||
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
|
||||
return { actualBase, socialEmp, socialOrg }
|
||||
// 医疗/生育保险基数(独立上下限,为 0 时 fallback 到统一基数)
|
||||
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||
let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 100
|
||||
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100
|
||||
// 附加险种(大病险/长护险等)
|
||||
const extraItems: any[] = []
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
for (const ins of config.extraInsurances) {
|
||||
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
|
||||
if (ins.baseType === 'fixed' && ins.fixedAmount) {
|
||||
const orgAmt = ins.fixedAmount
|
||||
const empAmt = ins.empFixedAmount || 0
|
||||
socialOrg += orgAmt
|
||||
socialEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgAmount: orgAmt, empAmount: empAmt })
|
||||
} else {
|
||||
const orgAmt = insBase * (ins.orgRate || 0) / 100
|
||||
const empAmt = insBase * (ins.empRate || 0) / 100
|
||||
socialOrg += orgAmt
|
||||
socialEmp += empAmt
|
||||
extraItems.push({ name: ins.name, orgAmount: orgAmt, empAmount: empAmt })
|
||||
}
|
||||
}
|
||||
}
|
||||
return { actualBase, medicalBase, socialEmp, socialOrg, extraItems }
|
||||
}
|
||||
|
||||
export function calcHousingFund(base: number, config: any) {
|
||||
@@ -106,11 +131,14 @@ export async function calcBatchEntry(
|
||||
batchType: string = 'REGULAR',
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||||
) {
|
||||
const [employee, socialConfig, housingConfig] = await Promise.all([
|
||||
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
|
||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
|
||||
const [socialConfig, housingConfig] = await Promise.all([
|
||||
prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
...cityWhere,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
@@ -118,14 +146,13 @@ export async function calcBatchEntry(
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
...cityWhere,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
// 社保基数:优先用员工核定基数,否则用基本工资
|
||||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
||||
|
||||
@@ -90,7 +90,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
id: 'tpl_termination_agreement',
|
||||
name: '解除劳动合同协议书',
|
||||
category: 'AGREEMENT',
|
||||
description: '协商一致解除劳动合同协议书模板',
|
||||
description: '协商一致解除劳动合同协议书模板(用人单位提出)',
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'terminationDate', 'compensation', 'lastWorkDay', 'socialInsEndMonth', 'housingFundEndMonth'],
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
@@ -123,6 +123,53 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
八、其他
|
||||
本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
|
||||
|
||||
甲方(盖章):____________ 乙方(签字):____________
|
||||
日期:____年__月__日 日期:____年__月__日`,
|
||||
},
|
||||
{
|
||||
id: 'tpl_termination_agreement_employee',
|
||||
name: '解除劳动合同协议书(员工提出离职)',
|
||||
category: 'AGREEMENT',
|
||||
description: '员工主动提出离职的解除劳动合同协议书模板',
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'terminationDate', 'lastWorkDay', 'socialInsEndMonth', 'housingFundEndMonth', 'resignationReason'],
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
||||
|
||||
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
一、离职原因
|
||||
乙方因{{resignationReason}},自愿提出解除劳动合同。
|
||||
|
||||
二、解除日期
|
||||
双方同意于{{terminationDate}}解除劳动合同,乙方最后工作日为{{lastWorkDay}}。
|
||||
|
||||
三、工资结算
|
||||
甲方结清乙方截至解除日的所有工资、加班费等劳动报酬,于乙方办理完工作交接手续后__个工作日内一次性支付。
|
||||
|
||||
四、社会保险和公积金
|
||||
甲方为乙方缴纳社会保险至{{socialInsEndMonth}}月,住房公积金缴存至{{housingFundEndMonth}}月。
|
||||
|
||||
五、工作交接
|
||||
乙方应在最后工作日前完成工作交接,归还甲方所有财物和资料。
|
||||
|
||||
六、经济补偿
|
||||
因乙方主动提出离职,甲方无需向乙方支付经济补偿金。乙方确认对此无异议。
|
||||
|
||||
七、保密义务
|
||||
乙方解除劳动合同后,仍应遵守保密义务,不得泄露甲方商业秘密。
|
||||
|
||||
八、竞业限制
|
||||
如双方另行签有竞业限制协议,乙方应继续履行竞业限制义务。
|
||||
|
||||
九、争议解决
|
||||
本协议履行过程中如发生争议,双方应协商解决;协商不成的,可向劳动争议仲裁委员会申请仲裁。
|
||||
|
||||
十、其他
|
||||
1. 本协议签订后,双方劳动关系即告终止,双方不再存在任何劳动争议。
|
||||
2. 本协议一式两份,甲乙双方各执一份,自双方签字盖章之日起生效。
|
||||
|
||||
甲方(盖章):____________ 乙方(签字):____________
|
||||
日期:____年__月__日 日期:____年__月__日`,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, AlertCircle, Info, Check, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Wallet, LayoutTemplate, Clock, Receipt, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -374,6 +375,8 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [showAddEmployee, setShowAddEmployee] = useState(false)
|
||||
const payrollFileRef = useRef<HTMLInputElement>(null)
|
||||
const [payrollImportResult, setPayrollImportResult] = useState<any>(null)
|
||||
|
||||
const { data: batch, isLoading } = useQuery<any>({
|
||||
queryKey: ['batch-detail', batchId],
|
||||
@@ -423,6 +426,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
},
|
||||
})
|
||||
|
||||
const importPayrollMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('batchId', batchId)
|
||||
const res = await fetch('/api/v1/import/payroll', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) throw new Error(data.message || '导入失败')
|
||||
return data.data
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||
setPayrollImportResult(data)
|
||||
if (data.updated > 0) {
|
||||
toast.success(`成功更新 ${data.updated} 条工资记录`)
|
||||
} else {
|
||||
toast.info('未更新任何记录')
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('工资表导入失败'),
|
||||
})
|
||||
|
||||
const deleteBatchMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/payroll2/batches/${batchId}`),
|
||||
onSuccess: () => {
|
||||
@@ -546,6 +576,33 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowAddEmployee(!showAddEmployee)}>
|
||||
<Plus className="w-4 h-4 mr-1" />添加人员
|
||||
</Button>
|
||||
<input
|
||||
ref={payrollFileRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) importPayrollMutation.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => payrollFileRef.current?.click()}
|
||||
disabled={importPayrollMutation.isPending}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{importPayrollMutation.isPending ? '导入中...' : '导入工资表'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('/api/v1/import/payroll-template', '_blank')}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />下载模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -636,6 +693,29 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
||||
<AddEmployeeToBatch batchId={batchId} onClose={() => setShowAddEmployee(false)} />
|
||||
)}
|
||||
|
||||
{/* 工资表导入结果 */}
|
||||
{payrollImportResult && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium">工资表导入结果</h3>
|
||||
<button onClick={() => setPayrollImportResult(null)} className="text-gray-500"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="text-gray-600">总计 {payrollImportResult.total} 行,成功更新 {payrollImportResult.updated} 条</div>
|
||||
{payrollImportResult.errors?.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-warning text-xs font-medium">错误详情({payrollImportResult.errors.length}条):</div>
|
||||
<ul className="mt-1 space-y-0.5 text-xs text-danger max-h-40 overflow-y-auto">
|
||||
{payrollImportResult.errors.map((err: string, i: number) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 提示 */}
|
||||
{!isArchived && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-1">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History } from 'lucide-react'
|
||||
import { Users, FileText, AlertTriangle, Calendar, TrendingUp, Scale, X, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, History, Upload, Wallet } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -263,6 +263,9 @@ export default function Roster() {
|
||||
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.hash = '#/settings'} className="h-9 shrink-0">
|
||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -436,6 +439,18 @@ export default function Roster() {
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="发薪"
|
||||
aria-label={`为${e.name}发薪`}
|
||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
window.location.hash = '#/money'
|
||||
}}
|
||||
>
|
||||
<Wallet className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="调部门"
|
||||
|
||||
@@ -14,9 +14,10 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing'>('monthly')
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
@@ -34,6 +35,8 @@ export default function SocialInsurance() {
|
||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
medicalBaseMin: 0, medicalBaseMax: 0,
|
||||
extraInsurances: [],
|
||||
})
|
||||
const [newHousingVersion, setNewHousingVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
@@ -320,7 +323,7 @@ export default function SocialInsurance() {
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
@@ -328,23 +331,22 @@ export default function SocialInsurance() {
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : '公积金'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{tab !== 'monthly' && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
<select
|
||||
<input
|
||||
list="social-cities"
|
||||
className="h-9 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
>
|
||||
{cities.length > 0 ? (
|
||||
cities.map((c) => <option key={c} value={c}>{c}</option>)
|
||||
) : (
|
||||
<option value="北京">北京</option>
|
||||
)}
|
||||
</select>
|
||||
placeholder="输入或选择城市"
|
||||
/>
|
||||
<datalist id="social-cities">
|
||||
{cities.map((c) => <option key={c} value={c} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -402,6 +404,12 @@ export default function SocialInsurance() {
|
||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数下限</span><span className="font-medium">¥{fmt(activeConfig.baseMin)}</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">缴费基数上限</span><span className="font-medium">¥{fmt(activeConfig.baseMax)}</span></div>
|
||||
{activeConfig.medicalBaseMin > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数下限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMin)}</span></div>
|
||||
)}
|
||||
{activeConfig.medicalBaseMax > 0 && (
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医保基数上限</span><span className="font-medium">¥{fmt(activeConfig.medicalBaseMax)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">养老(企业/个人)</span><span className="font-medium">{activeConfig.pensionOrg}% / {activeConfig.pensionEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">医疗(企业/个人)</span><span className="font-medium">{activeConfig.medicalOrg}% / {activeConfig.medicalEmp}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">失业(企业/个人)</span><span className="font-medium">{activeConfig.unemploymentOrg}% / {activeConfig.unemploymentEmp}%</span></div>
|
||||
@@ -571,6 +579,20 @@ export default function SocialInsurance() {
|
||||
<div><Label>缴费基数下限</Label><Input type="number" value={activeNewVersion.baseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} /></div>
|
||||
<div><Label>缴费基数上限</Label><Input type="number" value={activeNewVersion.baseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
{!isHousing && (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>医保/生育基数下限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMin} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMin: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数下限</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>医保/生育基数上限</Label>
|
||||
<Input type="number" value={activeNewVersion.medicalBaseMax} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalBaseMax: Number(e.target.value) })} />
|
||||
<p className="text-xs text-gray-400 mt-1">填 0 时使用统一基数上限</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
@@ -588,6 +610,48 @@ export default function SocialInsurance() {
|
||||
<div><Label>生育(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.maternityOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
)}
|
||||
{!isHousing && (
|
||||
<div className="border rounded-md p-3 space-y-2 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">附加险种(大病险/长护险等)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
extraInsurances: [...(activeNewVersion.extraInsurances || []), { name: '', orgRate: 0, empRate: 0, baseType: 'pension', fixedAmount: 0, empFixedAmount: 0 }],
|
||||
})}
|
||||
>
|
||||
+ 添加险种
|
||||
</button>
|
||||
</div>
|
||||
{(activeNewVersion.extraInsurances || []).map((ins: any, idx: number) => (
|
||||
<div key={idx} className="grid grid-cols-5 gap-2 items-end">
|
||||
<div><Label>险种名称</Label><Input value={ins.name} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, name: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div>
|
||||
<Label>计算方式</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input px-2 text-sm" value={ins.baseType} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, baseType: e.target.value }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>
|
||||
<option value="pension">按养老基数</option>
|
||||
<option value="medical">按医保基数</option>
|
||||
<option value="fixed">固定金额</option>
|
||||
</select>
|
||||
</div>
|
||||
{ins.baseType === 'fixed' ? (
|
||||
<>
|
||||
<div><Label>企业固定(元)</Label><Input type="number" value={ins.fixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, fixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人固定(元)</Label><Input type="number" value={ins.empFixedAmount} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empFixedAmount: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div><Label>企业%</Label><Input type="number" step="0.01" value={ins.orgRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, orgRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
<div><Label>个人%</Label><Input type="number" step="0.01" value={ins.empRate} onChange={(e) => { const arr = [...(activeNewVersion.extraInsurances || [])]; arr[idx] = { ...ins, empRate: Number(e.target.value) }; activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }} /></div>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-xs text-danger h-9" onClick={() => { const arr = (activeNewVersion.extraInsurances || []).filter((_: any, i: number) => i !== idx); activeSetNewVersion({ ...activeNewVersion, extraInsurances: arr }) }}>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => activeCreateMut.mutate(activeNewVersion)} disabled={activeCreateMut.isPending}>
|
||||
{activeCreateMut.isPending ? '保存中...' : '创建版本'}
|
||||
@@ -625,6 +689,37 @@ export default function SocialInsurance() {
|
||||
{(() => {
|
||||
const r = isHousing ? housingResult : result
|
||||
if (!r) return <div className="text-gray-400 text-sm">点击「开始计算」查看结果</div>
|
||||
if (isHousing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
缴费基数:<span className="text-gray-900 font-medium">¥{fmt(r.actualBase)}</span>
|
||||
{r.capped && <span className="text-warning ml-2">(已封顶)</span>}
|
||||
{r.floored && <span className="text-warning ml-2">(已保底)</span>}
|
||||
{r.configVersion && <span className="text-gray-400 ml-2">| 配置版本:{r.configVersion}</span>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">企业缴纳</span>
|
||||
<span className="font-medium text-danger">¥{fmt(r.housingOrg)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b pb-2 text-sm">
|
||||
<span className="text-gray-500">个人缴纳</span>
|
||||
<span className="font-medium text-warning">¥{fmt(r.housingEmp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">总费用</span>
|
||||
<span className="text-lg font-bold text-primary">¥{fmt(r.total)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
企业承担 ¥{fmt(r.housingOrg)} + 个人承担 ¥{fmt(r.housingEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-500">
|
||||
@@ -645,7 +740,7 @@ export default function SocialInsurance() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.items.map((item: any) => (
|
||||
{r.items?.map((item: any) => (
|
||||
<tr key={item.name} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.orgRate}%</td>
|
||||
@@ -904,6 +999,11 @@ export default function SocialInsurance() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
{tab === 'deduction' && (
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
@@ -980,3 +1080,173 @@ function MonthlyHousingRow({ item: i, type }: { item: any; type: 'add' | 'sub' |
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
/** 专项附加扣除按月录入组件 */
|
||||
function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m: string) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editForm, setEditForm] = useState<any>(null)
|
||||
|
||||
// 查询当月所有员工的专项附加扣除
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['special-deduction', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/social/special-deduction/batch', { params: { month } }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
// 查询所有员工列表(用于添加未录入的员工)
|
||||
const { data: employees = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-deduction'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster', { params: { pageSize: 999 } }) as any
|
||||
return res.data?.items || res.data || []
|
||||
},
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/social/special-deduction', { ...data, month }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['special-deduction'] })
|
||||
setEditing(null)
|
||||
setEditForm(null)
|
||||
},
|
||||
})
|
||||
|
||||
const recordMap = new Map(records.map((r: any) => [r.employeeId, r]))
|
||||
const unrecorded = employees.filter((e: any) => !recordMap.has(e.id))
|
||||
|
||||
const startEdit = (empId: string, existing?: any) => {
|
||||
setEditing(empId)
|
||||
setEditForm(existing ? {
|
||||
children: existing.children,
|
||||
elderly: existing.elderly,
|
||||
housing: existing.housing,
|
||||
education: existing.education,
|
||||
infant: existing.infant,
|
||||
remark: existing.remark,
|
||||
} : { children: 0, elderly: 0, housing: 0, education: 0, infant: 0, remark: '' })
|
||||
}
|
||||
|
||||
const calcTotal = (f: any) => (f.children || 0) + (f.elderly || 0) + (f.housing || 0) + (f.education || 0) + (f.infant || 0)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium">专项附加扣除 — {month}</h2>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-32" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 已录入列表 */}
|
||||
{records.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-2 font-medium">姓名</th>
|
||||
<th className="py-2 font-medium">部门</th>
|
||||
<th className="py-2 font-medium text-right">子女教育</th>
|
||||
<th className="py-2 font-medium text-right">赡养老人</th>
|
||||
<th className="py-2 font-medium text-right">住房</th>
|
||||
<th className="py-2 font-medium text-right">继续教育</th>
|
||||
<th className="py-2 font-medium text-right">婴幼儿照护</th>
|
||||
<th className="py-2 font-medium text-right">合计</th>
|
||||
<th className="py-2 font-medium">备注</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((r: any) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
{editing === r.employeeId ? (
|
||||
<>
|
||||
<td className="py-1.5">{r.employee?.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></td>
|
||||
<td className="py-1"><Input type="number" className="!w-20 !h-8 text-right" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(calcTotal(editForm))}</td>
|
||||
<td className="py-1"><Input className="!w-24 !h-8" value={editForm.remark || ''} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></td>
|
||||
<td className="py-1">
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" className="!h-7 !px-2" onClick={() => saveMutation.mutate({ employeeId: r.employeeId, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
||||
<Button size="sm" variant="secondary" className="!h-7 !px-2" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-1.5">{r.employee?.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{r.employee?.department}</td>
|
||||
<td className="py-1.5 text-right">{r.children > 0 ? `¥${fmt(r.children)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.elderly > 0 ? `¥${fmt(r.elderly)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.housing > 0 ? `¥${fmt(r.housing)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.education > 0 ? `¥${fmt(r.education)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right">{r.infant > 0 ? `¥${fmt(r.infant)}` : '-'}</td>
|
||||
<td className="py-1.5 text-right font-medium text-primary">¥{fmt(r.amount)}</td>
|
||||
<td className="py-1.5 text-gray-400 text-xs">{r.remark || '-'}</td>
|
||||
<td className="py-1.5"><button className="text-xs text-primary hover:underline" onClick={() => startEdit(r.employeeId, r)}>编辑</button></td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 未录入员工 */}
|
||||
{unrecorded.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<h3 className="text-xs font-medium text-gray-500 mb-2">未录入员工({unrecorded.length}人)</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unrecorded.map((e: any) => (
|
||||
<button
|
||||
key={e.id}
|
||||
className="px-2 py-1 rounded-md border border-gray-200 text-xs text-gray-600 hover:border-primary hover:text-primary"
|
||||
onClick={() => startEdit(e.id)}
|
||||
>
|
||||
{e.name}({e.department})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑表单 */}
|
||||
{editing && !recordMap.has(editing) && (
|
||||
<div className="border rounded-md p-3 bg-gray-50 space-y-2">
|
||||
<h3 className="text-xs font-medium">新增专项附加扣除 — {employees.find((e: any) => e.id === editing)?.name}</h3>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div><Label>子女教育</Label><Input type="number" value={editForm.children} onChange={(e) => setEditForm({ ...editForm, children: Number(e.target.value) })} /></div>
|
||||
<div><Label>赡养老人</Label><Input type="number" value={editForm.elderly} onChange={(e) => setEditForm({ ...editForm, elderly: Number(e.target.value) })} /></div>
|
||||
<div><Label>住房</Label><Input type="number" value={editForm.housing} onChange={(e) => setEditForm({ ...editForm, housing: Number(e.target.value) })} /></div>
|
||||
<div><Label>继续教育</Label><Input type="number" value={editForm.education} onChange={(e) => setEditForm({ ...editForm, education: Number(e.target.value) })} /></div>
|
||||
<div><Label>婴幼儿照护</Label><Input type="number" value={editForm.infant} onChange={(e) => setEditForm({ ...editForm, infant: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1"><Label>备注</Label><Input value={editForm.remark} onChange={(e) => setEditForm({ ...editForm, remark: e.target.value })} /></div>
|
||||
<div className="text-sm text-gray-500 pt-5">合计:<span className="font-medium text-primary">¥{fmt(calcTotal(editForm))}</span></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => saveMutation.mutate({ employeeId: editing, ...editForm })} disabled={saveMutation.isPending}>保存</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditing(null); setEditForm(null) }}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{records.length === 0 && unrecorded.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无员工数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -175,7 +175,15 @@ export default function ContractInfo({ employeeId, contracts, hireDate }: { empl
|
||||
</>
|
||||
)}
|
||||
<div className="md:col-span-2 flex gap-2">
|
||||
<Button onClick={() => addContractMutation.mutate(form)} disabled={
|
||||
<Button onClick={() => {
|
||||
const payload = {
|
||||
...form,
|
||||
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(payload)
|
||||
}} disabled={
|
||||
addContractMutation.isPending || !form.startDate ||
|
||||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
|
||||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
|
||||
|
||||
@@ -12,6 +12,7 @@ import AttendanceOvertimeInfo from './AttendanceOvertimeInfo'
|
||||
import PerformanceInfo from './PerformanceInfo'
|
||||
import TerminationInfo from './TerminationInfo'
|
||||
import ChangeHistoryTab from './ChangeHistoryTab'
|
||||
import EvidenceChain from './EvidenceChain'
|
||||
|
||||
/**
|
||||
* 员工详情档案页
|
||||
@@ -99,6 +100,7 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
||||
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,22 @@ export default function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">生成证据链中...</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">无数据</div>
|
||||
if (!data) return <div className="text-center py-8 text-gray-400">暂无证据链数据,请确保员工已录入合同、薪酬等信息</div>
|
||||
if (!data.evidence || data.evidence.length === 0) return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" />仲裁证据链</h2>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.employee?.name || '未知'} · {data.employee?.department || '未知'} · 入职{data.employee?.hireDate ? data.employee.hireDate.toString().slice(0, 10) : '未知'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card><div className="text-center py-8 text-gray-400">该员工暂无证据链记录,录入合同、薪酬、考勤等信息后将自动生成</div></Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const terminateReasonMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
/** 详情页 Tab 类型 */
|
||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'history'
|
||||
export type DetailTab = 'basic' | 'contract' | 'payslip' | 'attendance' | 'disciplinary' | 'performance' | 'termination' | 'evidence' | 'history'
|
||||
|
||||
/** Tab 分组 */
|
||||
export type TabGroup = '人事信息' | '考勤绩效' | '风险合规' | '薪酬' | '变更历史'
|
||||
@@ -45,6 +45,7 @@ export const TAB_GROUPS: { group: TabGroup; tabs: { key: DetailTab; label: strin
|
||||
tabs: [
|
||||
{ key: 'disciplinary', label: '违纪记录', icon: null },
|
||||
{ key: 'termination', label: '离职/解聘', icon: null },
|
||||
{ key: 'evidence', label: '证据链', icon: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user