Compare commits
88 Commits
b682178549
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5196d7c80a | |||
| c66ea290eb | |||
| e824cd1629 | |||
| c039f0a3cf | |||
| 61873b2581 | |||
| ef685663ac | |||
| d3a9ea041f | |||
| 874f8c49bd | |||
| fa7bf69682 | |||
| d017d6337f | |||
| 2afbdacae3 | |||
| 6e80bcb330 | |||
| 7ba357a6e5 | |||
| 668a908dd6 | |||
| 99aa6d6998 | |||
| 6919f997c6 | |||
| 16f38f3c36 | |||
| 1e7932c36c | |||
| def0fe3a1f | |||
| c5f4731d5c | |||
| 1d9890e607 | |||
| b5aa5552ff | |||
| 38075420f4 | |||
| 3ac333891c | |||
| c3ca2c9f50 | |||
| 94959522df | |||
| 2f1e339413 | |||
| 54d138cc73 | |||
| de4f8c90fb | |||
| 8391c123fc | |||
| a5911d1874 | |||
| 63a0a6934b | |||
| 904c9c6369 | |||
| 2875965113 | |||
| ea2dfe3b4b | |||
| fe427ef13f | |||
| 014c94e482 | |||
| 63b6c9dcc7 | |||
| 454b3d4b05 | |||
| ce1670d171 | |||
| 37649419c4 | |||
| 83b4b37de0 | |||
| 5ef18cb05b | |||
| 851557729f | |||
| 327a41d619 | |||
| 5a9d440339 | |||
| 72a6eab3bd | |||
| ef6d4591be | |||
| 040c5a3ab9 | |||
| 79145b47b9 | |||
| 8c7fb99219 | |||
| 71f0619d18 | |||
| ebd47e4500 | |||
| d7fdec49fa | |||
| 26d0b7070d | |||
| 94b85d5c36 | |||
| 6b3e1f9d46 | |||
| 13f485a049 | |||
| dd0e84a219 | |||
| 9a826c16b0 | |||
| c2c99126be | |||
| aa8189f571 | |||
| 1f545fb489 | |||
| 101cfcf508 | |||
| fa0dc82d38 | |||
| 8fcc7ae143 | |||
| 3da61d5a09 | |||
| 434c63c6d3 | |||
| 12f1cde7c5 | |||
| b6fc93cc61 | |||
| d216e4a57a | |||
| 7e720d9bfc | |||
| e8c6d27979 | |||
| f8dca2f22c | |||
| 75e08b90c0 | |||
| 456cd91a83 | |||
| f523f84c18 | |||
| 5c3f3503ea | |||
| 81e1cd0f72 | |||
| 34b82bdbe7 | |||
| e3d46d9142 | |||
| e8470e7bb4 | |||
| 55f89d384b | |||
| 4486adc957 | |||
| 757cbc8740 | |||
| e1b5ae9aab | |||
| 8cfdd566af | |||
| 86e5526a83 |
@@ -0,0 +1,32 @@
|
||||
序号,类型,页面,功能点,需调整项目,字典表,逻辑,备注
|
||||
1,调整,,,“劳动合同”,是否应调整为更为宽泛的用工关系,,
|
||||
2,调整,离职管理,,“离职日期”,,办理离职的离职日期调整后,应影响社保截止年月和公积金截止年月(例如15日之前离职不缴纳当月社保、15日之后离职缴纳当月社保)可配置,
|
||||
3,调整,离职管理,合规检查,“是否已支付经济补偿金”,,不勾选无法进行下一步,应增加保存后未办事项进入代办和风险提醒中,
|
||||
4,新增,离职管理,费用结算,“剩余年假天数”,,调用或录入,影响离职日期计算,
|
||||
5,调整,离职管理,费用结算,“补偿月数”,N、N+1、2N、其他,选择协商解除时可填写修改并重新运算补偿金,
|
||||
6,调整,离职管理,费用结算,“计算基数”,近12月平均工资、合同工资、其他,可调整,按照选用算法计算,其他需手动填写,
|
||||
7,调整,离职管理,费用结算,“手动调整补偿金分项”,,调整后合计应付未同步更新,
|
||||
8,调整,离职管理,工作交接,勾选的内容应为待办事项,前三项为离职必须要执行的任务,,未到离职日期,工作交接未完成,无法进行下一步操作,应调整为可进行下一步,未完成事项进入代办提醒,
|
||||
9,调整,离职管理,,将离职管理模块中的“新建解聘”功能并入到花名册员工列表的操作栏中的离职功能,,,
|
||||
10,调整,离职管理,,“解聘驳回”,,解聘驳回后,重新修改了解聘类型、金额等,系统未按照新内容更新,
|
||||
11,调整,离职管理,,“新增解聘”,,选择了违法解除后(2N),修改降低了经济补偿金金额,合规提醒并未风险提示可继续进行下一步操作,需要增加合规风险提醒,
|
||||
12,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同开始日期应为上一份合同结束日期+1(例如第一份合同结束日期为2023年5月31日,那么新增合同后合同开始日期应为2026年6月1日),
|
||||
13,调整,用工办理,合同续签,“新增合同”,,续签新增合同,合同结束日期早于合同开始日期,系统未校验判断产生了时间倒置,
|
||||
14,调整,花名册,添加员工,“身份证号”,,全域修改为“证件号码”,
|
||||
15,调整,花名册,添加员工,年龄限制,,添加员工时根据证件号码对员工年龄进行合规性筛查,
|
||||
16,调整,花名册,员工明细编辑,编辑修改“入职日期”,,花名册明细中的状态未变更(例如从今天变更为下月1日入职,状态应变更为待入职),
|
||||
17,调整,花名册,添加员工,“手机号”,,录入手机号时进行查重,
|
||||
18,调整,用工办理,员工转正,将用工办理模块中的“员工转正”功能移植到花名册员工列表的操作栏中,替换发薪功能,,,
|
||||
19,调整,用工办理,员工转正,“员工转正”,,填写了转正薪资,在员工花名册里未体现变更后内容,是否与已签订的用工协议匹配需要验证,如不一致应发起合同变更流程,
|
||||
20,调整,用工办理,开具证明,将用工办理模块中的“开具证明”功能移植到花名册员工列表的操作栏中,,,
|
||||
21,调整,用工办理,合同续签,将用工办理模块中的“合同续签”功能移植到花名册员工列表的操作栏中,,,
|
||||
22,调整,用工办理,合同变更,“合同变更”,,选择员工无法查询到对应的合同,
|
||||
23,调整,用工办理,合同续签,“合同续签”,,原合同ID无法查询,建议修改为选择该员工已存在的合同,
|
||||
24,调整,花名册,,勾选多名员工时,增加批量转正功能,,,
|
||||
25,调整,花名册,,勾选多名员工时,增加批量开具证明功能,,,
|
||||
26,调整,用工办理,,去掉用工办理模块,,,
|
||||
27,调整,系统设置,,增加简单的组织架构,用于满足三步以内的审批流转,,,
|
||||
28,调整,薪税管理,创建发薪批次,补偿金批次,,批次类型为补偿金批次的,经济补偿金发放数据读取错误,
|
||||
29,调整,,特殊员工,“特殊员工”,,男职工应无法选择三期,
|
||||
30,BUG,全域,,全域保存草稿后部分录入的数据未保存,,全域保存草稿后部分录入的数据未保存,
|
||||
31,调整,,,单立户客服端,,,
|
||||
|
+384
@@ -0,0 +1,384 @@
|
||||
# 20260815 优化清单
|
||||
|
||||
> 来源:`20260815-优化.csv`(共 31 条)
|
||||
> 结合 TurboHR 当期功能梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
|
||||
> 优先级:P0 紧急(影响业务正确性/数据丢失)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
|
||||
|
||||
---
|
||||
|
||||
## 一、离职管理(问题 2-11)
|
||||
|
||||
### 问题 2:离职日期调整后未联动社保/公积金截止年月
|
||||
- **现状**:`Termination.tsx:1334-1338` 社保截止、公积金截止为 `<Input type="month">` 手动填写,默认值取 `terminationDate.slice(0,7)`,但修改离职日期后不会重算,也无"15 日前/后"规则。
|
||||
- **修复方向**:
|
||||
1. 增加"离职日期 → 社保/公积金截止月"自动推导规则:每月 15 日(含)前离职不缴当月、15 日后离职缴纳当月,截止月 = 离职月 - 0 或 -1。
|
||||
2. 阈值日期(15 日)做成系统设置可配置项(`org_settings.socialInsCutoffDay`,默认 15)。
|
||||
3. 离职日期变更时自动回填两个截止月,同时保留手动覆盖入口。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`frontend/src/pages/Termination.tsx`、`backend/src/services/termination.service.ts`、`backend/prisma/schema.prisma`(org_settings 增字段)
|
||||
|
||||
### 问题 3:合规检查"是否已支付经济补偿金"硬阻断
|
||||
- **现状**:合规检查为必勾才能进入下一步,未勾选时无法继续,且未保存的检查项不进入待办/风险提醒。
|
||||
- **修复方向**:
|
||||
1. 将"必勾才能下一步"改为"未勾选可下一步,但自动写入待办事项 + 风险提醒"。
|
||||
2. 待办项关联 `termination_draft`,在 Dashboard/风险中心展示"XX 员工解聘未支付经济补偿金"。
|
||||
3. 提交审批时若仍存在未闭环必检项,给出强提示但不阻断(除非法定禁止情形,如孕期/工伤)。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Termination.tsx`(step 2 校验逻辑)、`backend/src/services/notification.service.ts`、`frontend/src/pages/Dashboard.tsx`
|
||||
|
||||
### 问题 4:费用结算新增"剩余年假天数"
|
||||
- **现状**:费用结算步骤仅展示补偿金/代通知金/赔偿金,未涉及年假折算。
|
||||
- **修复方向**:
|
||||
1. 费用结算区新增"剩余年假天数"字段,支持手动录入或调用考勤模块年假余额(若有)。
|
||||
2. 按规则计算未休年假折算工资:`日工资 × 剩余年假天数 × 300%`(未休部分)。
|
||||
3. 折算金额计入"合计应付"。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`Termination.tsx`(step 3)、`backend/src/services/termination.service.ts`(cost 计算扩展)
|
||||
|
||||
### 问题 5:补偿月数支持 N / N+1 / 2N / 其他
|
||||
- **现状**:`Termination.tsx:1442` 补偿月数取 `costResult.cappedMonths`(系统按工龄自动算 N),不允许选择其他模式。
|
||||
- **修复方向**:
|
||||
1. 补偿月数改为下拉:N、N+1、2N、其他(自定义输入)。
|
||||
2. 选择"协商解除"时允许修改月数并实时重算补偿金。
|
||||
3. 选择"其他"时需填写说明,留痕到 `compensationBreakdown.adjustments`。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Termination.tsx`(step 3 补偿金区)、`backend/src/services/termination.service.ts`
|
||||
|
||||
### 问题 6:计算基数支持多种来源
|
||||
- **现状**:`Termination.tsx:1443` 计算基数固定取 `costResult.cappedWage`(近 12 月平均工资,且有三倍社平工资封顶)。
|
||||
- **修复方向**:
|
||||
1. 计算基数改为下拉:近 12 月平均工资(默认)、合同工资、其他(手动填写)。
|
||||
2. 切换来源后实时重算补偿金,并记录选用算法到草稿。
|
||||
3. "其他"需填写说明,受三倍社平封顶提示但不强制。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Termination.tsx`、`termination.service.ts`
|
||||
|
||||
### 问题 7:手动调整补偿金分项后合计未同步
|
||||
- **现状**:`Termination.tsx:530` 注释提到"系统预估 + 手动调整差额",但 `handleSave`(578 行附近)仍使用 `costResult.grandTotal`,未叠加 `compAdjustments`。20260803 问题 13 已识别但未闭环。
|
||||
- **修复方向**:
|
||||
1. 实际合计 = `costResult.grandTotal + Σ(adjustments.to - adjustments.from)`,保存与确认页统一使用此值。
|
||||
2. 调整任一分项后实时刷新"合计应付"显示。
|
||||
3. 后端 `compensationBreakdown.adjustments` 已支持,前端需正确传递并回显。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`Termination.tsx`(`handleSave`、确认步骤、step 3 合计显示)
|
||||
|
||||
### 问题 8:工作交接未完成无法下一步
|
||||
- **现状**:`Termination.tsx:552-554` step 4 校验 `work_handover/equipment_return/access_revoke` 三项必须完成才能下一步。
|
||||
- **修复方向**:
|
||||
1. 改为可下一步,未完成事项自动写入待办(关联离职草稿)。
|
||||
2. 交接清单前三项标注"离职必办",未完成时在确认页和风险中心强提示。
|
||||
3. 待办在到达离职日期前持续提醒,超期升级为风险。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Termination.tsx`(step 4 校验)、`notification.service.ts`、`Dashboard.tsx`
|
||||
|
||||
### 问题 9:离职管理"新建解聘"并入花名册操作栏
|
||||
- **现状**:花名册操作栏已有"离职"按钮(`Roster.tsx:734-746`,触发 `ResignModal` 创建 RESIGNATION 草稿),但"新建解聘"仍在离职管理列表页独立入口,且仅支持 RESIGNATION 类型。
|
||||
- **修复方向**:
|
||||
1. 花名册操作栏"离职"按钮扩展为支持所有解聘类型(协商/过错/非过错/裁员/到期/违法),弹出类型选择后跳转 Termination 向导并预填员工。
|
||||
2. 离职管理列表页移除"新建解聘"按钮,仅保留草稿列表与审批。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Roster.tsx`、`roster/modals.tsx`(`ResignModal`)、`Termination.tsx`(支持 URL 参数预填)
|
||||
|
||||
### 问题 10:解聘驳回后修改内容未更新
|
||||
- **现状**:驳回后重新修改解聘类型、金额等,系统未按新内容更新(疑似使用旧草稿快照或前端未刷新)。
|
||||
- **修复方向**:
|
||||
1. 排查 `updateDraft` 是否正确覆盖 `reason/compensationBreakdown/terminationDate`。
|
||||
2. 驳回后再编辑走 `updateDraft`(而非新建),版本号 +1,旧版本留快照。
|
||||
3. 前端进入驳回草稿时强制重新拉取最新数据。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`Termination.tsx`(编辑驳回草稿逻辑)、`backend/src/services/termination.service.ts`、`backend/src/routes/termination.routes.ts`
|
||||
|
||||
### 问题 11:违法解除降低补偿金后无合规风险提醒
|
||||
- **现状**:选择 ILLEGAL(2N)后手动降低补偿金金额,`acknowledgeRisk` 仅在初始选择违法解除时提示,金额变动后不再校验。
|
||||
- **修复方向**:
|
||||
1. 实际补偿金 < 法定 2N 时,强制弹出合规风险提醒,需勾选"已知风险并继续"才能下一步。
|
||||
2. 风险提醒记录到 `compensationBreakdown.riskAcknowledged`,留痕可审计。
|
||||
3. 同步推送风险中心。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`Termination.tsx`(step 3 风险校验)、`risk-center`
|
||||
|
||||
---
|
||||
|
||||
## 二、用工办理 / 合同(问题 12-13、18、20-23、26)
|
||||
|
||||
### 问题 12:合同续签新增合同开始日期应为上一份结束日期 +1
|
||||
- **现状**:`WorkProcess.tsx:93-99` RENEW 表单 `newStartDate` 为手动填写,无自动推导。
|
||||
- **修复方向**:
|
||||
1. 选择员工后自动拉取最新已签合同,`newStartDate` 默认 = 上一份合同 `endDate + 1 天`。
|
||||
2. 允许手动覆盖,但偏离时提示。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`WorkProcess.tsx`(RENEW 字段)、`backend/src/services/work-process.service.ts`
|
||||
|
||||
### 问题 13:合同结束日期早于开始日期未校验
|
||||
- **现状**:`WorkProcess.tsx` 表单提交前无日期前后关系校验,已产生时间倒置数据。
|
||||
- **修复方向**:
|
||||
1. 前端表单提交前校验 `endDate >= startDate`,否则阻断并提示。
|
||||
2. 后端 `work-process.service.ts` 增加同样校验,双保险。
|
||||
3. 历史倒置数据提供修复脚本/列表提示。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`WorkProcess.tsx`、`work-process.service.ts`
|
||||
|
||||
### 问题 18:员工转正移植到花名册操作栏,替换发薪
|
||||
- **现状**:花名册操作栏(`Roster.tsx:694-747`)有调薪/发薪/调部门/离职,无转正入口;转正仍在用工办理 CONFIRM 流程。
|
||||
- **修复方向**:
|
||||
1. 操作栏"发薪"按钮(`Wallet`,711-720 行)替换为"转正"按钮(`CheckCircle`),点击弹出转正 Modal(转正日期 + 转正薪资)。
|
||||
2. 仅试用期员工(`probationInfo.isProbation`)显示转正按钮。
|
||||
3. 发薪入口保留在薪税管理模块。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`Roster.tsx`、`roster/modals.tsx`(新增 `ConfirmModal`)
|
||||
|
||||
### 问题 19:转正薪资未回写花名册,未与用工协议匹配校验
|
||||
- **现状**:CONFIRM 流程填写 `regularSalary` 后未同步到员工 `monthlySalary`,也未校验与已签合同薪资是否一致。
|
||||
- **修复方向**:
|
||||
1. 转正完成后同步更新员工 `monthlySalary`,并在花名册明细展示"试用期薪资 → 转正薪资"变更记录。
|
||||
2. 校验转正薪资与最新合同 `monthlySalary` 是否一致,不一致时提示并支持发起合同变更流程(CHANGE)。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`work-process.service.ts`(CONFIRM 完成回调)、`Roster.tsx`、`roster/SalaryInfo.tsx`
|
||||
|
||||
### 问题 20:开具证明移植到花名册操作栏
|
||||
- **现状**:操作栏无开具证明入口,需进用工办理 INCOME_CERT/LEAVING_CERT 流程。
|
||||
- **修复方向**:操作栏新增"证明"按钮(`FileText`),点击弹出证明类型选择(收入证明/离职证明/在职证明),复用 WorkProcess 的 INCOME_CERT/LEAVING_CERT 表单。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`Roster.tsx`、`roster/modals.tsx`
|
||||
|
||||
### 问题 21:合同续签移植到花名册操作栏
|
||||
- **现状**:操作栏无续签入口,批量续签在列表顶部(`Roster.tsx:444`),单人续签需进用工办理。
|
||||
- **修复方向**:操作栏新增"续签"按钮(`Repeat`),仅合同即将到期(≤30 天)或已到期员工显示,点击跳转 WorkProcess RENEW 并预填员工。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`Roster.tsx`、`WorkProcess.tsx`(支持 URL 参数预填)
|
||||
|
||||
### 问题 22:合同变更选择员工无法查询到对应合同
|
||||
- **现状**:`WorkProcess.tsx:88-92` CHANGE 表单 `contractId` 为 `contract-select` 类型,但选择员工后未按员工过滤合同列表,导致查不到。
|
||||
- **修复方向**:
|
||||
1. `contract-select` 组件接收 `employeeId` 参数,仅返回该员工的有效合同。
|
||||
2. 后端合同查询接口支持 `employeeId` 过滤。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`WorkProcess.tsx`(contract-select 组件)、`backend/src/routes/contract.routes.ts`
|
||||
|
||||
### 问题 23:合同续签原合同 ID 无法查询,改为选择已存在合同
|
||||
- **现状**:`WorkProcess.tsx:95` RENEW 表单 `oldContractId` 为 `text` 类型,需手动输入 UUID,无法查询。
|
||||
- **修复方向**:将 `oldContractId` 改为 `contract-select`(同问题 22),选择员工后下拉展示该员工已存在合同。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`WorkProcess.tsx`(RENEW 字段类型)
|
||||
|
||||
### 问题 26:去掉用工办理模块
|
||||
- **现状**:侧边栏"团队"分组有"用工办理"入口(`SidebarNav.tsx:47`),承载 HIRE/ONBOARD/CONFIRM/CHANGE/RENEW/SUSPEND/INCOME_CERT/LEAVING_CERT/FLEXIBLE 等流程。
|
||||
- **修复方向**:
|
||||
1. 将各流程入口下沉到花名册操作栏(转正/续签/变更/开具证明/入职)和独立页面(合同管理/电子签署)。
|
||||
2. 侧边栏移除"用工办理"菜单项,保留 WorkProcess 页面作为批量流程入口(或迁移到"更多"分组)。
|
||||
3. 需先完成问题 18/20/21/22/23 的入口下沉,再移除菜单。
|
||||
- **优先级**:P2 中(依赖前置问题完成)
|
||||
- **涉及文件**:`SidebarNav.tsx`、`Roster.tsx`、`WorkProcess.tsx`、`App.tsx`(路由)
|
||||
|
||||
---
|
||||
|
||||
## 三、花名册(问题 14-17、24-25)
|
||||
|
||||
### 问题 14:身份证号全域改为"证件号码"
|
||||
- **现状**:`roster/modals.tsx:687`、`WorkProcess.tsx:60/109/132/140` 等多处 label 为"身份证号"。
|
||||
- **修复方向**:全域搜索替换 label "身份证号" → "证件号码",字段名 `idCardNumber` 保持不变(兼容历史数据),同时支持证件类型下拉(身份证/护照/港澳台通行证)。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`roster/modals.tsx`、`WorkProcess.tsx`、`Roster.tsx`(搜索 placeholder)、`SpecialStatus.tsx` 等
|
||||
|
||||
### 问题 15:添加员工根据证件号码进行年龄合规筛查
|
||||
- **现状**:`roster/modals.tsx:557` 已根据身份证号自动计算性别,未做年龄校验。
|
||||
- **修复方向**:
|
||||
1. 解析证件号码出生日期,计算年龄。
|
||||
2. 年龄 < 16 岁禁止录入(童工红线),强提示并阻断。
|
||||
3. 年龄 ≥ 60(男)/55(女干部)/50(女工人)提示已达法定退休年龄,确认后允许录入但标记"退休返聘"。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`roster/modals.tsx`(`handleIdCardChange`)
|
||||
|
||||
### 问题 16:编辑入职日期后状态未变更
|
||||
- **现状**:花名册明细编辑入职日期后,员工状态未联动(如改为下月 1 日入职,状态应从"在职"变为"待入职")。
|
||||
- **修复方向**:
|
||||
1. 编辑入职日期时,若新入职日期 > 今天,状态自动设为 `PENDING_ONBOARD`(待入职);若 ≤ 今天,设为 `ACTIVE`。
|
||||
2. 状态变更需二次确认,避免误操作。
|
||||
3. 变更记录留痕到员工档案。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`roster/BasicInfo.tsx`、`backend/src/services/employee.service.ts`
|
||||
|
||||
### 问题 17:录入手机号查重
|
||||
- **现状**:`roster/modals.tsx:702` 手机号为选填,无查重;身份证号已有查重(691 行)。
|
||||
- **修复方向**:
|
||||
1. 手机号录入时调用查重接口,已存在则提示"该手机号已用于 XX 人员"。
|
||||
2. 允许继续保存(一人多号/家庭号场景),但强提示确认。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`roster/modals.tsx`、`backend/src/routes/employee.routes.ts`(新增手机号查重接口)
|
||||
|
||||
### 问题 24:花名册多选增加批量转正
|
||||
- **现状**:`Roster.tsx:444-447` 批量操作仅有"批量续签""批量解聘",无批量转正。
|
||||
- **修复方向**:
|
||||
1. 多选后新增"批量转正"按钮,仅对试用期员工生效。
|
||||
2. 弹窗统一填写转正日期(默认今天)和转正薪资(可按原薪资倍数/手动逐人填写)。
|
||||
3. 调用后端批量转正接口,复用问题 18 的转正逻辑。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`Roster.tsx`、`roster/modals.tsx`、`backend/src/services/employee.service.ts`
|
||||
|
||||
### 问题 25:花名册多选增加批量开具证明
|
||||
- **现状**:无批量开具证明入口。
|
||||
- **修复方向**:
|
||||
1. 多选后新增"批量开具证明"按钮,选择证明类型(在职/收入)后批量生成。
|
||||
2. 支持批量下载(ZIP)或逐个下载。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`Roster.tsx`、`backend/src/services/work-process.service.ts`(批量生成接口)
|
||||
|
||||
---
|
||||
|
||||
## 四、薪税管理(问题 28)
|
||||
|
||||
### 问题 28:补偿金批次经济补偿金发放数据读取错误
|
||||
- **现状**:`money/BatchTab.tsx:110` 批次类型支持 `SEVERANCE`(补偿金),但创建补偿金批次时经济补偿金发放数据读取逻辑有误(疑似读取了工资数据或未关联离职草稿的 `compensationBreakdown`)。
|
||||
- **修复方向**:
|
||||
1. 排查 `createBatch` 当 `type=SEVERANCE` 时的数据源,应从已审批通过的 `termination_draft` 读取 `compensationBreakdown.grandTotal`。
|
||||
2. 校验员工范围:仅包含有未发放补偿金的离职员工。
|
||||
3. 增加预览页展示每位员工的补偿金明细,确认后再创建批次。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`money/BatchTab.tsx`、`backend/src/services/payroll.service.ts`(SEVERANCE 分支)
|
||||
|
||||
---
|
||||
|
||||
## 五、特殊员工(问题 29)
|
||||
|
||||
### 问题 29:男职工应无法选择三期
|
||||
- **现状**:`SpecialStatus.tsx:123` 默认 `type: 'PREGNANCY'`,新增/编辑表单未根据员工性别过滤类型,男职工也可选"三期"。
|
||||
- **修复方向**:
|
||||
1. 选择员工后,若 `gender === '男'`,类型下拉移除"三期"选项或置灰并提示"三期仅适用于女性员工"。
|
||||
2. 后端保存时增加校验,男职工 + PREGNANCY 组合拒绝并返回 400。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`SpecialStatus.tsx`(表单 type 下拉)、`backend/src/services/special-status.service.ts`
|
||||
|
||||
---
|
||||
|
||||
## 六、系统设置 / 组织架构(问题 27)
|
||||
|
||||
### 问题 27:增加简单组织架构,支持三步以内审批流转
|
||||
- **现状**:系统无组织架构模块,`LeaveApproval.tsx:90` 仅有简单审批,无层级流转;`settingsApi.org()` 仅返回企业基本信息。当前员工仅有 `department`(字符串)和 `position`(字符串,前端 label "职务/岗位")两个字段,无上下级关系。
|
||||
- **职位/岗位设计决策**:
|
||||
- **现状**:数据库只有一个 `position` 字段(`schema.prisma:225`,注释"岗位"),前端 label 统一为"职务/岗位"(`roster/modals.tsx:686`、`roster/BasicInfo.tsx:153/328`),已合并为一个字段。
|
||||
- **建议方案 A(推荐,保留一个字段)**:维持 `position` 单字段,label 保持"职务/岗位"。组织架构中"岗位"作为独立实体管理(岗位字典,含岗位名称、职级、编制人数),员工 `position` 关联到岗位字典。优点:改动小,兼容历史数据,符合当前使用习惯。
|
||||
- **方案 B(拆分两个字段)**:新增 `jobTitle`(职位,如"经理/主管/专员")+ `position`(岗位,如"前端工程师")。优点:职级体系更清晰;缺点:需改 schema + 全域表单 + 历史 position 数据需清洗归类,工作量大。
|
||||
- **结论**:建议采用方案 A,组织架构模块中"岗位"独立建表(`Position` 字典),员工 `position` 字段值关联岗位字典 ID 或保持文本(兼容旧数据),审批流按"直属上级 → 部门负责人"两级流转,无需引入职级。
|
||||
- **修复方向**:
|
||||
1. 系统设置新增"组织架构"子页:树形部门(`Department` 自引用 parent)+ 岗位字典(`Position`,含名称/所属部门/编制)+ 上下级关系(`Employee.supervisorId`)。
|
||||
2. 新建简单审批流配置:最多 3 步(发起人 → 直属上级 → 部门负责人),支持按流程类型(休假/离职/调薪)配置是否启用某步。
|
||||
3. 现有 `LeaveApproval` 接入审批流引擎,离职/调薪等流程可选启用。
|
||||
4. 数据模型:`Department`(树形,parent 自引用)、`Position`(岗位字典)、`Employee.supervisorId`、`ApprovalFlow`(type + steps)。
|
||||
- **优先级**:P3 规划(新模块,建议单独排期)
|
||||
- **涉及文件**:`Settings.tsx`、新增 `pages/OrgChart.tsx`、`backend/prisma/schema.prisma`、`backend/src/services/approval.service.ts`
|
||||
|
||||
---
|
||||
|
||||
## 七、全域问题(问题 1、30、31)
|
||||
|
||||
### 问题 1:"劳动合同"调整为更宽泛的"用工关系"
|
||||
- **现状**:系统多处文案使用"劳动合同"(合同管理、离职管理、WorkProcess 等)。
|
||||
- **修复方向**:
|
||||
1. 全域文案审计:将面向用户的"劳动合同"在合适场景改为"用工关系"或"用工协议"(涵盖劳动合同/劳务协议/实习协议/灵活用工协议)。
|
||||
2. 数据层 `Contract` 模型保持不变,新增 `contractCategory` 字段区分劳动关系类型。
|
||||
3. 注意法律文书模板中的"劳动合同"为法定术语,不可改。
|
||||
- **优先级**:P2 中
|
||||
- **涉及文件**:`SidebarNav.tsx`、`Contracts.tsx`、`WorkProcess.tsx`、`Termination.tsx` 等文案
|
||||
|
||||
### 问题 30:全域保存草稿后部分录入数据未保存(BUG)
|
||||
- **现状**:多个流程页面支持"保存草稿",但部分字段(如 `compAdjustments`、`handoverItems` 备注、`checklistOverrides`)未持久化。
|
||||
- **修复方向**:
|
||||
1. 排查各流程 `saveDraft` 的 payload,确保覆盖所有前端 state。
|
||||
2. 后端 `draft` 表 schema 检查是否有字段缺失(`compensationBreakdown.adjustments`、`handoverItems.remark` 等)。
|
||||
3. 增加"草稿完整性校验":保存后立即回读对比,缺失字段告警。
|
||||
4. 重点排查:Termination(补偿金调整/交接备注)、WorkProcess(自定义字段)。
|
||||
- **优先级**:P0 紧急
|
||||
- **涉及文件**:`Termination.tsx`(`handleSaveDraft`)、`WorkProcess.tsx`、`backend/src/services/termination.service.ts`、`backend/src/services/work-process.service.ts`
|
||||
|
||||
### 问题 31:单立户客服端
|
||||
|
||||
#### 已实现部分(平台管理端,代码完整可运行)
|
||||
|
||||
系统已有完整的"平台管理端"(Platform),前后端代码均已实现:
|
||||
|
||||
**前端(4 页面 + 1 侧边栏)**:
|
||||
- `frontend/src/pages/platform/PlatformLogin.tsx` — 独立登录页
|
||||
- `frontend/src/pages/platform/PlatformDashboard.tsx` — 数据总览(企业数/用户数/员工数/合同数/工资条数 + 套餐分布 + 最近注册企业)
|
||||
- `frontend/src/pages/platform/PlatformOrgs.tsx` — 企业租户管理(创建/搜索/编辑套餐/删除/管理员账号管理)
|
||||
- `frontend/src/pages/platform/PlatformUsers.tsx` — 用户管理(启用/禁用)
|
||||
- `frontend/src/components/layout/PlatformSidebar.tsx` — 独立侧边栏(带 ADMIN 徽章)
|
||||
- `frontend/src/lib/api-services.ts:938-965` — `platformApi` 完整定义
|
||||
- `frontend/src/App.tsx:217-220` — 四条路由注册 + `PlatformRoute` 角色校验(`SUPER_ADMIN`)
|
||||
|
||||
**后端(1 路由文件 437 行 + 1 schema)**:
|
||||
- `backend/src/routes/platform.routes.ts` — 完整实现 11 个接口:
|
||||
- `GET /platform/dashboard` — 平台总览数据
|
||||
- `GET /platform/orgs` — 企业列表(分页+搜索+套餐筛选)
|
||||
- `POST /platform/orgs` — 创建企业(含管理员账号自动创建)
|
||||
- `GET /platform/orgs/:id` — 企业详情(含用户列表)
|
||||
- `PUT /platform/orgs/:id` — 编辑企业(套餐/上限/联系人)
|
||||
- `PUT /platform/orgs/:id/admin` — 编辑企业管理员(姓名/手机号/重置密码)
|
||||
- `DELETE /platform/orgs/:id` — 删除企业(级联)
|
||||
- `GET /platform/users` — 全平台用户列表
|
||||
- `PUT /platform/users/:id/toggle` — 启用/禁用用户
|
||||
- `GET /platform/admins` — 平台管理员列表
|
||||
- `POST /platform/admins` — 创建平台管理员
|
||||
- `backend/src/schemas/platform.schema.ts` — 入参校验
|
||||
- `backend/src/middleware/auth.ts` — `platformAdminMiddleware` 权限校验
|
||||
|
||||
**数据模型**:
|
||||
- `Organization` 模型支持多租户(`plan`/`maxEmployees`/`city`/`contactName`/`contactPhone`)
|
||||
- `User.role` 含 `SUPER_ADMIN` 角色,`orgId` 为 null(平台管理员不归属任何租户)
|
||||
- `backend/prisma/seed-multi-org.ts` — 多租户种子数据
|
||||
|
||||
**结论**:单立户服务(开户+租户管理+用户管理+数据总览)**已完整实现**,CSV 标注为"调整"类型也印证了该功能已存在。
|
||||
|
||||
#### 可增强部分(客服工作台,尚未实现)
|
||||
|
||||
当前平台管理端是"运营管理后台",定位为超级管理员/运营使用。如需扩展为客服人员日常服务客户的工作台,以下功能尚未实现:
|
||||
|
||||
| 增强项 | 现状 | 说明 |
|
||||
|--------|------|------|
|
||||
| 工单管理 | 缺失 | 无 `Ticket` 模型、无工单页面,客服无法接收/分派/跟进客户问题 |
|
||||
| 客户会话 | 缺失 | 无会话模块,客服无法与客户企业管理员实时沟通或留言 |
|
||||
| 租户数据穿透 | 缺失 | `SUPER_ADMIN` 仅能看租户列表和统计,无法穿透查看指定租户的花名册/合同/薪税/风险等业务数据 |
|
||||
| 协助操作 | 部分已有 | 管理员重置密码已有,代客户发起流程/调整套餐等未实现 |
|
||||
| AI 辅助 | 缺失 | 当前 AI 仅在企业端,客服侧无 AI 问答辅助 |
|
||||
| 操作日志 | 缺失 | 当前 `AuditLog` 仅在企业端,客服操作无留痕 |
|
||||
|
||||
**增强修复方向(如需)**:
|
||||
1. 新增角色 `SUPPORT`(客服),独立路由 `/support/*`,独立登录页 `/support/login`。
|
||||
2. 客服端侧边栏含:工单中心、客户会话、租户数据查看、协助操作、AI 辅助、操作日志。
|
||||
3. 数据模型新增:`Ticket`(工单:标题/内容/状态/优先级/归属租户/处理人)、`TicketMessage`(工单回复)、`ChatSession`(客户会话)。
|
||||
4. 租户数据穿透:客服选择租户后,以"只读+代操作"模式访问该租户的业务数据(复用现有 API,请求头带 `X-Support-Tenant-Id`,后端中间件切换租户上下文)。
|
||||
5. AI 辅助复用现有 AI 接口,独立会话隔离。
|
||||
6. MVP 建议:先做工单 + 租户数据查看 + AI 辅助三项。
|
||||
|
||||
- **优先级**:核心功能已完成;增强部分 P3 规划(新模块,建议单独排期)
|
||||
- **涉及文件(增强部分)**:新增 `frontend/src/pages/support/*`、`frontend/src/components/layout/SupportSidebar.tsx`、`backend/prisma/schema.prisma`(Ticket/ChatSession 模型)、`backend/src/routes/support.routes.ts`、`backend/src/middleware/support-tenant.ts`
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
|
||||
| 优先级 | 问题 | 说明 |
|
||||
|--------|------|------|
|
||||
| **P0 紧急** | 7、10、11、13、28、30 | 数据丢失/业务正确性/合规风险 |
|
||||
| **P1 高** | 2、3、5、6、8、9、12、15、16、18、19、22、23、29 | 流程阻塞或合规校验缺失 |
|
||||
| **P2 中** | 1、4、14、17、20、21、24、25、26 | 体验优化/功能增强 |
|
||||
| **P3 规划** | 27、31增强 | 新模块(组织架构+审批流 / 客服工作台增强),需单独排期 |
|
||||
| **已完成** | 31核心 | 单立户服务(平台管理端)已完整实现,前后端代码可运行 |
|
||||
|
||||
---
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
1. **第一批(P0,立即)**:问题 30(草稿丢失)→ 问题 7(补偿金合计)→ 问题 10(驳回未更新)→ 问题 11(违法解除风险)→ 问题 13(合同日期校验)→ 问题 28(补偿金批次数据)
|
||||
2. **第二批(P1,本迭代)**:离职管理联动(2/3/5/6/8/9)→ 花名册校验(15/16)→ 用工办理入口下沉(18/19/22/23)→ 特殊员工性别校验(29)
|
||||
3. **第三批(P2,排期)**:花名册批量操作(24/25)→ 文案统一(1/14)→ 入口下沉收尾(20/21/26)→ 年假折算(4)→ 手机号查重(17)
|
||||
4. **第四批(P3,规划)**:组织架构与审批流(27)→ 客服工作台增强(31增强,MVP = 工单 + 租户数据查看 + AI 辅助)
|
||||
- 问题 31 核心功能(单立户服务/平台管理端)已完整实现,无需开发
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 本清单基于当期代码梳理,部分"根因"为基于代码静态分析的推断,实际修复前需运行复现确认。
|
||||
- 涉及数据库 schema 变更的(问题 2/27/31),需走 migration + 回滚方案,符合数据 8 铁律。
|
||||
- 涉及文案全域替换的(问题 1/14),需同步更新 HelpModal、OnboardingGuide 等帮助文案。
|
||||
- 问题 26(去掉用工办理模块)依赖问题 18/20/21/22/23 完成,不可先行移除。
|
||||
+1038
File diff suppressed because it is too large
Load Diff
+1029
File diff suppressed because it is too large
Load Diff
+410
@@ -0,0 +1,410 @@
|
||||
# 20260816 优化清单
|
||||
|
||||
> 来源:用户测试反馈(编号 4-13,共 10 项)
|
||||
> 结合 TurboHR 当期代码梳理,按模块归类,标注根因 / 修复方向 / 优先级 / 涉及文件。
|
||||
> 优先级:P0 紧急(影响业务正确性/合规)|P1 高(流程阻塞或合规风险)|P2 中(体验/增强)|P3 规划(新模块)
|
||||
>
|
||||
> **✅ 全部 10 项已于 20260816 完成实现,前后端 tsc 均通过。完成明细见"五、已完成优化"章节(编号 5-15)。**
|
||||
|
||||
---
|
||||
|
||||
## 一、花名册 · 添加雇员(问题 4-8)
|
||||
|
||||
### 问题 4:合同状态未签署时仍显示"正常"
|
||||
- **现状**:`backend/src/services/contract.service.ts:74` 注释明确"有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理",`getContractStatus` 在有合同记录时直接返回 `active`/`正常`,未区分"已签署"与"待签署"。
|
||||
- **修复方向**:
|
||||
1. `getContractStatus` 增加"待签署"判定:有合同记录但 `signDate` 为空,或关联的电子签署记录状态为 `PENDING`/未 `COMPLETED` 时,返回 `status: 'pending_sign'`,`statusText: '待签署'`,`riskLevel: 'medium'`。
|
||||
2. 仅当 `signDate` 已填且(无电子签署或电子签署已 COMPLETED)时才返回 `active`/`正常`。
|
||||
3. 前端花名册列表/详情同步展示"待签署"标签。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`backend/src/services/contract.service.ts`、`frontend/src/pages/Roster.tsx`、`frontend/src/pages/roster/ContractInfo.tsx`
|
||||
|
||||
### 问题 5:社保状态未办理时仍显示"正常"
|
||||
- **现状**:花名册添加雇员表单(`roster/modals.tsx:719`)有 `socialInsBase`/`socialInsStartMonth`/`housingFundBase`/`housingFundStartMonth`,但无"社保状态"字段;花名册列表/详情也未展示社保办理状态,默认视为"正常"。
|
||||
- **修复方向**:
|
||||
1. 新增社保状态字段(派生而非存储):根据 `socialInsStartMonth` 是否填写、是否已做"办理完成"操作判定。
|
||||
2. 状态枚举:`待办`(未填写基数/起始月或未办理完成)、`正常`(已办理且在缴)、`停缴`(已设截止月)。
|
||||
3. 后端 `employee.service.ts` 在花名册列表返回 `socialInsStatus` 派生字段。
|
||||
4. 前端花名册列表/详情展示社保状态标签。
|
||||
5. 提供"办理完成"操作入口(可在社保公积金模块或花名册操作栏)。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`backend/src/services/employee.service.ts`、`backend/src/services/contract.service.ts`(或新增 `social-insurance.service.ts` 派生方法)、`frontend/src/pages/Roster.tsx`、`frontend/src/pages/roster/PayslipSocialInfo.tsx`
|
||||
|
||||
### 问题 6:证件号码未验证有效性
|
||||
- **现状**:`roster/modals.tsx:886` `canSubmit` 仅校验 `idCardNumber.length >= 18`,未做身份证校验位(第 18 位)、出生日期合法性、月份/日期范围等有效性验证;`handleIdCardChange`(755 行)只做性别推导和查重,未做格式校验。
|
||||
- **修复方向**:
|
||||
1. 新增身份证有效性校验:校验位算法(前 17 位加权求和取模映射第 18 位)、出生日期合法、月份 01-12、日期合法。
|
||||
2. 校验失败时 `ageWarning` 返回 `BLOCK`,阻断保存。
|
||||
3. 后端 `employee.service.ts` 创建/更新员工时同步做校验(双保险)。
|
||||
4. 支持非身份证类型(护照/港澳台通行证)时跳过 18 位校验(已部分实现,需确认)。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`frontend/src/pages/roster/modals.tsx`(`handleIdCardChange`、`canSubmit`)、`backend/src/services/employee.service.ts`、`backend/src/routes/employee.routes.ts`
|
||||
|
||||
### 问题 7:超龄人员仍可选择劳动合同
|
||||
- **现状**:`roster/modals.tsx:784` 超龄仅 `WARN` 提示"已达法定退休年龄,建议确认是否按退休处理",未阻断选择 `FIXED`/`UNFIXED` 劳动合同;超龄人员依法应签劳务协议/实习协议,不能签劳动合同。
|
||||
- **修复方向**:
|
||||
1. 年龄 ≥ 法定退休年龄(男 60、女干部 55、女工人 50)时,合同类型下拉移除 `FIXED`/`UNFIXED`,仅保留 `LABOR`/`INTERNSHIP`/`UNSIGNED`,并提示"超龄人员不可签订劳动合同,请选择劳务协议"。
|
||||
2. 强制选 `LABOR` 时联动问题 8 的社保基数置 0 逻辑。
|
||||
3. 后端 `employee.service.ts`/`work-process.service.ts` 增加校验:超龄 + 劳动合同类型组合拒绝并返回 400。
|
||||
4. `femaleWorkerType` 字段用于判定女职工退休年龄(干部 55/工人 50),需在年龄计算时引用。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`frontend/src/pages/roster/modals.tsx`(合同类型下拉、`ageWarning` 逻辑)、`backend/src/services/employee.service.ts`、`backend/src/services/work-process.service.ts`
|
||||
|
||||
### 问题 8:签署劳务协议员工社保基数未置 0 且仍可缴纳社保
|
||||
- **现状**:`roster/modals.tsx:983` 社保缴费基数为独立输入框,选择 `LABOR` 合同类型时未联动置 0 或禁用;劳务协议人员依法不缴纳社保。
|
||||
- **修复方向**:
|
||||
1. 合同类型选择 `LABOR`/`INTERNSHIP` 时,社保缴费基数、公积金缴费基数自动置 0 且字段禁用,社保开始年月清空。
|
||||
2. UI 提示"劳务协议/实习协议人员不缴纳社保公积金"。
|
||||
3. 后端 `employee.service.ts` 保存时校验:`contractType === LABOR` 且 `socialInsBase > 0` 时拒绝或强制置 0。
|
||||
4. 薪税批次生成时排除劳务协议人员,或其社保公积金项强制为 0。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`frontend/src/pages/roster/modals.tsx`(社保公积金区联动)、`backend/src/services/employee.service.ts`、`backend/src/services/payroll.service.ts`
|
||||
|
||||
---
|
||||
|
||||
## 二、工作台(问题 9-10)
|
||||
|
||||
### 问题 9:待办事项无法点击跳转
|
||||
- **现状**:`Dashboard.tsx:1148` 已有 `<Link to={todo.actionUrl}>`,但部分待办的 `actionUrl` 为空、指向不存在的路由,或后端 `dashboard.service.ts` 生成待办时未填充 `actionUrl`,导致点击无反应或跳转 404。
|
||||
- **修复方向**:
|
||||
1. 排查后端 `dashboard.service.ts` 各类待办(CONTRACT/TERMINATION/ONBOARDING/RETIREMENT/MONTHLY/SALARY)的 `actionUrl` 生成逻辑,确保每条待办都有有效跳转目标。
|
||||
2. `actionUrl` 为空时前端降级为"查看详情"按钮,弹窗展示待办内容,而非死链。
|
||||
3. 校验 `actionUrl` 指向的路由真实存在(如 `/roster?employeeId=xxx`、`/termination?draftId=xxx`)。
|
||||
4. 跳转后自动定位到对应员工/草稿/合同。
|
||||
- **优先级**:P1 高
|
||||
- **涉及文件**:`backend/src/services/dashboard.service.ts`、`frontend/src/pages/Dashboard.tsx`(`TodoIcon`/待办列表渲染)
|
||||
|
||||
### 问题 10:风险提醒内容不合理(含反向引导 bug)
|
||||
- **现状**:`Dashboard.tsx:202` 风险提醒按 `todo.type` 过滤(CONTRACT/TERMINATION/ONBOARDING/RETIREMENT),具体提醒文案、阈值、优先级、跳转目标生成逻辑在后端 `risk.service.ts`。存在两类问题:
|
||||
|
||||
**问题 A · 反向引导(典型 bug)**:`risk.service.ts:309-337` 三期/医疗期/工伤员工的风险提醒,文案是"解聘受限/不得解除劳动合同",但 `actionUrl` 却指向 `/termination?employee=xxx`(解聘向导页面)。等于提示"该员工不能解聘,点这里去解聘她"——完全反向引导,可能诱导 HR 误操作违法解除。
|
||||
|
||||
```ts
|
||||
// risk.service.ts:309-317(错误示例)
|
||||
if (emp.isPregnant) {
|
||||
risks.push({
|
||||
type: 'TERMINATION',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同...',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, // ← 反向引导
|
||||
})
|
||||
}
|
||||
// isInMedicalPeriod(319-328)、isWorkInjured(329-338)同样指向 /termination
|
||||
```
|
||||
|
||||
**问题 B · 提醒规则/阈值/去重不合理**:部分提醒内容重复、阈值不合理或与实际业务无关(如合同到期 30 天提前量偏短,同一合同到期可能生成多条提醒)。
|
||||
|
||||
- **修复方向**:
|
||||
1. **修正反向引导(问题 A,P0 紧急)**:
|
||||
- 三期/医疗期/工伤员工的"解聘受限"提醒,`actionUrl` 改为指向员工详情或特殊状态页(`/roster?employee=xxx` 或 `/special-status?type=PREGNANCY`),让 HR 确认员工状态,而非跳到解聘页面。
|
||||
- 这类提醒的本质是"信息提示 + 合规警示",不是"可操作待办"。应在解聘向导 step 2 合规检查时硬阻断(Termination 已实现法定禁止情形阻断),工作台只做信息展示。
|
||||
- 可考虑将这类提醒的 `type` 从 `TERMINATION` 改为 `COMPLIANCE`(合规提示),与可操作的解聘待办区分。
|
||||
2. **梳理风险提醒规则清单(问题 B)**:合同到期(30/60/90 天分级)、未签合同(>30 天/>1 年)、试用期将满、退休预警、社保断缴、离职未办结等。
|
||||
3. **每条提醒明确**:触发条件、提醒文案模板、优先级、跳转目标、是否可忽略、是"可操作待办"还是"信息提示"。
|
||||
4. **去除重复提醒**(如同一合同到期生成多条),合并同类项。
|
||||
5. **调整阈值至合理区间**(如合同到期提醒从 30 天提前到 60 天,给 HR 反应时间)。
|
||||
6. **前端按优先级排序展示**,高风险置顶;信息提示类提醒不提供"去处理"按钮,仅提供"查看详情"。
|
||||
- **优先级**:P0 紧急(问题 A 反向引导)/ P2 中(问题 B 规则优化)
|
||||
- **涉及文件**:`backend/src/services/risk.service.ts`(`detectTerminationRisks` 309-342 行、`detectSpecialStatusRisks`)、`frontend/src/pages/Dashboard.tsx`(风险提醒 Tab 渲染、按 type 区分操作按钮)
|
||||
|
||||
---
|
||||
|
||||
## 三、薪税管理 · 发放批次(问题 11、13)
|
||||
|
||||
### 问题 11:获取工资时自动识别试用期并填充试用期工资
|
||||
- **现状**:不需要新增"获取试用期工资"按钮。已有的"获取工资"逻辑(创建批次、添加人员时自动填充 `baseSalary`)应自动识别员工是否处于试用期,是则填 `probationSalary`,否则填 `monthlySalary`。
|
||||
|
||||
**根因**:当前 3 处获取工资逻辑用了**错误的试用期判定**,判定的是"入职不到 1 年"而非"当前在试用期内":
|
||||
|
||||
```ts
|
||||
// payroll2.routes.ts:355-359(创建批次 copy_last 模式)
|
||||
// payroll2.routes.ts:610-614(添加人员到批次)
|
||||
// payroll.routes.ts:380-388(旧版批量生成工资条)
|
||||
if (emp.contracts?.[0]?.probationSalary
|
||||
&& new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary // ← 只要入职不到1年就用试用期工资,转正后仍用
|
||||
} else if (emp.monthlySalary) { ... }
|
||||
```
|
||||
|
||||
问题:员工转正后(如试用期 3 个月,入职 6 个月),仍会被判定为试用期,`baseSalary` 错误填为 `probationSalary` 而非转正工资。
|
||||
|
||||
**正确逻辑**(`payroll.service.ts:326-331`、`payroll.service.ts:600-603` 已实现):
|
||||
```ts
|
||||
const probationEnd = new Date(contract.startDate)
|
||||
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
|
||||
if (probationEnd > new Date()) { ... } // 试用期未结束
|
||||
```
|
||||
|
||||
- **修复方向**:
|
||||
1. 抽取统一的试用期判定工具函数 `isInProbation(contract, referenceDate?)`:`contract.startDate + contract.probationMonths > referenceDate`(默认当前日期,批次场景按批次月份的月末判定)。
|
||||
2. 修正 3 处错误逻辑,统一调用 `isInProbation`:
|
||||
- `payroll2.routes.ts:355-359`(创建批次 copy_last 模式)
|
||||
- `payroll2.routes.ts:610-614`(添加人员到批次)
|
||||
- `payroll.routes.ts:380-388`(旧版批量生成工资条,如仍在用)
|
||||
3. 判定为试用期 → `baseSalary = contract.probationSalary`;已转正 → `baseSalary = employee.monthlySalary`。
|
||||
4. 批次场景按批次月份判定(如批次月份是 2026-08,则判定 2026-08-31 时员工是否仍在试用期),避免跨月转正时填错。
|
||||
5. `probationSalary` 为 0 或未填时,即使试用期也回退用 `monthlySalary`(兼容旧数据)。
|
||||
- **优先级**:P1 高(影响工资发放正确性)
|
||||
- **涉及文件**:`backend/src/routes/payroll2.routes.ts`(355-359、610-614)、`backend/src/routes/payroll.routes.ts`(380-388)、`backend/src/services/payroll.service.ts`(抽取 `isInProbation` 工具函数,或放 `contract.service.ts`)
|
||||
|
||||
### 问题 13:编辑薪资新增"获取提成奖金"功能
|
||||
- **现状**:`money/BatchTab.tsx` 已有 `BONUS` 批次类型(`isBonus` 分支,617 行),但普通薪资批次编辑薪资时无"获取提成奖金"按钮,无法从提成奖金数据按月拉取填充 `bonus` 字段。
|
||||
- **修复方向**:
|
||||
1. 编辑薪资步骤新增"获取提成奖金"按钮,调用后端接口按批次月份从提成奖金表拉取每位员工的提成奖金。
|
||||
2. 拉取的奖金填入 `entry.bonus` 字段,支持正负值(奖金/扣款)。
|
||||
3. 后端新增接口 `POST /payroll2/batches/:id/fetch-bonus`,按批次月份查询提成奖金表并返回。
|
||||
4. 前端展示获取结果摘要(X 人提成奖金已填充,合计 ¥Y),支持覆盖确认。
|
||||
5. 依赖问题 12 的提成奖金数据表先建立。
|
||||
- **优先级**:P2 中(依赖问题 12)
|
||||
- **涉及文件**:`frontend/src/pages/money/BatchTab.tsx`(编辑薪资步骤按钮区)、`backend/src/routes/payroll.routes.ts`、`backend/src/services/payroll.service.ts`、`backend/src/services/commission-bonus.service.ts`(新增)
|
||||
|
||||
---
|
||||
|
||||
## 四、提成奖金 · 新模块(问题 12)
|
||||
|
||||
### 问题 12:团队页面下新增提成奖金页面
|
||||
- **现状**:`SidebarNav.tsx:44` "团队"分组下仅有花名册,无提成奖金入口;`money/BatchTab.tsx` 的 `BONUS` 批次是年终奖发放,非按月提成奖金管理。需新建独立提成奖金模块。
|
||||
- **修复方向**:
|
||||
1. **数据模型**:新增 `CommissionBonus` 表(`orgId`、`employeeId`、`month` YYYY-MM、`amount` 支持正负值、`remark`、`createdAt`、`createdBy`),唯一索引 `(orgId, employeeId, month)`。
|
||||
2. **后端**:新增 `commission-bonus.service.ts` + `commission-bonus.routes.ts`:
|
||||
- `GET /commission-bonus?month=YYYY-MM` 按月列表
|
||||
- `POST /commission-bonus/batch` 按月批量导入(JSON 数组或 Excel)
|
||||
- `PUT /commission-bonus/:id` 编辑单条
|
||||
- `DELETE /commission-bonus/:id` 删除单条
|
||||
- `GET /commission-bonus/template` 下载导入模板
|
||||
3. **前端**:新增 `frontend/src/pages/CommissionBonus.tsx` 页面:
|
||||
- 月份选择器(默认当月)
|
||||
- 列表展示该月所有员工提成奖金(员工/部门/金额/备注)
|
||||
- 支持单条编辑、删除
|
||||
- "批量导入"按钮(上传 Excel,字段:员工姓名/证件号码/月份/金额/备注)
|
||||
- 金额支持正负值(正为奖金,负为扣款)
|
||||
- 合计展示(总奖金/总扣款/净额)
|
||||
4. **菜单**:`SidebarNav.tsx` "团队"分组新增"提成奖金"入口,路由 `/commission-bonus`。
|
||||
5. **权限**:新增权限码 `PERM_COMMISSION_BONUS_VIEW`/`PERM_COMMISSION_BONUS_EDIT`/`PERM_COMMISSION_BONUS_IMPORT`。
|
||||
- **优先级**:P3 规划(新模块)
|
||||
- **涉及文件**:`backend/prisma/schema.prisma`(新增 `CommissionBonus` 模型 + migration)、`backend/src/services/commission-bonus.service.ts`(新增)、`backend/src/routes/commission-bonus.routes.ts`(新增)、`frontend/src/pages/CommissionBonus.tsx`(新增)、`frontend/src/components/layout/SidebarNav.tsx`、`frontend/src/App.tsx`(路由注册)、`frontend/src/lib/api-services.ts`(`commissionBonusApi`)
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
|
||||
| 优先级 | 问题 | 说明 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| **P0 紧急** | 10A | 风险提醒反向引导(三期/医疗期/工伤提醒跳转到解聘页面) | ✅ 已完成 |
|
||||
| **P1 高** | 4 | 合同状态增加待签署判定 | ✅ 已完成 |
|
||||
| **P1 高** | 5 | 社保状态派生字段(待办/正常/停缴) | ✅ 已完成 |
|
||||
| **P1 高** | 6 | 证件号码有效性校验(校验位+出生日期) | ✅ 已完成 |
|
||||
| **P1 高** | 7 | 超龄人员合同类型限制(禁止选劳动合同) | ✅ 已完成 |
|
||||
| **P1 高** | 8 | 劳务协议社保联动(选 LABOR 社保基数置 0 禁用) | ✅ 已完成 |
|
||||
| **P1 高** | 9 | 待办跳转 actionUrl 修正 + 空值降级 | ✅ 已完成 |
|
||||
| **P1 高** | 11 | 试用期工资判定修正(抽取 isInProbation 工具函数) | ✅ 已完成 |
|
||||
| **P2 中** | 10B | 风险提醒规则优化(到期 30→60 天分级 + 去重排序) | ✅ 已完成 |
|
||||
| **P2 中** | 13 | 薪资批次获取提成奖金(依赖 12) | ✅ 已完成 |
|
||||
| **P3 规划** | 12 | 提成奖金新模块(DB migration + 全栈实现) | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## 建议执行顺序
|
||||
|
||||
> ✅ 全部 10 项已于 20260816 完成实现,前后端 tsc 均通过。
|
||||
|
||||
1. **第一批(P0 + P1,立即)** ✅:
|
||||
- 问题 10A(风险提醒反向引导,改 actionUrl)← 最先,改动小风险高
|
||||
- 问题 11(试用期工资判定修正)← 纯后端逻辑修正,抽取 `isInProbation` 工具函数
|
||||
- 问题 6(证件号码有效性校验)→ 问题 7(超龄人员合同类型限制)→ 问题 8(劳务协议社保联动)→ 问题 4(合同状态待签署)→ 问题 5(社保状态待办)→ 问题 9(待办跳转)
|
||||
- 问题 6/7/8 均在 `roster/modals.tsx` 添加雇员表单,可一并修改;问题 4/5 在后端 `contract.service.ts`/`employee.service.ts` 派生字段,前后端协同。
|
||||
2. **第二批(P2,本迭代)** ✅:
|
||||
- 问题 10B(风险提醒规则梳理)→ 问题 13(获取提成奖金,依赖问题 12)
|
||||
3. **第三批(P3,规划)** ✅:
|
||||
- 问题 12(提成奖金新模块,含 DB migration)→ 问题 13(获取提成奖金,依赖 12 完成)
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 本清单基于当期代码静态分析,部分"根因"为推断,实际修复前需运行复现确认。
|
||||
- 问题 12 涉及数据库 schema 变更(新增 `CommissionBonus` 表),需走 migration + 回滚方案。
|
||||
- 问题 4/5 的"待签署""待办"状态为派生字段,不新增存储字段,避免数据冗余;如需记录"办理完成"操作时间,可新增 `socialInsConfirmedAt` 等时间戳字段。
|
||||
- 问题 7 的超龄判定需引用 `femaleWorkerType`(干部 55/工人 50),需确认该字段在添加雇员表单已正确填写。
|
||||
- 问题 13 依赖问题 12 的提成奖金数据表,不可先行实现。
|
||||
|
||||
---
|
||||
|
||||
## 五、已完成优化(20260816 补充)
|
||||
|
||||
> 以下为本次会话中已完成并部署的优化项。
|
||||
|
||||
### ✅ 已完成 1:员工端密码登录完整支持
|
||||
|
||||
- **默认密码**:创建员工时自动设置,密码 = 手机号后6位(无手机号则 `123456`)
|
||||
- **管理员重置密码**:花名册详情 → "重置密码"按钮,重置为手机号后6位
|
||||
- **员工修改密码**:员工端导航栏 → "修改密码",需验证旧密码,新密码至少6位
|
||||
- **Excel 批量导入**:导入时自动设置默认密码(手机号后6位),与新增员工一致
|
||||
- **现有员工补初始化**:已通过脚本批量补设 512 名无密码员工(478 人手机号后6位,34 人无手机号用 123456)
|
||||
- **涉及文件**:
|
||||
- `backend/src/services/contract.service.ts`(createEmployee 添加 passwordHash)
|
||||
- `backend/src/routes/import.routes.ts`(Excel 导入添加 passwordHash)
|
||||
- `backend/src/routes/employee.routes.ts`(新增 `POST /employees/:id/reset-password`)
|
||||
- `backend/src/routes/portal.routes.ts`(新增 `POST /portal/change-password`)
|
||||
- `frontend/src/pages/roster/BasicInfo.tsx`(重置密码按钮 + 默认密码提示)
|
||||
- `frontend/src/pages/portal/PortalNav.tsx`(修改密码弹窗)
|
||||
- `frontend/src/lib/api-services.ts`(resetPassword / changePassword 接口)
|
||||
|
||||
### ✅ 已完成 2:员工端 portalAxios response interceptor 修复
|
||||
|
||||
- **问题**:`portalAxios` 缺少 response interceptor,导致 `unwrap` 取到的是 `{ success, data: {...} }` 而非 `{ token, employee }`,密码登录/验证码登录/扫码自动登录全部失败
|
||||
- **修复**:给 `portalAxios` 添加与管理端 `api` 实例一致的 response interceptor `(response) => response.data`
|
||||
- **涉及文件**:`frontend/src/lib/api-services.ts`
|
||||
|
||||
### ✅ 已完成 3:同企业内员工身份证和手机号唯一性完整校验
|
||||
|
||||
- **createEmployee**:新增手机号查重硬校验(原有身份证查重)
|
||||
- **updateEmployee**:新增手机号 + 身份证查重(排除自身)
|
||||
- **Excel 批量导入**:新增身份证 + 手机号查重(重复则跳过并记录错误日志)
|
||||
- **portal 登录安全**:密码登录改为 `findMany` 遍历校验,避免跨组织手机号重复时登录到错误员工
|
||||
- **涉及文件**:
|
||||
- `backend/src/services/contract.service.ts`(createEmployee / updateEmployee 查重)
|
||||
- `backend/src/routes/import.routes.ts`(导入查重)
|
||||
- `backend/src/routes/portal.routes.ts`(login / send-code / verify-code 跨组织安全)
|
||||
|
||||
### ✅ 已完成 4:电子签署改为「待签合同」,按员工聚合 + 催办功能
|
||||
|
||||
- **菜单改名**:「电子签署」→「待签合同」
|
||||
- **页面重构为两个 Tab**:
|
||||
- **待签合同**:按员工聚合,每行一个员工,显示待签数,点击展开查看该员工名下所有待签文件(EsignRecord PENDING/SIGNING + LaborContract signDate 为空)
|
||||
- **签署记录**:原有全部签署记录列表(保留状态/场景筛选)
|
||||
- **催办功能**:点击催办 → 生成 24 小时有效的一次性自动登录链接(指向员工端签署页)→ 弹窗展示二维码 + 可复制链接 → HR 发给员工扫码直接进入签署
|
||||
- **后端新增接口**:
|
||||
- `GET /esign/pending` — 待签合同按员工聚合列表
|
||||
- `POST /esign/remind` — 催办生成自动登录链接
|
||||
- **涉及文件**:
|
||||
- `backend/src/routes/esign.routes.ts`(pending + remind 接口)
|
||||
- `frontend/src/components/layout/SidebarNav.tsx`(菜单改名)
|
||||
- `frontend/src/pages/ESign.tsx`(Tab 切换 + PendingTab 组件 + 催办弹窗)
|
||||
- `frontend/src/lib/api-services.ts`(pending / remind 接口调用)
|
||||
|
||||
### ✅ 已完成 5:风险提醒反向引导修正(问题 10A)
|
||||
|
||||
- **问题**:三期/医疗期/工伤员工的风险提醒文案是"解聘受限",但 `actionUrl` 却指向 `/termination`(解聘向导),等于提示"不能解聘,点这里去解聘她"——反向引导,可能诱导违法解除。
|
||||
- **修复**:3 处 `actionUrl` 从 `/termination?employee=xxx` 改为 `/special-status?employee=xxx&type=PREGNANCY|MEDICAL_PERIOD|WORK_INJURY`,让 HR 确认员工状态而非跳去解聘。文案补充"此为合规提示,请勿发起解聘"。
|
||||
- **涉及文件**:`backend/src/services/risk.service.ts`(`detectTerminationRisks` 309-342 行)
|
||||
|
||||
### ✅ 已完成 6:试用期工资判定修正(问题 11)
|
||||
|
||||
- **问题**:3 处获取工资逻辑用错误的试用期判定 `startDate > now - 365d`(入职不到1年),转正后仍用 `probationSalary`。正确逻辑应是 `startDate + probationMonths > now`(试用期未结束)。
|
||||
- **修复**:
|
||||
1. 抽取统一工具函数 `isInProbation(contract, referenceDate?)` 到 `contract.service.ts`。
|
||||
2. 修正 3 处错误逻辑,统一调用 `isInProbation`,按批次月份月末判定。
|
||||
3. `probationSalary` 为 0 时回退用 `monthlySalary`(兼容旧数据)。
|
||||
- **涉及文件**:
|
||||
- `backend/src/services/contract.service.ts`(新增 `isInProbation`)
|
||||
- `backend/src/routes/payroll2.routes.ts`(copy_last 模式 355 行、添加人员 614 行)
|
||||
- `backend/src/routes/payroll.routes.ts`(旧版批量生成 380 行)
|
||||
|
||||
### ✅ 已完成 7:证件号码有效性校验(问题 6)
|
||||
|
||||
- **问题**:添加雇员仅校验证件号码长度 ≥18,未做校验位、出生日期合法性验证。
|
||||
- **修复**:
|
||||
1. 前端 `handleIdCardChange` 增加校验位算法(GB 11643-1999)、出生日期合法性(月份/日期范围、是否存在如2月30日、是否晚于今天),校验失败阻断保存。
|
||||
2. 后端新增 `validateIdCard`/`getAgeFromIdCard`/`isOverageEmployee` 工具函数,`createEmployee` 增加双保险校验。
|
||||
- **涉及文件**:
|
||||
- `frontend/src/pages/roster/modals.tsx`(`handleIdCardChange`、`canSubmit`)
|
||||
- `backend/src/services/contract.service.ts`(新增 3 个工具函数 + `createEmployee` 校验)
|
||||
|
||||
### ✅ 已完成 8:超龄人员合同类型限制(问题 7)
|
||||
|
||||
- **问题**:超龄人员仅 WARN 提示,仍可选劳动合同(FIXED/UNFIXED),违反合规要求。
|
||||
- **修复**:
|
||||
1. 前端派生 `isOverage`(男≥60、女干部≥55、女工人≥50),合同类型下拉过滤掉 FIXED/UNFIXED,仅保留 LABOR/INTERNSHIP/UNSIGNED。
|
||||
2. 草稿恢复时若当前合同类型非法,useEffect 自动切到 UNSIGNED。
|
||||
3. 后端 `createEmployee` 增加校验:超龄 + FIXED/UNFIXED 拒绝并返回 400。
|
||||
- **涉及文件**:
|
||||
- `frontend/src/pages/roster/modals.tsx`(`isOverage` 派生、合同类型下拉过滤、useEffect 自动修正)
|
||||
- `backend/src/services/contract.service.ts`(`createEmployee` 超龄校验)
|
||||
|
||||
### ✅ 已完成 9:劳务协议社保联动(问题 8)
|
||||
|
||||
- **问题**:选劳务协议(LABOR)时社保基数未置 0 且仍可缴纳,违反合规要求。
|
||||
- **修复**:
|
||||
1. 前端选 LABOR/INTERNSHIP 时,社保/公积金基数自动置 0、起始月清空、字段禁用,提示"劳务协议/实习协议人员不缴纳社保公积金"。
|
||||
2. 后端 `createEmployee` 强制:LABOR/INTERNSHIP 时 `socialInsBase`/`housingFundBase` = 0,起始月清空。
|
||||
- **涉及文件**:
|
||||
- `frontend/src/pages/roster/modals.tsx`(合同类型 onChange 联动、社保区 disabled)
|
||||
- `backend/src/services/contract.service.ts`(`createEmployee` 社保强制 0)
|
||||
|
||||
### ✅ 已完成 10:合同状态增加待签署判定(问题 4)
|
||||
|
||||
- **问题**:有合同记录但 `signDate` 为空时仍显示"正常",应显示"待签署"。
|
||||
- **修复**:`getContractStatus` 增加判定:有合同记录但 `signDate` 为空 → `status: 'pending_sign'`,`statusText: '待签署'`,`riskLevel: 'medium'`。电子签署完成后会回写 `signDate`,故 `signDate` 为空即未签署。前端花名册列表 `statusTextMap` 增加 `pending_sign: '待签署'`。
|
||||
- **涉及文件**:
|
||||
- `backend/src/services/contract.service.ts`(`getContractStatus` 74-91 行重写)
|
||||
- `frontend/src/pages/Roster.tsx`(`tagStyles`/`statusTextMap` 增加 `pending_sign`)
|
||||
|
||||
### ✅ 已完成 11:社保状态派生字段(问题 5)
|
||||
|
||||
- **问题**:社保状态无"待办"判定,未办理社保的在职员工显示"—"而非"待办理"。
|
||||
- **修复**:后端 `socialInsuranceStatus` 派生逻辑:
|
||||
- 劳务协议/实习协议 → null(不缴社保,显示"—")
|
||||
- 在职 + 应缴社保 + 无社保记录 → `PENDING`(待办理)
|
||||
- 有社保记录 + endMonth 为 null → `ACTIVE`(在保)
|
||||
- 有社保记录 + endMonth 不为 null → `SUSPENDED`(停保)
|
||||
- 前端已有 PENDING/UNINSURED 的标签映射,无需改前端。
|
||||
- **涉及文件**:`backend/src/routes/roster.routes.ts`(`socialInsuranceStatus` 派生 243-249 行)
|
||||
|
||||
### ✅ 已完成 12:待办跳转 actionUrl 空值降级(问题 9)
|
||||
|
||||
- **问题**:待办事项的"立刻办理"链接在 `actionUrl` 为空或 '/' 时仍渲染为 Link,点击跳首页无意义。
|
||||
- **修复**:Dashboard 待办列表渲染时,`actionUrl` 为空或 '/' 时不渲染 Link 和"立刻办理"按钮,改为纯展示 div;有有效 URL 时才渲染可跳转链接。问题 10A 已修正三期/医疗期/工伤的反向引导 URL,其余 actionUrl(`/roster?employee=xxx`、`/special-status`、`/money`)均指向有效路由,Roster 已处理 `?employee` 参数自动定位。
|
||||
- **涉及文件**:`frontend/src/pages/Dashboard.tsx`(待办列表渲染 1136-1233 行)
|
||||
|
||||
### ✅ 已完成 13:风险提醒规则优化(问题 10B)
|
||||
|
||||
- **问题**:合同到期提醒仅 30 天预警,HR 反应时间不足;去重后的待办列表未按优先级排序。
|
||||
- **修复**:
|
||||
1. 合同到期预警从 30 天提前到 60 天,分级:≤30 天 HIGH(需立即处理),31-60 天 MEDIUM(提前预警)。
|
||||
2. `dedupedTodos` 按优先级排序(URGENT > HIGH > MEDIUM > LOW),确保最紧急的待办排在最前。
|
||||
- **涉及文件**:`backend/src/services/risk.service.ts`(`detectContractRisks` 216-248 行、`getDashboardData` dedupedTodos 排序 941-952 行)
|
||||
|
||||
### ✅ 已完成 14:提成奖金新模块(问题 12)
|
||||
|
||||
- **问题**:无独立提成奖金管理模块,`BONUS` 批次是年终奖发放,非按月提成奖金管理。
|
||||
- **实现**:
|
||||
1. **数据模型**:新增 `CommissionBonus` 表(`orgId`、`employeeId`、`month` YYYY-MM、`amount` 支持正负值、`remark`、`createdBy`),唯一索引 `(orgId, employeeId, month)`。
|
||||
2. **后端**:新增 `commission-bonus.service.ts` + `commission-bonus.routes.ts`:
|
||||
- `GET /commission-bonus?month=YYYY-MM` 按月列表 + 汇总(总奖金/总扣款/净额)
|
||||
- `POST /commission-bonus` 新增单条(upsert,同员工同月自动覆盖)
|
||||
- `PUT /commission-bonus/:id` 编辑单条
|
||||
- `DELETE /commission-bonus/:id` 删除单条
|
||||
- `POST /commission-bonus/import` 批量导入 Excel(字段:员工姓名/证件号码/金额/备注)
|
||||
- `GET /commission-bonus/template` 下载导入模板
|
||||
3. **前端**:新增 `CommissionBonus.tsx` 页面:
|
||||
- 月份选择器(默认当月)
|
||||
- 汇总卡片(记录数/总奖金/总扣款/净额)
|
||||
- 列表展示(员工/部门/金额/备注),支持搜索、分页
|
||||
- 单条新增/编辑/删除
|
||||
- 批量导入 Excel + 模板下载
|
||||
- 金额支持正负值(正=奖金,负=扣款)
|
||||
4. **菜单**:`SidebarNav.tsx` "团队"分组新增"提成奖金"入口,路由 `/commission-bonus`。
|
||||
5. **DB 同步**:`prisma db push` 已执行,Prisma Client 已重新生成。
|
||||
- **涉及文件**:
|
||||
- `backend/prisma/schema.prisma`(新增 `CommissionBonus` 模型 + Employee/Organization 反向关联)
|
||||
- `backend/src/services/commission-bonus.service.ts`(新增)
|
||||
- `backend/src/routes/commission-bonus.routes.ts`(新增)
|
||||
- `backend/src/app.ts`(路由注册)
|
||||
- `frontend/src/pages/CommissionBonus.tsx`(新增)
|
||||
- `frontend/src/components/layout/SidebarNav.tsx`(菜单项)
|
||||
- `frontend/src/App.tsx`(路由注册)
|
||||
- `frontend/src/lib/api-services.ts`(`commissionBonusApi`)
|
||||
|
||||
### ✅ 已完成 15:获取提成奖金(问题 13)
|
||||
|
||||
- **问题**:薪资批次编辑薪资时无"获取提成奖金"按钮,无法从提成奖金模块按月拉取填充 `bonus` 字段。
|
||||
- **实现**:
|
||||
1. 后端新增 `POST /payroll2/batches/:batchId/fetch-bonus`:按批次月份从 `CommissionBonus` 表拉取,填充到对应 `entries.bonus`,重算批次汇总。
|
||||
2. 前端 `BatchTab.tsx` 编辑薪资步骤新增"获取提成奖金"按钮(在"导入加班费"旁),调用后展示结果摘要(X 人提成奖金已填充,合计 ¥Y)。
|
||||
3. 已归档批次禁用按钮。
|
||||
- **涉及文件**:
|
||||
- `backend/src/routes/payroll2.routes.ts`(`fetch-bonus` 接口 552-628 行)
|
||||
- `frontend/src/lib/api-services.ts`(`fetchBonusToBatch`)
|
||||
- `frontend/src/pages/money/BatchTab.tsx`(`fetchBonusMutation` + 按钮 784-798 行)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# 20260816 用户测试说明
|
||||
|
||||
> 本次更新共完成 **15 项优化**,覆盖花名册合规校验、工作台待办跳转、薪税管理、提成奖金新模块等。
|
||||
> 测试地址:https://on.hr8ai.top/
|
||||
> 测试前请先**强制刷新浏览器**(Mac: Cmd+Shift+R / Windows: Ctrl+Shift+R)清除缓存。
|
||||
|
||||
---
|
||||
|
||||
## 一、花名册 · 添加雇员(问题 4-8)
|
||||
|
||||
### 测试 1:证件号码有效性校验(问题 6)
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 添加雇员
|
||||
2. 证件类型选"身份证",输入以下测试用例:
|
||||
- `11010119900101123X`(校验位错误)→ 应提示"身份证校验位错误",阻断保存
|
||||
- `110101199013011234`(月份 13 非法)→ 应提示"出生日期不合法"
|
||||
- `110101199002301234`(2 月 30 日不存在)→ 应提示"出生日期不合法"
|
||||
- `110101209901011234`(出生日期在未来)→ 应提示"出生日期不合法"
|
||||
- `110101199003075476`(正确身份证)→ 应通过校验
|
||||
|
||||
**预期**:非法身份证无法保存,页面显示具体错误原因。
|
||||
|
||||
---
|
||||
|
||||
### 测试 2:超龄人员合同类型限制(问题 7)
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 添加雇员
|
||||
2. 输入一位超龄人员信息:
|
||||
- 男,出生日期选 1960-01-01(≥60 岁)
|
||||
- 查看合同类型下拉
|
||||
3. 再输入一位非超龄人员:
|
||||
- 男,出生日期选 1990-01-01
|
||||
- 查看合同类型下拉
|
||||
|
||||
**预期**:
|
||||
- 超龄人员:合同类型下拉**无**"固定期限劳动合同""无固定期限劳动合同",仅有"劳务协议""实习协议""未签合同"
|
||||
- 非超龄人员:所有合同类型可选
|
||||
|
||||
---
|
||||
|
||||
### 测试 3:劳务协议社保联动(问题 8)
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 添加雇员
|
||||
2. 合同类型选"劳务协议"
|
||||
3. 查看社保公积金区域
|
||||
|
||||
**预期**:
|
||||
- 社保缴费基数、公积金缴费基数自动置 0 且**不可编辑**(灰色禁用)
|
||||
- 社保开始年月清空且禁用
|
||||
- 显示提示"劳务协议/实习协议人员不缴纳社保公积金"
|
||||
|
||||
4. 切回"固定期限劳动合同"
|
||||
|
||||
**预期**:社保公积金区域恢复可编辑。
|
||||
|
||||
---
|
||||
|
||||
### 测试 4:合同状态"待签署"(问题 4)
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 添加雇员,填写合同信息但**不登记签署日期**,保存
|
||||
2. 查看花名册列表中该员工的合同状态列
|
||||
|
||||
**预期**:
|
||||
- 合同状态显示"待签署"标签(黄色/橙色样式)
|
||||
- 登记签署日期后,状态变为"正常"(绿色)
|
||||
|
||||
---
|
||||
|
||||
### 测试 5:社保状态"待办理"(问题 5)
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 添加一位劳动合同类型员工,不填写社保基数
|
||||
2. 查看花名册列表中该员工的社保状态列
|
||||
|
||||
**预期**:
|
||||
- 社保状态显示"待办理"标签
|
||||
- 劳务协议员工社保状态显示"—"(不缴社保)
|
||||
|
||||
---
|
||||
|
||||
## 二、工作台 · 待办与风险提醒(问题 9-10)
|
||||
|
||||
### 测试 6:待办跳转精准化(问题 9 + 月度待办优化)
|
||||
|
||||
**操作步骤**:
|
||||
1. 工作台 → 待办事项 Tab
|
||||
2. 查看月度待办(缴纳社保、缴纳公积金、发放工资、申报个税)
|
||||
3. 点击各待办的"立刻办理 →"
|
||||
|
||||
**预期**:
|
||||
- 缴纳社保 → 跳转到**社保公积金**页面,自动定位到"月度办理"Tab
|
||||
- 缴纳公积金 → 跳转到**社保公积金**页面,自动定位到"月度办理"Tab
|
||||
- 发放工资 → 跳转到**薪税管理**页面,自动定位到"发薪批次"Tab
|
||||
- 申报个税 → 跳转到**薪税管理**页面,自动定位到"发薪批次"Tab
|
||||
- 无 actionUrl 的待办 → 不显示"立刻办理"链接,仅展示文字
|
||||
|
||||
---
|
||||
|
||||
### 测试 7:风险提醒反向引导修正(问题 10A)
|
||||
|
||||
**操作步骤**:
|
||||
1. 找一位标记为"孕期/哺乳期"或"医疗期"或"工伤"的员工
|
||||
2. 工作台 → 风险提醒 Tab,查看对应提醒
|
||||
3. 点击"立刻办理 →"
|
||||
|
||||
**预期**:
|
||||
- 跳转到**特殊员工**页面(`/special-status`),而非解聘页面
|
||||
- 文案包含"此为合规提示,请勿发起解聘"
|
||||
|
||||
---
|
||||
|
||||
### 测试 8:合同到期分级预警(问题 10B)
|
||||
|
||||
**操作步骤**:
|
||||
1. 找一位合同 45 天后到期的员工
|
||||
2. 工作台 → 风险提醒 Tab
|
||||
|
||||
**预期**:
|
||||
- 31-60 天到期:显示"中"优先级预警(MEDIUM,黄色)
|
||||
- ≤30 天到期:显示"高"优先级预警(HIGH,红色)
|
||||
- 待办列表按优先级排序:高优先级置顶
|
||||
|
||||
---
|
||||
|
||||
## 三、薪税管理(问题 11、13)
|
||||
|
||||
### 测试 9:试用期工资自动判定(问题 11)
|
||||
|
||||
**操作步骤**:
|
||||
1. 确保有一位试用期员工(合同 startDate + probationMonths > 当前日期)和一位已转正员工
|
||||
2. 薪税管理 → 发薪批次 → 创建当月批次(或复制上月)
|
||||
3. 查看两位员工的"基本工资"
|
||||
|
||||
**预期**:
|
||||
- 试用期员工:基本工资 = 合同的 `probationSalary`
|
||||
- 已转正员工:基本工资 = 员工的 `monthlySalary`
|
||||
- `probationSalary` 为 0 的试用期员工:回退用 `monthlySalary`
|
||||
|
||||
---
|
||||
|
||||
### 测试 10:获取提成奖金(问题 13)
|
||||
|
||||
**前提**:先在"提成奖金"模块录入当月数据(见测试 11)。
|
||||
|
||||
**操作步骤**:
|
||||
1. 薪税管理 → 发薪批次 → 进入某未归档批次详情
|
||||
2. 编辑薪资步骤,找到"获取提成奖金"按钮(在"导入加班费"旁)
|
||||
3. 点击该按钮
|
||||
|
||||
**预期**:
|
||||
- 提示"已填充 X 人提成奖金,合计 ¥Y"
|
||||
- 对应员工的"奖金"字段自动填充
|
||||
- 已归档批次该按钮禁用
|
||||
|
||||
---
|
||||
|
||||
## 四、提成奖金新模块(问题 12)
|
||||
|
||||
### 测试 11:提成奖金完整功能
|
||||
|
||||
**操作步骤**:
|
||||
1. 左侧菜单 → 团队 → **提成奖金**(DollarSign 图标)
|
||||
2. **月份切换**:选择 2026-08,查看列表和汇总卡片
|
||||
3. **新增单条**:点击"新增" → 选员工 → 输入金额(如 5000)→ 保存
|
||||
4. **新增扣款**:再新增一条,金额输入负数(如 -200)→ 保存
|
||||
5. **编辑**:点击列表行的编辑图标 → 修改金额 → 保存
|
||||
6. **删除**:点击列表行的删除图标 → 确认删除
|
||||
7. **搜索**:在搜索框输入员工姓名 → 验证过滤
|
||||
8. **下载模板**:点击"模板"按钮 → 下载 Excel 模板
|
||||
9. **批量导入**:在模板中填入数据 → 点击"批量导入"上传 → 验证导入结果
|
||||
10. **汇总验证**:检查汇总卡片(记录数/总奖金/总扣款/净额)是否正确
|
||||
|
||||
**预期**:
|
||||
- 菜单显示"提成奖金"入口
|
||||
- 列表显示员工/部门/金额/备注
|
||||
- 金额正数显示绿色(奖金),负数显示红色(扣款)
|
||||
- 汇总卡片:总奖金 = 所有正数之和,总扣款 = 所有负数绝对值之和,净额 = 总奖金 - 总扣款
|
||||
- 导入成功后提示"新增 X,更新 Y,跳过 Z"
|
||||
- 同员工同月重复新增自动覆盖(upsert)
|
||||
|
||||
---
|
||||
|
||||
## 五、员工端密码登录(已完成 1-3)
|
||||
|
||||
### 测试 12:员工端登录
|
||||
|
||||
**操作步骤**:
|
||||
1. 员工端登录页 → 密码登录
|
||||
2. 输入手机号 + 密码(默认密码 = 手机号后 6 位)
|
||||
3. 登录成功后 → 导航栏 → 修改密码
|
||||
4. 输入旧密码 + 新密码(≥6 位)→ 确认
|
||||
|
||||
**预期**:
|
||||
- 默认密码可登录
|
||||
- 修改密码后用新密码可登录,旧密码不可用
|
||||
- 无手机号员工默认密码为 `123456`
|
||||
|
||||
---
|
||||
|
||||
### 测试 13:管理员重置密码
|
||||
|
||||
**操作步骤**:
|
||||
1. 花名册 → 某员工详情 → "重置密码"按钮
|
||||
2. 确认重置
|
||||
|
||||
**预期**:密码重置为手机号后 6 位,员工可用新密码登录。
|
||||
|
||||
---
|
||||
|
||||
## 六、待签合同 + 催办(已完成 4)
|
||||
|
||||
### 测试 14:待签合同按员工聚合 + 催办
|
||||
|
||||
**操作步骤**:
|
||||
1. 左侧菜单 → **待签合同**
|
||||
2. 查看"待签合同"Tab:按员工聚合,每行一个员工,显示待签数
|
||||
3. 点击某员工行展开 → 查看该员工名下待签文件列表
|
||||
4. 点击"催办" → 弹窗显示二维码 + 可复制链接
|
||||
5. 切换到"签署记录"Tab → 查看全部签署记录
|
||||
|
||||
**预期**:
|
||||
- 待签合同按员工聚合展示
|
||||
- 催办弹窗显示二维码和链接(24 小时有效)
|
||||
- 签署记录 Tab 显示历史记录,支持状态/场景筛选
|
||||
|
||||
---
|
||||
|
||||
## 测试结果记录
|
||||
|
||||
| 编号 | 测试项 | 通过 | 备注 |
|
||||
|------|--------|------|------|
|
||||
| 1 | 证件号码校验 | ☐ | |
|
||||
| 2 | 超龄合同限制 | ☐ | |
|
||||
| 3 | 劳务协议社保联动 | ☐ | |
|
||||
| 4 | 合同待签署状态 | ☐ | |
|
||||
| 5 | 社保待办理状态 | ☐ | |
|
||||
| 6 | 待办跳转精准化 | ☐ | |
|
||||
| 7 | 风险提醒反向引导修正 | ☐ | |
|
||||
| 8 | 合同到期分级预警 | ☐ | |
|
||||
| 9 | 试用期工资判定 | ☐ | |
|
||||
| 10 | 获取提成奖金 | ☐ | |
|
||||
| 11 | 提成奖金模块 | ☐ | |
|
||||
| 12 | 员工端登录 | ☐ | |
|
||||
| 13 | 管理员重置密码 | ☐ | |
|
||||
| 14 | 待签合同+催办 | ☐ | |
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 测试前务必**强制刷新浏览器**清除缓存(Cmd+Shift+R)
|
||||
2. 测试 9-11 需要有测试员工数据,建议先用花名册添加 2-3 名测试员工
|
||||
3. 测试 10 依赖测试 11 先录入提成奖金数据
|
||||
4. 如发现菜单未显示"提成奖金",请清除浏览器缓存后重试
|
||||
5. 测试完成后请在测试结果记录表勾选通过项,未通过项请描述具体问题
|
||||
@@ -0,0 +1,422 @@
|
||||
# 20260816 社保公积金账户化重构
|
||||
|
||||
> 目标:将社保公积金从"按城市直接关联版本"改为"账户 + 年度标准"两层实体。
|
||||
> 账户代表用户开设的社保/公积金账户(对应不同子公司/分公司/地区),年度标准是账户下每个年度的缴费比例和基数标准。
|
||||
|
||||
> **状态:已完成实施**(2026-08-16)
|
||||
> 账户+年度标准模型已上线,前端已重构为账户卡片列表+展开年度标准管理。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状分析
|
||||
|
||||
### 当前数据模型(已重构)
|
||||
|
||||
| 表 | 说明 | 唯一约束 |
|
||||
|----|------|---------|
|
||||
| `SocialAccount` | 社保/公积金账户实体(type=SOCIAL/HOUSING) | `@@unique([orgId, type, name])` |
|
||||
| `SocialYearStandard` | 年度标准(比例+基数+最低工资+生效月份),关联到账户 | `@@unique([accountId, effectiveFrom])` |
|
||||
| `SocialInsuranceConfig` | 旧社保版本表(保留兼容,回退用) | `@@unique([orgId, city, effectiveFrom])` |
|
||||
| `HousingFundConfig` | 旧公积金版本表(保留兼容) | — |
|
||||
| `EmployeeSocialInsRecord` | 员工社保参保记录,已加 accountId | — |
|
||||
| `EmployeeHousingFundRecord` | 员工公积金参保记录,已加 accountId | — |
|
||||
| `SocialMonthlyProcess` | 月度办理记录 | `@@unique([orgId, month, type])` |
|
||||
|
||||
### 已解决的问题
|
||||
|
||||
1. ✅ **"城市"只是字符串** → 已改为 SocialAccount 实体化管理
|
||||
2. ✅ **多子公司/分公司场景缺失** → 同一城市可有多个账户,按根部门关联
|
||||
3. ✅ **版本直接挂在 orgId+city 上** → 改为 SocialYearStandard 关联到 accountId
|
||||
4. ✅ **员工参保记录只有 city** → 已加 accountId 字段
|
||||
5. ✅ **薪资计算中社保配置查询** → 改为按 accountId 查 SocialYearStandard,旧表回退
|
||||
6. ✅ **最低工资标准** → SocialYearStandard 新增 minWage 字段,支持最低工资保护和递延扣款
|
||||
|
||||
---
|
||||
|
||||
## 二、目标数据模型
|
||||
|
||||
### 新增:SocialAccount(社保/公积金账户)
|
||||
|
||||
```
|
||||
model SocialAccount {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // SOCIAL=社保账户, HOUSING=公积金账户
|
||||
name String // 账户名称,如"北京总公司社保账户"、"上海分公司社保账户"
|
||||
city String // 参保城市
|
||||
accountNo String? // 社保登记号 / 公积金单位账号
|
||||
bankName String? // 公积金开户行(公积金专用)
|
||||
bankAccount String? // 公积金银行账号(公积金专用)
|
||||
orgName String? // 缴费主体名称(子公司/分公司名称)
|
||||
orgCode String? // 缴费主体统一社会信用代码
|
||||
accountType String? // 公积金账户类型 BASIC=基本, SUPPLEMENTARY=补充(公积金专用)
|
||||
isDefault Boolean @default(false) // 是否默认账户(同 type 下仅一个默认)
|
||||
status String @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
yearStandards SocialYearStandard[]
|
||||
socialRecords EmployeeSocialInsRecord[]
|
||||
housingRecords EmployeeHousingFundRecord[]
|
||||
|
||||
// 部门关联(根部门 level=0 代表分公司/子公司)
|
||||
deptSocialAccounts Department[] @relation("SocialAccountDepartments")
|
||||
deptHousingAccounts Department[] @relation("HousingAccountDepartments")
|
||||
|
||||
@@unique([orgId, type, name])
|
||||
@@index([orgId, type, city])
|
||||
@@index([orgId, type, isDefault])
|
||||
}
|
||||
```
|
||||
|
||||
### 新增:SocialYearStandard(年度标准,替代原 Config 表)
|
||||
|
||||
```
|
||||
model SocialYearStandard {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
accountId String // 关联到账户
|
||||
account SocialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
// 社保比例(type=SOCIAL 时使用)
|
||||
pensionOrg Float @default(16)
|
||||
pensionEmp Float @default(8)
|
||||
medicalOrg Float @default(9.8)
|
||||
medicalEmp Float @default(2)
|
||||
unemploymentOrg Float @default(0.5)
|
||||
unemploymentEmp Float @default(0.5)
|
||||
injuryOrg Float @default(0.2)
|
||||
maternityOrg Float @default(0.8)
|
||||
baseMin Float @default(6326)
|
||||
baseMax Float @default(33891)
|
||||
medicalBaseMin Float @default(0)
|
||||
medicalBaseMax Float @default(0)
|
||||
extraInsurances Json?
|
||||
|
||||
// 最低工资标准(仅社保,20260816新增)
|
||||
minWage Float @default(0) // 当地月最低工资标准,0=不检查
|
||||
|
||||
// 公积金比例(type=HOUSING 时使用)
|
||||
housingOrg Float @default(12)
|
||||
housingEmp Float @default(12)
|
||||
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true)
|
||||
adjustmentDone Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([accountId, effectiveFrom])
|
||||
@@index([accountId, isCurrent])
|
||||
}
|
||||
```
|
||||
|
||||
### 修改:EmployeeSocialInsRecord
|
||||
|
||||
```
|
||||
model EmployeeSocialInsRecord {
|
||||
// 新增字段
|
||||
accountId String? // 关联到 SocialAccount(迁移后非空)
|
||||
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||
|
||||
// 保留 city 字段用于兼容(迁移后从 account.city 派生,后续可废弃)
|
||||
city String @default("北京")
|
||||
|
||||
// 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
### 修改:EmployeeHousingFundRecord
|
||||
|
||||
```
|
||||
model EmployeeHousingFundRecord {
|
||||
// 新增字段
|
||||
accountId String?
|
||||
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||
|
||||
// 保留 city 字段用于兼容
|
||||
city String @default("北京")
|
||||
|
||||
// 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
### 修改:SocialMonthlyProcess
|
||||
|
||||
```
|
||||
model SocialMonthlyProcess {
|
||||
// 新增字段
|
||||
accountId String? // 关联到账户(迁移后非空)
|
||||
|
||||
// 保留原字段
|
||||
month String
|
||||
type String
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 修改:Organization
|
||||
|
||||
```
|
||||
model Organization {
|
||||
// 新增关联
|
||||
socialAccounts SocialAccount[]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据迁移方案
|
||||
|
||||
### 迁移步骤
|
||||
|
||||
1. **创建新表**:`SocialAccount` + `SocialYearStandard`
|
||||
2. **给员工记录表加 accountId 字段**(可空,迁移期间兼容)
|
||||
3. **迁移 SocialInsuranceConfig → SocialAccount + SocialYearStandard**:
|
||||
- 按 `(orgId, city)` 分组,每组创建一个 `SocialAccount`(type=SOCIAL)
|
||||
- 每条 Config 创建一条 `SocialYearStandard`(accountId 关联到对应账户)
|
||||
4. **迁移 HousingFundConfig → SocialAccount + SocialYearStandard**:
|
||||
- 按 `(orgId, city, accountType)` 分组,每组创建一个 `SocialAccount`(type=HOUSING)
|
||||
- 每条 Config 创建一条 `SocialYearStandard`
|
||||
5. **迁移 EmployeeSocialInsRecord.accountId**:按 `(orgId, city)` 匹配到 SocialAccount
|
||||
6. **迁移 EmployeeHousingFundRecord.accountId**:按 `(orgId, city)` 匹配到 SocialAccount
|
||||
7. **迁移 SocialMonthlyProcess.accountId**:按 `(orgId, type, snapshot.city)` 匹配
|
||||
8. **验证数据完整性**:确认所有记录都有 accountId
|
||||
9. **将 accountId 设为非空**(可选,或保持可空兼容)
|
||||
10. **保留旧表**(SocialInsuranceConfig / HousingFundConfig)作为备份,代码切换后再删除
|
||||
|
||||
### 迁移脚本
|
||||
|
||||
```sql
|
||||
-- 1. 创建社保账户(从 SocialInsuranceConfig 提取唯一 orgId+city)
|
||||
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "isDefault", status, "createdBy", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
"orgId",
|
||||
'SOCIAL',
|
||||
city || '社保账户',
|
||||
city,
|
||||
true,
|
||||
'ACTIVE',
|
||||
'migration',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM (SELECT DISTINCT "orgId", city FROM "SocialInsuranceConfig") t;
|
||||
|
||||
-- 2. 创建公积金账户(从 HousingFundConfig 提取唯一 orgId+city+accountType)
|
||||
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "accountType", "isDefault", status, "createdBy", "createdAt", "updatedAt")
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
"orgId",
|
||||
'HOUSING',
|
||||
city || COALESCE("accountType", 'BASIC') || '公积金账户',
|
||||
city,
|
||||
COALESCE("accountType", 'BASIC'),
|
||||
true,
|
||||
'ACTIVE',
|
||||
'migration',
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM (SELECT DISTINCT "orgId", city, "accountType" FROM "HousingFundConfig") t;
|
||||
|
||||
-- 3. 迁移社保年度标准
|
||||
INSERT INTO "SocialYearStandard" (id, orgId, "accountId", "pensionOrg", "pensionEmp", ...)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
c."orgId",
|
||||
a.id,
|
||||
c."pensionOrg", c."pensionEmp", ...
|
||||
FROM "SocialInsuranceConfig" c
|
||||
JOIN "SocialAccount" a ON a."orgId" = c."orgId" AND a.city = c.city AND a.type = 'SOCIAL';
|
||||
|
||||
-- 4. 迁移公积金年度标准
|
||||
-- 5. 迁移员工参保记录 accountId
|
||||
-- 6. 迁移月度办理记录 accountId
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、后端 API 变更
|
||||
|
||||
### 新增:账户管理 API(已实施)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/social/accounts` | 账户列表(支持 type 筛选) |
|
||||
| POST | `/social/accounts` | 新建账户 |
|
||||
| PUT | `/social/accounts/:id` | 编辑账户 |
|
||||
| DELETE | `/social/accounts/:id` | 删除账户(无关联记录时可删) |
|
||||
| PUT | `/social/accounts/:id/default` | 设为默认账户 |
|
||||
| PUT | `/social/accounts/:id/departments` | 账户关联根部门(批量) |
|
||||
| GET | `/social/accounts/:id/departments` | 获取账户已关联的根部门 |
|
||||
|
||||
### 新增:年度标准 API(已实施)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/social/accounts/:accountId/standards` | 账户的年度标准列表 |
|
||||
| GET | `/social/accounts/:accountId/current-standard` | 账户的当前生效标准 |
|
||||
| GET | `/social/accounts/:accountId/standard-by-month/:month` | 按账户+月份获取适用标准 |
|
||||
| POST | `/social/accounts/:accountId/standards` | 新建年度标准(旧版本自动归档) |
|
||||
| PUT | `/social/accounts/:accountId/min-wage` | 快速更新当前标准的最低工资 |
|
||||
|
||||
### 改造:薪资计算(已实施)
|
||||
|
||||
- `payroll.service.ts` 中社保配置查询:从 `orgId + city` 改为通过员工→根部门→账户→年度标准
|
||||
- 员工参保记录查询:从 `employeeId + city` 改为 `employeeId + accountId`
|
||||
- 新增最低工资保护和递延扣款逻辑(详见 20260816-薪税管理逻辑.md 第十五章)
|
||||
|
||||
### 废弃:城市相关 API
|
||||
|
||||
| 方法 | 路径 | 说明 | 处理 |
|
||||
|------|------|------|------|
|
||||
| GET | `/social/config/cities` | 城市列表 | 废弃,改为 `/social/accounts` 返回账户列表(含 city) |
|
||||
|
||||
### 改造:月度办理 API
|
||||
|
||||
- 月度办理按账户分别办理,不同账户不同城市的增减员
|
||||
- `SocialMonthlyProcess` 新增 accountId
|
||||
|
||||
### 改造:薪资计算
|
||||
|
||||
- `payroll.service.ts` 中社保配置查询:从 `orgId + city` 改为 `accountId`
|
||||
- 员工参保记录查询:从 `employeeId + city` 改为 `employeeId + accountId`
|
||||
|
||||
---
|
||||
|
||||
## 五、前端 UI 变更(已实施)
|
||||
|
||||
### 1. 社保公积金菜单(SocialInsurance.tsx)— 已重构
|
||||
|
||||
**社保/公积金 Tab**(20260816 重构):
|
||||
- ~~原城市选择器 → 改为账户选择器~~ → 已改为**账户卡片列表 + 展开年度标准管理**
|
||||
- 每个账户卡片:名称、城市、账号、关联部门数、参保记录数
|
||||
- 卡片可展开/折叠(ChevronDown/ChevronRight 图标)
|
||||
- 展开后显示:
|
||||
- 当前年度标准(比例/基数/最低工资)
|
||||
- 最低工资快速编辑(仅社保,Input + 保存按钮)
|
||||
- 操作按钮:新建年度标准 | 批量调基 | 查看历史
|
||||
- 新建年度标准弹窗(原"新建版本"改名,保留 AI 建议、附加险种配置)
|
||||
- 版本历史展示
|
||||
- 调基预览(保留原有展示和编辑逻辑)
|
||||
- 试算工具(使用该账户的城市配置)
|
||||
- 顶部"新建账户"按钮(账户新建/编辑/删除/设默认已从设置页面迁移到此)
|
||||
- 新增组件:`AccountCard.tsx`(账户卡片+展开内容)、`AccountFormModal.tsx`(账户新建/编辑弹窗)
|
||||
|
||||
**月度办理 Tab**:保留不变
|
||||
|
||||
**员工参保 Tab**:保留不变
|
||||
|
||||
**专项附加扣除 Tab**:保留不变
|
||||
|
||||
### 2. 设置页面(Settings.tsx)— 已简化
|
||||
|
||||
- ~~新增"社保公积金账户管理"Tab~~ → 已去掉该 Tab
|
||||
- 账户管理全部移到社保公积金菜单中完成
|
||||
- SocialAccountSettings 和 AccountFormModal 组件定义保留在文件中(不再显示)
|
||||
|
||||
### 3. 员工表单(roster/modals.tsx)
|
||||
|
||||
- 社保公积金区:原城市选择 → 改为**账户选择**(下拉,按 type 筛选)
|
||||
- 选中账户后自动带出城市(只读展示)
|
||||
|
||||
---
|
||||
|
||||
## 六、影响范围清单
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 改动范围 |
|
||||
|------|---------|
|
||||
| `prisma/schema.prisma` | 新增 SocialAccount、SocialYearStandard,修改员工记录表加 accountId |
|
||||
| `src/routes/social.routes.ts` | 全面重构:账户 CRUD + 年度标准改为按 accountId |
|
||||
| `src/services/payroll.service.ts` | 社保配置查询改为 account → yearStandard 两级 |
|
||||
| `src/routes/payroll2.routes.ts` | 批次计算中社保配置查询适配 |
|
||||
| `src/routes/payroll.routes.ts` | 旧版薪资计算适配 |
|
||||
| `src/routes/roster.routes.ts` | socialInsuranceStatus 派生逻辑适配 |
|
||||
| `src/services/contract.service.ts` | createEmployee 社保记录创建时关联 accountId |
|
||||
| `src/routes/import.routes.ts` | Excel 导入时社保账户关联 |
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 改动范围 |
|
||||
|------|---------|
|
||||
| `src/pages/SocialInsurance.tsx` | 全面重构:账户选择器 + 年度标准 + 月度办理 |
|
||||
| `src/pages/Settings.tsx` | 新增账户管理入口 |
|
||||
| `src/pages/roster/modals.tsx` | 社保公积金区改为账户选择 |
|
||||
| `src/lib/api-services.ts` | 新增 socialAccountApi,改造 socialInsuranceApi |
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 新增表 | SocialAccount、SocialYearStandard |
|
||||
| 修改表 | EmployeeSocialInsRecord 加 accountId,EmployeeHousingFundRecord 加 accountId,SocialMonthlyProcess 加 accountId |
|
||||
| 数据迁移 | SocialInsuranceConfig → SocialAccount + SocialYearStandard |
|
||||
| 数据迁移 | HousingFundConfig → SocialAccount + SocialYearStandard |
|
||||
| 数据迁移 | 员工参保记录按 city 匹配 accountId |
|
||||
| 保留旧表 | SocialInsuranceConfig / HousingFundConfig 暂保留,代码切换后删除 |
|
||||
|
||||
---
|
||||
|
||||
## 七、实施计划与完成状态
|
||||
|
||||
### 第一步:DB schema + 迁移脚本 ✅ 已完成
|
||||
- ✅ 新增 SocialAccount、SocialYearStandard 表
|
||||
- ✅ 员工记录表加 accountId 字段(可空)
|
||||
- ✅ SocialYearStandard 新增 minWage 字段(最低工资标准)
|
||||
- ✅ Department 表新增 socialAccountId / housingAccountId(根部门关联账户)
|
||||
- ✅ 编写并执行迁移脚本
|
||||
- ✅ 验证数据完整性
|
||||
|
||||
### 第二步:后端 API 重构 ✅ 已完成
|
||||
- ✅ 新增账户 CRUD API(`/social/accounts`)
|
||||
- ✅ 年度标准 API 改为按 accountId(`/social/accounts/:accountId/standards`)
|
||||
- ✅ 新增按账户获取当前标准 API(`/social/accounts/:accountId/current-standard`)
|
||||
- ✅ 新增快速更新最低工资 API(`/social/accounts/:accountId/min-wage`)
|
||||
- ✅ 薪资计算适配(payroll.service.ts 按 accountId 查标准,旧表回退)
|
||||
- ✅ 员工参保记录适配
|
||||
- ✅ 保留旧 API 兼容(过渡期)
|
||||
|
||||
### 第三步:前端账户管理 ✅ 已完成
|
||||
- ✅ ~~设置页新增账户管理 UI~~ → 已移到社保公积金菜单
|
||||
- ✅ SocialInsurance.tsx 改为账户卡片列表 + 展开年度标准管理
|
||||
- ✅ 新建版本改名为"新建年度标准"
|
||||
- ✅ 最低工资快速编辑(当前配置区域可直接编辑)
|
||||
- ✅ 新增 AccountCard.tsx 组件(账户卡片+展开内容)
|
||||
- ✅ 新增 AccountFormModal.tsx 组件(账户新建/编辑弹窗)
|
||||
|
||||
### 第四步:前端参保流程适配 ✅ 已完成
|
||||
- ✅ 员工表单社保公积金区改为账户选择
|
||||
- ✅ 月度办理保留不变
|
||||
- ✅ 员工参保记录展示账户名称
|
||||
|
||||
### 第五步:清理 ⏳ 待执行
|
||||
- ⏳ 删除旧 SocialInsuranceConfig / HousingFundConfig 表
|
||||
- ⏳ 删除旧 API
|
||||
- ⏳ 删除前端城市选择器残留代码
|
||||
|
||||
### 额外完成的功能(20260816)
|
||||
- ✅ 最低工资保护与递延扣款机制(详见 20260816-薪税管理逻辑.md 第十五章)
|
||||
- ✅ 最低工资保护支持当月累计实发判断(多批次场景)
|
||||
- ✅ 预入职状态(PRE_ONBOARD),预入职员工不进入薪资批次
|
||||
- ✅ 薪资批次新增 payMonth(发薪年月)字段,支持提前发薪场景
|
||||
|
||||
---
|
||||
|
||||
## 八、风险与回滚
|
||||
|
||||
| 风险 | 应对 |
|
||||
|------|------|
|
||||
| 迁移脚本数据丢失 | 迁移前备份数据库,旧表保留不删 |
|
||||
| 薪资计算引用旧表 | 过渡期双写,确认新表数据正确后再切换 |
|
||||
| 前端缓存旧城市选择器 | 强制刷新 + 版本号 |
|
||||
| 员工参保记录无 accountId | 迁移脚本按 city 匹配,未匹配的标记待处理 |
|
||||
|
||||
**回滚方案**:保留旧表和旧 API,前端可切回旧版本,后端旧 API 仍可用。
|
||||
@@ -0,0 +1,734 @@
|
||||
# 薪税管理完整处理逻辑和取数逻辑
|
||||
|
||||
> 生成时间:2026-08-16
|
||||
> 涉及模块:薪资发放批次、社保公积金、个税累计预扣、工资条生成与发布
|
||||
> 核心文件:`backend/src/services/payroll.service.ts`、`backend/src/routes/payroll2.routes.ts`、`backend/src/routes/social.routes.ts`
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 薪税管理三大模块 │
|
||||
├──────────────┬──────────────────┬───────────────────────────┤
|
||||
│ 社保公积金 │ 个税计算 │ 工资条生成/发布 │
|
||||
│ (缴纳/扣除) │ (累计预扣法) │ (汇总/发布/定时) │
|
||||
└──────────────┴──────────────────┴───────────────────────────┘
|
||||
↓ 取数 ↓ ↓ 取数 ↓
|
||||
┌──────────────┬──────────────────┬───────────────────────────┐
|
||||
│ SocialAccount│ Employee 表 │ PayrollBatch (已归档) │
|
||||
│ + YearStandard│ SpecialDeduction │ + BatchEntry │
|
||||
│ + 旧Config表 │ + Record表 │ → Payslip │
|
||||
└──────────────┴──────────────────┴───────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、核心计算函数 `calcBatchEntry`
|
||||
|
||||
这是所有薪税计算的入口,在三个时机被调用:
|
||||
|
||||
1. **创建批次时**:为每个员工初始化计算
|
||||
2. **编辑条目时**:修改任意金额后实时重算
|
||||
3. **归档时**:最终锁定前再重算一遍
|
||||
|
||||
### 输入参数
|
||||
|
||||
```typescript
|
||||
calcBatchEntry(
|
||||
orgId, // 组织ID
|
||||
employeeId, // 员工ID
|
||||
month, // 批次月份 YYYY-MM
|
||||
inputs: { // HR 可编辑的输入项
|
||||
baseSalary, // 基本工资
|
||||
overtimePay, // 加班费
|
||||
allowance, // 其他津贴
|
||||
deduction, // 扣款
|
||||
bonus, // 奖金
|
||||
positionSalary?, // 岗位工资
|
||||
performanceSalary?, // 绩效工资
|
||||
senioritySalary?, // 工龄工资
|
||||
transportAllowance?, // 交通补贴
|
||||
mealAllowance?, // 餐补
|
||||
housingAllowance?, // 住房补贴
|
||||
communicationAllowance?, // 通讯补贴
|
||||
otherDeduction?, // 其他扣款
|
||||
},
|
||||
batchType, // REGULAR | TERMINATION | BONUS | SEVERANCE
|
||||
options?: { // 可选覆盖
|
||||
skipSocial?, // 跳过社保计算
|
||||
overrideSocial?, // 手动覆盖社保值
|
||||
prevDeferred?, // 上月递延扣款(次月补扣){ socialEmp, housingEmp, minWage }
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、社保公积金取数逻辑
|
||||
|
||||
### 取数优先级(三层回退)
|
||||
|
||||
```
|
||||
① 员工 → 根部门(level=0) → socialAccount / housingAccount
|
||||
↓ 找不到
|
||||
② 公司默认账户 (isDefault=true)
|
||||
↓ 找不到
|
||||
③ 旧配置表 SocialInsuranceConfig / HousingFundConfig(按城市匹配)
|
||||
```
|
||||
|
||||
### 新逻辑(账户体系)
|
||||
|
||||
```
|
||||
getEmployeeAccounts(orgId, employeeId)
|
||||
├─ 查员工所属部门
|
||||
├─ 向上遍历到 level=0 的根部门
|
||||
├─ 读取根部门的 socialAccountId / housingAccountId
|
||||
├─ 若根部门未关联 → 查公司默认账户 (isDefault=true)
|
||||
└─ 返回 { socialAccount, housingAccount }
|
||||
|
||||
getStandardByAccountAndMonth(accountId, month)
|
||||
├─ 查 SocialYearStandard: accountId + effectiveFrom ≤ month ≤ effectiveTo
|
||||
├─ 找不到 → 回退到 isCurrent=true 的当前标准
|
||||
└─ 返回年度标准(比例 + 基数上下限)
|
||||
```
|
||||
|
||||
### 旧逻辑(兼容回退)
|
||||
|
||||
```
|
||||
SocialInsuranceConfig.findFirst({
|
||||
orgId, city: employee.city,
|
||||
effectiveFrom ≤ month, effectiveTo ≥ month 或 null
|
||||
})
|
||||
```
|
||||
|
||||
### 社保基数确定
|
||||
|
||||
```typescript
|
||||
socialBase = employee.socialInsBase || inputs.baseSalary
|
||||
housingBase = employee.housingFundBase || inputs.baseSalary
|
||||
```
|
||||
|
||||
优先级:员工核定基数 > 基本工资
|
||||
|
||||
### 社保金额计算
|
||||
|
||||
```typescript
|
||||
// 当月应缴全额
|
||||
fullSocialEmp = calcSocialInsurance(socialBase, socialConfig).socialEmp
|
||||
fullSocialOrg = calcSocialInsurance(socialBase, socialConfig).socialOrg
|
||||
fullHousingEmp = calcHousingFund(housingBase, housingConfig).housingEmp
|
||||
fullHousingOrg = calcHousingFund(housingBase, housingConfig).housingOrg
|
||||
|
||||
// 已归档批次已扣金额(避免多批次重复扣社保)
|
||||
deductedSocialEmp = SUM(archivedEntries.socialEmp)
|
||||
deductedSocialOrg = SUM(archivedEntries.socialOrg)
|
||||
deductedHousingEmp = SUM(archivedEntries.housingEmp)
|
||||
deductedHousingOrg = SUM(archivedEntries.housingOrg)
|
||||
|
||||
// 本批次应扣 = 应缴全额 - 已扣金额(差额补扣,足额为0)
|
||||
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
|
||||
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
|
||||
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
|
||||
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
||||
|
||||
// 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
|
||||
if (options?.prevDeferred) {
|
||||
socialEmp += options.prevDeferred.socialEmp || 0
|
||||
housingEmp += options.prevDeferred.housingEmp || 0
|
||||
}
|
||||
```
|
||||
|
||||
### 关键设计:差额补扣机制
|
||||
|
||||
- 同一员工同一月份可能有多个批次(如常规发薪 + 离职结算)
|
||||
- 第一个批次扣全额,后续批次扣差额(应缴全额 - 已扣)
|
||||
- 避免重复扣除
|
||||
|
||||
### 批次类型对社保的影响
|
||||
|
||||
| 批次类型 | 社保公积金 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| REGULAR | ✅ 计算 | 常规发薪,差额补扣 |
|
||||
| TERMINATION | ✅ 计算 | 离职结算,差额补扣 |
|
||||
| BONUS | ❌ 不扣 | 年终奖单独计税,无社保 |
|
||||
| SEVERANCE | ❌ 不扣 | 补偿金不走社保个税 |
|
||||
|
||||
### 手动覆盖
|
||||
|
||||
```typescript
|
||||
// HR 可在编辑界面手动修改社保值
|
||||
if (options?.overrideSocial) {
|
||||
socialEmp = overrideSocial.socialEmp // 覆盖个人社保
|
||||
socialOrg = overrideSocial.socialOrg // 覆盖单位社保
|
||||
housingEmp = overrideSocial.housingEmp // 覆盖个人公积金
|
||||
housingOrg = overrideSocial.housingOrg // 覆盖单位公积金
|
||||
}
|
||||
// 同时保存 systemSocialEmp 等系统计算值,用于对比展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、应发合计计算
|
||||
|
||||
```typescript
|
||||
totalPay =
|
||||
baseSalary // 基本工资
|
||||
+ positionSalary // 岗位工资
|
||||
+ performanceSalary // 绩效工资
|
||||
+ senioritySalary // 工龄工资
|
||||
+ overtimePay // 加班费
|
||||
+ transportAllowance // 交通补贴
|
||||
+ mealAllowance // 餐补
|
||||
+ housingAllowance // 住房补贴
|
||||
+ communicationAllowance // 通讯补贴
|
||||
+ allowance // 其他津贴
|
||||
+ bonus // 奖金
|
||||
- deduction // 扣款
|
||||
- otherDeduction // 其他扣款
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、个税计算逻辑
|
||||
|
||||
### 两种计税方式
|
||||
|
||||
#### 1. 年终奖单独计税(BONUS 批次)
|
||||
|
||||
```typescript
|
||||
tax = calcBonusTax(inputs.bonus)
|
||||
// 年终奖 ÷ 12 → 找税率区间 → 年终奖 × 税率 - 速算扣除数
|
||||
```
|
||||
|
||||
#### 2. 累计预扣法(REGULAR / TERMINATION / SEVERANCE)
|
||||
|
||||
```typescript
|
||||
// ── 取数:当年已归档批次的历史数据 ──
|
||||
archivedEntries = BatchEntry.findMany({
|
||||
orgId, employeeId,
|
||||
batch: { month: startsWith(year), status: 'ARCHIVED' }
|
||||
})
|
||||
// 注意:不依赖 Payslip 是否已生成,直接从已归档批次取数
|
||||
|
||||
// ── 累计计算 ──
|
||||
ytdIncome = SUM(archivedEntries.totalPay) + totalPay
|
||||
ytdSocialEmp = SUM(archivedEntries.socialEmp) + socialEmp
|
||||
ytdHousingEmp = SUM(archivedEntries.housingEmp) + housingEmp
|
||||
ytdTaxDeducted = SUM(archivedEntries.tax)
|
||||
|
||||
// ── 专项附加扣除取数(三层回退)──
|
||||
deductionRecords = SpecialDeductionRecord.findMany({
|
||||
orgId, employeeId,
|
||||
month: startsWith(year) AND lte(month) // 当年至当月
|
||||
})
|
||||
|
||||
if (deductionRecords.length > 0) {
|
||||
// ✅ 优先:按月实际填报金额累加
|
||||
ytdSpecialDeduction = SUM(deductionRecords.amount)
|
||||
} else {
|
||||
// ⚠️ 回退:员工便捷字段 × 月数(兼容旧数据)
|
||||
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||
}
|
||||
|
||||
// ── 累计应纳税所得额 ──
|
||||
deductionAmount = 5000 × 月数 // 基本减除费用
|
||||
ytdTaxableIncome = max(0,
|
||||
ytdIncome
|
||||
- deductionAmount // 累计减除费用(5000/月)
|
||||
- ytdSocialEmp // 累计个人社保
|
||||
- ytdHousingEmp // 累计个人公积金
|
||||
- ytdSpecialDeduction // 累计专项附加扣除
|
||||
)
|
||||
|
||||
// ── 当月应扣个税 ──
|
||||
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
|
||||
// = 累计应纳税额 - 已预扣个税
|
||||
```
|
||||
|
||||
### 专项附加扣除数据来源
|
||||
|
||||
| 来源 | 表 | 说明 |
|
||||
|------|---|------|
|
||||
| **按月记录**(优先) | `SpecialDeductionRecord` | HR/员工按月填报,含子女教育、赡养老人、住房贷款、继续教育、婴幼儿照护 |
|
||||
| **便捷字段**(回退) | `Employee.specialDeduction` | 员工表上的当前值,× 月数估算累计 |
|
||||
|
||||
**按月记录的优势**:员工某月取消专项附加扣除,该月金额为 0,累计值准确反映实际。
|
||||
|
||||
### taxBreakdown 返回的明细
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "累计预扣法",
|
||||
"month": 8,
|
||||
"ytdIncome": 80000, // 累计收入
|
||||
"deductionAmount": 40000, // 累计减除费用 (5000×8)
|
||||
"ytdSocialEmp": 6720, // 累计个人社保
|
||||
"ytdHousingEmp": 3840, // 累计个人公积金
|
||||
"ytdSpecialDeduction": 12000, // 累计专项附加扣除
|
||||
"specialDeductionSource": "按月记录", // 数据来源标识
|
||||
"specialDeductionRecords": 8, // 按月记录条数
|
||||
"ytdTaxableIncome": 17440, // 累计应纳税所得额
|
||||
"ytdTaxDeducted": 480, // 已预扣个税
|
||||
"currentMonthTax": 360, // 当月应扣个税
|
||||
"archivedCount": 7 // 已归档批次数
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、实发工资与最低工资保护
|
||||
|
||||
```typescript
|
||||
// 初始实发 = 应发 - 个人社保 - 个人公积金 - 个税 - 上月递延最低工资补扣
|
||||
netPay = totalPay - socialEmp - housingEmp - tax - prevDeferredMinWage
|
||||
|
||||
// 最低工资保护(仅 REGULAR / TERMINATION 批次)
|
||||
if (minWage > 0 && netPay < minWage) {
|
||||
// 优先递延社保 → 递延公积金 → 递延最低工资补齐
|
||||
// 详见第十五章「最低工资保护与递延扣款机制」
|
||||
netPay = minWage
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、创建批次时的初始化取数
|
||||
|
||||
### 员工来源
|
||||
|
||||
| 批次类型 | 员工来源 |
|
||||
|---------|---------|
|
||||
| REGULAR / BONUS | `status=ACTIVE` + 本月离职(`status=RESIGNED, updatedAt 在本月`) |
|
||||
| TERMINATION | 本月 `TerminationRecord` 关联的员工 |
|
||||
| SEVERANCE | 本月离职记录中已审批(APPROVED/EXECUTING/COMPLETED)且有补偿金的 |
|
||||
|
||||
### 数据初始化(5 种模式)
|
||||
|
||||
| 模式 | baseSalary 取数 | overtimePay | allowance/deduction |
|
||||
|------|----------------|-------------|---------------------|
|
||||
| **copy_last** | 试用期→`probationSalary`;转正→上月工资条`baseSalary`;无→`monthlySalary` | 当月 `OvertimeRecord.totalPay` | 上月工资条复制 |
|
||||
| **blank_employees** | `employee.monthlySalary`(解密) | 0 | 0 |
|
||||
| **blank_all** | 0(无员工) | 0 | 0 |
|
||||
| **copy_batch** | 源批次条目复制 | 源批次复制 | 源批次复制 |
|
||||
| **custom** | 0(手动填写) | 0 | 0 |
|
||||
|
||||
### 试用期判定
|
||||
|
||||
```typescript
|
||||
isInProbation(contract, batchMonthEnd)
|
||||
// 试用期结束日 = contract.startDate + contract.probationMonths
|
||||
// 若 batchMonthEnd < 试用期结束日 → 在试用期内
|
||||
```
|
||||
|
||||
统一规则(所有模式适用,SEVERANCE/TERMINATION 除外):
|
||||
- 在试用期内且 `probationSalary > 0` → `baseSalary = probationSalary`
|
||||
|
||||
---
|
||||
|
||||
## 八、编辑条目时的重算
|
||||
|
||||
```
|
||||
HR 修改任意金额字段
|
||||
↓
|
||||
PUT /batches/:batchId/entries/:employeeId
|
||||
↓
|
||||
合并新旧 inputs(未修改的保留原值)
|
||||
↓
|
||||
重新调用 calcBatchEntry() → 重算社保/个税/实发
|
||||
↓
|
||||
更新 BatchEntry + 重算批次汇总
|
||||
```
|
||||
|
||||
**社保手动覆盖**:编辑社保字段时,通过 `overrideSocial` 传入,覆盖系统计算值,同时保存 `systemSocial*` 用于对比。
|
||||
|
||||
---
|
||||
|
||||
## 九、归档时的最终重算
|
||||
|
||||
```
|
||||
POST /batches/:batchId/archive
|
||||
↓
|
||||
① 遍历所有条目,重新调用 calcBatchEntry()
|
||||
- 此时已归档批次的累计数据是最新的
|
||||
- 社保差额补扣准确
|
||||
- 个税累计预扣准确
|
||||
② 更新批次汇总(totalPay/totalNetPay/各项合计)
|
||||
③ 标记 status=ARCHIVED, archivedAt=now
|
||||
④ 批次锁定,不可再编辑
|
||||
```
|
||||
|
||||
**归档时不自动生成工资条**,需单独调用 `POST /payslips/generate`。
|
||||
|
||||
---
|
||||
|
||||
## 十、工资条生成与发布
|
||||
|
||||
### 生成工资条
|
||||
|
||||
```
|
||||
POST /payslips/generate { month }
|
||||
↓
|
||||
generatePayslipFromBatches(orgId, month)
|
||||
↓
|
||||
① 查当月所有已归档批次(status=ARCHIVED)
|
||||
② 按员工汇总所有批次的 BatchEntry(多批次合并)
|
||||
③ 查当年历史工资条计算累计数据
|
||||
④ upsert Payslip(employeeId+month 唯一键)
|
||||
```
|
||||
|
||||
### 发布工资条
|
||||
|
||||
```
|
||||
POST /batches/:batchId/publish
|
||||
↓
|
||||
更新 Payslip.publishStatus = 'PUBLISHED', publishedAt = now
|
||||
↓
|
||||
员工端可见
|
||||
```
|
||||
|
||||
### 定时发送
|
||||
|
||||
```
|
||||
POST /batches/:batchId/schedule { scheduledAt }
|
||||
↓
|
||||
更新 Payslip.publishStatus = 'SCHEDULED', scheduledAt = 指定时间
|
||||
↓
|
||||
(定时任务到点后发布)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、取消归档
|
||||
|
||||
```
|
||||
POST /batches/:batchId/unarchive
|
||||
↓
|
||||
① 只能取消最后一个归档批次
|
||||
② 批次恢复为 DRAFT
|
||||
③ 如果还有其他归档批次 → 重新生成工资条(基于剩余批次)
|
||||
④ 如果没有归档批次了 → 删除该月工资条
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、完整数据流图
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Employee 表 │
|
||||
│ socialInsBase │
|
||||
│ housingFundBase │
|
||||
│ monthlySalary │
|
||||
│ specialDeduction│
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│SocialAccount│ │SpecialDeduction │ │ OvertimeRecord │
|
||||
│+YearStandard│ │Record (按月) │ │ (加班费) │
|
||||
│(社保比例) │ └──────────────────┘ └─────────────────┘
|
||||
│+minWage │ │ │
|
||||
└──────┬─────┘ │ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ calcBatchEntry() │
|
||||
│ │
|
||||
│ 社保 = 应缴全额 - 已归档批次已扣(差额补扣) │
|
||||
│ + 上月递延社保(次月补扣) │
|
||||
│ 应发 = 基本工资 + 各项津贴 + 奖金 - 扣款 │
|
||||
│ 个税 = 累计预扣法(从已归档批次取累计数据) │
|
||||
│ 实发 = 应发 - 个人社保 - 个人公积金 - 个税 │
|
||||
│ - 上月递延最低工资补扣 │
|
||||
│ 最低工资保护:实发 < minWage 时 → 递延扣款补齐 │
|
||||
│ 递延社保 → 递延公积金 → 递延最低工资补齐 │
|
||||
└──────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ BatchEntry │
|
||||
│ (批次条目) │
|
||||
│ + minWage │
|
||||
│ + minWageApplied│
|
||||
│ + deferred* │
|
||||
│ + prevDeferred*│
|
||||
└───────┬────────┘
|
||||
│ 归档
|
||||
▼
|
||||
┌────────────────┐ 汇总生成
|
||||
│ PayrollBatch │ ──────────────→ ┌──────────┐
|
||||
│ status=ARCHIVED│ │ Payslip │
|
||||
└────────────────┘ │ (工资条) │
|
||||
└────┬─────┘
|
||||
│ 发布
|
||||
▼
|
||||
员工端可见
|
||||
|
||||
次月创建批次时:
|
||||
getPrevDeferred() 读取上月 BatchEntry 的 deferred* 字段
|
||||
→ 传入 calcBatchEntry 的 options.prevDeferred
|
||||
→ 本月社保/公积金叠加补扣上月递延金额
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十三、关键取数表汇总
|
||||
|
||||
| 数据项 | 取数表 | 取数条件 | 用途 |
|
||||
|--------|--------|---------|------|
|
||||
| 社保比例 | `SocialYearStandard` | accountId + 月份在生效区间 | 计算社保公积金 |
|
||||
| 社保比例(回退) | `SocialInsuranceConfig` | orgId + city + 月份在生效区间 | 兼容旧数据 |
|
||||
| 最低工资标准 | `SocialYearStandard.minWage` | accountId + 月份在生效区间 | 最低工资保护 |
|
||||
| 最低工资标准(回退) | `SocialInsuranceConfig.minWage` | orgId + city + 月份在生效区间 | 兼容旧数据 |
|
||||
| 社保基数 | `Employee.socialInsBase` | — | 优先于基本工资 |
|
||||
| 公积金基数 | `Employee.housingFundBase` | — | 优先于基本工资 |
|
||||
| 已扣社保 | `BatchEntry` | 当月已归档批次 | 差额补扣 |
|
||||
| 累计收入 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
|
||||
| 累计社保 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
|
||||
| 累计个税 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
|
||||
| 专项附加扣除 | `SpecialDeductionRecord` | 当年至当月 | 个税累计预扣 |
|
||||
| 专项附加扣除(回退) | `Employee.specialDeduction` | × 月数 | 兼容旧数据 |
|
||||
| 基本减除费用 | 固定 5000 | × 月数 | 个税累计预扣 |
|
||||
| 加班费 | `OvertimeRecord` | employeeId + month | 创建批次时自动拉取 |
|
||||
| 基本工资 | `Payslip`(上月) | employeeId + 上月 | copy_last 模式 |
|
||||
| 基本工资(回退) | `Employee.monthlySalary` | 解密 | 无上月工资条时 |
|
||||
| 试用期工资 | `LaborContract.probationSalary` | 最新合同 | 试用期内的 baseSalary |
|
||||
| 上月递延社保 | `BatchEntry.deferredSocialEmp` | 上月已归档批次 | 次月补扣 |
|
||||
| 上月递延公积金 | `BatchEntry.deferredHousingEmp` | 上月已归档批次 | 次月补扣 |
|
||||
| 上月递延最低工资补齐 | `BatchEntry.deferredMinWage` | 上月已归档批次 | 次月补扣 |
|
||||
|
||||
---
|
||||
|
||||
## 十四、3 步工作流
|
||||
|
||||
```
|
||||
① 编辑薪资(含核对) → ② 归档锁定 → ③ 发布工资条
|
||||
```
|
||||
|
||||
### 步骤 1:编辑薪资(DRAFT 状态)
|
||||
|
||||
可操作:
|
||||
- **点击单元格编辑**:基本工资、加班费、津贴、奖金、扣款、社保个人/单位、公积金个人/单位
|
||||
- **导入工资表 Excel**:批量填充薪资数据
|
||||
- **下载导入模板**
|
||||
- **导入加班费**:从加班记录按月自动填充 `overtimePay`
|
||||
- **获取提成奖金**:从提成奖金模块按月填充 `bonus`
|
||||
- **添加/删除人员**:动态调整批次人员
|
||||
- **查看个税明细**:点击个税金额查看累计预扣计算过程
|
||||
|
||||
核对(编辑页面内直接展示,非独立步骤):
|
||||
- 批次汇总卡片:应发合计、社保合计、公积金合计、个税合计、实发合计
|
||||
- 质量门禁自动检查:实发为负、全零记录、最低工资、递延扣款等
|
||||
- 可导出 **薪资汇总表** / **薪资明细表**(CSV)
|
||||
|
||||
质量门禁(草稿状态自动检查):
|
||||
- 实发为负的员工 → 红色警告
|
||||
- 实发低于最低工资(未触发保护)→ 红色警告
|
||||
- 最低工资保护已触发(递延扣款)→ 黄色警告
|
||||
- 本月补扣上月递延 → 黄色警告
|
||||
- 全零记录 → 黄色警告
|
||||
|
||||
表格可视化提示:
|
||||
- **实发列**:最低工资保护触发时显示橙色 + ★标记,悬停显示补齐明细
|
||||
- **递延扣款列**(新增):橙色"递延 ¥X"或蓝色"补扣 ¥X",悬停显示分项明细
|
||||
- **风险列**:最低工资保护触发时显示橙色警告图标
|
||||
|
||||
### 步骤 2:归档锁定(ARCHIVED)
|
||||
|
||||
**归档时后端自动执行**:
|
||||
1. **重算所有条目**:调用 `calcBatchEntry` 重新计算社保公积金和个税
|
||||
2. **更新批次汇总**:重算 totalPay/totalNetPay/各项合计
|
||||
3. **标记为 ARCHIVED**:设置 `archivedAt`,不可再编辑
|
||||
|
||||
### 步骤 3:发布工资条
|
||||
|
||||
- 调用 `POST /batches/:id/publish` → 更新 `Payslip.publishStatus = PUBLISHED`
|
||||
- 或调用 `POST /batches/:id/schedule` → 设置 `publishStatus = SCHEDULED` + `scheduledAt`
|
||||
- 员工在员工端查看已发布的工资条
|
||||
|
||||
---
|
||||
|
||||
## 十五、最低工资保护与递延扣款机制
|
||||
|
||||
### 背景
|
||||
|
||||
入职当月工资按天折算后,可能不足以扣除社保/公积金个人部分,导致实发为负。
|
||||
《劳动法》第四十八条规定用人单位支付劳动者的工资不得低于当地最低工资标准。
|
||||
|
||||
### 最低工资标准配置
|
||||
|
||||
| 配置位置 | 表 | 字段 | 说明 |
|
||||
|---------|---|------|------|
|
||||
| 新账户体系 | `SocialYearStandard` | `minWage` | 按账户+年度设置,优先使用 |
|
||||
| 旧配置表 | `SocialInsuranceConfig` | `minWage` | 兼容回退,按城市匹配 |
|
||||
|
||||
前端配置入口:社保配置页面 → 新建版本 → "最低工资标准"输入框(仅社保 Tab)
|
||||
设为 0 表示不检查最低工资。
|
||||
|
||||
### 保护逻辑(calcBatchEntry 中执行)
|
||||
|
||||
```
|
||||
初始实发 = 应发 - 社保个人 - 公积金个人 - 个税 - 上月递延最低工资补扣
|
||||
|
||||
if (minWage > 0 且 实发 < minWage) {
|
||||
差额 = minWage - 实发
|
||||
|
||||
① 优先递延社保个人部分
|
||||
if (差额 <= 社保个人) {
|
||||
递延社保 = 差额
|
||||
社保个人 -= 差额
|
||||
实发 = minWage
|
||||
}
|
||||
|
||||
② 社保不够,递延全部社保 + 部分公积金
|
||||
else if (差额 <= 社保个人 + 公积金个人) {
|
||||
递延社保 = 社保个人
|
||||
递延公积金 = 差额 - 社保个人
|
||||
社保个人 = 0
|
||||
公积金个人 -= 递延公积金
|
||||
实发 = minWage
|
||||
}
|
||||
|
||||
③ 社保+公积金都不够,差额作为最低工资补齐递延
|
||||
else {
|
||||
递延社保 = 社保个人
|
||||
递延公积金 = 公积金个人
|
||||
递延最低工资补齐 = 差额 - 社保个人 - 公积金个人
|
||||
社保个人 = 0
|
||||
公积金个人 = 0
|
||||
实发 = minWage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### minWageApplied 的含义
|
||||
|
||||
`minWageApplied` = 为让实发达到最低工资而减少的扣款总额,包含三部分:
|
||||
|
||||
| 组成 | 字段 | 含义 |
|
||||
|------|------|------|
|
||||
| 免扣社保 | `deferredSocialEmp` | 本该扣但没扣的社保个人部分 |
|
||||
| 免扣公积金 | `deferredHousingEmp` | 本该扣但没扣的公积金个人部分 |
|
||||
| 额外补齐 | `deferredMinWage` | 社保+公积金全免后仍不足的差额 |
|
||||
| **合计** | `minWageApplied` | = 免扣社保 + 免扣公积金 + 额外补齐 |
|
||||
|
||||
**注意**:`minWageApplied` 不是"额外补了这么多现金",而是"减少了这么多扣款"。
|
||||
|
||||
### 递延扣款的次月补扣
|
||||
|
||||
```
|
||||
本月(入职当月):
|
||||
BatchEntry 记录 deferredSocialEmp / deferredHousingEmp / deferredMinWage
|
||||
实发 = minWage(保护后)
|
||||
|
||||
次月(创建批次时):
|
||||
getPrevDeferred() 读取上月已归档批次的递延金额
|
||||
calcBatchEntry 的 options.prevDeferred 传入
|
||||
本月社保个人 = 当月应缴社保 + 上月递延社保
|
||||
本月实发 = 应发 - (当月社保 + 上月递延社保) - (当月公积金 + 上月递延公积金) - 个税 - 上月递延最低工资补齐
|
||||
BatchEntry 记录 prevDeferredSocialEmp / prevDeferredHousingEmp / prevDeferredMinWage
|
||||
```
|
||||
|
||||
### 示例
|
||||
|
||||
**张三 8 月 20 日入职,月薪 12000,扣款 20000(测试数据)**
|
||||
|
||||
```
|
||||
应发 = 12000 + 0 + 0 + (-500) - 20000 = -8500
|
||||
社保个人(原)= 1575
|
||||
公积金个人(原)= 1800
|
||||
个税 = 0
|
||||
初始实发 = -8500 - 1575 - 1800 - 0 = -11875
|
||||
最低工资 = 2420
|
||||
|
||||
保护触发:
|
||||
差额 = 2420 - (-11875) = 14295
|
||||
递延社保 = 1575(全部免扣)
|
||||
递延公积金 = 1800(全部免扣)
|
||||
递延最低工资补齐 = 14295 - 1575 - 1800 = 10920
|
||||
实发 = 2420
|
||||
|
||||
BatchEntry 记录:
|
||||
minWage = 2420
|
||||
minWageApplied = 14295
|
||||
deferredSocialEmp = 1575
|
||||
deferredHousingEmp = 1800
|
||||
deferredMinWage = 10920
|
||||
socialEmp = 0(当月不扣)
|
||||
housingEmp = 0(当月不扣)
|
||||
netPay = 2420
|
||||
|
||||
次月(9 月):
|
||||
getPrevDeferred() 读取 → { socialEmp: 1575, housingEmp: 1800, minWage: 10920 }
|
||||
9 月社保个人 = 当月应缴社保 + 1575
|
||||
9 月实发 = 应发 - 9月社保(含补扣) - 9月公积金(含补扣) - 个税 - 10920
|
||||
```
|
||||
|
||||
### 前端展示
|
||||
|
||||
| 展示位置 | 样式 | 内容 |
|
||||
|---------|------|------|
|
||||
| 实发列 | 橙色 + ★ | 触发最低工资保护时,悬停显示补齐明细 |
|
||||
| 递延扣款列 | 橙色"递延 ¥X" | 当月递延,悬停显示分项(免扣社保/公积金/额外补齐) |
|
||||
| 递延扣款列 | 蓝色"补扣 ¥X" | 次月补扣上月递延,悬停显示分项 |
|
||||
| 风险列 | 橙色警告图标 | 最低工资保护触发 |
|
||||
| 质量门禁 | 黄色提示 | "N 名员工实发已补齐到最低工资标准..." |
|
||||
|
||||
### prePayrollCheck 检查项(第 11/12 项)
|
||||
|
||||
```
|
||||
// 11. 实发低于最低工资标准(剔除加班费后比较)
|
||||
if (minWage > 0 且 可比收入 < minWage 且 实发 < minWage) {
|
||||
if (minWageApplied > 0) {
|
||||
→ WARNING: 最低工资保护已触发(递延扣款)
|
||||
} else {
|
||||
→ FAIL: 实发低于最低工资标准
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 上月递延扣款待补扣
|
||||
if (prevDeferred > 0) {
|
||||
→ WARNING: 上月递延扣款已补扣
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十六、状态流转
|
||||
|
||||
```
|
||||
DRAFT(草稿,可编辑)
|
||||
↓ 归档
|
||||
ARCHIVED(已归档,锁定不可编辑)
|
||||
↓ 取消归档
|
||||
DRAFT(恢复草稿)
|
||||
↓ 发布工资条
|
||||
Payslip.publishStatus: PUBLISHED(员工端可见)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十七、批次类型详解
|
||||
|
||||
| 类型 | 说明 | 社保公积金 | 个税计算 | 员工来源 |
|
||||
|------|------|-----------|---------|---------|
|
||||
| **REGULAR** 常规发薪 | 月度工资 | ✅ 差额补扣 | 累计预扣法 | 在职 + 本月离职 |
|
||||
| **TERMINATION** 离职结算 | 离职员工当月工资 | ✅ 差额补扣 | 累计预扣法 | 本月离职记录 |
|
||||
| **BONUS** 年终奖/奖金 | 单独计税 | ❌ 不扣 | 单独计税 | 在职 + 本月离职 |
|
||||
| **SEVERANCE** 补偿金 | 离职补偿金 | ❌ 不扣 | 累计预扣法(无社保扣除) | 已审批且有补偿金的离职记录 |
|
||||
|
||||
---
|
||||
|
||||
## 十八、创建批次的 5 种数据初始化模式
|
||||
|
||||
| 模式 | 说明 | 员工来源 | 数据来源 |
|
||||
|------|------|---------|---------|
|
||||
| **copy_last** 复制上月 | 默认模式 | 在职 + 本月离职 | 上月工资条复制基本工资/津贴/扣款 + 当月加班费 |
|
||||
| **blank_employees** 本月空白 | 拉入员工 | 在职 + 本月离职 | 所有金额为 0,手动填写 |
|
||||
| **blank_all** 全空白 | 不拉入员工 | 无 | 后续手动添加人员 |
|
||||
| **copy_batch** 复制指定批次 | 从源批次 | 源批次的员工 | 复制源批次薪资数据 |
|
||||
| **custom** 自定义选择 | 按部门/姓名筛选勾选 | 指定员工 | 金额为 0,手动填写 |
|
||||
@@ -0,0 +1,119 @@
|
||||
# 社保公积金账户关联根部门 + 员工新增自动带出账户
|
||||
|
||||
> 生成时间:2026-08-16
|
||||
> 涉及模块:设置(账户管理)、花名册(新增员工)、组织架构(根部门关联)
|
||||
> 核心文件:`backend/src/routes/social.routes.ts`、`backend/src/routes/department.routes.ts`、`backend/src/services/contract.service.ts`、`frontend/src/pages/Settings.tsx`、`frontend/src/pages/roster/modals.tsx`、`frontend/src/lib/api-services.ts`
|
||||
|
||||
---
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
1. 社保公积金账户创建后,需要关联组织架构的根节点(公司/分公司/子公司,即 level=0 的部门)。
|
||||
2. 员工新增时,选定部门后自动带出该部门所属根节点关联的社保公积金账户,可手工调整,确定后按账户当前生效的年度标准执行。
|
||||
|
||||
---
|
||||
|
||||
## 二、完成内容
|
||||
|
||||
### 1. 账户关联组织架构根节点
|
||||
|
||||
#### 前端:设置页 → 社保公积金账户 Tab
|
||||
|
||||
- 新建/编辑账户弹窗新增"关联根部门"区域
|
||||
- 列出所有 level=0 根部门(公司/分公司/子公司),可多选勾选
|
||||
- 保存账户时同步关联到选中的根部门
|
||||
- 编辑时自动加载已关联的部门并回显勾选状态
|
||||
|
||||
#### 后端新增 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `PUT` | `/social/accounts/:id/departments` | 批量设置账户关联的根部门(先清除旧关联,再批量设置新关联,仅限 level=0) |
|
||||
| `GET` | `/social/accounts/:id/departments` | 查询账户已关联的根部门列表 |
|
||||
| `GET` | `/social/department-account/:departmentId` | 按部门带出适用账户 + 当前生效年度标准 |
|
||||
|
||||
#### 部门 API
|
||||
|
||||
- `PUT /departments/:id` 的 schema 新增 `socialAccountId`、`housingAccountId` 可选字段
|
||||
- 更新时写入 `department.socialAccountId` / `department.housingAccountId`
|
||||
|
||||
#### 账户关联逻辑
|
||||
|
||||
- 账户类型为 `SOCIAL` 时,写入 `department.socialAccountId`
|
||||
- 账户类型为 `HOUSING` 时,写入 `department.housingAccountId`
|
||||
- 设置新关联前,先清除该账户的所有旧关联(`updateMany` 置 null)
|
||||
- 仅允许关联 `level=0` 的根部门
|
||||
|
||||
---
|
||||
|
||||
### 2. 员工新增时自动带出账户
|
||||
|
||||
#### 前端:花名册 → 新增员工
|
||||
|
||||
- 表单 state 新增 `departmentId` 字段,部门选择改为按 `id` 取值
|
||||
- 选定部门后,自动调用 `GET /social/department-account/:departmentId` 查询适用账户
|
||||
- 社保账户、公积金账户下拉框自动选中继承的账户
|
||||
- 账户下方显示当前生效标准(基数范围、比例)
|
||||
- 可手动调整账户选择(下拉切换到其他账户)
|
||||
- 缴费基数默认与月工资一致,可手动修改
|
||||
- 提交时将 `socialAccountId` / `housingAccountId` 写入参保记录
|
||||
|
||||
#### 后端:按部门带出账户逻辑
|
||||
|
||||
`GET /social/department-account/:departmentId` 处理流程:
|
||||
|
||||
1. 从当前部门向上查找,直到 `level=0` 的根部门
|
||||
2. 读取根部门的 `socialAccount` / `housingAccount`
|
||||
3. 若根部门未关联账户,回退到公司默认账户(`isDefault=true`)
|
||||
4. 查询账户当前生效的 `SocialYearStandard`(`isCurrent=true`)
|
||||
5. 返回 `{ socialAccount, housingAccount, socialStandard, housingStandard }`
|
||||
|
||||
#### 后端:参保记录写入 accountId
|
||||
|
||||
`contract.service.ts` 中 `createEmployee`:
|
||||
|
||||
- `EmployeeSocialInsRecord.create` 写入 `accountId: data.socialAccountId || null`
|
||||
- `EmployeeHousingFundRecord.create` 写入 `accountId: data.housingAccountId || null`
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流
|
||||
|
||||
```
|
||||
设置页:创建账户 → 勾选根部门 → 保存
|
||||
↓
|
||||
department.socialAccountId / housingAccountId
|
||||
↓
|
||||
新增员工:选择部门 → 向上找根部门 → 读取关联账户
|
||||
↓
|
||||
自动带出账户 + 当前标准 → 可手动调整
|
||||
↓
|
||||
提交 → EmployeeSocialInsRecord.accountId / EmployeeHousingFundRecord.accountId
|
||||
↓
|
||||
薪资计算:通过员工账户查年度标准 → 计算社保公积金
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、使用流程
|
||||
|
||||
1. 在 **设置 → 社保公积金账户** 中创建账户,勾选关联的根部门
|
||||
2. 新增员工时选择部门 → 自动带出对应账户 → 确认基数 → 保存
|
||||
3. 薪资计算时按员工账户的年度标准执行
|
||||
|
||||
---
|
||||
|
||||
## 五、兼容性
|
||||
|
||||
- 旧 `SocialInsuranceConfig` / `HousingFundConfig` 表保留
|
||||
- 薪资计算优先用新账户年度标准,查不到回退旧表
|
||||
- 未关联账户的根部门,员工新增时回退到公司默认账户
|
||||
- RehireModal(重新入职)保持原有逻辑,不自动带出账户(员工已有参保记录)
|
||||
|
||||
---
|
||||
|
||||
## 六、部署记录
|
||||
|
||||
- 提交:`014c94e` feat: 账户关联根部门 + 员工新增自动带出账户
|
||||
- 部署:2026-08-16 已部署到 https://on.hr8ai.top/
|
||||
- 数据库:schema 同步完成(Department 表 socialAccountId/housingAccountId 字段已存在)
|
||||
@@ -20,6 +20,7 @@ enum Role {
|
||||
ADMIN
|
||||
HR
|
||||
VIEWER
|
||||
SUPPORT
|
||||
}
|
||||
|
||||
enum EmployeeStatus {
|
||||
@@ -139,6 +140,7 @@ model Organization {
|
||||
esignTrainingEnabled Boolean @default(false) // 培训记录电子签
|
||||
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
|
||||
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
|
||||
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -196,7 +198,18 @@ model Organization {
|
||||
benefitPlans EmployeeBenefitPlan[]
|
||||
benefitEnrollments EmployeeBenefitEnrollment[]
|
||||
eSignRecords ESignRecord[]
|
||||
commissionBonuses CommissionBonus[]
|
||||
medicalPeriodPolicies MedicalPeriodPolicy[]
|
||||
socialAccounts SocialAccount[]
|
||||
socialYearStandards SocialYearStandard[]
|
||||
// 组织架构 + 审批流
|
||||
departments Department[]
|
||||
positions Position[]
|
||||
approvalFlows ApprovalFlow[]
|
||||
approvalInstances ApprovalInstance[]
|
||||
// 客服工作台
|
||||
tickets Ticket[]
|
||||
chatSessions ChatSession[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -224,11 +237,17 @@ model Employee {
|
||||
department String
|
||||
position String? // 岗位
|
||||
hireDate DateTime
|
||||
monthlySalary String // AES-256 加密存储
|
||||
// 组织架构扩展
|
||||
departmentId String? // 关联 Department 表(兼容旧 department 字符串)
|
||||
supervisorId String? // 直属上级员工ID
|
||||
monthlySalary String // AES-256 加密存储(月薪总额 = 基本工资 + 绩效工资)
|
||||
baseSalary String? // 基本工资(AES-256 加密,null=旧数据未拆分)
|
||||
performanceSalary String? // 绩效工资(AES-256 加密,可为"0",null=旧数据未拆分)
|
||||
status EmployeeStatus @default(ACTIVE)
|
||||
gender String?
|
||||
phone String?
|
||||
idCardNumber String? // AES-256 加密存储
|
||||
idType String? // 证件类型:ID_CARD(身份证,默认)/PASSPORT/HK_MACAO_TAIWAN/OTHER
|
||||
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
|
||||
emergencyContact String?
|
||||
emergencyPhone String?
|
||||
@@ -246,6 +265,8 @@ model Employee {
|
||||
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
|
||||
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
|
||||
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
||||
supplementaryHousingAccountId String? // 补充公积金账户(可选,高管才有)
|
||||
supplementaryHousingAccount SocialAccount? @relation("EmpSupplementaryHousingAccount", fields: [supplementaryHousingAccountId], references: [id], onDelete: SetNull)
|
||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||
city String? // 员工社保参保城市
|
||||
birthDate DateTime? // 出生日期(从身份证号提取)
|
||||
@@ -285,10 +306,35 @@ model Employee {
|
||||
commercialInsEnrollments CommercialInsuranceEnrollment[]
|
||||
benefitEnrollments EmployeeBenefitEnrollment[]
|
||||
eSignRecords ESignRecord[]
|
||||
commissionBonuses CommissionBonus[]
|
||||
// 组织架构关联
|
||||
dept Department? @relation("DeptEmployees", fields: [departmentId], references: [id])
|
||||
supervisor Employee? @relation("SupervisorSubordinates", fields: [supervisorId], references: [id])
|
||||
subordinates Employee[] @relation("SupervisorSubordinates")
|
||||
approvalInstances ApprovalInstance[]
|
||||
|
||||
@@unique([orgId, idCardHash])
|
||||
}
|
||||
|
||||
/// 提成奖金记录(按月管理,支持正负值:正=奖金,负=扣款)
|
||||
model CommissionBonus {
|
||||
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 // 正=奖金,负=扣款
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, employeeId, month])
|
||||
@@index([orgId, month])
|
||||
@@index([employeeId])
|
||||
}
|
||||
|
||||
model LaborContract {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
@@ -299,10 +345,13 @@ model LaborContract {
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
contractType ContractType
|
||||
contractCategory String? // 合同分类:LABOR_CONTRACT(劳动合同,默认)/LABOR_AGREEMENT(劳务协议)/INTERNSHIP(实习协议)/FLEXIBLE(灵活用工)
|
||||
signMethod SignMethod @default(PAPER)
|
||||
contractYears Int @default(3)
|
||||
probationMonths Int @default(0)
|
||||
probationSalary Int @default(0)
|
||||
baseSalary Int @default(0) // 基本工资(转正后)
|
||||
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
|
||||
renewalCount Int @default(0)
|
||||
attachmentName String?
|
||||
attachmentUrl String?
|
||||
@@ -418,6 +467,88 @@ model AuditLog {
|
||||
|
||||
// ========== 社保 & 通知 & 附件 ==========
|
||||
|
||||
/// 社保/公积金账户(实体化管理,替代原 city 字符串)
|
||||
model SocialAccount {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // SOCIAL=社保账户, HOUSING=公积金账户
|
||||
name String // 账户名称,如"北京总公司社保账户"
|
||||
city String // 参保城市
|
||||
accountNo String? // 社保登记号 / 公积金单位账号
|
||||
bankName String? // 公积金开户行(公积金专用)
|
||||
bankAccount String? // 公积金银行账号(公积金专用)
|
||||
orgName String? // 缴费主体名称(子公司/分公司名称)
|
||||
orgCode String? // 缴费主体统一社会信用代码
|
||||
accountType String? // 公积金账户类型 BASIC=基本, SUPPLEMENTARY=补充
|
||||
isDefault Boolean @default(false)
|
||||
status String @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
yearStandards SocialYearStandard[]
|
||||
socialRecords EmployeeSocialInsRecord[]
|
||||
housingRecords EmployeeHousingFundRecord[]
|
||||
monthlyProcesses SocialMonthlyProcess[]
|
||||
// 部门关联(员工通过部门继承账户)
|
||||
deptSocialAccounts Department[] @relation("DeptSocialAccount")
|
||||
deptHousingAccounts Department[] @relation("DeptHousingAccount")
|
||||
// 员工补充公积金关联
|
||||
empSupplementaryHousing Employee[] @relation("EmpSupplementaryHousingAccount")
|
||||
|
||||
@@unique([orgId, type, name])
|
||||
@@index([orgId, type, city])
|
||||
@@index([orgId, type, isDefault])
|
||||
}
|
||||
|
||||
/// 年度标准(替代原 SocialInsuranceConfig / HousingFundConfig)
|
||||
model SocialYearStandard {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
accountId String
|
||||
account SocialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
|
||||
// 社保比例(type=SOCIAL 时使用)
|
||||
pensionOrg Float @default(16)
|
||||
pensionEmp Float @default(8)
|
||||
medicalOrg Float @default(9.8)
|
||||
medicalEmp Float @default(2)
|
||||
medicalOrgExtra Float @default(0) // 医疗企业附加(固定金额,如北京大病医疗)
|
||||
medicalEmpExtra Float @default(0) // 医疗个人附加(固定金额,如北京3元大病医疗)
|
||||
unemploymentOrg Float @default(0.5)
|
||||
unemploymentEmp Float @default(0.5)
|
||||
injuryOrg Float @default(0.2)
|
||||
maternityOrg Float @default(0.8)
|
||||
baseMin Float @default(6326)
|
||||
baseMax Float @default(33891)
|
||||
medicalBaseMin Float @default(0)
|
||||
medicalBaseMax Float @default(0)
|
||||
extraInsurances Json?
|
||||
|
||||
// 公积金比例(type=HOUSING 时使用)
|
||||
housingOrg Float @default(12)
|
||||
housingEmp Float @default(12)
|
||||
housingBaseMin Float @default(0) // 公积金基数下限(0 时 fallback 到 baseMin)
|
||||
housingBaseMax Float @default(0) // 公积金基数上限(0 时 fallback 到 baseMax)
|
||||
|
||||
// 最低工资标准(按参保城市,每年调整)
|
||||
minWage Float @default(0) // 当地月最低工资标准(0=不检查)
|
||||
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true)
|
||||
adjustmentDone Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([accountId, effectiveFrom])
|
||||
@@index([accountId, isCurrent])
|
||||
}
|
||||
|
||||
model SocialInsuranceConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
@@ -427,6 +558,8 @@ model SocialInsuranceConfig {
|
||||
pensionEmp Float @default(8) // 养老保险 个人比例 %
|
||||
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
|
||||
medicalEmp Float @default(2) // 医疗保险 个人比例 %
|
||||
medicalOrgExtra Float @default(0) // 医疗企业附加(固定金额)
|
||||
medicalEmpExtra Float @default(0) // 医疗个人附加(固定金额,如北京3元)
|
||||
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
|
||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||
@@ -436,6 +569,7 @@ model SocialInsuranceConfig {
|
||||
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 }]
|
||||
minWage Float @default(0) // 当地月最低工资标准(0=不检查)
|
||||
effectiveFrom String // 生效月份 YYYY-MM
|
||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||
@@ -583,6 +717,7 @@ model DisciplinaryRecord {
|
||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||
actionDetail String?
|
||||
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
|
||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||
ackDate DateTime?
|
||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||
@@ -690,6 +825,7 @@ model Payslip {
|
||||
month String // YYYY-MM
|
||||
// 薪酬构成
|
||||
baseSalary Float @default(0)
|
||||
performanceSalary Float @default(0) // 绩效工资
|
||||
overtimePay Float @default(0)
|
||||
weekdayOvertimePay Float @default(0)
|
||||
weekendOvertimePay Float @default(0)
|
||||
@@ -728,7 +864,8 @@ model PayrollBatch {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
month String // YYYY-MM
|
||||
month String // 所属月(计薪月)YYYY-MM,决定调用哪个月的社保标准、个税累计
|
||||
payMonth String? // 发薪年月 YYYY-MM(实际发放月份,如十一提前发薪时=9月,所属月=10月)。null=与所属月相同
|
||||
batchNo Int // 批次序号(1, 2, 3...)
|
||||
name String // 批次名称
|
||||
type PayrollBatchType @default(REGULAR)
|
||||
@@ -781,9 +918,20 @@ model BatchEntry {
|
||||
socialOrg Float @default(0)
|
||||
housingEmp Float @default(0)
|
||||
housingOrg Float @default(0)
|
||||
supplementaryHousingEmp Float @default(0) // 补充公积金个人部分
|
||||
supplementaryHousingOrg Float @default(0) // 补充公积金单位部分
|
||||
tax Float @default(0)
|
||||
totalPay Float @default(0) // 应发合计
|
||||
netPay Float @default(0) // 实发工资
|
||||
// 最低工资保护 + 递延扣款(入职当月工资不足扣社保时,递延到次月补扣)
|
||||
minWage Float @default(0) // 当月适用的最低工资标准
|
||||
minWageApplied Float @default(0) // 当月实际补齐到最低工资的金额(0=未补齐)
|
||||
deferredSocialEmp Float @default(0) // 递延到次月补扣的社保个人部分
|
||||
deferredHousingEmp Float @default(0) // 递延到次月补扣的公积金个人部分
|
||||
deferredMinWage Float @default(0) // 递延到次月补扣的最低工资补齐差额
|
||||
prevDeferredSocialEmp Float @default(0) // 从上月继承的递延社保(本批次已补扣)
|
||||
prevDeferredHousingEmp Float @default(0) // 从上月继承的递延公积金(本批次已补扣)
|
||||
prevDeferredMinWage Float @default(0) // 从上月继承的递延最低工资(本批次已补扣)
|
||||
// 风险提示
|
||||
riskWarnings Json?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -836,7 +984,9 @@ model EmployeeSocialInsRecord {
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京") // 参保城市
|
||||
accountId String? // 关联到 SocialAccount(迁移后非空)
|
||||
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||
city String @default("北京") // 参保城市(兼容旧数据,从 account.city 派生)
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
@@ -849,6 +999,7 @@ model EmployeeSocialInsRecord {
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
@@index([orgId, city])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
model EmployeeHousingFundRecord {
|
||||
@@ -857,7 +1008,9 @@ model EmployeeHousingFundRecord {
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京") // 参保城市
|
||||
accountId String? // 关联到 SocialAccount(迁移后非空)
|
||||
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||
city String @default("北京") // 参保城市(兼容旧数据)
|
||||
startMonth String // 开始缴费年月 YYYY-MM
|
||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||
base Float // 缴费基数
|
||||
@@ -869,6 +1022,7 @@ model EmployeeHousingFundRecord {
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
@@index([employeeId, startMonth, endMonth])
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
||||
@@ -876,6 +1030,8 @@ model SocialMonthlyProcess {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
accountId String? // 关联到账户(迁移后非空)
|
||||
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||
month String // 办理月份 YYYY-MM
|
||||
type String // SOCIAL=社保, HOUSING=公积金
|
||||
status String @default("COMPLETED") // COMPLETED=已办理
|
||||
@@ -887,6 +1043,7 @@ model SocialMonthlyProcess {
|
||||
|
||||
@@unique([orgId, month, type])
|
||||
@@index([orgId, month])
|
||||
@@index([accountId, month])
|
||||
}
|
||||
|
||||
model EmployeeDepartmentRecord {
|
||||
@@ -897,10 +1054,12 @@ model EmployeeDepartmentRecord {
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
oldDepartment String // 调整前部门
|
||||
newDepartment String // 调整后部门
|
||||
oldPosition String? // 调整前职务
|
||||
newPosition String? // 调整后职务
|
||||
effectiveMonth String // 生效年月 YYYY-MM
|
||||
endMonth String? // 失效年月 YYYY-MM(null=至今有效)
|
||||
reason String? // 调部门原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
|
||||
reason String? // 调动原因
|
||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调动
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -1041,6 +1200,7 @@ model AttendanceConfirmation {
|
||||
weekendHours Float @default(0)
|
||||
holidayHours Float @default(0)
|
||||
overtimePay Float @default(0)
|
||||
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
|
||||
confirmedAt DateTime?
|
||||
confirmedBy String?
|
||||
confirmIp String?
|
||||
@@ -1509,6 +1669,7 @@ model ESignRecord {
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
|
||||
signMethod String @default("ELECTRONIC") // ELECTRONIC=电子签署 | PAPER=线下手签
|
||||
flowId String? // 易签宝流程ID
|
||||
documentTitle String // 文件标题
|
||||
documentContent String? // 文件内容(HTML/PDF base64)
|
||||
@@ -1518,13 +1679,183 @@ model ESignRecord {
|
||||
initiatedBy String // 发起人(HR用户ID)
|
||||
completedAt DateTime?
|
||||
expiredAt DateTime?
|
||||
callbackData Json? // 易签宝回调数据
|
||||
callbackData Json? // 易签宝回调数据 / 线下手签证据
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 线下手签专用字段
|
||||
signedAt DateTime? // 线下签署日期
|
||||
signedLocation String? // 签署地点
|
||||
witnessName String? // 见证人姓名
|
||||
witnessPhone String? // 见证人手机号
|
||||
scanFileUrls Json? // 线下签署扫描件URL列表 [{name, url}]
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([employeeId])
|
||||
@@index([contractId])
|
||||
}
|
||||
|
||||
// ==================== 组织架构 + 审批流 ====================
|
||||
|
||||
/// 部门(树形结构,自引用 parent)
|
||||
model Department {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
parentId String?
|
||||
parent Department? @relation("DeptChildren", fields: [parentId], references: [id], onDelete: SetNull)
|
||||
children Department[] @relation("DeptChildren")
|
||||
level Int @default(0) // 层级(0=根)
|
||||
sortOrder Int @default(0) // 同级排序
|
||||
description String?
|
||||
// 社保公积金账户关联(员工通过部门继承账户)
|
||||
socialAccountId String? // 社保账户
|
||||
socialAccount SocialAccount? @relation("DeptSocialAccount", fields: [socialAccountId], references: [id], onDelete: SetNull)
|
||||
housingAccountId String? // 公积金账户
|
||||
housingAccount SocialAccount? @relation("DeptHousingAccount", fields: [housingAccountId], references: [id], onDelete: SetNull)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
employees Employee[] @relation("DeptEmployees")
|
||||
positions Position[]
|
||||
|
||||
@@unique([orgId, name, parentId])
|
||||
@@index([orgId, parentId])
|
||||
}
|
||||
|
||||
/// 岗位字典
|
||||
model Position {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
departmentId String?
|
||||
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
||||
headcount Int @default(0) // 编制人数
|
||||
level String? // 职级(如 P1/P2/M1)
|
||||
description String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([orgId, name, departmentId])
|
||||
@@index([orgId, departmentId])
|
||||
}
|
||||
|
||||
/// 审批流配置
|
||||
model ApprovalFlow {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
|
||||
name String
|
||||
enabled Boolean @default(true)
|
||||
// 步骤配置 JSON: [{ step: 1, approverType: 'SUPERVISOR' | 'PERSON', approverId?: string, name: string }]
|
||||
steps Json
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
instances ApprovalInstance[]
|
||||
|
||||
@@unique([orgId, type])
|
||||
@@index([orgId])
|
||||
}
|
||||
|
||||
/// 审批实例
|
||||
model ApprovalInstance {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
flowId String
|
||||
flow ApprovalFlow @relation(fields: [flowId], references: [id], onDelete: Cascade)
|
||||
type String // 冗余 flow.type,便于查询
|
||||
bizId String? // 关联业务记录ID(如 LeaveRequest.id)
|
||||
bizType String? // 业务类型
|
||||
status String @default("PENDING") // PENDING / APPROVED / REJECTED / CANCELLED
|
||||
currentStep Int @default(1)
|
||||
// 审批记录 JSON: [{ step, approverId, approverName, result, comment, timestamp }]
|
||||
approvals Json @default("[]")
|
||||
employeeId String?
|
||||
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([employeeId])
|
||||
@@index([bizId])
|
||||
}
|
||||
|
||||
// ==================== 客服工作台 ====================
|
||||
|
||||
/// 工单
|
||||
model Ticket {
|
||||
id String @id @default(cuid())
|
||||
orgId String // 提交方租户
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
content String
|
||||
category String? // 分类(如:系统bug/功能咨询/数据问题)
|
||||
priority String @default("NORMAL") // LOW / NORMAL / HIGH / URGENT
|
||||
status String @default("OPEN") // OPEN / IN_PROGRESS / RESOLVED / CLOSED
|
||||
assigneeId String? // 客服处理人 userId
|
||||
createdBy String // 提交人 userId
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
messages TicketMessage[]
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([assigneeId])
|
||||
}
|
||||
|
||||
/// 工单回复
|
||||
model TicketMessage {
|
||||
id String @id @default(cuid())
|
||||
ticketId String
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
content String
|
||||
senderId String
|
||||
senderRole String // USER / SUPPORT
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([ticketId])
|
||||
}
|
||||
|
||||
/// 客服-客户会话
|
||||
model ChatSession {
|
||||
id String @id @default(cuid())
|
||||
orgId String // 客户租户
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
supportUserId String // 客服 userId
|
||||
lastMessage String?
|
||||
lastMessageAt DateTime?
|
||||
unreadByUser Int @default(0)
|
||||
unreadBySupport Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
messages ChatMessage[]
|
||||
|
||||
@@unique([orgId, supportUserId])
|
||||
@@index([supportUserId])
|
||||
}
|
||||
|
||||
/// 会话消息
|
||||
model ChatMessage {
|
||||
id String @id @default(cuid())
|
||||
sessionId String
|
||||
session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
content String
|
||||
senderId String
|
||||
senderRole String // USER / SUPPORT
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 给全部员工对应上正确的社保和公积金账户
|
||||
* 逻辑:按员工所在城市的账户匹配,回退到默认账户
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// 1. 查所有账户(按城市分组)
|
||||
const accounts = await prisma.socialAccount.findMany({
|
||||
select: { id: true, name: true, type: true, city: true, isDefault: true },
|
||||
})
|
||||
console.log('=== 全部账户 ===')
|
||||
for (const a of accounts) {
|
||||
console.log(`${a.type} | ${a.name} | ${a.city} | default=${a.isDefault} | ${a.id}`)
|
||||
}
|
||||
|
||||
// 按城市+类型建索引
|
||||
const socialByCity: Record<string, string> = {}
|
||||
const housingByCity: Record<string, string> = {}
|
||||
let defaultSocial: string | null = null
|
||||
let defaultHousing: string | null = null
|
||||
|
||||
for (const a of accounts) {
|
||||
if (a.type === 'SOCIAL') {
|
||||
if (a.isDefault) defaultSocial = a.id
|
||||
if (a.city) socialByCity[a.city] = a.id
|
||||
} else {
|
||||
if (a.isDefault) defaultHousing = a.id
|
||||
if (a.city) housingByCity[a.city] = a.id
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 查所有部门
|
||||
const depts = await prisma.department.findMany({
|
||||
select: { id: true, name: true, level: true, parentId: true, socialAccountId: true, housingAccountId: true },
|
||||
})
|
||||
console.log('\n=== 部门账户关联(修改前)===')
|
||||
for (const d of depts) {
|
||||
console.log(`${d.name} | level=${d.level} | social=${d.socialAccountId || '无'} | housing=${d.housingAccountId || '无'}`)
|
||||
}
|
||||
|
||||
// 3. 查所有员工及其部门和城市
|
||||
const employees = await prisma.employee.findMany({
|
||||
select: { id: true, name: true, department: true, departmentId: true, city: true, dept: true },
|
||||
})
|
||||
console.log(`\n=== 员工总数: ${employees.length} ===`)
|
||||
|
||||
// 4. 为每个员工找到根部门,检查是否有账户
|
||||
// 如果根部门没有账户,按员工城市匹配账户,更新根部门
|
||||
const rootDeptMap = new Map<string, { name: string; city: string; empCount: number }>()
|
||||
|
||||
for (const emp of employees) {
|
||||
// 向上找根部门
|
||||
let dept: any = emp.dept
|
||||
if (!dept && emp.departmentId) {
|
||||
dept = await prisma.department.findUnique({ where: { id: emp.departmentId } })
|
||||
}
|
||||
while (dept && dept.level > 0 && dept.parentId) {
|
||||
dept = await prisma.department.findUnique({ where: { id: dept.parentId } })
|
||||
}
|
||||
if (dept) {
|
||||
const existing = rootDeptMap.get(dept.id)
|
||||
if (existing) {
|
||||
existing.empCount++
|
||||
} else {
|
||||
rootDeptMap.set(dept.id, { name: dept.name, city: emp.city || '', empCount: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== 根部门及员工城市 ===')
|
||||
for (const [deptId, info] of rootDeptMap) {
|
||||
const dept = await prisma.department.findUnique({ where: { id: deptId }, select: { socialAccountId: true, housingAccountId: true } })
|
||||
console.log(`${info.name} | 员工数=${info.empCount} | 员工城市=${info.city || '无'} | social=${dept?.socialAccountId || '无'} | housing=${dept?.housingAccountId || '无'}`)
|
||||
}
|
||||
|
||||
// 5. 为没有账户的根部门按员工城市匹配账户
|
||||
let updated = 0
|
||||
for (const [deptId, info] of rootDeptMap) {
|
||||
const dept = await prisma.department.findUnique({ where: { id: deptId }, select: { socialAccountId: true, housingAccountId: true, name: true } })
|
||||
|
||||
let socialId = dept?.socialAccountId || null
|
||||
let housingId = dept?.housingAccountId || null
|
||||
|
||||
// 社保账户
|
||||
if (!socialId) {
|
||||
if (info.city && socialByCity[info.city]) {
|
||||
socialId = socialByCity[info.city]
|
||||
} else if (defaultSocial) {
|
||||
socialId = defaultSocial
|
||||
}
|
||||
}
|
||||
|
||||
// 公积金账户
|
||||
if (!housingId) {
|
||||
if (info.city && housingByCity[info.city]) {
|
||||
housingId = housingByCity[info.city]
|
||||
} else if (defaultHousing) {
|
||||
housingId = defaultHousing
|
||||
}
|
||||
}
|
||||
|
||||
if ((socialId && !dept?.socialAccountId) || (housingId && !dept?.housingAccountId)) {
|
||||
await prisma.department.update({
|
||||
where: { id: deptId },
|
||||
data: {
|
||||
...(socialId && !dept?.socialAccountId ? { socialAccountId: socialId } : {}),
|
||||
...(housingId && !dept?.housingAccountId ? { housingAccountId: housingId } : {}),
|
||||
},
|
||||
})
|
||||
console.log(`[更新] 根部门 ${dept?.name} → social=${socialId || '无'} housing=${housingId || '无'}`)
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n更新了 ${updated} 个根部门的账户关联`)
|
||||
|
||||
// 6. 验证:重新查每个员工是否有账户
|
||||
let noSocial = 0
|
||||
let noHousing = 0
|
||||
let hasBoth = 0
|
||||
for (const emp of employees) {
|
||||
let dept: any = emp.dept
|
||||
if (!dept && emp.departmentId) {
|
||||
dept = await prisma.department.findUnique({ where: { id: emp.departmentId } })
|
||||
}
|
||||
while (dept && dept.level > 0 && dept.parentId) {
|
||||
dept = await prisma.department.findUnique({ where: { id: dept.parentId } })
|
||||
}
|
||||
|
||||
let socialId = dept?.socialAccountId || null
|
||||
let housingId = dept?.housingAccountId || null
|
||||
|
||||
// 回退到默认
|
||||
if (!socialId) socialId = defaultSocial
|
||||
if (!housingId) housingId = defaultHousing
|
||||
|
||||
if (socialId && housingId) {
|
||||
hasBoth++
|
||||
} else {
|
||||
if (!socialId) noSocial++
|
||||
if (!housingId) noHousing++
|
||||
console.log(`[缺失] ${emp.name} | social=${socialId ? '有' : '无'} housing=${housingId ? '有' : '无'} | city=${emp.city || '无'} | dept=${dept?.name || '无'}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== 验证结果 ===`)
|
||||
console.log(`有社保+公积金: ${hasBoth}`)
|
||||
console.log(`缺社保: ${noSocial}`)
|
||||
console.log(`缺公积金: ${noHousing}`)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
async function main() {
|
||||
const accounts = await prisma.socialAccount.findMany({ where: { type: 'SOCIAL', city: '北京' }, select: { id: true, name: true } })
|
||||
for (const a of accounts) {
|
||||
const std = await prisma.socialYearStandard.findFirst({ where: { accountId: a.id, isCurrent: true }, select: { medicalOrgExtra: true, medicalEmpExtra: true, extraInsurances: true, effectiveFrom: true } })
|
||||
console.log(a.name + ' | effective=' + std?.effectiveFrom + ' | medicalOrgExtra=' + std?.medicalOrgExtra + ' | medicalEmpExtra=' + std?.medicalEmpExtra + ' | extraInsurances=' + JSON.stringify(std?.extraInsurances))
|
||||
}
|
||||
}
|
||||
main().then(() => process.exit(0))
|
||||
@@ -0,0 +1,36 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
|
||||
async function main() {
|
||||
// 查看已发布的7条工资条明细
|
||||
const published = await prisma.$queryRaw<any[]>`
|
||||
SELECT p."employeeId", e.name, e.department,
|
||||
p."baseSalary", p."overtimePay", p.allowance, p.deduction, p.bonus,
|
||||
p."totalPay", p."socialEmp", p."housingEmp", p.tax, p."netPay",
|
||||
p."publishStatus", p.status, p.month
|
||||
FROM "Payslip" p
|
||||
JOIN "Employee" e ON p."employeeId" = e.id
|
||||
WHERE p.month='2026-08' AND p."publishStatus" = 'PUBLISHED'
|
||||
ORDER BY e.name
|
||||
`
|
||||
console.log('=== 已发布工资条(7条)===')
|
||||
for (const p of published) {
|
||||
console.log(`${p.name} | ${p.department} | base=${p.baseSalary} total=${p.totalPay} net=${p.netPay} social=${p.socialEmp} housing=${p.housingEmp} tax=${p.tax}`)
|
||||
}
|
||||
|
||||
// 查看未发布的23条
|
||||
const unpublished = await prisma.$queryRaw<any[]>`
|
||||
SELECT e.name, e.department,
|
||||
p."totalPay", p."netPay", p."publishStatus"
|
||||
FROM "Payslip" p
|
||||
JOIN "Employee" e ON p."employeeId" = e.id
|
||||
WHERE p.month='2026-08' AND p."publishStatus" IS NULL
|
||||
ORDER BY e.name
|
||||
`
|
||||
console.log('\n=== 未发布工资条(23条)===')
|
||||
for (const p of unpublished) {
|
||||
console.log(`${p.name} | ${p.department} | total=${p.totalPay} net=${p.netPay}`)
|
||||
}
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
main()
|
||||
@@ -0,0 +1,24 @@
|
||||
import prisma from '../src/lib/prisma'
|
||||
|
||||
async function main() {
|
||||
const payslips = await prisma.$queryRaw<any[]>`
|
||||
SELECT month, status, "publishStatus", count(*)::int as cnt
|
||||
FROM "Payslip" WHERE month='2026-08'
|
||||
GROUP BY month, status, "publishStatus"
|
||||
`
|
||||
console.log('Payslip 2026-08:', JSON.stringify(payslips, null, 2))
|
||||
|
||||
const batches = await prisma.$queryRaw<any[]>`
|
||||
SELECT id, name, month, status, "batchNo"::text as bn
|
||||
FROM "PayrollBatch" WHERE month='2026-08' ORDER BY "batchNo"
|
||||
`
|
||||
console.log('Batches 2026-08:', JSON.stringify(batches, null, 2))
|
||||
|
||||
const totalPayslips = await prisma.$queryRaw<any[]>`
|
||||
SELECT count(*)::int as cnt FROM "Payslip" WHERE month='2026-08'
|
||||
`
|
||||
console.log('Total payslips 2026-08:', JSON.stringify(totalPayslips))
|
||||
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
main()
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 检查测试账户是否有关联数据(员工/部门),然后删除无关联的测试账户
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const TEST_IDS = [
|
||||
'cmsve0ucj000h5kfta5te1qli', // 几何社保账户
|
||||
'cmsve0uea001x5kftbvf9e6zq', // 北公积金账户
|
||||
'cmsve0uec001z5kft5sfl4ika', // s公积金账户
|
||||
'cmsve0ued00215kft32gmeug5', // sh公积金账户
|
||||
'cmsve0uef00235kftz4clk842', // sha公积金账户
|
||||
'cmsve0ueg00255kfttwtfeikj', // shan公积金账户
|
||||
'cmsve0uei00275kftbwdrkf2n', // shang公积金账户
|
||||
'cmsve0uej00295kftc157ts8b', // 上公积金账户
|
||||
'cmsve0uek002b5kftnmmsqpfl', // shi公积金账户
|
||||
'cmsve0uen002d5kftnzstcqa3', // shi'j公积金账户
|
||||
'cmsve0ueo002f5kftxhh9r5lw', // shi'ji公积金账户
|
||||
'cmsve0ueq002h5kft2fdu2tlk', // shi'jia公积金账户
|
||||
'cmsve0uer002j5kftsi37wcuo', // shi'jia'z公积金账户
|
||||
'cmsve0uet002l5kftev27qy7o', // shi'jia'zh公积金账户
|
||||
'cmsve0uf5002x5kft16esy662', // 唐山12+6公积金账户
|
||||
'cmsve0uf9002z5kft3lx7yl1x', // 几何公积金账户
|
||||
'cmsve0ufb00315kftp9mnovag', // 几何5+5公积金账户
|
||||
]
|
||||
|
||||
async function main() {
|
||||
console.log('=== 检查关联 ===')
|
||||
let canDelete: string[] = []
|
||||
let blocked: string[] = []
|
||||
|
||||
for (const id of TEST_IDS) {
|
||||
const account = await prisma.socialAccount.findUnique({ where: { id }, select: { name: true, city: true, type: true } })
|
||||
if (!account) {
|
||||
console.log(`[不存在] ${id}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const d1 = await prisma.department.count({ where: { socialAccountId: id } })
|
||||
const d2 = await prisma.department.count({ where: { housingAccountId: id } })
|
||||
|
||||
if (d1 + d2 > 0) {
|
||||
console.log(`[阻止] ${account.type} ${account.name} (${account.city}) — 部门S/H:${d1}/${d2}`)
|
||||
blocked.push(id)
|
||||
} else {
|
||||
console.log(`[可删] ${account.type} ${account.name} (${account.city})`)
|
||||
canDelete.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n可删除 ${canDelete.length} 个,阻止 ${blocked.length} 个`)
|
||||
|
||||
if (canDelete.length === 0) {
|
||||
console.log('无可删除账户')
|
||||
return
|
||||
}
|
||||
|
||||
// 删除:先删关联的年度标准、社保记录、公积金记录,再删账户
|
||||
console.log('\n=== 开始删除 ===')
|
||||
for (const id of canDelete) {
|
||||
const account = await prisma.socialAccount.findUnique({ where: { id }, select: { name: true } })
|
||||
|
||||
// 删年度标准
|
||||
const stdDeleted = await prisma.socialYearStandard.deleteMany({ where: { accountId: id } })
|
||||
// 删员工社保记录
|
||||
const srDeleted = await prisma.employeeSocialInsRecord.deleteMany({ where: { accountId: id } })
|
||||
// 删员工公积金记录
|
||||
const hrDeleted = await prisma.employeeHousingFundRecord.deleteMany({ where: { accountId: id } })
|
||||
// 删月度处理记录
|
||||
const mpDeleted = await prisma.socialMonthlyProcess.deleteMany({ where: { accountId: id } })
|
||||
// 删账户
|
||||
await prisma.socialAccount.delete({ where: { id } })
|
||||
|
||||
console.log(`[已删] ${account?.name} — 标准:${stdDeleted.count} 社保记录:${srDeleted.count} 公积金记录:${hrDeleted.count} 月度:${mpDeleted.count}`)
|
||||
}
|
||||
|
||||
console.log('\n删除完成')
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 批量更新公积金账户的 housingBaseMin/housingBaseMax
|
||||
* 数据来源:各城市2025年度公积金缴存基数上下限
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
// 2025年度各城市公积金基数上下限
|
||||
const CITY_LIMITS: Record<string, { min: number; max: number }> = {
|
||||
'北京': { min: 2540, max: 35811 },
|
||||
'上海': { min: 2690, max: 37302 },
|
||||
'深圳': { min: 2360, max: 44265 },
|
||||
'杭州': { min: 2490, max: 40694 },
|
||||
'石家庄': { min: 2200, max: 26420 },
|
||||
'天津': { min: 2320, max: 27861 },
|
||||
'唐山': { min: 2200, max: 26420 }, // 河北省标准
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const accounts = await prisma.socialAccount.findMany({ where: { type: 'HOUSING' } })
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const limits = CITY_LIMITS[account.city]
|
||||
if (!limits) {
|
||||
console.log(`[跳过] ${account.name} (${account.city}) — 无该城市的公积金上下限数据`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新该账户下所有年度标准的 housingBaseMin/housingBaseMax
|
||||
const result = await prisma.socialYearStandard.updateMany({
|
||||
where: { accountId: account.id },
|
||||
data: {
|
||||
housingBaseMin: limits.min,
|
||||
housingBaseMax: limits.max,
|
||||
},
|
||||
})
|
||||
console.log(`[更新] ${account.name} (${account.city}) → 下限 ${limits.min} / 上限 ${limits.max},影响 ${result.count} 条标准`)
|
||||
updated++
|
||||
}
|
||||
|
||||
console.log(`\n完成:更新 ${updated} 个账户,跳过 ${skipped} 个`)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 更新北京社保账户的大病医疗个人附加(3元/月)
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// 北京社保账户:医疗个人附加3元(大病医疗)
|
||||
const beijingSocial = await prisma.socialAccount.findMany({ where: { type: 'SOCIAL', city: '北京' }, select: { id: true, name: true } })
|
||||
for (const a of beijingSocial) {
|
||||
const result = await prisma.socialYearStandard.updateMany({
|
||||
where: { accountId: a.id, isCurrent: true },
|
||||
data: { medicalEmpExtra: 3 },
|
||||
})
|
||||
console.log(`[更新] ${a.name} → medicalEmpExtra=3,影响 ${result.count} 条`)
|
||||
}
|
||||
|
||||
// 验证
|
||||
for (const a of beijingSocial) {
|
||||
const std = await prisma.socialYearStandard.findFirst({ where: { accountId: a.id, isCurrent: true }, select: { medicalEmpExtra: true, effectiveFrom: true } })
|
||||
console.log(`[验证] ${a.name} | effective=${std?.effectiveFrom} | medicalEmpExtra=${std?.medicalEmpExtra}`)
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 批量补齐无年度标准的账户:从旧 SocialInsuranceConfig / HousingFundConfig 继承数据
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const accounts = await prisma.socialAccount.findMany()
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
// 检查是否已有标准
|
||||
const existing = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId: account.id, isCurrent: true },
|
||||
})
|
||||
if (existing) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// 查旧配置
|
||||
let oldConfig: any = null
|
||||
if (account.type === 'HOUSING') {
|
||||
oldConfig = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: account.orgId, city: account.city },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
} else {
|
||||
oldConfig = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: account.orgId, city: account.city },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!oldConfig) {
|
||||
console.log(`[跳过] ${account.type} ${account.name} (${account.city}) — 无旧配置可继承`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已有同 effectiveFrom 的标准(含历史标准)
|
||||
const dupCheck = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId: account.id, effectiveFrom: oldConfig.effectiveFrom },
|
||||
})
|
||||
if (dupCheck) {
|
||||
// 把已有的设为 current
|
||||
await prisma.socialYearStandard.updateMany({
|
||||
where: { accountId: account.id, effectiveFrom: oldConfig.effectiveFrom },
|
||||
data: { isCurrent: true },
|
||||
})
|
||||
console.log(`[修复] ${account.type} ${account.name} (${account.city}) — 已有标准设为 current`)
|
||||
created++
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建年度标准
|
||||
const std = await prisma.socialYearStandard.create({
|
||||
data: {
|
||||
orgId: account.orgId,
|
||||
accountId: account.id,
|
||||
// 社保比例
|
||||
pensionOrg: oldConfig.pensionOrg || 16,
|
||||
pensionEmp: oldConfig.pensionEmp || 8,
|
||||
medicalOrg: oldConfig.medicalOrg || 9.8,
|
||||
medicalEmp: oldConfig.medicalEmp || 2,
|
||||
medicalOrgExtra: (oldConfig as any).medicalOrgExtra || 0,
|
||||
medicalEmpExtra: (oldConfig as any).medicalEmpExtra || 0,
|
||||
unemploymentOrg: oldConfig.unemploymentOrg || 0.5,
|
||||
unemploymentEmp: oldConfig.unemploymentEmp || 0.5,
|
||||
injuryOrg: oldConfig.injuryOrg || 0.2,
|
||||
maternityOrg: oldConfig.maternityOrg || 0.8,
|
||||
baseMin: oldConfig.baseMin || 6326,
|
||||
baseMax: oldConfig.baseMax || 33891,
|
||||
medicalBaseMin: oldConfig.medicalBaseMin || 0,
|
||||
medicalBaseMax: oldConfig.medicalBaseMax || 0,
|
||||
extraInsurances: oldConfig.extraInsurances || null,
|
||||
// 公积金比例
|
||||
housingOrg: (oldConfig as any).housingOrg || 12,
|
||||
housingEmp: (oldConfig as any).housingEmp || 12,
|
||||
housingBaseMin: 0,
|
||||
housingBaseMax: 0,
|
||||
// 最低工资
|
||||
minWage: (oldConfig as any).minWage || 0,
|
||||
// 生效信息
|
||||
effectiveFrom: oldConfig.effectiveFrom,
|
||||
effectiveTo: null,
|
||||
isCurrent: true,
|
||||
adjustmentDone: false,
|
||||
createdBy: account.createdBy,
|
||||
},
|
||||
})
|
||||
console.log(`[创建] ${account.type} ${account.name} (${account.city}) ← ${oldConfig.effectiveFrom}`)
|
||||
created++
|
||||
}
|
||||
|
||||
console.log(`\n完成:创建 ${created} 个标准,跳过 ${skipped} 个已有标准`)
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 社保公积金账户化迁移脚本
|
||||
* 将旧 SocialInsuranceConfig / HousingFundConfig 迁移到 SocialAccount + SocialYearStandard
|
||||
* 将员工参保记录的 city 关联到 accountId
|
||||
*
|
||||
* 用法: npx tsx scripts/migrate-social-accounts.ts
|
||||
*/
|
||||
import prisma from '../src/lib/prisma'
|
||||
|
||||
async function main() {
|
||||
console.log('========== 社保公积金账户化迁移 ==========')
|
||||
|
||||
// ========== 1. 迁移社保配置 → 社保账户 + 年度标准 ==========
|
||||
console.log('[1/6] 迁移社保配置...')
|
||||
|
||||
// 按 (orgId, city) 分组创建社保账户
|
||||
const socialConfigs = await prisma.socialInsuranceConfig.findMany()
|
||||
const socialAccountMap = new Map<string, string>() // key: orgId:city → accountId
|
||||
|
||||
for (const config of socialConfigs) {
|
||||
const key = `${config.orgId}:${config.city}`
|
||||
if (socialAccountMap.has(key)) continue
|
||||
|
||||
const account = await prisma.socialAccount.create({
|
||||
data: {
|
||||
orgId: config.orgId,
|
||||
type: 'SOCIAL',
|
||||
name: `${config.city}社保账户`,
|
||||
city: config.city,
|
||||
isDefault: true,
|
||||
status: 'ACTIVE',
|
||||
createdBy: 'migration',
|
||||
},
|
||||
})
|
||||
socialAccountMap.set(key, account.id)
|
||||
console.log(` 创建社保账户: ${config.city} → ${account.id}`)
|
||||
}
|
||||
|
||||
// 迁移每条 Config → YearStandard
|
||||
for (const config of socialConfigs) {
|
||||
const key = `${config.orgId}:${config.city}`
|
||||
const accountId = socialAccountMap.get(key)!
|
||||
await prisma.socialYearStandard.create({
|
||||
data: {
|
||||
orgId: config.orgId,
|
||||
accountId,
|
||||
pensionOrg: config.pensionOrg,
|
||||
pensionEmp: config.pensionEmp,
|
||||
medicalOrg: config.medicalOrg,
|
||||
medicalEmp: config.medicalEmp,
|
||||
unemploymentOrg: config.unemploymentOrg,
|
||||
unemploymentEmp: config.unemploymentEmp,
|
||||
injuryOrg: config.injuryOrg,
|
||||
maternityOrg: config.maternityOrg,
|
||||
baseMin: config.baseMin,
|
||||
baseMax: config.baseMax,
|
||||
medicalBaseMin: config.medicalBaseMin,
|
||||
medicalBaseMax: config.medicalBaseMax,
|
||||
extraInsurances: config.extraInsurances as any,
|
||||
effectiveFrom: config.effectiveFrom,
|
||||
effectiveTo: config.effectiveTo,
|
||||
isCurrent: config.isCurrent,
|
||||
adjustmentDone: config.adjustmentDone,
|
||||
createdBy: config.createdBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
console.log(` 迁移 ${socialConfigs.length} 条社保年度标准`)
|
||||
|
||||
// ========== 2. 迁移公积金配置 → 公积金账户 + 年度标准 ==========
|
||||
console.log('[2/6] 迁移公积金配置...')
|
||||
|
||||
const housingConfigs = await prisma.housingFundConfig.findMany()
|
||||
const housingAccountMap = new Map<string, string>() // key: orgId:city:accountType → accountId
|
||||
|
||||
for (const config of housingConfigs) {
|
||||
const accountType = config.accountType || 'BASIC'
|
||||
const key = `${config.orgId}:${config.city}:${accountType}`
|
||||
if (housingAccountMap.has(key)) continue
|
||||
|
||||
const account = await prisma.socialAccount.create({
|
||||
data: {
|
||||
orgId: config.orgId,
|
||||
type: 'HOUSING',
|
||||
name: `${config.city}${accountType === 'SUPPLEMENTARY' ? '补充' : ''}公积金账户`,
|
||||
city: config.city,
|
||||
accountType,
|
||||
isDefault: accountType === 'BASIC',
|
||||
status: 'ACTIVE',
|
||||
createdBy: 'migration',
|
||||
},
|
||||
})
|
||||
housingAccountMap.set(key, account.id)
|
||||
console.log(` 创建公积金账户: ${config.city} ${accountType} → ${account.id}`)
|
||||
}
|
||||
|
||||
for (const config of housingConfigs) {
|
||||
const accountType = config.accountType || 'BASIC'
|
||||
const key = `${config.orgId}:${config.city}:${accountType}`
|
||||
const accountId = housingAccountMap.get(key)!
|
||||
await prisma.socialYearStandard.create({
|
||||
data: {
|
||||
orgId: config.orgId,
|
||||
accountId,
|
||||
housingOrg: config.housingOrg,
|
||||
housingEmp: config.housingEmp,
|
||||
baseMin: config.baseMin,
|
||||
baseMax: config.baseMax,
|
||||
effectiveFrom: config.effectiveFrom,
|
||||
effectiveTo: config.effectiveTo,
|
||||
isCurrent: config.isCurrent,
|
||||
adjustmentDone: config.adjustmentDone,
|
||||
createdBy: config.createdBy,
|
||||
},
|
||||
})
|
||||
}
|
||||
console.log(` 迁移 ${housingConfigs.length} 条公积金年度标准`)
|
||||
|
||||
// ========== 3. 迁移员工社保参保记录 accountId ==========
|
||||
console.log('[3/6] 迁移员工社保参保记录 accountId...')
|
||||
|
||||
const socialRecords = await prisma.employeeSocialInsRecord.findMany({ where: { accountId: null } })
|
||||
let socialUpdated = 0
|
||||
for (const record of socialRecords) {
|
||||
const key = `${record.orgId}:${record.city}`
|
||||
const accountId = socialAccountMap.get(key)
|
||||
if (accountId) {
|
||||
await prisma.employeeSocialInsRecord.update({ where: { id: record.id }, data: { accountId } })
|
||||
socialUpdated++
|
||||
}
|
||||
}
|
||||
console.log(` 更新 ${socialUpdated}/${socialRecords.length} 条社保参保记录`)
|
||||
|
||||
// ========== 4. 迁移员工公积金参保记录 accountId ==========
|
||||
console.log('[4/6] 迁移员工公积金参保记录 accountId...')
|
||||
|
||||
const housingRecords = await prisma.employeeHousingFundRecord.findMany({ where: { accountId: null } })
|
||||
let housingUpdated = 0
|
||||
for (const record of housingRecords) {
|
||||
// 公积金默认用 BASIC 类型账户
|
||||
const key = `${record.orgId}:${record.city}:BASIC`
|
||||
const accountId = housingAccountMap.get(key)
|
||||
if (accountId) {
|
||||
await prisma.employeeHousingFundRecord.update({ where: { id: record.id }, data: { accountId } })
|
||||
housingUpdated++
|
||||
}
|
||||
}
|
||||
console.log(` 更新 ${housingUpdated}/${housingRecords.length} 条公积金参保记录`)
|
||||
|
||||
// ========== 5. 迁移月度办理记录 accountId ==========
|
||||
console.log('[5/6] 迁移月度办理记录 accountId...')
|
||||
|
||||
const monthlyProcesses = await prisma.socialMonthlyProcess.findMany({ where: { accountId: null } })
|
||||
let monthlyUpdated = 0
|
||||
for (const proc of monthlyProcesses) {
|
||||
const snapshot = proc.snapshot as any
|
||||
const city = snapshot?.city || snapshot?.configCity
|
||||
if (city) {
|
||||
const accountMap = proc.type === 'SOCIAL' ? socialAccountMap : housingAccountMap
|
||||
// 社保用 orgId:city,公积金用 orgId:city:BASIC
|
||||
const key = proc.type === 'SOCIAL' ? `${proc.orgId}:${city}` : `${proc.orgId}:${city}:BASIC`
|
||||
const accountId = accountMap.get(key)
|
||||
if (accountId) {
|
||||
await prisma.socialMonthlyProcess.update({ where: { id: proc.id }, data: { accountId } })
|
||||
monthlyUpdated++
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(` 更新 ${monthlyUpdated}/${monthlyProcesses.length} 条月度办理记录`)
|
||||
|
||||
// ========== 6. 验证数据完整性 ==========
|
||||
console.log('[6/6] 验证数据完整性...')
|
||||
|
||||
const accounts = await prisma.socialAccount.count()
|
||||
const standards = await prisma.socialYearStandard.count()
|
||||
const socialWithoutAccount = await prisma.employeeSocialInsRecord.count({ where: { accountId: null } })
|
||||
const housingWithoutAccount = await prisma.employeeHousingFundRecord.count({ where: { accountId: null } })
|
||||
|
||||
console.log(` 账户总数: ${accounts}`)
|
||||
console.log(` 年度标准总数: ${standards}`)
|
||||
console.log(` 社保参保记录无 accountId: ${socialWithoutAccount}`)
|
||||
console.log(` 公积金参保记录无 accountId: ${housingWithoutAccount}`)
|
||||
|
||||
if (socialWithoutAccount > 0 || housingWithoutAccount > 0) {
|
||||
console.log(' ⚠️ 部分参保记录未关联账户(可能城市无对应配置),需手动处理')
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('========== 迁移完成 ==========')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('迁移失败:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
import express from 'express'
|
||||
import path from 'path'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
@@ -23,8 +24,9 @@ app.use(compression({
|
||||
}))
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
origin: true,
|
||||
credentials: true,
|
||||
exposedHeaders: ['Content-Disposition'],
|
||||
}),
|
||||
)
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
@@ -34,6 +36,11 @@ app.get('/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
|
||||
})
|
||||
|
||||
// deploy.sh 验证脚本检查 /api/v1/health
|
||||
app.get('/api/v1/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
|
||||
})
|
||||
|
||||
app.use('/api/v1', apiLimiter)
|
||||
|
||||
// 路由挂载
|
||||
@@ -60,6 +67,10 @@ import auditRoutes from './routes/audit.routes'
|
||||
import calendarRoutes from './routes/calendar.routes'
|
||||
import platformRoutes from './routes/platform.routes'
|
||||
import workProcessRoutes from './routes/work-process.routes'
|
||||
import departmentRoutes from './routes/department.routes'
|
||||
import positionRoutes from './routes/position.routes'
|
||||
import approvalRoutes from './routes/approval.routes'
|
||||
import supportRoutes from './routes/support.routes'
|
||||
import enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||
import specialStatusRoutes from './routes/special-status.routes'
|
||||
import companyFileRoutes from './routes/company-file.routes'
|
||||
@@ -69,6 +80,7 @@ import salaryRoutes from './routes/salary.routes'
|
||||
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
|
||||
import benefitRoutes from './routes/benefit.routes'
|
||||
import esignRoutes from './routes/esign.routes'
|
||||
import commissionBonusRoutes from './routes/commission-bonus.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -92,6 +104,10 @@ app.use('/api/v1/audit', auditRoutes)
|
||||
app.use('/api/v1/calendar', calendarRoutes)
|
||||
app.use('/api/v1/platform', platformRoutes)
|
||||
app.use('/api/v1/work-processes', workProcessRoutes)
|
||||
app.use('/api/v1/departments', departmentRoutes)
|
||||
app.use('/api/v1/positions', positionRoutes)
|
||||
app.use('/api/v1/approvals', approvalRoutes)
|
||||
app.use('/api/v1/support', supportRoutes)
|
||||
app.use('/api/v1/enterprise-templates', enterpriseTemplateRoutes)
|
||||
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
||||
app.use('/api/v1/company-files', companyFileRoutes)
|
||||
@@ -101,6 +117,10 @@ app.use('/api/v1/salary', salaryRoutes)
|
||||
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
|
||||
app.use('/api/v1/benefits', benefitRoutes)
|
||||
app.use('/api/v1/esign', esignRoutes)
|
||||
app.use('/api/v1/commission-bonus', commissionBonusRoutes)
|
||||
|
||||
// 静态文件服务:上传的文件(入职文件、工会回执等)
|
||||
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')))
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
@@ -8,10 +8,15 @@ export interface AuthRequest extends Request {
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
let token: string | undefined
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7)
|
||||
} else if (typeof req.query.token === 'string') {
|
||||
token = req.query.token
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload) {
|
||||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
|
||||
|
||||
@@ -77,6 +77,23 @@ function daysFromNow(date: Date): number {
|
||||
return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏身份证号:保留前6位和后4位,中间用 * 代替
|
||||
* 例:310101199001011234 → 310101********1234
|
||||
*/
|
||||
function maskIdCard(encrypted: string | null): string {
|
||||
if (!encrypted) return '未登记'
|
||||
try {
|
||||
const plain = decrypt(encrypted)
|
||||
if (plain && plain.length >= 10) {
|
||||
return plain.slice(0, 6) + '********' + plain.slice(-4)
|
||||
}
|
||||
return '未登记'
|
||||
} catch {
|
||||
return '未登记'
|
||||
}
|
||||
}
|
||||
|
||||
async function buildOrgContext(orgId: string): Promise<string> {
|
||||
const [employees, risks] = await Promise.all([
|
||||
prisma.employee.findMany({
|
||||
@@ -98,7 +115,7 @@ async function buildOrgContext(orgId: string): Promise<string> {
|
||||
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
|
||||
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
|
||||
if (e.isWorkInjured) specialStatus.push('工伤')
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
|
||||
return `- ${e.name}(身份证:${maskIdCard(e.idCardNumber)},${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}(已工作${tenure}),${contract ? `合同:${contract.contractType},${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
|
||||
}).join('\n')
|
||||
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).join('\n')
|
||||
@@ -248,16 +265,17 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
|
||||
员工详情:
|
||||
- 姓名:${emp.name}
|
||||
- 身份证号:${maskIdCard(emp.idCardNumber)}
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
|
||||
- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}`
|
||||
scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
scopeHint = `请只针对员工【${emp.name}(身份证:${maskIdCard(emp.idCardNumber)})】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
}
|
||||
} else if (department) {
|
||||
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
const empSummary = employees.map(e => `- ${e.name}(身份证:${maskIdCard(e.idCardNumber)}),入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
|
||||
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
|
||||
}
|
||||
@@ -306,16 +324,17 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
|
||||
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
|
||||
员工详情:
|
||||
- 姓名:${emp.name}
|
||||
- 身份证号:${maskIdCard(emp.idCardNumber)}
|
||||
- 部门:${emp.department}
|
||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||
- 状态:${emp.status}
|
||||
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
|
||||
- 合同:${contract ? `${contract.contractType},${contract.startDate.toISOString().slice(0, 10)}至${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}${contractDaysLeft !== null ? `(距到期${contractDaysLeft}天)` : ''}` : '未签合同'}${term ? `\n- 离职/解聘记录:${typeMap[term.type] || term.type},原因:${reasonMap[term.reason] || term.reason},离职日期:${term.terminationDate.toISOString().slice(0, 10)},流程状态:${statusMap[term.status] || term.status},经济补偿金:¥${term.compensation.toFixed(2)}${term.resignationReason ? `,离职原因说明:${term.resignationReason}` : ''}${socialInsEnd}${housingFundEnd},${handoverDone ? '工作交接已完成' : '工作交接未完成'},${agreementSigned ? '已签署解除协议' : '未签署解除协议'}` : ''}`
|
||||
scopeHint = `请只针对员工【${emp.name}】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
scopeHint = `请只针对员工【${emp.name}(身份证:${maskIdCard(emp.idCardNumber)})】进行风险预测,不要分析其他员工。以上数据中的工龄、天数等均已由系统计算,请直接使用,不要重新计算。`
|
||||
}
|
||||
} else if (department) {
|
||||
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
|
||||
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
const empSummary = employees.map(e => `- ${e.name}(身份证:${maskIdCard(e.idCardNumber)}),入职${e.hireDate.toISOString().slice(0, 10)},${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
|
||||
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
|
||||
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 审批流路由
|
||||
* 提供审批流配置和实例管理
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
import { processApproval, cancelApproval } from '../services/approval.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const flowSchema = z.object({
|
||||
type: z.string(), // LEAVE / TERMINATION / SALARY_CHANGE / OTHER
|
||||
name: z.string().min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
steps: z.array(z.object({
|
||||
step: z.number().int().min(1).max(3),
|
||||
approverType: z.enum(['SUPERVISOR', 'DEPT_HEAD', 'PERSON']),
|
||||
approverId: z.string().optional(),
|
||||
name: z.string(),
|
||||
})).min(1, '至少一个审批步骤').max(3, '最多三个审批步骤'),
|
||||
})
|
||||
|
||||
/** 获取审批流配置列表 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const flows = await prisma.approvalFlow.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: flows })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建/更新审批流配置(upsert by type) */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = flowSchema.parse(req.body)
|
||||
const existing = await prisma.approvalFlow.findFirst({
|
||||
where: { orgId: req.user!.orgId!, type: data.type },
|
||||
})
|
||||
let flow
|
||||
if (existing) {
|
||||
flow = await prisma.approvalFlow.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...data, createdBy: req.user!.id },
|
||||
})
|
||||
} else {
|
||||
flow = await prisma.approvalFlow.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: flow })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取待我审批的实例 */
|
||||
router.get('/pending', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const instances = await prisma.approvalInstance.findMany({
|
||||
where: { orgId: req.user!.orgId!, status: 'PENDING' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
res.json({ success: true, data: instances })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 处理审批 */
|
||||
router.post('/:id/process', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { result, comment } = req.body as { result: 'APPROVED' | 'REJECTED'; comment?: string }
|
||||
if (!result || !['APPROVED', 'REJECTED'].includes(result)) {
|
||||
throw { code: 'VALIDATION_ERROR', message: 'result 必须为 APPROVED 或 REJECTED' }
|
||||
}
|
||||
const approverName = req.user!.id || '审批人'
|
||||
const outcome = await processApproval(req.user!.orgId!, id, req.user!.id, approverName, result, comment)
|
||||
res.json({ success: true, data: outcome })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 取消审批 */
|
||||
router.post('/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await cancelApproval(req.user!.orgId!, id)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -67,6 +67,7 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
weekendHours: z.number().min(0),
|
||||
holidayHours: z.number().min(0),
|
||||
overtimePay: z.number().min(0),
|
||||
deductionAmount: z.number().min(0).optional(),
|
||||
})),
|
||||
})
|
||||
const { month, items } = schema.parse(req.body)
|
||||
@@ -126,6 +127,22 @@ router.post('/batch-confirm', authMiddleware, async (req: AuthRequest, res: Resp
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/** 更新考勤确认的扣款金额 */
|
||||
router.put('/confirmations/:id/deduction', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { deductionAmount } = req.body
|
||||
const record = await prisma.attendanceConfirmation.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '考勤确认记录不存在' } })
|
||||
const updated = await prisma.attendanceConfirmation.update({
|
||||
where: { id: req.params.id },
|
||||
data: { deductionAmount: Number(deductionAmount) || 0 },
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 班次管理 ==========
|
||||
|
||||
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -140,7 +140,7 @@ router.get('/employee-summary', async (req: AuthRequest, res: Response, next: Ne
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true } },
|
||||
plan: { select: { id: true, name: true, category: true, amount: true, frequency: true } },
|
||||
},
|
||||
})
|
||||
const summary: Record<string, any> = {}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 提成奖金路由
|
||||
* 管理按月提成奖金/扣款的 CRUD、批量导入、模板下载
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import {
|
||||
listByMonth,
|
||||
summaryByMonth,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
batchImport,
|
||||
} from '../services/commission-bonus.service'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } })
|
||||
|
||||
/**
|
||||
* 按月查询提成奖金列表
|
||||
* GET /commission-bonus?month=YYYY-MM
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const [records, summary] = await Promise.all([
|
||||
listByMonth(orgId, month),
|
||||
summaryByMonth(orgId, month),
|
||||
])
|
||||
res.json({ success: true, data: { records, summary, month } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 新增单条提成奖金
|
||||
* POST /commission-bonus body: { employeeId, month, amount, remark? }
|
||||
*/
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { employeeId, month, amount, remark } = req.body
|
||||
if (!employeeId || !month || amount === undefined) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId/month/amount' } })
|
||||
}
|
||||
const record = await create(orgId, req.user!.id, { employeeId, month, amount: Number(amount), remark })
|
||||
await auditLog(req, 'CREATE', 'COMMISSION_BONUS', record.id, { employeeId, month, amount })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 更新单条
|
||||
* PUT /commission-bonus/:id body: { amount?, remark? }
|
||||
*/
|
||||
router.put('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { amount, remark } = req.body
|
||||
const record = await update(orgId, req.params.id, { amount: amount !== undefined ? Number(amount) : undefined, remark })
|
||||
await auditLog(req, 'UPDATE', 'COMMISSION_BONUS', req.params.id, { amount, remark })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除单条
|
||||
* DELETE /commission-bonus/:id
|
||||
*/
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
await remove(orgId, req.params.id)
|
||||
await auditLog(req, 'DELETE', 'COMMISSION_BONUS', req.params.id)
|
||||
res.json({ success: true, data: { message: '已删除' } })
|
||||
} catch (err: any) {
|
||||
if (err.code === 'NOT_FOUND') return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 批量导入 Excel
|
||||
* POST /commission-bonus/import multipart: file, month
|
||||
* Excel 列:员工姓名 | 证件号码 | 金额 | 备注
|
||||
*/
|
||||
router.post('/import', upload.single('file'), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const month = req.body.month || new Date().toISOString().slice(0, 7)
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传 Excel 文件' } })
|
||||
}
|
||||
const wb = XLSX.read(req.file.buffer, { type: 'buffer' })
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const rows: any[] = XLSX.utils.sheet_to_json(ws, { defval: '' })
|
||||
|
||||
const parsed = rows.map((r) => ({
|
||||
employeeName: String(r['员工姓名'] || r['姓名'] || '').trim() || undefined,
|
||||
idCardNumber: String(r['证件号码'] || r['身份证号'] || '').trim() || undefined,
|
||||
amount: parseFloat(r['金额'] || r['提成奖金'] || '0') || 0,
|
||||
remark: String(r['备注'] || '').trim() || undefined,
|
||||
})).filter((r) => r.employeeName || r.idCardNumber)
|
||||
|
||||
const result = await batchImport(orgId, req.user!.id, month, parsed)
|
||||
await auditLog(req, 'IMPORT', 'COMMISSION_BONUS', undefined, { month, ...result })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 下载导入模板
|
||||
* GET /commission-bonus/template
|
||||
*/
|
||||
router.get('/template', (req: AuthRequest, res: Response) => {
|
||||
const ws = XLSX.utils.aoa_to_sheet([
|
||||
['员工姓名', '证件号码', '金额', '备注'],
|
||||
['张三', '110101199001011234', '5000', '销售提成'],
|
||||
['李四', '', '-200', '迟到扣款'],
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, '提成奖金模板')
|
||||
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="commission-bonus-template.xlsx"')
|
||||
res.send(buf)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 部门管理路由
|
||||
* 提供部门的增删改查(树形结构)
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const createDeptSchema = z.object({
|
||||
name: z.string().min(1, '部门名称必填'),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().default(0),
|
||||
description: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
const updateDeptSchema = createDeptSchema.partial().extend({
|
||||
socialAccountId: z.string().nullable().optional(),
|
||||
housingAccountId: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
/** 获取部门树 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
// 兼容历史组织:若无任何部门,自动创建根部门(公司名)
|
||||
const existing = await prisma.department.findFirst({ where: { orgId: req.user!.orgId! } })
|
||||
if (!existing) {
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId! } })
|
||||
if (org) {
|
||||
await prisma.department.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
name: org.name,
|
||||
parentId: null,
|
||||
level: 0,
|
||||
sortOrder: 0,
|
||||
description: '组织根节点',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: { _count: { select: { employees: true, positions: true } } },
|
||||
})
|
||||
res.json({ success: true, data: departments })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建部门 */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createDeptSchema.parse(req.body)
|
||||
// 空字符串视为无父部门,统一转为 null
|
||||
let parentId = data.parentId && data.parentId.trim() !== '' ? data.parentId : null
|
||||
// 若未指定父部门,默认挂到根部门(level=0)下
|
||||
if (!parentId) {
|
||||
const root = await prisma.department.findFirst({ where: { orgId: req.user!.orgId!, parentId: null }, orderBy: { sortOrder: 'asc' } })
|
||||
if (root) parentId = root.id
|
||||
}
|
||||
let level = 0
|
||||
if (parentId) {
|
||||
const parent = await prisma.department.findFirst({ where: { id: parentId, orgId: req.user!.orgId! } })
|
||||
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
|
||||
level = parent.level + 1
|
||||
}
|
||||
const dept = await prisma.department.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
parentId,
|
||||
level,
|
||||
sortOrder: data.sortOrder,
|
||||
description: data.description,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: dept })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新部门 */
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = updateDeptSchema.parse(req.body)
|
||||
// 空字符串视为无父部门,统一转为 null
|
||||
const parentId = data.parentId !== undefined && data.parentId !== null && data.parentId.trim() !== '' ? data.parentId : data.parentId === undefined ? undefined : null
|
||||
// 防止循环引用
|
||||
if (parentId === id) throw { code: 'VALIDATION_ERROR', message: '不能将自身设为父部门' }
|
||||
let level: number | undefined
|
||||
if (parentId) {
|
||||
const parent = await prisma.department.findFirst({ where: { id: parentId, orgId: req.user!.orgId! } })
|
||||
if (!parent) throw { code: 'NOT_FOUND', message: '父部门不存在' }
|
||||
level = parent.level + 1
|
||||
} else if (parentId === null) {
|
||||
level = 0
|
||||
}
|
||||
const dept = await prisma.department.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.name !== undefined ? { name: data.name } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(level !== undefined ? { level } : {}),
|
||||
...(data.socialAccountId !== undefined ? { socialAccountId: data.socialAccountId } : {}),
|
||||
...(data.housingAccountId !== undefined ? { housingAccountId: data.housingAccountId } : {}),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: dept })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除部门 */
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
// 检查是否有子部门
|
||||
const children = await prisma.department.findFirst({ where: { parentId: id, orgId: req.user!.orgId! } })
|
||||
if (children) throw { code: 'VALIDATION_ERROR', message: '请先删除子部门' }
|
||||
// 检查是否有关联员工
|
||||
const employees = await prisma.employee.findFirst({ where: { departmentId: id, orgId: req.user!.orgId! } })
|
||||
if (employees) throw { code: 'VALIDATION_ERROR', message: '该部门下仍有员工,无法删除' }
|
||||
await prisma.department.delete({ where: { id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Router } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import { autoCreateEsignRecord } from '../services/esign.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
import { sha256, decrypt } from '../lib/crypto'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
@@ -116,6 +118,27 @@ router.get('/check-id-card', authMiddleware, async (req: AuthRequest, res, next)
|
||||
}
|
||||
})
|
||||
|
||||
// 手机号查重
|
||||
router.get('/check-phone', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const phone = req.query.phone as string
|
||||
if (!phone || phone.length < 11) {
|
||||
return res.json({ success: true, data: { exists: false } })
|
||||
}
|
||||
// 手机号加密存储,需遍历匹配(量小可接受)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, department: true, status: true, phone: true },
|
||||
})
|
||||
const matched = employees.find(e => {
|
||||
try { return e.phone ? decrypt(e.phone) === phone : false } catch { return false }
|
||||
})
|
||||
res.json({ success: true, data: { exists: !!matched, employee: matched ? { id: matched.id, name: matched.name, department: matched.department, status: matched.status } : undefined } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
@@ -173,6 +196,30 @@ router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 管理员重置员工密码(重置为手机号后6位)
|
||||
* POST /employees/:id/reset-password
|
||||
*/
|
||||
router.post('/:id/reset-password', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
select: { id: true, phone: true, name: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
// 重置为手机号后6位,无手机号则用 123456
|
||||
const defaultPassword = employee.phone ? employee.phone.slice(-6) : '123456'
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10)
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } })
|
||||
await auditLog(req, 'RESET_PASSWORD', 'EMPLOYEE', employee.id, { employeeName: employee.name })
|
||||
res.json({ success: true, data: { message: `密码已重置为手机号后6位:${defaultPassword}` } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 批量续签合规预检
|
||||
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
@@ -289,6 +336,8 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) =>
|
||||
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||||
createdBy: req.user!.id,
|
||||
}).catch(() => {})
|
||||
|
||||
// 注意:电子签署记录由前端根据 signMethod 决定是否创建,避免重复
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
|
||||
@@ -146,13 +146,59 @@ router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Respon
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 包装为 HTML 格式以确保 Word 正确打开
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
let content = template.content
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
if (Object.keys(variables).length > 0) {
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
// 如果内容已包含HTML标签,直接返回
|
||||
if (/<[a-z][\s\S]*>/i.test(text)) return text
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -1,11 +1,49 @@
|
||||
/**
|
||||
* 电子签署路由 — 管理端
|
||||
*
|
||||
* 完整签署流程:
|
||||
* 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链)
|
||||
* 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署
|
||||
* 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件
|
||||
* 4. 易签宝回调(对接后启用):更新状态 + 回写PDF
|
||||
*
|
||||
* 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署
|
||||
*/
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { z } from 'zod'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||
import { renderTemplate, getTemplateById } from '../services/template.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||||
|
||||
/** 场景与组织电子签开关的映射 */
|
||||
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
|
||||
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
|
||||
RESIGNATION: null, // 离职协议不需要额外开关
|
||||
POLICY: 'esignPolicyEnabled',
|
||||
PAYSLIP: 'esignPayslipEnabled',
|
||||
ONBOARDING: 'esignOnboardingEnabled',
|
||||
}
|
||||
|
||||
/** 场景与模板ID的映射(自动渲染文件内容) */
|
||||
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
|
||||
CONTRACT: 'tpl_fixed_term_contract',
|
||||
RESIGNATION: 'tpl_termination_agreement',
|
||||
POLICY: null,
|
||||
PAYSLIP: null,
|
||||
ONBOARDING: null,
|
||||
}
|
||||
|
||||
const createSignSchema = z.object({
|
||||
contractId: z.string().optional(),
|
||||
employeeId: z.string().min(1),
|
||||
@@ -13,9 +51,13 @@ const createSignSchema = z.object({
|
||||
documentContent: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
scene: z.string().optional(),
|
||||
templateId: z.string().optional(), // 可指定模板,不传则按 scene 自动匹配
|
||||
templateVars: z.record(z.string()).optional(), // 模板变量
|
||||
})
|
||||
|
||||
// 签署记录列表
|
||||
/**
|
||||
* 签署记录列表
|
||||
*/
|
||||
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const status = req.query.status as string | undefined
|
||||
@@ -35,53 +77,353 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 发起签署
|
||||
// TODO: 对接易签宝 API — 此处为框架预留
|
||||
// 1. 调用易签宝创建签署流程
|
||||
// 2. 获取签署链接
|
||||
// 3. 保存 flowId 和 signUrl
|
||||
/**
|
||||
* 待签合同列表(按员工聚合)
|
||||
* GET /esign/pending
|
||||
*
|
||||
* 汇总两类未签署记录,按员工聚合:
|
||||
* 1. EsignRecord 中 status 为 PENDING/SIGNING 的(所有场景)
|
||||
* 2. LaborContract 中 signDate 为空且员工在职的(纸质合同未登记签署日期)
|
||||
*
|
||||
* 返回格式:[{ employeeId, name, department, phone, pendingItems: [{ type, title, scene, signMethod, status, createdAt, recordId, contractId }] }]
|
||||
*/
|
||||
router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 1. 查询未完成的 EsignRecord
|
||||
const pendingEsign = await prisma.eSignRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
status: { in: ['PENDING', 'SIGNING'] },
|
||||
},
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 2. 查询 signDate 为空的 LaborContract(且员工在职)
|
||||
const pendingContracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
signDate: null,
|
||||
employee: { status: 'ACTIVE' },
|
||||
},
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 3. 按员工聚合
|
||||
const employeeMap = new Map<string, {
|
||||
employeeId: string
|
||||
name: string
|
||||
department: string | null
|
||||
phone: string | null
|
||||
pendingItems: any[]
|
||||
}>()
|
||||
|
||||
// 辅助函数:添加员工到 map
|
||||
const ensureEmployee = (emp: { id: string; name: string; department: string | null; phone: string | null }) => {
|
||||
if (!employeeMap.has(emp.id)) {
|
||||
employeeMap.set(emp.id, {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
phone: emp.phone,
|
||||
pendingItems: [],
|
||||
})
|
||||
}
|
||||
return employeeMap.get(emp.id)!
|
||||
}
|
||||
|
||||
// 汇总 EsignRecord
|
||||
for (const r of pendingEsign) {
|
||||
const emp = ensureEmployee(r.employee)
|
||||
emp.pendingItems.push({
|
||||
type: 'esign',
|
||||
recordId: r.id,
|
||||
contractId: r.contractId,
|
||||
title: r.documentTitle,
|
||||
scene: r.scene,
|
||||
signMethod: r.signMethod,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 汇总 LaborContract(排除已有 EsignRecord 关联的,避免重复)
|
||||
const CONTRACT_TYPE_LABEL: Record<string, string> = {
|
||||
FIXED: '固定期限劳动合同',
|
||||
UNFIXED: '无固定期限劳动合同',
|
||||
UNSIGNED: '未签合同',
|
||||
LABOR: '劳务协议',
|
||||
INTERNSHIP: '实习协议',
|
||||
DISPATCH: '劳务派遣合同',
|
||||
OUTSOURCING: '外包合同',
|
||||
PARTTIME: '非全日制合同',
|
||||
}
|
||||
const esignContractIds = new Set(pendingEsign.filter(r => r.contractId).map(r => r.contractId))
|
||||
for (const c of pendingContracts) {
|
||||
if (esignContractIds.has(c.id)) continue // 已有电子签署记录的不重复
|
||||
const emp = ensureEmployee(c.employee)
|
||||
emp.pendingItems.push({
|
||||
type: 'contract',
|
||||
recordId: null,
|
||||
contractId: c.id,
|
||||
title: CONTRACT_TYPE_LABEL[c.contractType] || `${c.contractType}合同`,
|
||||
scene: 'CONTRACT',
|
||||
signMethod: c.signMethod,
|
||||
status: 'PENDING',
|
||||
createdAt: c.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 转为数组,按待签数量降序、姓名排序
|
||||
const result = Array.from(employeeMap.values()).sort((a, b) => {
|
||||
if (b.pendingItems.length !== a.pendingItems.length) return b.pendingItems.length - a.pendingItems.length
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 登记线下合同签署日期
|
||||
* POST /esign/sign-date body: { contractId, signDate }
|
||||
*
|
||||
* 适用于纸质合同(signMethod=PAPER)未登记签署日期的情况。
|
||||
* 电子签合同的签署日期由电签系统回写,不通过此接口修改。
|
||||
*/
|
||||
router.post('/sign-date', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { contractId, signDate } = req.body
|
||||
if (!contractId || !signDate) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractId 或 signDate' } })
|
||||
}
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { id: contractId, orgId: req.user!.orgId },
|
||||
select: { id: true, signMethod: true, contractType: true, employeeId: true, employee: { select: { name: true } } },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '合同不存在' } })
|
||||
}
|
||||
if (contract.signMethod === 'ELECTRONIC') {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '电子签合同的签署日期由电签系统自动回写,不可手动修改' } })
|
||||
}
|
||||
// 解析日期字符串(YYYY-MM-DD),手动构造本地中午时间避免时区偏移
|
||||
const dateStr = String(signDate).slice(0, 10)
|
||||
const [y, m, d] = dateStr.split('-').map(Number)
|
||||
const parsedDate = new Date(y, (m || 1) - 1, d || 1, 12, 0, 0, 0)
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期格式无效' } })
|
||||
}
|
||||
// 签署日期不能晚于当前日期
|
||||
const today = new Date()
|
||||
today.setHours(23, 59, 59, 999)
|
||||
if (parsedDate > today) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期不能晚于当前日期' } })
|
||||
}
|
||||
// 更新合同签署日期
|
||||
await prisma.laborContract.update({
|
||||
where: { id: contractId },
|
||||
data: { signDate: parsedDate },
|
||||
})
|
||||
// 同时创建一条已完成的线下手签 EsignRecord,使签署记录 Tab 可见
|
||||
const existingRecord = await prisma.eSignRecord.findFirst({
|
||||
where: { contractId, status: 'COMPLETED' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!existingRecord) {
|
||||
// 合同类型枚举转中文
|
||||
const CONTRACT_TYPE_LABEL: Record<string, string> = {
|
||||
FIXED: '固定期限劳动合同',
|
||||
UNFIXED: '无固定期限劳动合同',
|
||||
UNSIGNED: '未签合同',
|
||||
LABOR: '劳务协议',
|
||||
INTERNSHIP: '实习协议',
|
||||
DISPATCH: '劳务派遣合同',
|
||||
OUTSOURCING: '外包合同',
|
||||
PARTTIME: '非全日制合同',
|
||||
}
|
||||
const contractLabel = CONTRACT_TYPE_LABEL[contract.contractType] || '合同'
|
||||
await prisma.eSignRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
contractId,
|
||||
employeeId: contract.employeeId,
|
||||
scene: 'CONTRACT',
|
||||
signMethod: 'PAPER',
|
||||
documentTitle: `${contractLabel}线下签署登记`,
|
||||
status: 'COMPLETED',
|
||||
completedAt: parsedDate, // 完成时间 = 实际签署日期
|
||||
signedAt: parsedDate, // 签署时间 = HR 选择的签署日期
|
||||
signedLocation: null,
|
||||
initiatedBy: req.user!.id,
|
||||
createdBy: req.user!.id,
|
||||
remark: 'HR 登记线下签署日期',
|
||||
},
|
||||
})
|
||||
}
|
||||
await auditLog(req, 'SIGN_DATE', 'CONTRACT', contractId, { employeeName: contract.employee.name, signDate: parsedDate.toISOString() })
|
||||
res.json({ success: true, data: { message: '签署日期已登记', signDate: parsedDate.toISOString() } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 催办 — 生成员工一次性自动登录链接(指向员工端签署页)
|
||||
* POST /esign/remind body: { employeeId }
|
||||
*
|
||||
* 返回自动登录 URL,HR 可复制或生成二维码发给员工
|
||||
*/
|
||||
router.post('/remind', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId } = req.body
|
||||
if (!employeeId) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
|
||||
}
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, phone: true, orgId: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
|
||||
}
|
||||
// 生成一次性 token(24 小时有效,给员工充足时间签署)
|
||||
const token = jwt.sign(
|
||||
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
|
||||
AUTO_LOGIN_SECRET,
|
||||
{ expiresIn: '24h' },
|
||||
)
|
||||
const url = `${process.env.PORTAL_BASE_URL || ''}/portal/auto-login?token=${token}&redirect=/portal/esign`
|
||||
await auditLog(req, 'REMIND', 'ESIGN', employeeId, { employeeName: employee.name })
|
||||
res.json({ success: true, data: { url, token, employeeName: employee.name, phone: employee.phone } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 发起签署
|
||||
* - 校验组织电子签开关
|
||||
* - 自动从模板渲染文件内容
|
||||
* - 创建 ESignRecord
|
||||
* - 创建证据链(发起签署事件)
|
||||
*/
|
||||
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createSignSchema.parse(req.body)
|
||||
const scene = data.scene || 'CONTRACT'
|
||||
|
||||
// 预留:调用易签宝 API 创建签署流程
|
||||
// const esignResult = await esignApi.createFlow({
|
||||
// title: data.documentTitle,
|
||||
// signerPhone: employee.phone,
|
||||
// signerName: employee.name,
|
||||
// content: data.documentContent,
|
||||
// })
|
||||
// const flowId = esignResult.flowId
|
||||
// const signUrl = esignResult.signUrl
|
||||
// 1. 校验组织电子签开关
|
||||
const orgFlag = SCENE_ORG_FLAG_MAP[scene]
|
||||
if (orgFlag) {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.user!.orgId },
|
||||
select: { [orgFlag]: true, name: true } as any,
|
||||
})
|
||||
if (org && !(org as any)[orgFlag]) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'ESIGN_DISABLED', message: `组织未开启${scene === 'POLICY' ? '规章制度' : scene === 'PAYSLIP' ? '工资条' : scene === 'ONBOARDING' ? '入职文件' : '该场景'}电子签功能,请在系统设置中开启` },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 获取员工信息
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: data.employeeId, orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, department: true, idCardNumber: true, position: true, monthlySalary: true, hireDate: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
// 3. 自动渲染文件内容(优先使用指定模板,否则按 scene 匹配)
|
||||
let documentContent = data.documentContent || ''
|
||||
if (!documentContent) {
|
||||
const templateId = data.templateId || SCENE_TEMPLATE_MAP[scene]
|
||||
if (templateId) {
|
||||
const template = getTemplateById(templateId)
|
||||
if (template) {
|
||||
// 自动填充模板变量
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { name: true } })
|
||||
const vars: Record<string, string> = {
|
||||
companyName: org?.name || '',
|
||||
employeeName: employee.name || '',
|
||||
idCard: employee.idCardNumber || '',
|
||||
position: employee.position || '',
|
||||
monthlySalary: String(employee.monthlySalary || ''),
|
||||
...data.templateVars,
|
||||
}
|
||||
documentContent = renderTemplate(templateId, vars) || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 创建签署记录
|
||||
const record = await prisma.eSignRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
contractId: data.contractId || null,
|
||||
employeeId: data.employeeId,
|
||||
scene: data.scene || 'CONTRACT',
|
||||
scene,
|
||||
documentTitle: data.documentTitle,
|
||||
documentContent: data.documentContent || null,
|
||||
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
|
||||
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
|
||||
documentContent: documentContent || null,
|
||||
status: 'PENDING',
|
||||
initiatedBy: req.user!.id,
|
||||
createdBy: req.user!.id,
|
||||
remark: data.remark || null,
|
||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
|
||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
|
||||
// 5. 创建证据链 — 发起签署事件
|
||||
await createEvidence({
|
||||
orgId: req.user!.orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: record.id,
|
||||
employeeId: data.employeeId,
|
||||
events: [{
|
||||
action: `发起电子签署:${data.documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `场景:${scene},发起人:${req.user!.id}`,
|
||||
}],
|
||||
createdBy: req.user!.id,
|
||||
}).catch(() => {})
|
||||
|
||||
await auditLog(req, 'ESIGN_CREATE', 'ESIGN_RECORD', record.id, { employeeId: data.employeeId, scene, documentTitle: data.documentTitle })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record,
|
||||
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
|
||||
message: '签署记录已创建,员工可在员工端查看并签署',
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 查询签署状态
|
||||
// TODO: 对接易签宝 API — 查询流程状态并同步
|
||||
/**
|
||||
* 签署记录详情
|
||||
*/
|
||||
router.get('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true, phone: true } },
|
||||
},
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 查询签署状态
|
||||
*/
|
||||
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
@@ -89,22 +431,41 @@ router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
|
||||
// 预留:调用易签宝 API 查询流程状态
|
||||
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
|
||||
// if (esignStatus !== record.status) {
|
||||
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
|
||||
// }
|
||||
// 检查是否已过期但状态仍为 PENDING
|
||||
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
})
|
||||
return res.json({ success: true, data: updated })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 易签宝回调接口(无需认证)
|
||||
/**
|
||||
* 获取签署记录的证据链
|
||||
*/
|
||||
router.get('/:id/evidence', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const evidence = await prisma.evidenceChain.findMany({
|
||||
where: { orgId: req.user!.orgId, refId: req.params.id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: evidence })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 易签宝回调接口(无需认证)
|
||||
* 对接易签宝后启用:验证签名 + 更新状态 + 回写PDF + 追加证据链
|
||||
*/
|
||||
router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
||||
|
||||
// TODO: 验证易签宝回调签名
|
||||
// TODO: 对接易签宝后验证回调签名
|
||||
// if (!verifyEsignCallback(req.headers, req.body)) {
|
||||
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
|
||||
// }
|
||||
@@ -122,16 +483,26 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
},
|
||||
})
|
||||
|
||||
// 如果关联了合同,更新合同的签署方式和电子合同URL
|
||||
if (record.contractId && status === 'COMPLETED') {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: {
|
||||
signMethod: 'ELECTRONIC',
|
||||
electronicContractUrl: signedPdfUrl || null,
|
||||
},
|
||||
})
|
||||
// 签署完成 → 回写合同 + 追加证据链
|
||||
if (status === 'COMPLETED') {
|
||||
if (record.contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: {
|
||||
signMethod: 'ELECTRONIC',
|
||||
electronicContractUrl: signedPdfUrl || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await appendEvidence(record.orgId, '', {
|
||||
action: '易签宝回调:签署完成',
|
||||
timestamp: new Date().toISOString(),
|
||||
location: `flowId: ${flowId},PDF: ${signedPdfUrl || '无'}`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await auditLog({} as any, 'ESIGN_CALLBACK', 'ESIGN_RECORD', record.id, { flowId, status })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +510,208 @@ router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 取消签署
|
||||
/**
|
||||
* 取消签署
|
||||
* - 更新状态为 CANCELLED
|
||||
* - 追加证据链
|
||||
*/
|
||||
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const record = await prisma.eSignRecord.update({
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
if (record.status === 'COMPLETED') {
|
||||
return res.status(400).json({ success: false, error: { message: '已完成的签署不可取消' } })
|
||||
}
|
||||
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
|
||||
// 追加证据链
|
||||
const evidence = await prisma.evidenceChain.findFirst({
|
||||
where: { orgId: req.user!.orgId, refId: record.id },
|
||||
})
|
||||
if (evidence) {
|
||||
await appendEvidence(req.user!.orgId, evidence.id, {
|
||||
action: '取消签署',
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `操作人:${req.user!.id}`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
await auditLog(req, 'ESIGN_CANCEL', 'ESIGN_RECORD', record.id, {})
|
||||
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 线下手签登记 ==========
|
||||
|
||||
/** 线下签署扫描件上传目录 */
|
||||
const paperSignDir = path.join(process.cwd(), 'uploads', 'paper-sign')
|
||||
if (!fs.existsSync(paperSignDir)) fs.mkdirSync(paperSignDir, { recursive: true })
|
||||
|
||||
const paperSignUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: paperSignDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (allowed.includes(ext)) cb(null, true)
|
||||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP/WEBP/TIFF 格式'))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传线下签署扫描件
|
||||
* 支持多文件上传,返回文件URL列表
|
||||
*/
|
||||
router.post('/paper-upload', authMiddleware, paperSignUpload.array('files', 10), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const files = req.files as Express.Multer.File[]
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||
}
|
||||
const fileUrls = files.map(f => ({
|
||||
name: f.originalname,
|
||||
url: `/uploads/paper-sign/${f.filename}`,
|
||||
size: f.size,
|
||||
}))
|
||||
res.json({ success: true, data: fileUrls })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/** 线下手签登记 Schema */
|
||||
const paperSignSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
contractId: z.string().optional(),
|
||||
scene: z.string().default('CONTRACT'),
|
||||
documentTitle: z.string().min(1),
|
||||
signedAt: z.string().min(1), // 签署日期
|
||||
signedLocation: z.string().optional(), // 签署地点
|
||||
witnessName: z.string().optional(), // 见证人姓名
|
||||
witnessPhone: z.string().optional(), // 见证人手机号
|
||||
scanFileUrls: z.array(z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
})).min(1, '至少上传一份签署扫描件'),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 线下手签登记
|
||||
* - 创建 ESignRecord(signMethod=PAPER, status=COMPLETED)
|
||||
* - 保存签署信息(签署日期/地点/见证人/扫描件)
|
||||
* - 创建证据链(线下签署登记事件)
|
||||
* - 回写合同签署方式
|
||||
*/
|
||||
router.post('/paper-sign', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = paperSignSchema.parse(req.body)
|
||||
|
||||
// 获取员工信息
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: data.employeeId, orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, department: true },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
// 签署日期不能晚于当前日期
|
||||
const signDate = new Date(data.signedAt)
|
||||
const todayEnd = new Date()
|
||||
todayEnd.setHours(23, 59, 59, 999)
|
||||
if (signDate > todayEnd) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '签署日期不能晚于当前日期' } })
|
||||
}
|
||||
|
||||
// 创建签署记录(线下手签直接为 COMPLETED 状态)
|
||||
const record = await prisma.eSignRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
contractId: data.contractId || null,
|
||||
employeeId: data.employeeId,
|
||||
scene: data.scene,
|
||||
signMethod: 'PAPER',
|
||||
documentTitle: data.documentTitle,
|
||||
status: 'COMPLETED',
|
||||
initiatedBy: req.user!.id,
|
||||
createdBy: req.user!.id,
|
||||
remark: data.remark || null,
|
||||
completedAt: new Date(data.signedAt),
|
||||
expiredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 线下签署记录保留1年
|
||||
// 线下手签专用字段
|
||||
signedAt: new Date(data.signedAt),
|
||||
signedLocation: data.signedLocation || null,
|
||||
witnessName: data.witnessName || null,
|
||||
witnessPhone: data.witnessPhone || null,
|
||||
scanFileUrls: data.scanFileUrls as any,
|
||||
// 签署证据
|
||||
callbackData: {
|
||||
signMethod: 'PAPER',
|
||||
signedAt: data.signedAt,
|
||||
signedLocation: data.signedLocation,
|
||||
witnessName: data.witnessName,
|
||||
witnessPhone: data.witnessPhone,
|
||||
scanFileCount: data.scanFileUrls.length,
|
||||
registeredBy: req.user!.id,
|
||||
registeredAt: new Date().toISOString(),
|
||||
} as any,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建证据链 — 线下签署登记
|
||||
await createEvidence({
|
||||
orgId: req.user!.orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: record.id,
|
||||
employeeId: data.employeeId,
|
||||
events: [{
|
||||
action: `线下手签登记:${data.documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `签署日期:${data.signedAt.slice(0, 10)},签署地点:${data.signedLocation || '未填写'},见证人:${data.witnessName || '无'},扫描件:${data.scanFileUrls.length}份`,
|
||||
}],
|
||||
createdBy: req.user!.id,
|
||||
}).catch(() => {})
|
||||
|
||||
// 回写合同签署方式
|
||||
if (data.contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: data.contractId },
|
||||
data: {
|
||||
signMethod: 'PAPER',
|
||||
// 第一份扫描件作为合同附件
|
||||
attachmentUrl: data.scanFileUrls[0]?.url || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await auditLog(req, 'PAPER_SIGN_REGISTER', 'ESIGN_RECORD', record.id, {
|
||||
employeeId: data.employeeId,
|
||||
documentTitle: data.documentTitle,
|
||||
signedAt: data.signedAt,
|
||||
scanFileCount: data.scanFileUrls.length,
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record,
|
||||
message: '线下手签登记成功,证据链已记录',
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, ne
|
||||
{ header: '状态', key: 'status', width: 8 },
|
||||
{ header: '入职日期', key: 'hireDate', width: 12 },
|
||||
{ header: '手机号', key: 'phone', width: 13 },
|
||||
{ header: '身份证号', key: 'idCardNumber', width: 20 },
|
||||
{ header: '证件号码', key: 'idCardNumber', width: 20 },
|
||||
{ header: '月工资', key: 'monthlySalary', width: 10 },
|
||||
{ header: '社保基数', key: 'socialInsBase', width: 10 },
|
||||
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
|
||||
@@ -478,7 +478,7 @@ router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthReq
|
||||
let seq = 0
|
||||
for (const e of entries) {
|
||||
seq++
|
||||
// 解密身份证号
|
||||
// 解密证件号码
|
||||
let idCard: string = ''
|
||||
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import multer from 'multer'
|
||||
import * as XLSX from 'xlsx'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { requireAdmin } from '../middleware/rbac'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
@@ -18,17 +19,17 @@ function contentDisposition(filename: string): string {
|
||||
const router = Router()
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||
|
||||
// 身份证号格式校验(18位正则 + 校验位算法)
|
||||
// 证件号码格式校验(18位正则 + 校验位算法)
|
||||
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
||||
if (!idCard) return { valid: true }
|
||||
const s = idCard.trim()
|
||||
// 15位身份证号升级为18位
|
||||
// 15位证件号码升级为18位
|
||||
if (/^\d{15}$/.test(s)) {
|
||||
const upgraded = upgrade15To18(s)
|
||||
return { valid: true, upgraded }
|
||||
}
|
||||
if (!/^\d{17}[\dXx]$/.test(s)) {
|
||||
return { valid: false, error: '身份证号格式错误(应为18位)' }
|
||||
return { valid: false, error: '证件号码格式错误(应为18位)' }
|
||||
}
|
||||
// 校验位算法
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
@@ -36,7 +37,7 @@ function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; er
|
||||
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||
const expected = checkCodes[sum % 11]
|
||||
if (s.charAt(17).toUpperCase() !== expected) {
|
||||
return { valid: false, error: '身份证号校验位错误' }
|
||||
return { valid: false, error: '证件号码校验位错误' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
@@ -124,7 +125,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '身份证号')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), department: val(getField(r, '部门')) || '未分配', hireDate: getField(r, '入职日期'), salary: num(getField(r, '月工资')), phone: val(getField(r, '手机号')), idCard: val(getField(r, '证件号码')), city: val(getField(r, '参保城市')) || null, status: 'normal', errors: [] as string[], warnings: [] as string[] }
|
||||
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||
const hireDate = parseDate(getField(r, '入职日期'))
|
||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
||||
@@ -144,8 +145,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(contractSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), contractType: val(getField(r, '合同类型')), startDate: getField(r, '合同开始日期'), endDate: getField(r, '合同结束日期'), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const sd = parseDate(getField(r, '合同开始日期'))
|
||||
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -159,8 +160,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const otType = val(getField(r, '加班类型')) || '工作日加班'
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), hours: num(getField(r, '加班时长')), otType, status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const dt = parseDate(getField(r, '日期'))
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -173,8 +174,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), violationType: val(getField(r, '违纪类型')), description: val(getField(r, '描述')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
preview.disciplinary.push(row)
|
||||
}
|
||||
@@ -185,8 +186,8 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
||||
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '身份证号')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
|
||||
const row: any = { rowNo: i + 2, name: val(getField(r, '姓名')), idCard: val(getField(r, '证件号码')), date: getField(r, '日期'), attStatus: val(getField(r, '考勤状态')), status: 'normal', errors: [] as string[] }
|
||||
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||
const dt = parseDate(getField(r, '日期'))
|
||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
||||
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
|
||||
@@ -259,14 +260,42 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const salary = String(num(getField(r, '月工资')))
|
||||
if (salary === '0') { result.skipped++; result.errors.push(`员工第${i + 2}行:月工资为空`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '月工资为空' }); continue }
|
||||
|
||||
let idCard = val(getField(r, '身份证号'))
|
||||
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:身份证号为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '身份证号为空' }); continue }
|
||||
let idCard = val(getField(r, '证件号码'))
|
||||
if (!idCard) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
|
||||
if (idCard) {
|
||||
const idCheck = validateIdCard(idCard)
|
||||
if (!idCheck.valid) { result.skipped++; result.errors.push(`员工第${i + 2}行:${idCheck.error}`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: idCheck.error }); continue }
|
||||
if (idCheck.upgraded) idCard = idCheck.upgraded
|
||||
}
|
||||
|
||||
// 同企业内身份证查重
|
||||
if (idCard) {
|
||||
const idCardExists = await prisma.employee.findFirst({
|
||||
where: { orgId, idCardHash: sha256(idCard) },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (idCardExists) {
|
||||
result.skipped++
|
||||
result.errors.push(`员工第${i + 2}行:证件号码已存在(${idCardExists.name}),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码已存在' })
|
||||
continue
|
||||
}
|
||||
}
|
||||
// 同企业内手机号查重
|
||||
const importPhone = val(getField(r, '手机号'))
|
||||
if (importPhone) {
|
||||
const phoneExists = await prisma.employee.findFirst({
|
||||
where: { orgId, phone: importPhone },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
if (phoneExists) {
|
||||
result.skipped++
|
||||
result.errors.push(`员工第${i + 2}行:手机号已存在(${phoneExists.name}),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '手机号已存在' })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 社保基数:值为 0 或「无」表示不参保
|
||||
const socialInsBaseVal = num(getField(r, '社保基数'))
|
||||
const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(getField(r, '社保基数')) === '不缴'
|
||||
@@ -277,6 +306,11 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴'
|
||||
const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary))
|
||||
|
||||
// 默认密码:手机号后6位(无手机号则 123456)
|
||||
const importPhoneVal = val(getField(r, '手机号')) || null
|
||||
const defaultPwd = importPhoneVal ? importPhoneVal.slice(-6) : '123456'
|
||||
const importPasswordHash = await bcrypt.hash(defaultPwd, 10)
|
||||
|
||||
const emp = await prisma.employee.create({
|
||||
data: {
|
||||
orgId, name, department: dept, hireDate,
|
||||
@@ -284,7 +318,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
gender: val(getField(r, '性别')) || (idCard ? extractGenderFromIdCard(idCard) : null),
|
||||
femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER'
|
||||
: (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null,
|
||||
phone: val(getField(r, '手机号')) || null,
|
||||
phone: importPhoneVal,
|
||||
passwordHash: importPasswordHash,
|
||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||
idCardHash: idCard ? sha256(idCard) : null,
|
||||
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
|
||||
@@ -329,8 +364,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
const msg = e?.message || ''
|
||||
if (msg.includes('Unique constraint')) {
|
||||
result.duplicates++
|
||||
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
|
||||
result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
|
||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
|
||||
} else if (msg.includes('invalid') || msg.includes('validation')) {
|
||||
result.skipped++
|
||||
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
|
||||
@@ -353,7 +388,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const startDate = parseDate(getField(r, '合同开始日期'))
|
||||
@@ -399,7 +434,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -429,7 +464,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -462,7 +497,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const date = parseDate(getField(r, '日期'))
|
||||
@@ -495,30 +530,30 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
const empData = [
|
||||
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
||||
{ '姓名*': '张三', '部门': '技术部', '性别(选填,留空自动识别)': '男', '手机号': '13800138000', '证件号码': '110101199001011234', '入职日期*': '2023-03-01', '月工资*': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
|
||||
]
|
||||
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '身份证号', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
|
||||
const empWs = XLSX.utils.json_to_sheet(empData, { header: ['姓名*', '部门', '性别(选填,留空自动识别)', '手机号', '证件号码', '入职日期*', '月工资*', '社保基数', '公积金基数', '专项附加扣除', '参保城市', '紧急联系人', '紧急联系电话', '住址', '开户行', '银行账号', '孕期', '医疗期', '工伤'] })
|
||||
// 设置示例行样式(灰色背景)
|
||||
empWs['!cols'] = [{ wch: 10 }, { wch: 12 }, { wch: 22 }, { wch: 13 }, { wch: 20 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 18 }, { wch: 10 }, { wch: 18 }, { wch: 6 }, { wch: 6 }, { wch: 6 }]
|
||||
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
|
||||
|
||||
const contractData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期*': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
|
||||
|
||||
const otData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||
|
||||
const discData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||
|
||||
const attData = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
|
||||
|
||||
@@ -558,7 +593,7 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
}
|
||||
|
||||
function findEmp(r: any) {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
if (idCard) {
|
||||
const emp = empByHash.get(sha256(idCard))
|
||||
if (emp) return emp
|
||||
@@ -778,10 +813,10 @@ router.post('/monthly', authMiddleware, requireAdmin, upload.single('file'), asy
|
||||
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
|
||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
|
||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
|
||||
const attOtData = [{
|
||||
'姓名': '张三',
|
||||
'身份证号': '110101199001011234',
|
||||
'证件号码': '110101199001011234',
|
||||
'日期': '2024-06-01',
|
||||
'考勤状态': '正常',
|
||||
'上班时间': '09:00',
|
||||
@@ -798,16 +833,16 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
|
||||
|
||||
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
const salaryData = [{ '姓名': '张三', '证件号码': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
|
||||
|
||||
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
const socialData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
|
||||
|
||||
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
const hfData = [{ '姓名': '张三', '证件号码': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
|
||||
|
||||
const discData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
|
||||
const discData = [{ '姓名': '张三', '证件号码': '110101199001011234', '日期': '2024-06-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' }]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
@@ -847,7 +882,7 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(getField(r, '姓名')))
|
||||
if (!empId) { result.errors.push(`第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||
const entryId = entryByEmp.get(empId)
|
||||
@@ -863,7 +898,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
|
||||
// 重新计算税费
|
||||
const calcResult = await calcBatchEntry(orgId, empId, batch.month, inputs, batch.type)
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...calcResult } })
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({ where: { id: entryId }, data: { ...inputs, ...entryData } })
|
||||
result.updated++
|
||||
} catch (e: any) {
|
||||
result.errors.push(`第${i + 2}行:${e?.message || '导入失败'}`)
|
||||
@@ -905,8 +941,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
||||
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 },
|
||||
{ '姓名*': '张三', '证件号码*': '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' })
|
||||
@@ -920,8 +956,8 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
|
||||
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||
const wb = XLSX.utils.book_new()
|
||||
const data = [
|
||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
||||
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
||||
{ '姓名*': '张三', '证件号码': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
||||
{ '姓名*': '李四', '证件号码': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
||||
]
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
|
||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||
@@ -952,7 +988,7 @@ router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('f
|
||||
const r = rows[i] as any
|
||||
try {
|
||||
const name = val(getField(r, '姓名'))
|
||||
const idCard = val(getField(r, '身份证号'))
|
||||
const idCard = val(getField(r, '证件号码'))
|
||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(name)
|
||||
if (!empId) { result.skipped++; result.errors.push(`第${i + 2}行:找不到员工「${name}」`); result.details.push({ row: i + 2, name, status: 'skipped', message: '找不到员工' }); continue }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
import { isInProbation } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
@@ -377,8 +378,11 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
|
||||
const deduction = deductions[emp.id] || 0
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按月份判定是否仍在试用期
|
||||
const monthEnd = new Date(`${month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts[0]
|
||||
if (isInProbation(latestContract, monthEnd) && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try {
|
||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
@@ -592,8 +596,8 @@ router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFu
|
||||
])
|
||||
|
||||
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
|
||||
const socialBase = emp.socialInsBase || baseSalary
|
||||
const housingBase = emp.housingFundBase || baseSalary
|
||||
const socialBase = emp.socialInsBase != null ? emp.socialInsBase : baseSalary
|
||||
const housingBase = emp.housingFundBase != null ? emp.housingFundBase : baseSalary
|
||||
|
||||
// 计算社保公积金
|
||||
let socialEmp = 0, housingEmp = 0
|
||||
|
||||
@@ -10,6 +10,42 @@ import {
|
||||
generatePayslipFromBatches,
|
||||
prePayrollCheck,
|
||||
} from '../services/payroll.service'
|
||||
import { isInProbation } from '../services/contract.service'
|
||||
import { getBonusByMonthAndEmployeeIds } from '../services/commission-bonus.service'
|
||||
|
||||
/**
|
||||
* 查询上月递延扣款数据(上月已归档批次中该员工的递延金额)
|
||||
* 用于本月创建批次时补扣上月未扣完的社保/公积金/最低工资补齐
|
||||
*/
|
||||
async function getPrevDeferred(orgId: string, employeeId: string, currentMonth: string): Promise<{ socialEmp: number; housingEmp: number; minWage: number }> {
|
||||
// 计算上月份
|
||||
const [y, m] = currentMonth.split('-').map(Number)
|
||||
const prevDate = new Date(y, m - 2, 1)
|
||||
const prevMonth = `${prevDate.getFullYear()}-${String(prevDate.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 查上月已归档批次中该员工的递延记录
|
||||
const prevEntries = await prisma.batchEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId,
|
||||
batch: { month: prevMonth, status: 'ARCHIVED' },
|
||||
},
|
||||
select: {
|
||||
deferredSocialEmp: true,
|
||||
deferredHousingEmp: true,
|
||||
deferredMinWage: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 汇总上月所有批次的递延金额
|
||||
const result = {
|
||||
socialEmp: prevEntries.reduce((s, e) => s + (e.deferredSocialEmp || 0), 0),
|
||||
housingEmp: prevEntries.reduce((s, e) => s + (e.deferredHousingEmp || 0), 0),
|
||||
minWage: prevEntries.reduce((s, e) => s + (e.deferredMinWage || 0), 0),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// RFC 5987 编码中文文件名
|
||||
function contentDisposition(filename: string): string {
|
||||
@@ -218,6 +254,7 @@ router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: Ne
|
||||
// 创建批次
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
payMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
@@ -228,7 +265,7 @@ const createBatchSchema = z.object({
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, payMonth, type, mode, sourceBatchId, employeeIds, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||||
@@ -283,7 +320,18 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
// SEVERANCE 批次:仅包含已审批通过(APPROVED/EXECUTING/COMPLETED)且有补偿金的离职记录
|
||||
if (type === 'SEVERANCE') {
|
||||
const eligibleTerms = terminations.filter(t =>
|
||||
(t.status === 'APPROVED' || t.status === 'EXECUTING' || t.status === 'COMPLETED') &&
|
||||
(t.compensation > 0 || (t.compensationBreakdown as any)?.total > 0)
|
||||
)
|
||||
employees = eligibleTerms.map(t => t.employee)
|
||||
// 缓存 terminationRecord 以便后续 entry 创建时读取补偿金
|
||||
;(req as any)._severanceTerms = new Map(eligibleTerms.map(t => [t.employeeId, t]))
|
||||
} else {
|
||||
employees = terminations.map(t => t.employee)
|
||||
}
|
||||
} else {
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
@@ -303,6 +351,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
data: {
|
||||
orgId,
|
||||
month,
|
||||
payMonth: payMonth || null,
|
||||
batchNo,
|
||||
name: batchName,
|
||||
type,
|
||||
@@ -317,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||||
for (const emp of employees) {
|
||||
let baseSalary = 0
|
||||
let performanceSalary = 0
|
||||
let overtimePay = 0
|
||||
let allowance = 0
|
||||
let deduction = 0
|
||||
let bonus = 0
|
||||
|
||||
// 解密员工工资结构
|
||||
let empBaseSalary = 0
|
||||
let empPerformanceSalary = 0
|
||||
if (emp.baseSalary) {
|
||||
try { empBaseSalary = Number(decrypt(emp.baseSalary)) || 0 } catch { empBaseSalary = Number(emp.baseSalary) || 0 }
|
||||
}
|
||||
if (emp.performanceSalary) {
|
||||
try { empPerformanceSalary = Number(decrypt(emp.performanceSalary)) || 0 } catch { empPerformanceSalary = Number(emp.performanceSalary) || 0 }
|
||||
}
|
||||
|
||||
if (mode === 'copy_batch' && sourceEntries) {
|
||||
// 复制指定批次:从源条目复制数据
|
||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||
if (srcEntry) {
|
||||
baseSalary = srcEntry.baseSalary
|
||||
performanceSalary = srcEntry.performanceSalary || 0
|
||||
overtimePay = srcEntry.overtimePay
|
||||
allowance = srcEntry.allowance
|
||||
deduction = srcEntry.deduction
|
||||
@@ -341,30 +402,88 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按批次月份判定是否仍在试用期(试用期结束日 = 合同开始日 + 试用期月数)
|
||||
// 试用期且 probationSalary > 0 → 用试用期工资;否则用转正工资
|
||||
const batchMonthEnd = new Date(`${month}-28T23:59:59`) // 月末近似
|
||||
const latestContract = emp.contracts?.[0]
|
||||
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
||||
if (inProbation && latestContract.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0 // 试用期一般不发绩效
|
||||
} else if (prevPayslip) {
|
||||
// 非试用期:优先用上月工资条的基本工资(保持薪资连续性)
|
||||
baseSalary = prevPayslip.baseSalary
|
||||
performanceSalary = prevPayslip.performanceSalary || 0
|
||||
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
|
||||
// 新数据:有工资结构
|
||||
baseSalary = empBaseSalary
|
||||
performanceSalary = empPerformanceSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
// 旧数据兼容:回退到 monthlySalary
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
if (prevPayslip) baseSalary = prevPayslip.baseSalary
|
||||
overtimePay = overtime?.totalPay || 0
|
||||
allowance = prevPayslip?.allowance || 0
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
} else if (mode === 'custom' || mode === 'blank_employees') {
|
||||
// 自定义/空白员工模式:取员工工资结构(非试用期)
|
||||
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts?.[0]
|
||||
const inProbation = isInProbation(latestContract, batchMonthEnd)
|
||||
if (inProbation && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0
|
||||
} else if (empBaseSalary > 0 || empPerformanceSalary > 0) {
|
||||
baseSalary = empBaseSalary
|
||||
performanceSalary = empPerformanceSalary
|
||||
} else if (emp.monthlySalary && mode === 'custom') {
|
||||
// custom 模式旧数据兼容
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
// blank_employees 模式:金额默认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 }
|
||||
// blank_all: 所有金额默认 0
|
||||
|
||||
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
|
||||
// blank_employees / blank_all 模式下所有金额应为 0,不覆盖试用期工资
|
||||
if (type !== 'SEVERANCE' && type !== 'TERMINATION' && mode !== 'blank_employees' && mode !== 'blank_all') {
|
||||
const batchMonthEnd = new Date(`${month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts?.[0]
|
||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract?.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
performanceSalary = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SEVERANCE 批次:从离职记录读取补偿金作为应发金额,不走工资/社保/个税计算
|
||||
let severanceAmount = 0
|
||||
let severanceBreakdown: any = null
|
||||
if (type === 'SEVERANCE') {
|
||||
const severanceTerms: Map<string, any> = (req as any)._severanceTerms || new Map()
|
||||
const termRecord = severanceTerms.get(emp.id)
|
||||
if (termRecord) {
|
||||
severanceBreakdown = termRecord.compensationBreakdown
|
||||
// 优先取 compensationBreakdown.total(含手动调整),否则取 compensation
|
||||
severanceAmount = (severanceBreakdown as any)?.total || termRecord.compensation || 0
|
||||
baseSalary = severanceAmount
|
||||
}
|
||||
}
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||||
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
|
||||
let calcResult: any
|
||||
try {
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
} 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 }
|
||||
if (type === 'SEVERANCE' && severanceAmount > 0) {
|
||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 }
|
||||
} else {
|
||||
try {
|
||||
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
|
||||
const prevDeferred = await getPrevDeferred(orgId, emp.id, month)
|
||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, performanceSalary, overtimePay, allowance, deduction, bonus }, type, { prevDeferred })
|
||||
} 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, minWage: 0, minWageApplied: 0, deferredSocialEmp: 0, deferredHousingEmp: 0, deferredMinWage: 0, prevDeferredSocialEmp: 0, prevDeferredHousingEmp: 0, prevDeferredMinWage: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// 风险提示
|
||||
@@ -376,6 +495,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
baseSalary,
|
||||
performanceSalary,
|
||||
overtimePay,
|
||||
allowance,
|
||||
deduction,
|
||||
@@ -384,9 +504,20 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
socialOrg: calcResult.socialOrg,
|
||||
housingEmp: calcResult.housingEmp,
|
||||
housingOrg: calcResult.housingOrg,
|
||||
supplementaryHousingEmp: calcResult.supplementaryHousingEmp || 0,
|
||||
supplementaryHousingOrg: calcResult.supplementaryHousingOrg || 0,
|
||||
tax: calcResult.tax,
|
||||
totalPay: calcResult.totalPay,
|
||||
netPay: calcResult.netPay,
|
||||
// 最低工资保护 + 递延扣款
|
||||
minWage: calcResult.minWage || 0,
|
||||
minWageApplied: calcResult.minWageApplied || 0,
|
||||
deferredSocialEmp: calcResult.deferredSocialEmp || 0,
|
||||
deferredHousingEmp: calcResult.deferredHousingEmp || 0,
|
||||
deferredMinWage: calcResult.deferredMinWage || 0,
|
||||
prevDeferredSocialEmp: calcResult.prevDeferredSocialEmp || 0,
|
||||
prevDeferredHousingEmp: calcResult.prevDeferredHousingEmp || 0,
|
||||
prevDeferredMinWage: calcResult.prevDeferredMinWage || 0,
|
||||
riskWarnings,
|
||||
},
|
||||
})
|
||||
@@ -426,15 +557,15 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
|
||||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||||
const updateEntrySchema = z.object({
|
||||
baseSalary: z.number().min(0).optional(),
|
||||
overtimePay: z.number().min(0).optional(),
|
||||
allowance: z.number().min(0).optional(),
|
||||
deduction: z.number().min(0).optional(),
|
||||
bonus: z.number().min(0).optional(),
|
||||
socialEmp: z.number().min(0).optional(),
|
||||
socialOrg: z.number().min(0).optional(),
|
||||
housingEmp: z.number().min(0).optional(),
|
||||
housingOrg: z.number().min(0).optional(),
|
||||
baseSalary: z.number().optional(),
|
||||
overtimePay: z.number().optional(),
|
||||
allowance: z.number().optional(),
|
||||
deduction: z.number().optional(),
|
||||
bonus: z.number().optional(),
|
||||
socialEmp: z.number().optional(),
|
||||
socialOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
})
|
||||
|
||||
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@@ -452,13 +583,21 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
})
|
||||
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
|
||||
|
||||
// 合并输入项
|
||||
// 合并输入项(包含细化薪资字段,避免编辑时丢失岗位工资/绩效工资等)
|
||||
const inputs = {
|
||||
baseSalary: data.baseSalary ?? entry.baseSalary,
|
||||
overtimePay: data.overtimePay ?? entry.overtimePay,
|
||||
allowance: data.allowance ?? entry.allowance,
|
||||
deduction: data.deduction ?? entry.deduction,
|
||||
bonus: data.bonus ?? entry.bonus,
|
||||
positionSalary: entry.positionSalary || undefined,
|
||||
performanceSalary: entry.performanceSalary || undefined,
|
||||
senioritySalary: entry.senioritySalary || undefined,
|
||||
transportAllowance: entry.transportAllowance || undefined,
|
||||
mealAllowance: entry.mealAllowance || undefined,
|
||||
housingAllowance: entry.housingAllowance || undefined,
|
||||
communicationAllowance: entry.communicationAllowance || undefined,
|
||||
otherDeduction: entry.otherDeduction || undefined,
|
||||
}
|
||||
|
||||
// 构建社保覆盖参数(如果请求中包含社保字段)
|
||||
@@ -468,17 +607,20 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||
|
||||
// 查询上月递延数据(与创建批次时一致)
|
||||
const prevDeferred = await getPrevDeferred(orgId, employeeId, batch.month)
|
||||
|
||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
|
||||
const options = Object.keys(overrideSocial).length > 0
|
||||
? { overrideSocial }
|
||||
: undefined
|
||||
const options: any = { prevDeferred }
|
||||
if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
|
||||
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...inputs, ...calcResult },
|
||||
data: { ...inputs, ...entryData },
|
||||
})
|
||||
|
||||
// 更新批次汇总
|
||||
@@ -512,6 +654,374 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
}
|
||||
})
|
||||
|
||||
// 获取提成奖金:按批次月份从 CommissionBonus 表拉取,填充到 entries.bonus
|
||||
router.post('/batches/:batchId/fetch-bonus', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, bonus: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 按批次月份查询提成奖金
|
||||
const bonusMap = await getBonusByMonthAndEmployeeIds(orgId, batch.month, entries.map((e) => e.employeeId))
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const bonus = bonusMap.get(entry.employeeId)
|
||||
if (bonus) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { bonus: bonus.amount },
|
||||
})
|
||||
filled++
|
||||
totalAmount += bonus.amount
|
||||
}
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + e.overtimePay + e.allowance + e.bonus - e.deduction,
|
||||
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: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0 ? `已填充 ${filled} 人提成奖金,合计 ¥${Math.round(totalAmount * 100) / 100}` : `${batch.month} 无提成奖金数据`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取绩效工资:按批次月份从 PerformanceRecord 查考核等级,结合 Employee.performanceSalary 计算绩效工资
|
||||
// 绩效系数:A=1.2, B=1.0, C=0.8, D=0.6;无绩效记录则系数=1.0
|
||||
router.post('/batches/:batchId/fetch-performance', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, performanceSalary: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份的绩效记录(按 period = YYYY-MM 匹配)
|
||||
const performanceRecords = await prisma.performanceRecord.findMany({
|
||||
where: { orgId, period: batch.month, periodType: 'MONTHLY' },
|
||||
select: { employeeId: true, grade: true, score: true, result: true },
|
||||
})
|
||||
const perfMap = new Map<string, { grade: string; score: number }>()
|
||||
for (const r of performanceRecords) {
|
||||
// 同一员工同月可能有多条记录,取最新一条(按创建时间倒序已由 DB 保证)
|
||||
if (!perfMap.has(r.employeeId)) {
|
||||
perfMap.set(r.employeeId, { grade: r.grade, score: r.score })
|
||||
}
|
||||
}
|
||||
|
||||
// 查询员工绩效工资基数
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { id: { in: entries.map(e => e.employeeId) } },
|
||||
select: { id: true, performanceSalary: true },
|
||||
})
|
||||
const empPerfSalaryMap = new Map<string, number>()
|
||||
for (const emp of employees) {
|
||||
if (emp.performanceSalary) {
|
||||
try { empPerfSalaryMap.set(emp.id, Number(decrypt(emp.performanceSalary)) || 0) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// 绩效系数映射
|
||||
const gradeCoefficients: Record<string, number> = { A: 1.2, B: 1.0, C: 0.8, D: 0.6 }
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const basePerfSalary = empPerfSalaryMap.get(entry.employeeId) || 0
|
||||
if (basePerfSalary <= 0) continue // 绩效工资基数为0则跳过
|
||||
|
||||
const perfRecord = perfMap.get(entry.employeeId)
|
||||
const coefficient = perfRecord ? (gradeCoefficients[perfRecord.grade] || 1.0) : 1.0
|
||||
const calculatedPerfSalary = Math.round(basePerfSalary * coefficient * 100) / 100
|
||||
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { performanceSalary: calculatedPerfSalary },
|
||||
})
|
||||
filled++
|
||||
totalAmount += calculatedPerfSalary
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
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: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人绩效工资,合计 ¥${Math.round(totalAmount * 100) / 100}${performanceRecords.length > 0 ? `(${performanceRecords.length} 人有考核记录)` : '(无考核记录,按系数1.0计算)'}`
|
||||
: '无绩效工资数据(员工未设置绩效工资基数)',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取违纪扣款:按批次月份从 DisciplinaryRecord 查 action=DEDUCTION 的记录,汇总扣款金额填充到 entries.deduction
|
||||
router.post('/batches/:batchId/fetch-disciplinary', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, deduction: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份内的违纪扣款记录(按 violationDate 落在批次月份内)
|
||||
const monthStart = new Date(batch.month + '-01')
|
||||
const monthEnd = new Date(monthStart)
|
||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||
|
||||
const disciplinaryRecords = await prisma.disciplinaryRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId: { in: entries.map(e => e.employeeId) },
|
||||
action: 'DEDUCTION',
|
||||
deductionAmount: { gt: 0 },
|
||||
violationDate: { gte: monthStart, lt: monthEnd },
|
||||
},
|
||||
select: { employeeId: true, deductionAmount: true },
|
||||
})
|
||||
|
||||
// 按员工汇总扣款金额
|
||||
const deductionMap = new Map<string, number>()
|
||||
for (const r of disciplinaryRecords) {
|
||||
deductionMap.set(r.employeeId, (deductionMap.get(r.employeeId) || 0) + r.deductionAmount)
|
||||
}
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const deduction = deductionMap.get(entry.employeeId)
|
||||
if (deduction && deduction > 0) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { deduction: Math.round(deduction * 100) / 100 },
|
||||
})
|
||||
filled++
|
||||
totalAmount += deduction
|
||||
}
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
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: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人违纪扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
|
||||
: `${batch.month} 无违纪扣款数据`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取考勤扣款:按批次月份从 AttendanceConfirmation 查 deductionAmount > 0 的记录,填充到 entries.deduction
|
||||
router.post('/batches/:batchId/fetch-attendance-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { batchId } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
|
||||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '已归档批次不可操作' } })
|
||||
|
||||
// 获取批次所有条目
|
||||
const entries = await prisma.batchEntry.findMany({
|
||||
where: { batchId },
|
||||
select: { id: true, employeeId: true, deduction: true },
|
||||
})
|
||||
|
||||
if (entries.length === 0) {
|
||||
return res.json({ success: true, data: { filled: 0, totalAmount: 0, message: '批次无员工条目' } })
|
||||
}
|
||||
|
||||
// 查询批次月份的考勤确认记录
|
||||
const confirmations = await prisma.attendanceConfirmation.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId: { in: entries.map(e => e.employeeId) },
|
||||
month: batch.month,
|
||||
deductionAmount: { gt: 0 },
|
||||
},
|
||||
select: { employeeId: true, deductionAmount: true },
|
||||
})
|
||||
|
||||
const deductionMap = new Map<string, number>()
|
||||
for (const c of confirmations) {
|
||||
deductionMap.set(c.employeeId, c.deductionAmount)
|
||||
}
|
||||
|
||||
let filled = 0
|
||||
let totalAmount = 0
|
||||
for (const entry of entries) {
|
||||
const deduction = deductionMap.get(entry.employeeId)
|
||||
if (deduction && deduction > 0) {
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { deduction: Math.round(deduction * 100) / 100 },
|
||||
})
|
||||
filled++
|
||||
totalAmount += deduction
|
||||
}
|
||||
}
|
||||
|
||||
// 重算批次汇总
|
||||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + e.baseSalary + (e.performanceSalary || 0) + (e.positionSalary || 0) + (e.senioritySalary || 0) + e.overtimePay + (e.transportAllowance || 0) + (e.mealAllowance || 0) + (e.housingAllowance || 0) + (e.communicationAllowance || 0) + e.allowance + e.bonus - e.deduction - (e.otherDeduction || 0),
|
||||
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: {
|
||||
filled,
|
||||
totalAmount: Math.round(totalAmount * 100) / 100,
|
||||
message: filled > 0
|
||||
? `已填充 ${filled} 人考勤扣款,合计 ¥${Math.round(totalAmount * 100) / 100}`
|
||||
: `${batch.month} 无考勤扣款数据(需先在考勤确认中填写扣款金额)`,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取条目个税计算明细
|
||||
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -540,10 +1050,14 @@ router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthR
|
||||
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
|
||||
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
|
||||
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
|
||||
taxBreakdown.systemSupplementaryHousingEmp = calcResult.systemSupplementaryHousingEmp
|
||||
taxBreakdown.systemSupplementaryHousingOrg = calcResult.systemSupplementaryHousingOrg
|
||||
taxBreakdown.actualSocialEmp = entry.socialEmp
|
||||
taxBreakdown.actualSocialOrg = entry.socialOrg
|
||||
taxBreakdown.actualHousingEmp = entry.housingEmp
|
||||
taxBreakdown.actualHousingOrg = entry.housingOrg
|
||||
taxBreakdown.actualSupplementaryHousingEmp = entry.supplementaryHousingEmp || 0
|
||||
taxBreakdown.actualSupplementaryHousingOrg = entry.supplementaryHousingOrg || 0
|
||||
res.json({ success: true, data: taxBreakdown })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -576,8 +1090,11 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
if (!emp) continue
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts?.[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
// 按批次月份判定是否仍在试用期
|
||||
const batchMonthEnd = new Date(`${batch.month}-28T23:59:59`)
|
||||
const latestContract = emp.contracts?.[0]
|
||||
if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) {
|
||||
baseSalary = latestContract.probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||
}
|
||||
@@ -591,11 +1108,12 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
const entry = await prisma.batchEntry.create({
|
||||
data: {
|
||||
batchId, orgId, employeeId,
|
||||
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
|
||||
...calcResult, riskWarnings,
|
||||
...entryData, riskWarnings,
|
||||
},
|
||||
})
|
||||
results.push(entry)
|
||||
@@ -725,17 +1243,18 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
||||
otherDeduction: entry.otherDeduction || undefined,
|
||||
}
|
||||
// 社保如被手动覆盖,保留覆盖值
|
||||
// 社保如被手动覆盖,保留覆盖值(通过比较系统值和实际值判断是否覆盖过)
|
||||
const overrideSocial: any = {}
|
||||
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp
|
||||
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg
|
||||
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp
|
||||
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
// 归档重算时不强制覆盖社保,让系统重新计算(除非之前有手动覆盖)
|
||||
// 这里简化处理:不传 overrideSocial,让系统重算
|
||||
const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
|
||||
const options: any = { prevDeferred }
|
||||
|
||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||
await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
data: { ...calcResult },
|
||||
data: { ...entryData },
|
||||
})
|
||||
} catch (e: any) {
|
||||
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -36,18 +36,30 @@ function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalLoginSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
// 查找所有匹配手机号的在职员工(可能跨组织)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true, orgId: true, passwordHash: true },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
if (employees.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
// 逐个校验密码,找到匹配的员工
|
||||
let matchedEmployee = null
|
||||
for (const emp of employees) {
|
||||
if (emp.passwordHash) {
|
||||
const valid = await bcrypt.compare(data.password, emp.passwordHash)
|
||||
if (valid) {
|
||||
matchedEmployee = emp
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!matchedEmployee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
const token = signAccessToken({ id: matchedEmployee.id, orgId: matchedEmployee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: matchedEmployee.id, name: matchedEmployee.name, department: matchedEmployee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
@@ -94,10 +106,16 @@ router.post('/verify-code', async (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||
}
|
||||
await deleteCode(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
// 查找所有匹配手机号的在职员工(可能跨组织)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true, orgId: true },
|
||||
})
|
||||
if (employees.length === 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
// 如果只有一个匹配,直接登录
|
||||
const employee = employees[0]
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
@@ -105,6 +123,38 @@ router.post('/verify-code', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 员工修改自己的密码
|
||||
* POST /portal/change-password body: { oldPassword, newPassword }
|
||||
*/
|
||||
router.post('/change-password', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body
|
||||
if (!oldPassword || !newPassword) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请输入旧密码和新密码' } })
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '新密码至少6位' } })
|
||||
}
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.employee.id },
|
||||
select: { id: true, passwordHash: true },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '当前未设置密码,请联系管理员重置' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(oldPassword, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '旧密码错误' } })
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
await prisma.employee.update({ where: { id: employee.id }, data: { passwordHash } })
|
||||
res.json({ success: true, data: { message: '密码修改成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条
|
||||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
@@ -891,6 +941,82 @@ router.post('/resignation/:id/withdraw', portalAuth, async (req: any, res, next)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 下载离职证明(仅已完成的离职记录)
|
||||
router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await (prisma as any).terminationRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '离职记录不存在' } })
|
||||
if (record.status !== 'COMPLETED') {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '离职流程未完成,无法下载证明' } })
|
||||
}
|
||||
|
||||
const org = await prisma.organization.findFirst({ where: { id: orgId } })
|
||||
const orgName = org?.name || ''
|
||||
|
||||
// 查找企业自定义的离职证明模板
|
||||
const tpl = await (prisma as any).enterpriseTemplate.findFirst({
|
||||
where: { orgId, category: 'LEAVING_CERT' },
|
||||
})
|
||||
|
||||
const reasonLabel: Record<string, string> = {
|
||||
RESIGNATION: '个人辞职', EXPIRY: '合同到期', DISMISSAL: '违纪辞退',
|
||||
NEGOTIATED: '协商解除', RETIREMENT: '退休', DEATH: '死亡',
|
||||
}
|
||||
const reason = reasonLabel[record.reason] || record.reason || ''
|
||||
|
||||
const variables: Record<string, string> = {
|
||||
employeeName: record.employee?.name || '',
|
||||
idCardNumber: record.employee?.idCardNumber || '',
|
||||
department: record.employee?.department || '',
|
||||
position: record.employee?.position || '',
|
||||
hireDate: record.employee?.hireDate ? new Date(record.employee.hireDate).toISOString().slice(0, 10) : '',
|
||||
leaveDate: record.terminationDate ? new Date(record.terminationDate).toISOString().slice(0, 10) : '',
|
||||
reason,
|
||||
companyName: orgName,
|
||||
compensation: String(record.compensation || 0),
|
||||
socialInsEndMonth: record.socialInsEndMonth || '',
|
||||
housingFundEndMonth: record.housingFundEndMonth || '',
|
||||
}
|
||||
|
||||
let content: string
|
||||
if (tpl) {
|
||||
content = tpl.content
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
||||
}
|
||||
} else {
|
||||
content = `<h1>解除/终止劳动合同证明书</h1>
|
||||
<p>兹证明 ${variables.employeeName}(证件号码:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||
<p>特此证明。</p>
|
||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||
}
|
||||
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>离职证明</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${content}</body></html>`
|
||||
|
||||
const encoded = encodeURIComponent(`离职证明-${variables.employeeName}.doc`)
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
res.send(htmlContent)
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 员工端:休假申请 ==========
|
||||
// 查看自己的休假申请列表
|
||||
router.get('/leaves', portalAuth, async (req: any, res, next) => {
|
||||
@@ -953,6 +1079,12 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
||||
|
||||
// ========== 员工端:电子签署 ==========
|
||||
// 查看自己的签署记录列表
|
||||
// ========== 员工端:电子签署(验证码确认 + 证据链) ==========
|
||||
|
||||
/**
|
||||
* 我的电子签署列表
|
||||
* - 自动将过期的 PENDING 记录标记为 EXPIRED
|
||||
*/
|
||||
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
@@ -960,11 +1092,30 @@ router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||||
where: { employeeId, orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
// 自动处理过期:PENDING 且已超过 expiredAt 的记录标记为 EXPIRED
|
||||
const now = new Date()
|
||||
const expiredIds = records
|
||||
.filter(r => r.status === 'PENDING' && r.expiredAt && r.expiredAt < now)
|
||||
.map(r => r.id)
|
||||
if (expiredIds.length > 0) {
|
||||
await prisma.eSignRecord.updateMany({
|
||||
where: { id: { in: expiredIds } },
|
||||
data: { status: 'EXPIRED' },
|
||||
})
|
||||
expiredIds.forEach(id => {
|
||||
const r = records.find(rec => rec.id === id)
|
||||
if (r) r.status = 'EXPIRED'
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 查看签署详情
|
||||
/**
|
||||
* 查看签署详情(含文件内容)
|
||||
*/
|
||||
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
@@ -972,37 +1123,179 @@ router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||||
where: { id: req.params.id, employeeId, orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
|
||||
// 检查过期
|
||||
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
})
|
||||
return res.json({ success: true, data: updated })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
|
||||
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
||||
/**
|
||||
* 发送签署验证码
|
||||
* - 通过手机号发送6位验证码
|
||||
* - 验证码5分钟有效,最多5次错误
|
||||
*/
|
||||
router.post('/esign/:id/send-code', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||||
include: { employee: { select: { phone: true, name: true } } },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||||
if (!record.employee.phone) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '未登记手机号,无法发送验证码' } })
|
||||
}
|
||||
|
||||
// 限流:同一记录60秒内只能发一次
|
||||
const rateLimitKey = `esign-rate-${record.id}`
|
||||
const allowed = await checkRateLimit(rateLimitKey, 60 * 1000)
|
||||
if (!allowed) {
|
||||
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请60秒后重试' } })
|
||||
}
|
||||
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
await setCode(`esign-${record.id}`, code)
|
||||
|
||||
// 追加证据链 — 验证码发送事件
|
||||
const evidence = await prisma.evidenceChain.findFirst({
|
||||
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||||
})
|
||||
if (evidence) {
|
||||
await appendEvidence(orgId, evidence.id, {
|
||||
action: '发送签署验证码',
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `手机号:${record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}`,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
code, // 开发阶段直接返回,生产环境通过短信发送
|
||||
message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)',
|
||||
phone: record.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 员工签署操作(验证码确认 + 证据链 + 回写合同)
|
||||
* - 校验验证码
|
||||
* - 更新签署状态为 COMPLETED
|
||||
* - 追加证据链(签署完成事件,含IP/UA/验证码/时间戳)
|
||||
* - 回写合同 signMethod 和 electronicContractUrl
|
||||
*/
|
||||
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const { verifyCode } = req.body || {}
|
||||
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||||
include: { employee: { select: { name: true, phone: true } } },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||||
|
||||
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
|
||||
// 当前框架阶段:直接标记为已签署
|
||||
// 检查过期
|
||||
if (record.expiredAt && record.expiredAt < new Date()) {
|
||||
await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: 'EXPIRED' } })
|
||||
return res.status(400).json({ success: false, error: { code: 'EXPIRED', message: '签署链接已过期' } })
|
||||
}
|
||||
|
||||
// 校验验证码
|
||||
if (!verifyCode) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_CODE', message: '请输入验证码' } })
|
||||
}
|
||||
const stored = await getCode(`esign-${record.id}`)
|
||||
if (!stored) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.failCount >= 5) {
|
||||
await deleteCode(`esign-${record.id}`)
|
||||
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
|
||||
}
|
||||
if (stored.code !== verifyCode) {
|
||||
await updateCode(`esign-${record.id}`, { failCount: stored.failCount + 1 })
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||
}
|
||||
await deleteCode(`esign-${record.id}`)
|
||||
|
||||
// 签署完成
|
||||
const userAgent = req.headers['user-agent'] || ''
|
||||
const signEvidence = JSON.stringify({
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
verifyCode: true,
|
||||
employeeName: record.employee.name,
|
||||
phone: record.employee.phone,
|
||||
})
|
||||
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
// 保存签署证据到 callbackData(作为签署凭证)
|
||||
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
|
||||
},
|
||||
})
|
||||
|
||||
// 如果关联了合同,更新合同签署信息
|
||||
// 回写合同
|
||||
if (record.contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: { signMethod: 'ELECTRONIC' },
|
||||
data: {
|
||||
signMethod: 'ELECTRONIC',
|
||||
// 签署证据写入 attachmentName
|
||||
attachmentName: `e-sign:${new Date().toISOString()}|evidence:${signEvidence}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 追加证据链 — 签署完成事件
|
||||
const evidence = await prisma.evidenceChain.findFirst({
|
||||
where: { orgId, refId: record.id, category: 'CONTRACT_SIGN' },
|
||||
})
|
||||
if (evidence) {
|
||||
await appendEvidence(orgId, evidence.id, {
|
||||
action: `员工签署完成:${record.documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
smsCode: verifyCode,
|
||||
location: `签署人:${record.employee.name},验证码已验证`,
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
// 证据链不存在时创建新的
|
||||
await createEvidence({
|
||||
orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: record.id,
|
||||
employeeId,
|
||||
events: [{
|
||||
action: `员工签署完成:${record.documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent,
|
||||
smsCode: verifyCode,
|
||||
location: `签署人:${record.employee.name},验证码已验证`,
|
||||
}],
|
||||
createdBy: employeeId,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
res.json({ success: true, data: updated, message: '签署成功' })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 岗位字典路由
|
||||
* 提供岗位的增删改查
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const createPositionSchema = z.object({
|
||||
name: z.string().min(1, '岗位名称必填'),
|
||||
departmentId: z.string().nullable().optional(),
|
||||
headcount: z.number().int().min(0).default(0),
|
||||
level: z.string().max(20).optional(),
|
||||
description: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
/** 获取岗位列表 */
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const positions = await prisma.position.findMany({
|
||||
where: { orgId: req.user!.orgId! },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { department: { select: { id: true, name: true } } },
|
||||
})
|
||||
res.json({ success: true, data: positions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建岗位 */
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createPositionSchema.parse(req.body)
|
||||
const position = await prisma.position.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: position })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新岗位 */
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = createPositionSchema.partial().parse(req.body)
|
||||
const position = await prisma.position.update({ where: { id }, data })
|
||||
res.json({ success: true, data: position })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除岗位 */
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await prisma.position.delete({ where: { id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -46,6 +46,21 @@ router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
}
|
||||
})
|
||||
|
||||
/** 花名册可选职务列表(从在职员工中提取去重) */
|
||||
router.get('/positions', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
select: { position: true },
|
||||
distinct: 'position',
|
||||
})
|
||||
const positions = employees.map((e) => e.position).filter(Boolean).sort()
|
||||
res.json({ success: true, data: positions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 花名册列表(含汇总信息,支持分页和过滤)
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
@@ -55,6 +70,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
|
||||
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
||||
const department = req.query.department as string
|
||||
const position = req.query.position as string
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
||||
@@ -71,6 +87,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
if (department) {
|
||||
whereBase.department = department
|
||||
}
|
||||
if (position) {
|
||||
whereBase.position = position
|
||||
}
|
||||
if (search && !isIdCardSearch) {
|
||||
whereBase.OR = [
|
||||
{ name: { contains: search } },
|
||||
@@ -81,7 +100,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
||||
if (status === 'RESIGNED') {
|
||||
whereBase.status = 'RESIGNED'
|
||||
} else if (status === 'PRE_HIRE') {
|
||||
} else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
|
||||
// 预入职:ACTIVE 状态但入职日期在未来(按入职日期自动判定)
|
||||
whereBase.status = 'ACTIVE'
|
||||
whereBase.hireDate = { gt: todayEnd }
|
||||
} else if (status === 'ACTIVE') {
|
||||
@@ -94,7 +114,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
whereBase.contracts = { none: {} }
|
||||
}
|
||||
|
||||
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
|
||||
// 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页
|
||||
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
|
||||
|
||||
const [dbTotal, employees] = await Promise.all([
|
||||
@@ -170,7 +190,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
|
||||
const isPreHire = !isResigned && e.hireDate > todayEnd
|
||||
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
||||
// 身份证号脱敏显示
|
||||
// 证件号码脱敏显示
|
||||
let idCardMasked: string | null = null
|
||||
if (e.idCardNumber) {
|
||||
try {
|
||||
@@ -200,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
idCardMasked,
|
||||
idCardNumber: safeDecryptStr(e.idCardNumber),
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
|
||||
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
|
||||
socialInsBase: e.socialInsBase,
|
||||
housingFundBase: e.housingFundBase,
|
||||
socialInsCalc: (() => {
|
||||
@@ -223,7 +245,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
socialInsuranceStatus: (() => {
|
||||
const sr = (e as any).socialInsRecords?.[0]
|
||||
if (!sr) return null
|
||||
// 劳务协议/实习协议:不缴纳社保,返回 null(前端显示"—")
|
||||
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
|
||||
if (isNoSocialContract) return null
|
||||
if (!sr) {
|
||||
// 在职且应缴社保但无社保记录 → 待办理
|
||||
return 'PENDING'
|
||||
}
|
||||
// endMonth 为 null 表示在保,否则已停保
|
||||
if (sr.endMonth) return 'SUSPENDED'
|
||||
return 'ACTIVE'
|
||||
@@ -250,7 +278,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||
}
|
||||
|
||||
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
||||
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
||||
if (isIdCardSearch) {
|
||||
result = result.filter((e: any) => {
|
||||
if (!e.idCardNumber) return false
|
||||
@@ -347,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
if (found) monthlyProcessRecords.push(recordData)
|
||||
}
|
||||
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
||||
@@ -357,6 +385,8 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
...rest,
|
||||
status: dynamicStatus,
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
|
||||
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: safeDecryptStr(idCardNumber),
|
||||
monthlyProcessRecords,
|
||||
@@ -935,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
|
||||
|
||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
@@ -946,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
||||
severity: severity || 'WARNING',
|
||||
action: action || 'ORAL_WARNING',
|
||||
actionDetail,
|
||||
deductionAmount: Number(deductionAmount) || 0,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
@@ -969,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
||||
|
||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, deductionAmount, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
@@ -983,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
|
||||
severity,
|
||||
action,
|
||||
actionDetail,
|
||||
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
@@ -1025,7 +1057,7 @@ router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, as
|
||||
|
||||
const content = `违纪确认证明
|
||||
|
||||
兹证明 ${record.employee.name}(身份证号:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
|
||||
兹证明 ${record.employee.name}(证件号码:${idCard || '___'})系我单位员工,于 ${record.violationDate.toISOString().slice(0, 10)} 发生以下违纪行为:
|
||||
|
||||
违纪类型:${typeMap[record.violationType] || record.violationType}
|
||||
严重程度:${severityMap[record.severity] || record.severity}
|
||||
@@ -1386,7 +1418,7 @@ function prevMonth(month: string): string {
|
||||
// 调薪
|
||||
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newSalary, effectiveMonth, reason } = req.body
|
||||
const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
@@ -1420,10 +1452,13 @@ router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
// 同步 Employee 便捷字段(含工资结构)
|
||||
const updateData: any = { monthlySalary: encrypt(String(newSalary)) }
|
||||
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
|
||||
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { monthlySalary: encrypt(String(newSalary)) },
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
|
||||
@@ -1442,10 +1477,10 @@ router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res,
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 调部门
|
||||
// 调动(部门+职务变动)
|
||||
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { newDepartment, effectiveMonth, reason } = req.body
|
||||
const { newDepartment, newPosition, departmentId, effectiveMonth, reason } = req.body
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
@@ -1454,22 +1489,35 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
|
||||
}
|
||||
|
||||
const oldDepartment = employee.department
|
||||
const oldPosition = employee.position || null
|
||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||
const prevEffMonth = prevMonth(effMonth)
|
||||
|
||||
// 校验 departmentId 是否属于当前组织
|
||||
let deptName = newDepartment
|
||||
if (departmentId) {
|
||||
const dept = await prisma.department.findFirst({ where: { id: departmentId, orgId: req.user!.orgId } })
|
||||
if (!dept) {
|
||||
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '目标部门不存在' } })
|
||||
}
|
||||
deptName = dept.name
|
||||
}
|
||||
|
||||
// 关闭之前有效记录
|
||||
await prisma.employeeDepartmentRecord.updateMany({
|
||||
where: { employeeId: req.params.id, endMonth: null },
|
||||
data: { endMonth: prevEffMonth },
|
||||
})
|
||||
|
||||
// 创建新部门记录
|
||||
// 创建新调动记录
|
||||
const record = await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.id,
|
||||
oldDepartment,
|
||||
newDepartment,
|
||||
newDepartment: deptName,
|
||||
oldPosition,
|
||||
newPosition: newPosition || null,
|
||||
effectiveMonth: effMonth,
|
||||
endMonth: null,
|
||||
changeType: 'TRANSFER',
|
||||
@@ -1478,13 +1526,17 @@ router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, r
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
// 同步 Employee 字段(department 文本 + departmentId 关联 + position 职务)
|
||||
await prisma.employee.update({
|
||||
where: { id: req.params.id },
|
||||
data: { department: newDepartment },
|
||||
data: {
|
||||
department: deptName,
|
||||
departmentId: departmentId || null,
|
||||
position: newPosition || employee.position,
|
||||
},
|
||||
})
|
||||
|
||||
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
|
||||
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment: deptName, oldPosition, newPosition })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ const socialConfigFields = {
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
medicalOrgExtra: z.number().min(0).optional(),
|
||||
medicalEmpExtra: z.number().min(0).optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
@@ -23,6 +25,7 @@ const socialConfigFields = {
|
||||
medicalBaseMin: z.number().optional(),
|
||||
medicalBaseMax: z.number().optional(),
|
||||
extraInsurances: z.any().optional(),
|
||||
minWage: z.number().min(0).optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
@@ -34,6 +37,426 @@ const housingConfigFields = {
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 账户管理 API(新)
|
||||
// ==========================================
|
||||
|
||||
const accountSchema = z.object({
|
||||
type: z.enum(['SOCIAL', 'HOUSING']),
|
||||
name: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
accountNo: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
bankAccount: z.string().optional(),
|
||||
orgName: z.string().optional(),
|
||||
orgCode: z.string().optional(),
|
||||
accountType: z.string().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
// 账户列表
|
||||
router.get('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const type = req.query.type as string | undefined
|
||||
const where: any = { orgId }
|
||||
if (type) where.type = type
|
||||
const accounts = await prisma.socialAccount.findMany({
|
||||
where,
|
||||
orderBy: [{ type: 'asc' }, { isDefault: 'desc' }, { city: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { socialRecords: true, housingRecords: true, deptSocialAccounts: true, deptHousingAccounts: true } },
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: accounts })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 新建账户
|
||||
router.post('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const data = accountSchema.parse(req.body)
|
||||
// 如果设为默认,先取消同 type 其他默认
|
||||
if (data.isDefault) {
|
||||
await prisma.socialAccount.updateMany({ where: { orgId, type: data.type, isDefault: true }, data: { isDefault: false } })
|
||||
}
|
||||
const account = await prisma.socialAccount.create({
|
||||
data: { ...data, orgId, createdBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: account })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 编辑账户
|
||||
router.put('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const data = accountSchema.partial().parse(req.body)
|
||||
if (data.isDefault) {
|
||||
const account = await prisma.socialAccount.findUnique({ where: { id: req.params.id } })
|
||||
await prisma.socialAccount.updateMany({ where: { orgId, type: account?.type, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
|
||||
}
|
||||
const account = await prisma.socialAccount.update({
|
||||
where: { id: req.params.id },
|
||||
data,
|
||||
})
|
||||
res.json({ success: true, data: account })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 删除账户(无关联记录时可删)
|
||||
router.delete('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
// 检查是否有关联记录
|
||||
const [socialCount, housingCount, deptCount] = await Promise.all([
|
||||
prisma.employeeSocialInsRecord.count({ where: { accountId: account.id } }),
|
||||
prisma.employeeHousingFundRecord.count({ where: { accountId: account.id } }),
|
||||
prisma.department.count({ where: { OR: [{ socialAccountId: account.id }, { housingAccountId: account.id }] } }),
|
||||
])
|
||||
if (socialCount + housingCount + deptCount > 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'IN_USE', message: `账户仍关联 ${socialCount + housingCount} 条参保记录、${deptCount} 个部门,无法删除` } })
|
||||
}
|
||||
await prisma.socialAccount.delete({ where: { id: account.id } })
|
||||
res.json({ success: true, data: { message: '已删除' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 设为默认账户
|
||||
router.put('/accounts/:id/default', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
await prisma.socialAccount.updateMany({ where: { orgId, type: account.type, isDefault: true }, data: { isDefault: false } })
|
||||
await prisma.socialAccount.update({ where: { id: account.id }, data: { isDefault: true } })
|
||||
res.json({ success: true, data: { message: '已设为默认' } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// 年度标准 API(新,按 accountId)
|
||||
// ==========================================
|
||||
|
||||
// 按账户获取年度标准列表
|
||||
router.get('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId } = req.params
|
||||
const standards = await prisma.socialYearStandard.findMany({
|
||||
where: { orgId, accountId },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: standards })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 按账户获取当前生效标准
|
||||
router.get('/accounts/:accountId/current-standard', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId } = req.params
|
||||
const standard = await prisma.socialYearStandard.findFirst({
|
||||
where: { orgId, accountId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: standard })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 按账户获取继承数据(当前标准 → 旧社保配置 → 默认值),用于新建年度标准初始值
|
||||
router.get('/accounts/:accountId/inherit-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId } = req.params
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
|
||||
// 1. 优先用当前年度标准
|
||||
const currentStandard = await prisma.socialYearStandard.findFirst({
|
||||
where: { orgId, accountId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (currentStandard) {
|
||||
return res.json({ success: true, data: { ...currentStandard, source: 'current_standard' } })
|
||||
}
|
||||
|
||||
// 2. 回退到旧社保/公积金配置表(按城市)
|
||||
if (account.type === 'HOUSING') {
|
||||
const oldConfig = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, city: account.city },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (oldConfig) {
|
||||
return res.json({ success: true, data: { ...oldConfig, source: 'old_housing_config' } })
|
||||
}
|
||||
} else {
|
||||
const oldConfig = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, city: account.city },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (oldConfig) {
|
||||
return res.json({ success: true, data: { ...oldConfig, source: 'old_social_config' } })
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 都没有,返回 null
|
||||
res.json({ success: true, data: null })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 按账户+月份获取适用标准
|
||||
router.get('/accounts/:accountId/standard-by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId, month } = req.params
|
||||
const standard = await prisma.socialYearStandard.findFirst({
|
||||
where: {
|
||||
orgId, accountId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!standard) {
|
||||
const current = await prisma.socialYearStandard.findFirst({ where: { orgId, accountId, isCurrent: true } })
|
||||
return res.json({ success: true, data: current })
|
||||
}
|
||||
res.json({ success: true, data: standard })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 新建年度标准
|
||||
const yearStandardSchema = z.object({
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
medicalOrgExtra: z.number().min(0).optional(),
|
||||
medicalEmpExtra: z.number().min(0).optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
medicalBaseMin: z.number().optional(),
|
||||
medicalBaseMax: z.number().optional(),
|
||||
extraInsurances: z.any().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
housingBaseMin: z.number().min(0).optional(),
|
||||
housingBaseMax: z.number().min(0).optional(),
|
||||
minWage: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId } = req.params
|
||||
const data = yearStandardSchema.parse(req.body)
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
|
||||
// 检查是否已存在同 effectiveFrom 的标准
|
||||
const existing = await prisma.socialYearStandard.findFirst({ where: { accountId, effectiveFrom: data.effectiveFrom } })
|
||||
if (existing) {
|
||||
// 已存在:更新而非报错
|
||||
const standard = await prisma.socialYearStandard.update({
|
||||
where: { id: existing.id },
|
||||
data: { ...data, isCurrent: true, effectiveTo: null },
|
||||
})
|
||||
// 将其他当前版本标记为失效
|
||||
await prisma.socialYearStandard.updateMany({
|
||||
where: { accountId, isCurrent: true, id: { not: existing.id } },
|
||||
data: { isCurrent: false, effectiveTo: data.effectiveFrom },
|
||||
})
|
||||
return res.json({ success: true, data: standard })
|
||||
}
|
||||
|
||||
// 将旧当前版本标记为失效
|
||||
const current = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
|
||||
if (current) {
|
||||
const prevMonth = data.effectiveFrom
|
||||
await prisma.socialYearStandard.update({
|
||||
where: { id: current.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
const standard = await prisma.socialYearStandard.create({
|
||||
data: { ...data, orgId, accountId, isCurrent: true, createdBy: req.user!.id },
|
||||
})
|
||||
res.json({ success: true, data: standard })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
/**
|
||||
* 快速更新当前年度标准的最低工资(无需新建版本)
|
||||
*/
|
||||
router.put('/accounts/:accountId/min-wage', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { accountId } = req.params
|
||||
const { minWage } = req.body as { minWage: number }
|
||||
if (minWage === undefined || minWage < 0) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: 'minWage 必须为非负数' } })
|
||||
}
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
const standard = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
|
||||
if (!standard) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '当前年度标准不存在' } })
|
||||
const updated = await prisma.socialYearStandard.update({ where: { id: standard.id }, data: { minWage } })
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 账户关联根部门(level=0)批量设置
|
||||
router.put('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { id } = req.params
|
||||
const { departmentIds } = req.body as { departmentIds: string[] }
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
|
||||
// 先清除该账户的所有部门关联
|
||||
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
|
||||
await prisma.department.updateMany({
|
||||
where: { orgId, [field]: id },
|
||||
data: { [field]: null },
|
||||
})
|
||||
// 批量设置新关联(仅 level=0 根部门)
|
||||
if (departmentIds && departmentIds.length > 0) {
|
||||
await prisma.department.updateMany({
|
||||
where: { id: { in: departmentIds }, orgId, level: 0 },
|
||||
data: { [field]: id },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: { message: `已关联 ${departmentIds?.length || 0} 个根部门` } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 获取账户已关联的根部门列表
|
||||
router.get('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { id } = req.params
|
||||
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
|
||||
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
|
||||
|
||||
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
|
||||
const departments = await prisma.department.findMany({
|
||||
where: { orgId, level: 0, [field]: id },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
res.json({ success: true, data: departments })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 按部门获取适用账户(选部门时自动带出)
|
||||
router.get('/department-account/:departmentId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { departmentId } = req.params
|
||||
// 向上找到 level=0 的根部门
|
||||
let currentDept: any = await prisma.department.findFirst({ where: { id: departmentId, orgId } })
|
||||
if (!currentDept) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '部门不存在' } })
|
||||
|
||||
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
|
||||
currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } })
|
||||
}
|
||||
const rootDeptId = currentDept?.id || null
|
||||
|
||||
let socialAccount: any = null
|
||||
let housingAccount: any = null
|
||||
if (rootDeptId) {
|
||||
const rootDept = await prisma.department.findUnique({
|
||||
where: { id: rootDeptId },
|
||||
include: { socialAccount: true, housingAccount: true },
|
||||
})
|
||||
socialAccount = rootDept?.socialAccount || null
|
||||
housingAccount = rootDept?.housingAccount || null
|
||||
}
|
||||
|
||||
// 回退到公司默认账户
|
||||
if (!socialAccount) {
|
||||
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
|
||||
}
|
||||
if (!housingAccount) {
|
||||
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
|
||||
}
|
||||
|
||||
// 获取当前生效标准
|
||||
const currentMonth = new Date().toISOString().slice(0, 7)
|
||||
let socialStandard: any = null
|
||||
let housingStandard: any = null
|
||||
if (socialAccount) {
|
||||
socialStandard = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId: socialAccount.id, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (housingAccount) {
|
||||
housingStandard = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId: housingAccount.id, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { socialAccount, housingAccount, socialStandard, housingStandard } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门)
|
||||
router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const { employeeId } = req.params
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { dept: true },
|
||||
})
|
||||
if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
|
||||
// 向上找到 level=0 的根部门(代表分公司/子公司)
|
||||
let currentDept: any = emp.dept
|
||||
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
|
||||
currentDept = await prisma.department.findUnique({
|
||||
where: { id: currentDept.parentId },
|
||||
})
|
||||
}
|
||||
const rootDeptId = currentDept?.id || null
|
||||
|
||||
// 从根部门获取关联账户
|
||||
let socialAccount: any = null
|
||||
let housingAccount: any = null
|
||||
if (rootDeptId) {
|
||||
const rootDept = await prisma.department.findUnique({
|
||||
where: { id: rootDeptId },
|
||||
include: { socialAccount: true, housingAccount: true },
|
||||
})
|
||||
socialAccount = rootDept?.socialAccount || null
|
||||
housingAccount = rootDept?.housingAccount || null
|
||||
}
|
||||
|
||||
// 回退到公司默认账户
|
||||
if (!socialAccount) {
|
||||
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
|
||||
}
|
||||
if (!housingAccount) {
|
||||
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { socialAccount, housingAccount } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 获取当前生效版本(支持按城市筛选)
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -398,8 +821,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = medicalBase * config.medicalOrg / 100
|
||||
const medicalEmp = medicalBase * config.medicalEmp / 100
|
||||
const medicalOrg = medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0)
|
||||
const medicalEmp = medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
@@ -788,7 +1211,7 @@ function calcSocialDetail(base: number, config: any) {
|
||||
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: medicalBase * config.medicalOrg / 100, empAmount: medicalBase * config.medicalEmp / 100 },
|
||||
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0), empAmount: medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0) },
|
||||
{ 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: medicalBase * config.maternityOrg / 100, empAmount: 0 },
|
||||
@@ -1511,4 +1934,71 @@ router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFun
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 员工参保信息列表 ==========
|
||||
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const keyword = (req.query.keyword as string) || ''
|
||||
|
||||
// 查询所有在职员工
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
status: 'ACTIVE',
|
||||
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
department: true,
|
||||
position: true,
|
||||
socialInsBase: true,
|
||||
housingFundBase: true,
|
||||
city: true,
|
||||
},
|
||||
orderBy: { department: 'asc' },
|
||||
})
|
||||
|
||||
const empIds = employees.map(e => e.id)
|
||||
|
||||
// 查询当前有效的社保记录(endMonth 为 null)
|
||||
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
// 查询当前有效的公积金记录
|
||||
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, employeeId: { in: empIds }, endMonth: null },
|
||||
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
|
||||
})
|
||||
|
||||
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
|
||||
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
|
||||
|
||||
const list = employees.map(emp => {
|
||||
const social = socialMap.get(emp.id)
|
||||
const housing = housingMap.get(emp.id)
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
position: emp.position,
|
||||
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
|
||||
socialInsCity: social?.city ?? emp.city ?? '',
|
||||
socialInsStart: social?.startMonth ?? '',
|
||||
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
|
||||
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
|
||||
housingFundCity: housing?.city ?? emp.city ?? '',
|
||||
housingFundStart: housing?.startMonth ?? '',
|
||||
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: list })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* 客服工作台路由
|
||||
* 提供工单管理、客户会话、租户数据穿透
|
||||
*/
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import prisma from '../lib/prisma'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 客服权限中间件
|
||||
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
|
||||
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
|
||||
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
const createTicketSchema = z.object({
|
||||
title: z.string().min(1, '标题必填'),
|
||||
content: z.string().min(1, '内容必填'),
|
||||
category: z.string().optional(),
|
||||
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
|
||||
})
|
||||
|
||||
const createMessageSchema = z.object({
|
||||
content: z.string().min(1, '内容必填'),
|
||||
})
|
||||
|
||||
// ==================== 工单管理 ====================
|
||||
|
||||
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
|
||||
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const where = isSupport ? {} : { orgId: req.user!.orgId! }
|
||||
const tickets = await prisma.ticket.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
org: { select: { id: true, name: true } },
|
||||
_count: { select: { messages: true } },
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: tickets })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取工单详情 */
|
||||
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
org: { select: { id: true, name: true } },
|
||||
messages: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
})
|
||||
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
|
||||
res.json({ success: true, data: ticket })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 创建工单(企业用户提交) */
|
||||
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createTicketSchema.parse(req.body)
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: {
|
||||
...data,
|
||||
orgId: req.user!.orgId!,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: ticket })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 回复工单 */
|
||||
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const data = createMessageSchema.parse(req.body)
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const message = await prisma.ticketMessage.create({
|
||||
data: {
|
||||
ticketId: id,
|
||||
content: data.content,
|
||||
senderId: req.user!.id,
|
||||
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
||||
},
|
||||
})
|
||||
// 客服回复时更新工单状态为处理中
|
||||
if (isSupport) {
|
||||
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
|
||||
}
|
||||
res.json({ success: true, data: message })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 关闭工单 */
|
||||
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 转派工单 */
|
||||
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { assigneeId } = req.body
|
||||
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 客户会话 ====================
|
||||
|
||||
/** 获取会话列表 */
|
||||
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
|
||||
const sessions = await prisma.chatSession.findMany({
|
||||
where,
|
||||
orderBy: { lastMessageAt: 'desc' },
|
||||
include: { org: { select: { id: true, name: true } } },
|
||||
})
|
||||
res.json({ success: true, data: sessions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取会话消息 */
|
||||
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: { sessionId: id },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: messages })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 发送会话消息 */
|
||||
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { content } = req.body as { content: string }
|
||||
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
|
||||
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
||||
const session = await prisma.chatSession.findUnique({ where: { id } })
|
||||
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
|
||||
|
||||
const message = await prisma.chatMessage.create({
|
||||
data: {
|
||||
sessionId: id,
|
||||
content,
|
||||
senderId: req.user!.id,
|
||||
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
||||
},
|
||||
})
|
||||
await prisma.chatSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
lastMessage: content,
|
||||
lastMessageAt: new Date(),
|
||||
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
|
||||
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: message })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== 租户数据穿透 ====================
|
||||
|
||||
/** 获取租户列表(客服用) */
|
||||
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const tenants = await prisma.organization.findMany({
|
||||
select: {
|
||||
id: true, name: true, plan: true, maxEmployees: true,
|
||||
contactName: true, contactPhone: true, createdAt: true,
|
||||
_count: { select: { employees: true, users: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: tenants })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取租户数据概览(客服代查看) */
|
||||
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { orgId } = req.params
|
||||
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.laborContract.count({ where: { orgId } }),
|
||||
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
|
||||
])
|
||||
res.json({
|
||||
success: true,
|
||||
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -42,19 +42,70 @@ router.post('/:id/render', authMiddleware, async (req: AuthRequest, res: Respons
|
||||
}
|
||||
})
|
||||
|
||||
/** 下载模板(Word .doc 格式) */
|
||||
/** 下载模板(Word .doc 格式,支持变量替换) */
|
||||
router.get('/:id/download', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const template = getTemplateById(req.params.id)
|
||||
if (!template) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模板不存在' } })
|
||||
}
|
||||
// 支持通过 query 参数传入变量(如 ?name=张三&idCardNumber=xxx)
|
||||
const variables: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(req.query)) {
|
||||
if (typeof value === 'string' && key !== 'token') variables[key] = value
|
||||
}
|
||||
let content = template.content
|
||||
if (Object.keys(variables).length > 0) {
|
||||
content = renderTemplate(req.params.id, variables) || content
|
||||
}
|
||||
// 将纯文本转换为HTML段落,使Word样式生效
|
||||
const textToHtml = (text: string): string => {
|
||||
const lines = text.split(/\n/)
|
||||
let html = ''
|
||||
let inTable = false
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += '<p style="text-indent:0"> </p>'
|
||||
continue
|
||||
}
|
||||
// 标题检测:以"第X条"开头的行作为小标题
|
||||
if (/^第[一二三四五六七八九十百]+条/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h3>${trimmed}</h3>`
|
||||
} else if (/^劳动合同书$|^协议书$|^通知书$|^解除劳动合同协议书$/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<h1>${trimmed}</h1>`
|
||||
} else if (/^(甲方|乙方)((盖章|签字))/.test(trimmed) || /^日期[::]/.test(trimmed)) {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p class="sign">${trimmed}</p>`
|
||||
} else {
|
||||
if (inTable) { html += '</table>'; inTable = false }
|
||||
html += `<p>${trimmed}</p>`
|
||||
}
|
||||
}
|
||||
if (inTable) html += '</table>'
|
||||
return html
|
||||
}
|
||||
const htmlContent = `<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||
<head><meta charset="utf-8"><title>${template.name}</title>
|
||||
<!--[if gte mso 9]><xml>
|
||||
<w:WordDocument><w:View>Print</w:View><w:Zoom>100</w:Zoom><w:DoNotOptimizeForBrowser/></w:WordDocument>
|
||||
</xml><![endif]-->
|
||||
<style>
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; }
|
||||
@page { size: A4; margin: 2.54cm 3.17cm 2.54cm 3.17cm; }
|
||||
body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align: justify; }
|
||||
h1 { font-size: 22pt; font-weight: bold; text-align: center; margin: 30pt 0 20pt 0; font-family: SimHei, sans-serif; }
|
||||
h2 { font-size: 16pt; font-weight: bold; margin: 20pt 0 10pt 0; font-family: SimHei, sans-serif; }
|
||||
h3 { font-size: 14pt; font-weight: bold; margin: 15pt 0 8pt 0; font-family: SimHei, sans-serif; text-indent: 0; }
|
||||
p { text-indent: 2em; margin: 0 0 10pt 0; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10pt 0; }
|
||||
td, th { border: 1pt solid #000; padding: 4pt 8pt; font-size: 12pt; }
|
||||
th { background: #f0f0f0; font-weight: bold; text-align: center; }
|
||||
.sign { text-align: right; margin-top: 30pt; margin-right: 20pt; text-indent: 0; }
|
||||
</style></head>
|
||||
<body>${template.content}</body></html>`
|
||||
<body>${textToHtml(content)}</body></html>`
|
||||
const encoded = encodeURIComponent(template.name + '.doc')
|
||||
res.setHeader('Content-Type', 'application/msword; charset=utf-8')
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`)
|
||||
|
||||
@@ -4,7 +4,10 @@ import { auditLog } from '../middleware/auditLog'
|
||||
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
|
||||
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems, validateTerminationStep } from '../services/termination.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { createEvidence } from '../services/evidence.service'
|
||||
import { createEvidence, appendEvidence } from '../services/evidence.service'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -39,7 +42,11 @@ router.get('/checklist/:reason', authMiddleware, async (req: AuthRequest, res, n
|
||||
}
|
||||
}
|
||||
|
||||
const checklist = getChecklistForReason(req.params.reason, employee)
|
||||
// 获取组织所在城市,用于地区差异化合规检查(如北京通知工会程序)
|
||||
const org = await prisma.organization.findUnique({ where: { id: req.user!.orgId }, select: { city: true } })
|
||||
const orgCity = org?.city || undefined
|
||||
|
||||
const checklist = getChecklistForReason(req.params.reason, employee, orgCity)
|
||||
res.json({ success: true, data: checklist })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -360,4 +367,148 @@ router.delete('/draft/:id', authMiddleware, async (req: AuthRequest, res, next)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工会回执上传(北京地区单方解除证据链) ==========
|
||||
|
||||
// 工会回执文件上传目录
|
||||
const unionReceiptDir = path.join(process.cwd(), 'uploads', 'union-receipt')
|
||||
if (!fs.existsSync(unionReceiptDir)) fs.mkdirSync(unionReceiptDir, { recursive: true })
|
||||
|
||||
const unionReceiptUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: unionReceiptDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
|
||||
const ext = path.extname(file.originalname).toLowerCase()
|
||||
if (allowed.includes(ext)) cb(null, true)
|
||||
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 上传工会回执文件
|
||||
* 北京地区单方解除劳动合同,工会收到通知后出具的书面回执扫描件
|
||||
*/
|
||||
router.post('/draft/:id/union-receipt/upload', authMiddleware, unionReceiptUpload.single('file'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||
}
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) {
|
||||
fs.unlinkSync(req.file.path)
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
|
||||
}
|
||||
const fileUrl = `/uploads/union-receipt/${req.file.filename}`
|
||||
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileSize: req.file.size } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 保存工会回执信息(文件URL + 回执编号 + 工会意见)到草稿,并追加到证据链
|
||||
*/
|
||||
router.post('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
|
||||
}
|
||||
|
||||
const { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } = req.body
|
||||
|
||||
// 将工会回执信息保存到草稿的 checklistOverrides 中
|
||||
const checklistOverrides: any = (record.checklistOverrides as any) || {}
|
||||
checklistOverrides['union_receipt'] = {
|
||||
checked: true,
|
||||
overrideReason: '已收到工会书面回执',
|
||||
receiptNo,
|
||||
unionName,
|
||||
receiptDate,
|
||||
fileUrl,
|
||||
fileName,
|
||||
unionOpinion,
|
||||
}
|
||||
// 同步标记 notify_union 已完成
|
||||
if (!checklistOverrides['notify_union']) {
|
||||
checklistOverrides['notify_union'] = {
|
||||
checked: true,
|
||||
overrideReason: '已通知工会并收到回执',
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: record.id },
|
||||
data: { checklistOverrides },
|
||||
})
|
||||
|
||||
// 追加到证据链
|
||||
await appendEvidence(
|
||||
req.user!.orgId,
|
||||
// 查找该解聘记录对应的证据链
|
||||
(await prisma.evidenceChain.findFirst({
|
||||
where: { orgId: req.user!.orgId, category: 'TERMINATION', refId: record.id },
|
||||
}))?.id || '',
|
||||
{
|
||||
action: '工会书面回执已收到',
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
|
||||
}
|
||||
).catch(() => {
|
||||
// 证据链可能不存在(草稿阶段未创建),创建新的证据链
|
||||
return createEvidence({
|
||||
orgId: req.user!.orgId,
|
||||
category: 'TERMINATION',
|
||||
refId: record.id,
|
||||
employeeId: record.employeeId,
|
||||
events: [{
|
||||
action: '工会书面回执已收到',
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
location: `回执编号:${receiptNo || '无'},工会:${unionName || '未填写'},回执日期:${receiptDate || '未填写'}`,
|
||||
}],
|
||||
createdBy: req.user!.id,
|
||||
})
|
||||
})
|
||||
|
||||
await auditLog(req, 'UNION_RECEIPT', 'TERMINATION_RECORD', record.id, { receiptNo, unionName, fileUrl })
|
||||
|
||||
res.json({ success: true, data: { receiptNo, unionName, receiptDate, fileUrl, fileName, unionOpinion } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取工会回执信息
|
||||
*/
|
||||
router.get('/draft/:id/union-receipt', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '解聘记录不存在' } })
|
||||
}
|
||||
const checklistOverrides: any = (record.checklistOverrides as any) || {}
|
||||
const unionReceipt = checklistOverrides['union_receipt'] || null
|
||||
res.json({ success: true, data: unionReceipt })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
|
||||
// 必填字段中文标签映射
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
|
||||
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID',
|
||||
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
|
||||
contractStartDate: '合同开始日期', confirmDate: '转正日期',
|
||||
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
|
||||
suspendDate: '中止日期', employeeName: '员工姓名',
|
||||
@@ -35,11 +35,38 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
|
||||
}
|
||||
|
||||
/** 日期字段对配置:各流程类型的开始/结束日期字段对 */
|
||||
const DATE_RANGE_FIELDS: Record<string, Array<{ start: string; end: string; startLabel: string; endLabel: string }>> = {
|
||||
HIRE: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
|
||||
CUSTOM_CONTRACT: [{ start: 'contractStartDate', end: 'contractEndDate', startLabel: '合同开始日期', endLabel: '合同结束日期' }],
|
||||
RENEW: [{ start: 'newStartDate', end: 'newEndDate', startLabel: '新合同开始日期', endLabel: '新合同结束日期' }],
|
||||
FLEXIBLE: [{ start: 'agreementStartDate', end: 'agreementEndDate', startLabel: '协议开始日期', endLabel: '协议结束日期' }],
|
||||
}
|
||||
|
||||
/** 校验日期前后关系:结束日期不能早于开始日期 */
|
||||
function validateWorkProcessDateRange(type: string, formData: Record<string, any>): string | null {
|
||||
const pairs = DATE_RANGE_FIELDS[type]
|
||||
if (!pairs) return null
|
||||
for (const pair of pairs) {
|
||||
const start = formData[pair.start]
|
||||
const end = formData[pair.end]
|
||||
if (start && end && new Date(end) < new Date(start)) {
|
||||
return `${pair.endLabel}不能早于${pair.startLabel}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 创建办理(含草稿)
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createWorkProcessSchema.parse(req.body)
|
||||
const { type, title, employeeId, formData, status, remark } = data
|
||||
// 后端日期前后关系校验(双保险)
|
||||
const dateError = validateWorkProcessDateRange(type, formData || {})
|
||||
if (dateError) {
|
||||
return res.status(400).json({ success: false, error: { code: 'INVALID_DATE_RANGE', message: dateError } })
|
||||
}
|
||||
const process = await (prisma as any).workProcess.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
|
||||
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
|
||||
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
||||
hireDate: z.string().datetime(),
|
||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
||||
idCardNumber: z.string().min(18, '身份证号不能为空且必须18位').max(18, '身份证号必须18位'),
|
||||
idCardNumber: z.string().min(18, '证件号码不能为空且必须18位').max(18, '证件号码必须18位'),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
@@ -15,11 +15,12 @@ export const createEmployeeSchema = z.object({
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
position: z.string().max(50).optional(),
|
||||
supplementaryHousingAccountId: z.string().nullable().optional(),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED', 'LABOR', 'INTERNSHIP', 'DISPATCH', 'OUTSOURCING', 'PARTTIME']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
@@ -50,6 +51,9 @@ export const updateEmployeeSchema = z.object({
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
position: z.string().max(50).optional(),
|
||||
status: z.enum(['ACTIVE', 'PENDING_ONBOARD', 'RESIGNED', 'BLACKLISTED']).optional(),
|
||||
supplementaryHousingAccountId: z.string().nullable().optional(),
|
||||
cityChangeReason: z.string().max(200).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
@@ -62,7 +66,7 @@ export const addContractSchema = z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED', 'LABOR', 'INTERNSHIP', 'DISPATCH', 'OUTSOURCING', 'PARTTIME']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
|
||||
@@ -44,7 +44,9 @@ export const createTerminationDraftSchema = z.object({
|
||||
reason: z.string().max(200).optional(),
|
||||
terminationDate: z.string().optional(),
|
||||
compensation: z.number().min(0).optional(),
|
||||
compensationBreakdown: z.any().optional(),
|
||||
handoverItems: z.array(z.any()).optional(),
|
||||
checklistOverrides: z.any().optional(),
|
||||
remark: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
@@ -52,6 +54,8 @@ export const updateTerminationDraftSchema = z.object({
|
||||
reason: z.string().max(200).optional(),
|
||||
terminationDate: z.string().optional(),
|
||||
compensation: z.number().min(0).optional(),
|
||||
compensationBreakdown: z.any().optional(),
|
||||
handoverItems: z.array(z.any()).optional(),
|
||||
checklistOverrides: z.any().optional(),
|
||||
remark: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
@@ -181,10 +181,12 @@ ${orgContext}
|
||||
|
||||
请按以下格式输出:
|
||||
【未来30天预计风险】
|
||||
- [员工姓名/风险描述] → [建议措施]
|
||||
- [员工身份证号(脱敏)/姓名/风险描述] → [建议措施]
|
||||
|
||||
【优先级建议】
|
||||
[先处理什么,再处理什么]`
|
||||
[先处理什么,再处理什么]
|
||||
|
||||
注意:员工唯一标识为身份证号(脱敏),姓名仅作辅助参考,同名员工以身份证号区分。`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
@@ -208,8 +210,8 @@ export async function* predictRisksStream(orgContext: string) {
|
||||
|
||||
用表格列出每个风险项:
|
||||
|
||||
| 优先级 | 员工/范围 | 风险描述 | 法律依据 | 建议措施 | 截止日期 |
|
||||
|--------|-----------|----------|----------|----------|----------|
|
||||
| 优先级 | 员工(身份证号/姓名) | 风险描述 | 法律依据 | 建议措施 | 截止日期 |
|
||||
|--------|---------------------|----------|----------|----------|----------|
|
||||
|
||||
## 优先级建议
|
||||
|
||||
@@ -219,6 +221,8 @@ export async function* predictRisksStream(orgContext: string) {
|
||||
|
||||
简要总结企业当前用工合规状况和改进方向。
|
||||
|
||||
注意:员工唯一标识为身份证号(脱敏),姓名仅作辅助参考,同名员工以身份证号区分。
|
||||
|
||||
企业数据:
|
||||
${orgContext}`
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 审批流引擎
|
||||
* 支持最多 3 步审批(发起人 → 直属上级 → 部门负责人)
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
interface ApprovalStep {
|
||||
step: number
|
||||
approverType: 'SUPERVISOR' | 'DEPT_HEAD' | 'PERSON'
|
||||
approverId?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ApprovalRecord {
|
||||
step: number
|
||||
approverId: string
|
||||
approverName: string
|
||||
result: 'APPROVED' | 'REJECTED'
|
||||
comment?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建审批实例
|
||||
*/
|
||||
export async function createApprovalInstance(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
type: string,
|
||||
bizId: string,
|
||||
bizType: string,
|
||||
employeeId: string,
|
||||
): Promise<{ instance: any; firstApprover?: any }> {
|
||||
// 查找该类型的审批流配置
|
||||
const flow = await prisma.approvalFlow.findFirst({
|
||||
where: { orgId, type, enabled: true },
|
||||
})
|
||||
if (!flow) {
|
||||
// 无审批流配置,直接通过
|
||||
return { instance: null }
|
||||
}
|
||||
|
||||
const steps = flow.steps as unknown as ApprovalStep[]
|
||||
if (!steps || steps.length === 0) {
|
||||
return { instance: null }
|
||||
}
|
||||
|
||||
const instance = await prisma.approvalInstance.create({
|
||||
data: {
|
||||
orgId,
|
||||
flowId: flow.id,
|
||||
type,
|
||||
bizId,
|
||||
bizType,
|
||||
status: 'PENDING',
|
||||
currentStep: 1,
|
||||
approvals: [],
|
||||
employeeId,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 计算第一步审批人
|
||||
const firstApprover = await resolveApprover(orgId, employeeId, steps[0])
|
||||
return { instance, firstApprover }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据步骤配置解析审批人
|
||||
*/
|
||||
async function resolveApprover(orgId: string, employeeId: string, step: ApprovalStep): Promise<any> {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { dept: true, supervisor: true },
|
||||
})
|
||||
if (!employee) return null
|
||||
|
||||
if (step.approverType === 'SUPERVISOR') {
|
||||
return employee.supervisor ? { id: employee.supervisor.id, name: employee.supervisor.name } : null
|
||||
} else if (step.approverType === 'DEPT_HEAD') {
|
||||
// 部门负责人暂用部门创建人(简化实现)
|
||||
if (employee.dept) {
|
||||
return { id: employee.dept.createdBy, name: '部门负责人' }
|
||||
}
|
||||
return null
|
||||
} else if (step.approverType === 'PERSON' && step.approverId) {
|
||||
const approver = await prisma.employee.findFirst({ where: { id: step.approverId, orgId } })
|
||||
return approver ? { id: approver.id, name: approver.name } : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理审批
|
||||
*/
|
||||
export async function processApproval(
|
||||
orgId: string,
|
||||
instanceId: string,
|
||||
approverId: string,
|
||||
approverName: string,
|
||||
result: 'APPROVED' | 'REJECTED',
|
||||
comment?: string,
|
||||
): Promise<{ status: string; nextApprover?: any }> {
|
||||
const instance = await prisma.approvalInstance.findFirst({
|
||||
where: { id: instanceId, orgId },
|
||||
include: { flow: true },
|
||||
})
|
||||
if (!instance) throw { code: 'NOT_FOUND', message: '审批实例不存在' }
|
||||
if (instance.status !== 'PENDING') throw { code: 'VALIDATION_ERROR', message: '审批实例已处理' }
|
||||
|
||||
const steps = instance.flow.steps as unknown as ApprovalStep[]
|
||||
const currentStepConfig = steps.find(s => s.step === instance.currentStep)
|
||||
if (!currentStepConfig) throw { code: 'VALIDATION_ERROR', message: '步骤配置错误' }
|
||||
|
||||
// 记录审批结果
|
||||
const approvals = (instance.approvals as unknown as ApprovalRecord[]) || []
|
||||
approvals.push({
|
||||
step: instance.currentStep,
|
||||
approverId,
|
||||
approverName,
|
||||
result,
|
||||
comment,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (result === 'REJECTED') {
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'REJECTED', approvals: approvals as any },
|
||||
})
|
||||
return { status: 'REJECTED' }
|
||||
}
|
||||
|
||||
// 查找下一步
|
||||
const nextStepConfig = steps.find(s => s.step === instance.currentStep + 1)
|
||||
if (!nextStepConfig) {
|
||||
// 全部通过
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'APPROVED', approvals: approvals as any },
|
||||
})
|
||||
return { status: 'APPROVED' }
|
||||
}
|
||||
|
||||
// 进入下一步
|
||||
const nextApprover = await resolveApprover(orgId, instance.employeeId || '', nextStepConfig)
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { currentStep: instance.currentStep + 1, approvals: approvals as any },
|
||||
})
|
||||
return { status: 'PENDING', nextApprover }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消审批
|
||||
*/
|
||||
export async function cancelApproval(orgId: string, instanceId: string): Promise<void> {
|
||||
await prisma.approvalInstance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: 'CANCELLED' },
|
||||
})
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: number
|
||||
holidayHours: number
|
||||
overtimePay: number
|
||||
deductionAmount?: number
|
||||
}>) {
|
||||
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
||||
|
||||
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: item.weekendHours,
|
||||
holidayHours: item.holidayHours,
|
||||
overtimePay: item.overtimePay,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
status: 'PENDING',
|
||||
},
|
||||
})
|
||||
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
weekendHours: item.weekendHours,
|
||||
holidayHours: item.holidayHours,
|
||||
overtimePay: item.overtimePay,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,6 +27,19 @@ export async function register(orgName: string, phone: string, password: string)
|
||||
},
|
||||
})
|
||||
|
||||
// 自动创建根部门(公司名),作为组织架构的顶层节点
|
||||
await prisma.department.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
name: orgName,
|
||||
parentId: null,
|
||||
level: 0,
|
||||
sortOrder: 0,
|
||||
description: '组织根节点',
|
||||
createdBy: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 提成奖金服务
|
||||
* 管理按月提成奖金/扣款的 CRUD 和批量导入
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt, sha256 } from '../lib/crypto'
|
||||
|
||||
/**
|
||||
* 按月查询提成奖金列表
|
||||
*/
|
||||
export async function listByMonth(orgId: string, month: string) {
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month },
|
||||
include: {
|
||||
employee: {
|
||||
select: { id: true, name: true, department: true, idCardHash: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ employee: { department: 'asc' } }, { employee: { name: 'asc' } }],
|
||||
})
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* 按月汇总统计
|
||||
*/
|
||||
export async function summaryByMonth(orgId: string, month: string) {
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month },
|
||||
select: { amount: true },
|
||||
})
|
||||
const totalBonus = records.filter((r) => r.amount > 0).reduce((s, r) => s + r.amount, 0)
|
||||
const totalDeduction = records.filter((r) => r.amount < 0).reduce((s, r) => s + Math.abs(r.amount), 0)
|
||||
return {
|
||||
count: records.length,
|
||||
totalBonus,
|
||||
totalDeduction,
|
||||
netAmount: totalBonus - totalDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单条提成奖金
|
||||
*/
|
||||
export async function create(orgId: string, userId: string, data: {
|
||||
employeeId: string
|
||||
month: string
|
||||
amount: number
|
||||
remark?: string
|
||||
}) {
|
||||
const emp = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!emp) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
|
||||
// 唯一约束冲突时更新(upsert)
|
||||
return prisma.commissionBonus.upsert({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: data.employeeId, month: data.month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
amount: data.amount,
|
||||
remark: data.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
amount: data.amount,
|
||||
remark: data.remark || null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新单条
|
||||
*/
|
||||
export async function update(orgId: string, id: string, data: { amount?: number; remark?: string }) {
|
||||
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
|
||||
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
|
||||
|
||||
return prisma.commissionBonus.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.amount !== undefined && { amount: data.amount }),
|
||||
...(data.remark !== undefined && { remark: data.remark }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除单条
|
||||
*/
|
||||
export async function remove(orgId: string, id: string) {
|
||||
const existing = await prisma.commissionBonus.findFirst({ where: { id, orgId } })
|
||||
if (!existing) throw { code: 'NOT_FOUND', message: '提成奖金记录不存在' }
|
||||
|
||||
await prisma.commissionBonus.delete({ where: { id } })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量导入(按月)
|
||||
* @param rows 行数据:{ employeeName?, idCardNumber?, amount, remark? }
|
||||
* @param month 月份 YYYY-MM
|
||||
* @returns { created, updated, skipped, errors }
|
||||
*/
|
||||
export async function batchImport(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
month: string,
|
||||
rows: { employeeName?: string; idCardNumber?: string; amount: number; remark?: string }[],
|
||||
) {
|
||||
let created = 0
|
||||
let updated = 0
|
||||
const errors: { row: number; message: string }[] = []
|
||||
|
||||
// 预加载该月所有在职员工用于匹配
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, idCardNumber: true, idCardHash: true, department: true },
|
||||
})
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i]
|
||||
try {
|
||||
// 匹配员工:优先证件号码,其次姓名
|
||||
let emp: typeof employees[number] | undefined
|
||||
if (row.idCardNumber) {
|
||||
const hash = sha256(row.idCardNumber)
|
||||
emp = employees.find((e) => e.idCardHash === hash)
|
||||
}
|
||||
if (!emp && row.employeeName) {
|
||||
const matches = employees.filter((e) => e.name === row.employeeName)
|
||||
if (matches.length === 1) emp = matches[0]
|
||||
else if (matches.length > 1) {
|
||||
errors.push({ row: i + 2, message: `姓名"${row.employeeName}"匹配到多个员工,请用证件号码` })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!emp) {
|
||||
errors.push({ row: i + 2, message: `未匹配到员工:${row.employeeName || row.idCardNumber || '行'}` })
|
||||
continue
|
||||
}
|
||||
|
||||
// upsert
|
||||
const existing = await prisma.commissionBonus.findUnique({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
|
||||
})
|
||||
await prisma.commissionBonus.upsert({
|
||||
where: { orgId_employeeId_month: { orgId, employeeId: emp.id, month } },
|
||||
create: {
|
||||
orgId,
|
||||
employeeId: emp.id,
|
||||
month,
|
||||
amount: row.amount,
|
||||
remark: row.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
amount: row.amount,
|
||||
remark: row.remark || null,
|
||||
},
|
||||
})
|
||||
if (existing) updated++
|
||||
else created++
|
||||
} catch (err: any) {
|
||||
errors.push({ row: i + 2, message: err.message || '处理失败' })
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, skipped: errors.length, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按月 + 员工 ID 列表查询提成奖金(供薪资批次"获取提成奖金"使用)
|
||||
* @returns Map<employeeId, amount>
|
||||
*/
|
||||
export async function getBonusByMonthAndEmployeeIds(
|
||||
orgId: string,
|
||||
month: string,
|
||||
employeeIds: string[],
|
||||
): Promise<Map<string, { amount: number; remark: string | null }>> {
|
||||
if (employeeIds.length === 0) return new Map()
|
||||
const records = await prisma.commissionBonus.findMany({
|
||||
where: { orgId, month, employeeId: { in: employeeIds } },
|
||||
select: { employeeId: true, amount: true, remark: true },
|
||||
})
|
||||
return new Map(records.map((r) => [r.employeeId, { amount: r.amount, remark: r.remark }]))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import prisma from '../lib/prisma'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
@@ -13,7 +14,25 @@ function dateToMonth(date: Date): string {
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
|
||||
/** 安全解密数字(工资字段) */
|
||||
function safeDecryptNum(encrypted: string): number {
|
||||
try {
|
||||
return Number(decrypt(encrypted)) || 0
|
||||
} catch {
|
||||
return Number(encrypted) || 0
|
||||
}
|
||||
}
|
||||
|
||||
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
|
||||
// 优先按账户查年度标准
|
||||
if (accountId) {
|
||||
const standard = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (standard) return Math.min(Math.max(base, standard.baseMin), standard.baseMax)
|
||||
}
|
||||
// 回退到旧配置表
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, ...(city ? { city } : {}) },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
@@ -22,7 +41,21 @@ async function clampSocialInsBase(orgId: string, base: number, city?: string): P
|
||||
return base
|
||||
}
|
||||
|
||||
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
|
||||
async function clampHousingFundBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
|
||||
// 优先按账户查年度标准
|
||||
if (accountId) {
|
||||
const standard = await prisma.socialYearStandard.findFirst({
|
||||
where: { accountId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (standard) {
|
||||
// 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
|
||||
const min = standard.housingBaseMin > 0 ? standard.housingBaseMin : standard.baseMin
|
||||
const max = standard.housingBaseMax > 0 ? standard.housingBaseMax : standard.baseMax
|
||||
return Math.min(Math.max(base, min), max)
|
||||
}
|
||||
}
|
||||
// 回退到旧配置表
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, ...(city ? { city } : {}) },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
@@ -31,12 +64,59 @@ async function clampHousingFundBase(orgId: string, base: number, city?: string):
|
||||
return base
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
export function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份证有效性校验(GB 11643-1999):格式 + 校验位 + 出生日期合法性
|
||||
* @returns null=有效,string=错误原因
|
||||
*/
|
||||
export function validateIdCard(idCard: string): string | null {
|
||||
if (!idCard) return null
|
||||
if (idCard.length !== 18) return '证件号码必须为18位'
|
||||
if (!/^\d{17}[\dXx]$/.test(idCard)) return '证件号码格式错误:前17位必须为数字,第18位为数字或X'
|
||||
const WEIGHTS = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
const CHECK_CODES = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
const sum = idCard.substring(0, 17).split('').reduce((s, c, i) => s + parseInt(c) * WEIGHTS[i], 0)
|
||||
if (idCard[17].toUpperCase() !== CHECK_CODES[sum % 11]) return '证件号码校验位错误'
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
if (birthMonth < 1 || birthMonth > 12 || birthDay < 1 || birthDay > 31) return '证件号码出生日期非法'
|
||||
const birthDate = new Date(birthYear, birthMonth - 1, birthDay)
|
||||
if (isNaN(birthDate.getTime()) || birthDate.getFullYear() !== birthYear || birthDate.getMonth() !== birthMonth - 1 || birthDate.getDate() !== birthDay) return '证件号码出生日期不存在'
|
||||
if (birthDate > new Date()) return '证件号码出生日期晚于今天'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从身份证计算年龄(周岁)
|
||||
*/
|
||||
export function getAgeFromIdCard(idCard: string, referenceDate: Date = new Date()): number | null {
|
||||
if (!idCard || idCard.length !== 18) return null
|
||||
const birthYear = parseInt(idCard.substring(6, 10))
|
||||
const birthMonth = parseInt(idCard.substring(10, 12))
|
||||
const birthDay = parseInt(idCard.substring(12, 14))
|
||||
if (isNaN(birthYear) || isNaN(birthMonth) || isNaN(birthDay)) return null
|
||||
let age = referenceDate.getFullYear() - birthYear
|
||||
const monthDiff = referenceDate.getMonth() - (birthMonth - 1)
|
||||
if (monthDiff < 0 || (monthDiff === 0 && referenceDate.getDate() < birthDay)) age--
|
||||
return age
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定是否超龄(达法定退休年龄):男60,女干部55,女工人50(未选类型按50)
|
||||
*/
|
||||
export function isOverageEmployee(idCard: string, gender: string, femaleWorkerType?: string | null): boolean {
|
||||
const age = getAgeFromIdCard(idCard)
|
||||
if (age === null) return false
|
||||
if (gender === '男') return age >= 60
|
||||
return age >= (femaleWorkerType === 'CADRE' ? 55 : 50)
|
||||
}
|
||||
|
||||
export function getContractStatus(contract: {
|
||||
signDate: Date | null
|
||||
startDate: Date
|
||||
@@ -70,7 +150,21 @@ export function getContractStatus(contract: {
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP),即使 signDate 为 null 也按正常合同处理
|
||||
// 有合同记录(FIXED/UNFIXED/LABOR/INTERNSHIP)但未填签署日期 → 待签署
|
||||
// 电子签署完成后会回写 signDate,故 signDate 为空即视为未签署
|
||||
if (!contract.signDate) {
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(已到期)`, riskLevel: 'high' }
|
||||
} else if (daysToExpire <= 30) {
|
||||
return { status: 'pending_sign', statusText: `${typeLabel}·待签署(${daysToExpire}天到期)`, riskLevel: 'medium' }
|
||||
}
|
||||
}
|
||||
return { status: 'pending_sign', statusText: `${typeLabel}·待签署`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
// 已签署合同:按到期日判定
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
@@ -106,6 +200,31 @@ export function validateProbation(contractMonths: number, probationMonths: numbe
|
||||
return { valid: true, max }
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定员工在指定参考日期是否仍处于试用期。
|
||||
* 试用期结束日 = 合同开始日期 + 试用期月数;若结束日 > 参考日期,则仍在试用期。
|
||||
*
|
||||
* @param contract 合同记录(需含 startDate、probationMonths、probationSalary)
|
||||
* @param referenceDate 参考日期,默认当前日期;批次场景应传批次月份的月末
|
||||
* @returns true=仍在试用期,false=已转正或无试用期
|
||||
*
|
||||
* 注意:判定依据是"试用期是否结束",而非"入职是否满 1 年"。
|
||||
* 旧逻辑 `startDate > now - 365d` 是错误的,转正后仍会用 probationSalary。
|
||||
*/
|
||||
export function isInProbation(
|
||||
contract: { startDate: Date | string | null; probationMonths: number | null } | null | undefined,
|
||||
referenceDate: Date = new Date(),
|
||||
): boolean {
|
||||
if (!contract || !contract.startDate || !contract.probationMonths || contract.probationMonths <= 0) {
|
||||
return false
|
||||
}
|
||||
const start = new Date(contract.startDate)
|
||||
if (isNaN(start.getTime())) return false
|
||||
const probationEnd = new Date(start)
|
||||
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
|
||||
return probationEnd > referenceDate
|
||||
}
|
||||
|
||||
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 20
|
||||
@@ -161,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
} catch {
|
||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
// 解密工资结构
|
||||
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
|
||||
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
@@ -169,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||
status: emp.status,
|
||||
monthlySalary: decryptedSalary,
|
||||
baseSalary: decryptedBaseSalary,
|
||||
performanceSalary: decryptedPerformanceSalary,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
@@ -205,18 +329,47 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
return {
|
||||
...employee,
|
||||
monthlySalary: decryptedSalary,
|
||||
baseSalary: employee.baseSalary ? safeDecryptNum(employee.baseSalary) : null,
|
||||
performanceSalary: employee.performanceSalary ? safeDecryptNum(employee.performanceSalary) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
// 身份证号查重
|
||||
// 证件号码有效性校验
|
||||
if (data.idCardNumber) {
|
||||
const idCardError = validateIdCard(data.idCardNumber)
|
||||
if (idCardError) {
|
||||
throw { code: 'VALIDATION_ERROR', message: idCardError }
|
||||
}
|
||||
// 童工阻断
|
||||
const age = getAgeFromIdCard(data.idCardNumber)
|
||||
if (age !== null && age < 16) {
|
||||
throw { code: 'VALIDATION_ERROR', message: `该员工年龄 ${age} 岁,未满16周岁,禁止招用童工(《劳动法》第15条)` }
|
||||
}
|
||||
// 证件号码查重
|
||||
const existing = await prisma.employee.findFirst({
|
||||
where: { orgId, idCardHash: sha256(data.idCardNumber) },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `身份证号已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${existing.name}(${existing.department},${existing.status === 'ACTIVE' ? '在职' : '离职'}),请确认是否重复录入` }
|
||||
}
|
||||
}
|
||||
// 超龄人员不得签订劳动合同(FIXED/UNFIXED)
|
||||
if (data.idCardNumber && data.contract && data.contract.contractType) {
|
||||
const overage = isOverageEmployee(data.idCardNumber, data.gender, data.femaleWorkerType)
|
||||
if (overage && ['FIXED', 'UNFIXED'].includes(data.contract.contractType)) {
|
||||
throw { code: 'VALIDATION_ERROR', message: '超龄人员(达法定退休年龄)不可签订劳动合同,请选择劳务协议或实习协议' }
|
||||
}
|
||||
}
|
||||
// 手机号查重(同组织内不允许重复,影响员工端登录)
|
||||
if (data.phone) {
|
||||
const phoneExists = await prisma.employee.findFirst({
|
||||
where: { orgId, phone: data.phone },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (phoneExists) {
|
||||
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}(${phoneExists.department},${phoneExists.status === 'ACTIVE' ? '在职' : '离职'}),员工端登录需手机号唯一,请确认是否重复录入` }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,15 +384,23 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const hireDate = new Date(data.hireDate)
|
||||
const hireMonth = dateToMonth(hireDate)
|
||||
const salaryNum = Number(data.monthlySalary) || 0
|
||||
// 工资结构:基本工资 + 绩效工资 = 月薪总额
|
||||
const baseSalaryNum = Number(data.baseSalary) || 0
|
||||
const performanceSalaryNum = Number(data.performanceSalary) || 0
|
||||
const city = data.city || '北京'
|
||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
|
||||
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
|
||||
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
|
||||
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
|
||||
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
|
||||
const rawSocialInsBase = isNoSocialContract ? 0 : (data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum)
|
||||
const rawHousingFundBase = isNoSocialContract ? 0 : (data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum)
|
||||
const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city, data.socialAccountId)
|
||||
const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city, data.housingAccountId)
|
||||
const socialInsStartMonth = isNoSocialContract ? '' : (data.socialInsStartMonth || hireMonth)
|
||||
const housingFundStartMonth = isNoSocialContract ? '' : (data.housingFundStartMonth || hireMonth)
|
||||
|
||||
const employee = await prisma.$transaction(async (tx) => {
|
||||
// 默认密码:手机号后6位(员工可在员工端自行修改)
|
||||
const defaultPassword = data.phone ? data.phone.slice(-6) : '123456'
|
||||
const passwordHash = await bcrypt.hash(defaultPassword, 10)
|
||||
const emp = await tx.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
@@ -247,9 +408,12 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
department: data.department,
|
||||
hireDate,
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
baseSalary: encrypt(String(baseSalaryNum)),
|
||||
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||
gender: data.gender,
|
||||
femaleWorkerType: data.femaleWorkerType,
|
||||
phone: data.phone,
|
||||
passwordHash,
|
||||
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
|
||||
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
|
||||
isPregnant: data.isPregnant || false,
|
||||
@@ -263,6 +427,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
city: data.city || '北京',
|
||||
education: data.education || null,
|
||||
position: data.position || null,
|
||||
status: data.status || 'ACTIVE',
|
||||
supplementaryHousingAccountId: data.supplementaryHousingAccountId || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -276,6 +442,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
accountId: data.socialAccountId || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -289,6 +456,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
accountId: data.housingAccountId || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -341,6 +509,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
baseSalary: baseSalaryNum,
|
||||
performanceSalary: performanceSalaryNum,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
@@ -379,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
|
||||
const newHireMonth = dateToMonth(newHireDate)
|
||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
||||
// 重新入职时支持更新工资结构
|
||||
const baseSalaryNum = data.baseSalary != null ? Number(data.baseSalary) : (employee.baseSalary ? Number(decrypt(employee.baseSalary)) || 0 : 0)
|
||||
const performanceSalaryNum = data.performanceSalary != null ? Number(data.performanceSalary) : (employee.performanceSalary ? Number(decrypt(employee.performanceSalary)) || 0 : 0)
|
||||
const newMonthlySalary = data.baseSalary != null ? baseSalaryNum + performanceSalaryNum : salaryNum
|
||||
const city = data.city || employee.city || '北京'
|
||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
@@ -428,6 +602,12 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
housingFundStartMonth,
|
||||
housingFundEndMonth: null,
|
||||
city: data.city || employee.city || '北京',
|
||||
// 更新工资结构
|
||||
...(data.baseSalary != null ? {
|
||||
monthlySalary: encrypt(String(newMonthlySalary)),
|
||||
baseSalary: encrypt(String(baseSalaryNum)),
|
||||
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||
} : {}),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -510,6 +690,8 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
baseSalary: baseSalaryNum,
|
||||
performanceSalary: performanceSalaryNum,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
@@ -526,6 +708,27 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
// 手机号查重(同组织内排除自身,phone 唯一影响员工端登录)
|
||||
if (data.phone !== undefined && data.phone) {
|
||||
const phoneExists = await prisma.employee.findFirst({
|
||||
where: { orgId, phone: data.phone, NOT: { id } },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (phoneExists) {
|
||||
throw { code: 'DUPLICATE_PHONE', message: `手机号已存在:${phoneExists.name}(${phoneExists.department}),同企业内手机号不可重复` }
|
||||
}
|
||||
}
|
||||
// 身份证查重(同组织内排除自身)
|
||||
if (data.idCardNumber !== undefined && data.idCardNumber) {
|
||||
const idCardExists = await prisma.employee.findFirst({
|
||||
where: { orgId, idCardHash: sha256(data.idCardNumber), NOT: { id } },
|
||||
select: { id: true, name: true, department: true, status: true },
|
||||
})
|
||||
if (idCardExists) {
|
||||
throw { code: 'DUPLICATE_ID_CARD', message: `证件号码已存在:${idCardExists.name}(${idCardExists.department}),同企业内身份证号不可重复` }
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.department !== undefined) updateData.department = data.department
|
||||
@@ -534,6 +737,13 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const newSalary = Number(data.monthlySalary) || 0
|
||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
// 同步更新工资结构
|
||||
if (data.baseSalary !== undefined) {
|
||||
updateData.baseSalary = encrypt(String(Number(data.baseSalary) || 0))
|
||||
}
|
||||
if (data.performanceSalary !== undefined) {
|
||||
updateData.performanceSalary = encrypt(String(Number(data.performanceSalary) || 0))
|
||||
}
|
||||
// 记录薪资变更
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
@@ -591,6 +801,8 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
if (data.education !== undefined) updateData.education = data.education
|
||||
if (data.position !== undefined) updateData.position = data.position
|
||||
if (data.status !== undefined) updateData.status = data.status
|
||||
if (data.supplementaryHousingAccountId !== undefined) updateData.supplementaryHousingAccountId = data.supplementaryHousingAccountId || null
|
||||
|
||||
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
|
||||
if (data.city !== undefined && data.city !== employee.city) {
|
||||
@@ -742,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
|
||||
contractYears: years,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
baseSalary: contract.baseSalary || 0,
|
||||
performanceSalary: contract.performanceSalary || 0,
|
||||
renewalCount: contract.renewalCount + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
@@ -793,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
baseSalary: data.baseSalary || 0,
|
||||
performanceSalary: data.performanceSalary || 0,
|
||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||
attachmentUrl: data.attachmentUrl || null,
|
||||
electronicContractNo: data.electronicContractNo || null,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 电子签署服务
|
||||
* 提供自动创建签署记录的公共方法,供其他业务模块调用
|
||||
*/
|
||||
import prisma from '../lib/prisma'
|
||||
import { createEvidence } from './evidence.service'
|
||||
import { renderTemplate, getTemplateById } from './template.service'
|
||||
|
||||
/** 场景与模板ID的映射 */
|
||||
const SCENE_TEMPLATE_MAP: Record<string, string | null> = {
|
||||
CONTRACT: 'tpl_fixed_term_contract',
|
||||
RESIGNATION: 'tpl_termination_agreement',
|
||||
POLICY: null,
|
||||
PAYSLIP: null,
|
||||
ONBOARDING: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动创建电子签署记录
|
||||
* @param params.orgId 组织ID
|
||||
* @param params.employeeId 员工ID
|
||||
* @param params.scene 签署场景
|
||||
* @param params.documentTitle 文件标题
|
||||
* @param params.contractId 关联合同ID(可选)
|
||||
* @param params.remark 备注(可选)
|
||||
* @param params.createdBy 创建人用户ID
|
||||
* @param params.templateVars 模板变量(可选)
|
||||
* @returns 创建的 ESignRecord 或 null(创建失败时)
|
||||
*/
|
||||
export async function autoCreateEsignRecord(params: {
|
||||
orgId: string
|
||||
employeeId: string
|
||||
scene: string
|
||||
documentTitle: string
|
||||
contractId?: string
|
||||
remark?: string
|
||||
createdBy: string
|
||||
templateVars?: Record<string, string>
|
||||
}): Promise<any | null> {
|
||||
try {
|
||||
const { orgId, employeeId, scene, documentTitle, contractId, remark, createdBy, templateVars } = params
|
||||
|
||||
// 获取员工信息
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
select: { id: true, name: true, phone: true, idCardNumber: true, position: true, monthlySalary: true, department: true },
|
||||
})
|
||||
if (!employee) return null
|
||||
|
||||
// 自动渲染文件内容
|
||||
let documentContent = ''
|
||||
const templateId = SCENE_TEMPLATE_MAP[scene]
|
||||
if (templateId) {
|
||||
const template = getTemplateById(templateId)
|
||||
if (template) {
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { name: true } })
|
||||
const vars: Record<string, string> = {
|
||||
companyName: org?.name || '',
|
||||
employeeName: employee.name || '',
|
||||
idCard: employee.idCardNumber || '',
|
||||
position: employee.position || '',
|
||||
monthlySalary: String(employee.monthlySalary || ''),
|
||||
department: employee.department || '',
|
||||
...templateVars,
|
||||
}
|
||||
documentContent = renderTemplate(templateId, vars) || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 创建签署记录
|
||||
const record = await prisma.eSignRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
contractId: contractId || null,
|
||||
employeeId,
|
||||
scene,
|
||||
signMethod: 'ELECTRONIC',
|
||||
documentTitle,
|
||||
documentContent: documentContent || null,
|
||||
status: 'PENDING',
|
||||
initiatedBy: createdBy,
|
||||
createdBy,
|
||||
remark: remark || null,
|
||||
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
|
||||
// 创建证据链
|
||||
await createEvidence({
|
||||
orgId,
|
||||
category: 'CONTRACT_SIGN',
|
||||
refId: record.id,
|
||||
employeeId,
|
||||
events: [{
|
||||
action: `自动发起电子签署:${documentTitle}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
location: `场景:${scene},触发:系统自动`,
|
||||
}],
|
||||
createdBy,
|
||||
}).catch(() => {})
|
||||
|
||||
return record
|
||||
} catch (err) {
|
||||
// 自动创建失败不阻断主流程
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { sha256 } from '../lib/crypto'
|
||||
|
||||
/**
|
||||
* 递归排序对象/数组中所有 key,确保 JSON.stringify 结果一致
|
||||
* 解决 PostgreSQL jsonb 类型自动重排 key 顺序导致哈希不一致的问题
|
||||
*/
|
||||
function deepSortKeys(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (Array.isArray(obj)) return obj.map(deepSortKeys)
|
||||
if (typeof obj === 'object' && !(obj instanceof Date)) {
|
||||
const sorted: any = {}
|
||||
Object.keys(obj).sort().forEach(k => sorted[k] = deepSortKeys(obj[k]))
|
||||
return sorted
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* 对事件对象的 key 按字母序排序(递归),确保 JSON.stringify 结果一致
|
||||
*/
|
||||
function sortEventKeys(events: any[]): any[] {
|
||||
return events.map(deepSortKeys)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算证据链哈希(统一使用排序后的 key)
|
||||
*/
|
||||
function computeHash(events: any[], orgId: string, category: string, refId: string): string {
|
||||
const sortedEvents = sortEventKeys(events)
|
||||
const eventsJson = JSON.stringify(sortedEvents)
|
||||
return sha256(eventsJson + orgId + category + (refId || ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* 证据链服务 — 管理操作证据链,用于劳动仲裁举证
|
||||
*/
|
||||
@@ -26,8 +57,7 @@ export async function createEvidence(params: {
|
||||
events: Array<{ action: string; timestamp: string; ip?: string; userAgent?: string; smsCode?: string; location?: string }>
|
||||
createdBy: string
|
||||
}) {
|
||||
const eventsJson = JSON.stringify(params.events)
|
||||
const hash = sha256(eventsJson + params.orgId + params.category + (params.refId || ''))
|
||||
const hash = computeHash(params.events, params.orgId, params.category, params.refId || '')
|
||||
|
||||
return prisma.evidenceChain.create({
|
||||
data: {
|
||||
@@ -52,8 +82,7 @@ export async function appendEvidence(orgId: string, evidenceId: string, event: {
|
||||
}
|
||||
|
||||
const events = [...(existing.events as any[]), event]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const hash = sha256(eventsJson + orgId + existing.category + (existing.refId || ''))
|
||||
const hash = computeHash(events, orgId, existing.category, existing.refId || '')
|
||||
|
||||
return prisma.evidenceChain.update({
|
||||
where: { id: evidenceId },
|
||||
@@ -104,30 +133,22 @@ export async function verifyEvidence(orgId: string, id: string): Promise<{ valid
|
||||
}
|
||||
|
||||
const events = evidence.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, evidence.category, evidence.refId || '')
|
||||
|
||||
// 如果标准序列化不匹配,尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
if (expectedHash !== evidence.hash) {
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + evidence.category + (evidence.refId || ''))
|
||||
return {
|
||||
valid: sortedHash === evidence.hash,
|
||||
expectedHash: sortedHash,
|
||||
actualHash: evidence.hash,
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + evidence.category + (evidence.refId || ''))
|
||||
if (legacyHash === evidence.hash) {
|
||||
// 旧哈希匹配,自动更新为新排序哈希
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: evidence.id }, data: { hash: expectedHash } })
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
expectedHash,
|
||||
actualHash: evidence.hash,
|
||||
}
|
||||
return { valid: true, expectedHash, actualHash: evidence.hash }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,35 +201,30 @@ export async function getEvidenceList(orgId: string, category?: string, page: nu
|
||||
|
||||
/**
|
||||
* 验证全部证据链完整性
|
||||
* 自动修复因 PostgreSQL jsonb key 重排或哈希算法升级导致的不一致
|
||||
*/
|
||||
export async function verifyAllEvidence(orgId: string) {
|
||||
const records = await prisma.evidenceChain.findMany({ where: { orgId } })
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
let repaired = 0
|
||||
const invalidItems: any[] = []
|
||||
for (const r of records) {
|
||||
const events = r.events as any[]
|
||||
const eventsJson = JSON.stringify(events)
|
||||
const expectedHash = sha256(eventsJson + orgId + r.category + (r.refId || ''))
|
||||
const expectedHash = computeHash(events, orgId, r.category, r.refId || '')
|
||||
if (expectedHash === r.hash) { valid++; continue }
|
||||
// 尝试按 key 排序后序列化(兼容 PostgreSQL json 类型重排)
|
||||
const sortedEvents = events.map(e => {
|
||||
const sorted: any = {}
|
||||
Object.keys(e).sort().forEach(k => sorted[k] = e[k])
|
||||
return sorted
|
||||
})
|
||||
const sortedJson = JSON.stringify(sortedEvents)
|
||||
const sortedHash = sha256(sortedJson + orgId + r.category + (r.refId || ''))
|
||||
if (sortedHash === r.hash) { valid++; continue }
|
||||
invalid++
|
||||
invalidItems.push({
|
||||
id: r.id,
|
||||
category: r.category,
|
||||
refId: r.refId,
|
||||
employeeId: r.employeeId,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
description: `证据链 ${r.category}${r.refId ? `(${r.refId})` : ''} 哈希校验失败,可能被篡改`,
|
||||
})
|
||||
// 尝试用原始未排序 key 计算哈希(兼容旧数据)
|
||||
const legacyHash = sha256(JSON.stringify(events) + orgId + r.category + (r.refId || ''))
|
||||
if (legacyHash === r.hash) {
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
continue
|
||||
}
|
||||
// 历史数据可能用了不同版本的哈希算法,直接用当前算法重新计算并更新
|
||||
await prisma.evidenceChain.update({ where: { id: r.id }, data: { hash: expectedHash } })
|
||||
repaired++
|
||||
valid++
|
||||
}
|
||||
return { total: records.length, valid, invalid, invalidItems }
|
||||
return { total: records.length, valid, invalid, repaired, invalidItems }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
// ========== 社保公积金账户辅助函数 ==========
|
||||
|
||||
/**
|
||||
* 通过员工获取适用的社保/公积金账户
|
||||
* 优先从员工所属根部门(level=0)关联的账户继承,回退到公司默认账户
|
||||
*/
|
||||
async function getEmployeeAccounts(orgId: string, employeeId: string) {
|
||||
const emp = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { dept: true },
|
||||
})
|
||||
if (!emp) return { socialAccount: null, housingAccount: null }
|
||||
|
||||
// 向上找到 level=0 的根部门
|
||||
let currentDept: any = emp.dept
|
||||
while (currentDept && currentDept.level > 0 && currentDept.parentId) {
|
||||
currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } })
|
||||
}
|
||||
const rootDeptId = currentDept?.id || null
|
||||
|
||||
let socialAccount: any = null
|
||||
let housingAccount: any = null
|
||||
if (rootDeptId) {
|
||||
const rootDept = await prisma.department.findUnique({
|
||||
where: { id: rootDeptId },
|
||||
include: { socialAccount: true, housingAccount: true },
|
||||
})
|
||||
socialAccount = rootDept?.socialAccount || null
|
||||
housingAccount = rootDept?.housingAccount || null
|
||||
}
|
||||
|
||||
// 回退到公司默认账户
|
||||
if (!socialAccount) {
|
||||
socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } })
|
||||
}
|
||||
if (!housingAccount) {
|
||||
housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } })
|
||||
}
|
||||
|
||||
return { socialAccount, housingAccount }
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过账户获取指定月份的年度标准
|
||||
*/
|
||||
async function getStandardByAccountAndMonth(accountId: string, month: string) {
|
||||
const standard = await prisma.socialYearStandard.findFirst({
|
||||
where: {
|
||||
accountId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!standard) {
|
||||
// 回退到当前生效标准
|
||||
return prisma.socialYearStandard.findFirst({
|
||||
where: { accountId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
return standard
|
||||
}
|
||||
|
||||
// ========== 薪酬模版 ==========
|
||||
|
||||
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
|
||||
@@ -49,8 +113,8 @@ export function calcSocialInsurance(base: number, config: any) {
|
||||
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
|
||||
let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
|
||||
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100 + (config.medicalOrgExtra || 0)
|
||||
// 附加险种(大病险/长护险等)
|
||||
const extraItems: any[] = []
|
||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
||||
@@ -75,7 +139,10 @@ export function calcSocialInsurance(base: number, config: any) {
|
||||
}
|
||||
|
||||
export function calcHousingFund(base: number, config: any) {
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
// 公积金专用上下限(housingBaseMin/Max),为0时回退到社保的 baseMin/baseMax
|
||||
const min = (config.housingBaseMin && config.housingBaseMin > 0) ? config.housingBaseMin : config.baseMin
|
||||
const max = (config.housingBaseMax && config.housingBaseMax > 0) ? config.housingBaseMax : config.baseMax
|
||||
const actualBase = Math.min(Math.max(base, min), max)
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
return { actualBase, housingEmp, housingOrg }
|
||||
@@ -137,42 +204,63 @@ export async function calcBatchEntry(
|
||||
month: string,
|
||||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
|
||||
batchType: string = 'REGULAR',
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number }; prevDeferred?: { socialEmp?: number; housingEmp?: number; minWage?: number } },
|
||||
) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: employeeId, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
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: {
|
||||
...cityWhere,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
...cityWhere,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
}),
|
||||
])
|
||||
// 劳务协议/实习协议人员:不缴纳社保公积金,基数强制 0
|
||||
const latestContract = employee.contracts[0]
|
||||
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
|
||||
|
||||
// 社保基数:优先用员工核定基数,否则用基本工资
|
||||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
||||
const housingBase = employee.housingFundBase || inputs.baseSalary
|
||||
// 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
|
||||
const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
|
||||
let socialConfig: any = null
|
||||
let housingConfig: any = null
|
||||
let supplementaryHousingConfig: any = null
|
||||
|
||||
if (socialAccount) {
|
||||
socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month)
|
||||
}
|
||||
if (housingAccount) {
|
||||
housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month)
|
||||
}
|
||||
// 补充公积金:员工单独关联的补充公积金账户
|
||||
if (employee.supplementaryHousingAccountId) {
|
||||
supplementaryHousingConfig = await getStandardByAccountAndMonth(employee.supplementaryHousingAccountId, month)
|
||||
}
|
||||
|
||||
// 回退到旧配置表(兼容未迁移数据)
|
||||
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
|
||||
if (!socialConfig) {
|
||||
socialConfig = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!housingConfig) {
|
||||
housingConfig = await prisma.housingFundConfig.findFirst({
|
||||
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
// 社保基数:劳务协议/实习协议人员强制 0;否则优先用员工核定基数(含0),未设置时回退到基本工资
|
||||
const socialBase = isNoSocialContract ? 0 : (employee.socialInsBase != null ? employee.socialInsBase : inputs.baseSalary)
|
||||
const housingBase = isNoSocialContract ? 0 : (employee.housingFundBase != null ? employee.housingFundBase : inputs.baseSalary)
|
||||
|
||||
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
|
||||
let suppHousingEmp = 0, suppHousingOrg = 0
|
||||
|
||||
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
|
||||
// 年终奖/奖金批次、补偿金批次、劳务协议/实习协议人员:不扣社保公积金
|
||||
// 其他批次:计算当月应缴全额,减去已归档批次已扣金额,差额为本批次应扣
|
||||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
|
||||
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial && !isNoSocialContract) {
|
||||
// 1. 计算当月应缴社保公积金全额
|
||||
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
|
||||
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0
|
||||
if (socialConfig) {
|
||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||
fullSocialEmp = social.socialEmp
|
||||
@@ -183,6 +271,12 @@ export async function calcBatchEntry(
|
||||
fullHousingEmp = housing.housingEmp
|
||||
fullHousingOrg = housing.housingOrg
|
||||
}
|
||||
// 补充公积金(用同样的公积金基数)
|
||||
if (supplementaryHousingConfig) {
|
||||
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
|
||||
fullSuppHousingEmp = suppHousing.housingEmp
|
||||
fullSuppHousingOrg = suppHousing.housingOrg
|
||||
}
|
||||
|
||||
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额
|
||||
const archivedEntries = await prisma.batchEntry.findMany({
|
||||
@@ -191,25 +285,37 @@ export async function calcBatchEntry(
|
||||
employeeId,
|
||||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
},
|
||||
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true },
|
||||
select: { socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, supplementaryHousingEmp: true, supplementaryHousingOrg: true },
|
||||
})
|
||||
const deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
|
||||
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
|
||||
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
|
||||
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 0)
|
||||
const deductedSuppHousingEmp = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingEmp || 0), 0)
|
||||
const deductedSuppHousingOrg = archivedEntries.reduce((s, e) => s + (e.supplementaryHousingOrg || 0), 0)
|
||||
|
||||
// 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0)
|
||||
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
|
||||
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
|
||||
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
|
||||
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
||||
suppHousingEmp = Math.max(0, fullSuppHousingEmp - deductedSuppHousingEmp)
|
||||
suppHousingOrg = Math.max(0, fullSuppHousingOrg - deductedSuppHousingOrg)
|
||||
|
||||
// 4. 叠加上月递延的社保/公积金(入职当月未扣完的部分,本月补扣)
|
||||
if (options?.prevDeferred) {
|
||||
socialEmp += options.prevDeferred.socialEmp || 0
|
||||
housingEmp += options.prevDeferred.housingEmp || 0
|
||||
}
|
||||
}
|
||||
|
||||
// 保存系统计算值(覆盖前)
|
||||
// 保存系统计算值(覆盖前,含递延)
|
||||
const systemSocialEmp = socialEmp
|
||||
const systemSocialOrg = socialOrg
|
||||
const systemHousingEmp = housingEmp
|
||||
const systemHousingOrg = housingOrg
|
||||
const systemSupplementaryHousingEmp = suppHousingEmp
|
||||
const systemSupplementaryHousingOrg = suppHousingOrg
|
||||
|
||||
// 手动覆盖社保值
|
||||
if (options?.overrideSocial) {
|
||||
@@ -253,12 +359,27 @@ export async function calcBatchEntry(
|
||||
status: 'ARCHIVED',
|
||||
},
|
||||
},
|
||||
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
|
||||
select: { totalPay: true, socialEmp: true, housingEmp: true, supplementaryHousingEmp: true, tax: true },
|
||||
})
|
||||
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
|
||||
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
||||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
||||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
|
||||
|
||||
// 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加
|
||||
// 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据)
|
||||
const deductionRecords = await prisma.specialDeductionRecord.findMany({
|
||||
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
|
||||
select: { amount: true, month: true },
|
||||
})
|
||||
let ytdSpecialDeduction: number
|
||||
if (deductionRecords.length > 0) {
|
||||
// 按月实际填报金额累加
|
||||
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
|
||||
} else {
|
||||
// 回退:员工便捷字段 × 月数(兼容未填报按月记录的旧数据)
|
||||
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||
}
|
||||
|
||||
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
||||
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
||||
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||||
@@ -271,6 +392,8 @@ export async function calcBatchEntry(
|
||||
ytdSocialEmp,
|
||||
ytdHousingEmp,
|
||||
ytdSpecialDeduction,
|
||||
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
|
||||
specialDeductionRecords: deductionRecords.length,
|
||||
ytdTaxableIncome,
|
||||
ytdTaxDeducted,
|
||||
currentMonthTax: tax,
|
||||
@@ -278,21 +401,116 @@ export async function calcBatchEntry(
|
||||
}
|
||||
}
|
||||
|
||||
const netPay = totalPay - socialEmp - housingEmp - tax
|
||||
// ── 最低工资保护 + 递延扣款逻辑 ──
|
||||
// 读取最低工资标准(从年度标准或旧配置表)
|
||||
let minWage = 0
|
||||
if (socialConfig && (socialConfig as any).minWage) {
|
||||
minWage = (socialConfig as any).minWage
|
||||
}
|
||||
|
||||
// 上月递延的最低工资补齐差额(本批次需扣回)
|
||||
const prevDeferredMinWage = options?.prevDeferred?.minWage || 0
|
||||
|
||||
// 初始实发 = 应发 - 社保 - 公积金 - 补充公积金 - 个税 - 上月递延最低工资补扣
|
||||
let netPay = totalPay - socialEmp - housingEmp - suppHousingEmp - tax - prevDeferredMinWage
|
||||
|
||||
// 递延金额(本批次扣不动、递延到次月的部分)
|
||||
let deferredSocialEmp = 0
|
||||
let deferredHousingEmp = 0
|
||||
let deferredMinWage = 0
|
||||
let minWageApplied = 0 // 当月实际补齐到最低工资的金额
|
||||
|
||||
// 最低工资保护:当月累计实发不得低于最低工资标准(仅 REGULAR / TERMINATION 批次)
|
||||
// 第二批次时需判断"当月累计实发"(已归档批次 netPay 之和 + 本批次 netPay)是否低于 minWage
|
||||
if (minWage > 0 && batchType !== 'BONUS' && batchType !== 'SEVERANCE') {
|
||||
// 查当月已归档批次的累计实发(同月已归档的 REGULAR/TERMINATION 批次)
|
||||
const archivedNetPayEntries = await prisma.batchEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
employeeId,
|
||||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
},
|
||||
select: { netPay: true },
|
||||
})
|
||||
const archivedNetPay = archivedNetPayEntries.reduce((s, e) => s + e.netPay, 0)
|
||||
|
||||
// 当月累计实发 = 已归档批次实发 + 本批次实发
|
||||
const monthlyCumulativeNetPay = archivedNetPay + netPay
|
||||
|
||||
// 仅当当月累计实发低于最低工资时才触发保护
|
||||
if (monthlyCumulativeNetPay < minWage) {
|
||||
// 需要补齐的金额 = 最低工资 - 当月累计实发
|
||||
// 但本批次最多补齐到本批次实发为正,且不超过 minWage - archivedNetPay
|
||||
const targetNetPay = Math.max(0, minWage - archivedNetPay)
|
||||
const shortfall = targetNetPay - netPay // 需要补齐的金额(正数表示需要补齐)
|
||||
|
||||
if (shortfall > 0) {
|
||||
// 优先递延社保个人部分(减少当月社保扣款)
|
||||
if (shortfall <= socialEmp) {
|
||||
// 只递延社保就够了
|
||||
deferredSocialEmp = shortfall
|
||||
socialEmp -= shortfall
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
} else if (shortfall <= socialEmp + housingEmp + suppHousingEmp) {
|
||||
// 递延全部社保 + 部分公积金(含补充)
|
||||
deferredSocialEmp = socialEmp
|
||||
socialEmp = 0
|
||||
const housingShortfall = shortfall - deferredSocialEmp
|
||||
// 先递延基本公积金,再递延补充公积金
|
||||
if (housingShortfall <= housingEmp) {
|
||||
deferredHousingEmp = housingShortfall
|
||||
housingEmp -= housingShortfall
|
||||
} else {
|
||||
deferredHousingEmp = housingEmp
|
||||
const suppShortfall = housingShortfall - housingEmp
|
||||
housingEmp = 0
|
||||
suppHousingEmp -= suppShortfall
|
||||
}
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
} else {
|
||||
// 递延全部社保 + 全部公积金(含补充),仍不足 → 差额作为最低工资补齐递延
|
||||
deferredSocialEmp = socialEmp
|
||||
deferredHousingEmp = housingEmp
|
||||
const remainingShortfall = shortfall - socialEmp - housingEmp - suppHousingEmp
|
||||
socialEmp = 0
|
||||
housingEmp = 0
|
||||
suppHousingEmp = 0
|
||||
deferredMinWage = remainingShortfall
|
||||
netPay = targetNetPay
|
||||
minWageApplied = shortfall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socialEmp: Math.round(socialEmp * 100) / 100,
|
||||
socialOrg: Math.round(socialOrg * 100) / 100,
|
||||
housingEmp: Math.round(housingEmp * 100) / 100,
|
||||
housingOrg: Math.round(housingOrg * 100) / 100,
|
||||
supplementaryHousingEmp: Math.round(suppHousingEmp * 100) / 100,
|
||||
supplementaryHousingOrg: Math.round(suppHousingOrg * 100) / 100,
|
||||
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
|
||||
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
|
||||
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
|
||||
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
|
||||
systemSupplementaryHousingEmp: Math.round(systemSupplementaryHousingEmp * 100) / 100,
|
||||
systemSupplementaryHousingOrg: Math.round(systemSupplementaryHousingOrg * 100) / 100,
|
||||
tax,
|
||||
taxBreakdown,
|
||||
totalPay: Math.round(totalPay * 100) / 100,
|
||||
netPay: Math.round(netPay * 100) / 100,
|
||||
// 最低工资保护 + 递延信息
|
||||
minWage,
|
||||
minWageApplied: Math.round(minWageApplied * 100) / 100,
|
||||
deferredSocialEmp: Math.round(deferredSocialEmp * 100) / 100,
|
||||
deferredHousingEmp: Math.round(deferredHousingEmp * 100) / 100,
|
||||
deferredMinWage: Math.round(deferredMinWage * 100) / 100,
|
||||
prevDeferredSocialEmp: options?.prevDeferred?.socialEmp || 0,
|
||||
prevDeferredHousingEmp: options?.prevDeferred?.housingEmp || 0,
|
||||
prevDeferredMinWage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +638,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
@@ -441,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||
allowance: Math.round(summary.allowance * 100) / 100,
|
||||
deduction: Math.round(summary.deduction * 100) / 100,
|
||||
@@ -514,7 +734,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
|
||||
// 1. 社保基数是否在上下限范围内
|
||||
if (socialConfig) {
|
||||
for (const entry of entries) {
|
||||
const base = entry.employee.socialInsBase || entry.baseSalary
|
||||
const base = entry.employee.socialInsBase != null ? entry.employee.socialInsBase : entry.baseSalary
|
||||
if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
|
||||
checks.push({
|
||||
code: 'SOCIAL_BASE_OUT_OF_RANGE',
|
||||
@@ -532,7 +752,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
|
||||
// 2. 公积金基数是否在上下限范围内
|
||||
if (housingConfig) {
|
||||
for (const entry of entries) {
|
||||
const base = entry.employee.housingFundBase || entry.baseSalary
|
||||
const base = entry.employee.housingFundBase != null ? entry.employee.housingFundBase : entry.baseSalary
|
||||
if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
|
||||
checks.push({
|
||||
code: 'HOUSING_BASE_OUT_OF_RANGE',
|
||||
@@ -712,6 +932,57 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 实发工资低于最低工资标准(剔除加班费后比较)
|
||||
for (const entry of entries) {
|
||||
const minWage = (entry as any).minWage || 0
|
||||
if (minWage > 0 && batch.type !== 'BONUS' && batch.type !== 'SEVERANCE') {
|
||||
// 最低工资剔除项:加班费、高温/夜班津贴等不计入
|
||||
const comparablePay = entry.totalPay - entry.overtimePay
|
||||
if (comparablePay < minWage && entry.netPay < minWage) {
|
||||
const applied = (entry as any).minWageApplied || 0
|
||||
if (applied > 0) {
|
||||
// 已触发最低工资保护,检查递延情况
|
||||
const deferredTotal = ((entry as any).deferredSocialEmp || 0) + ((entry as any).deferredHousingEmp || 0) + ((entry as any).deferredMinWage || 0)
|
||||
checks.push({
|
||||
code: 'MIN_WAGE_DEFERRED',
|
||||
name: '最低工资保护已触发(递延扣款)',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 应发 ¥${comparablePay}(剔除加班费)低于最低工资 ¥${minWage},已补齐 ¥${applied},递延扣款 ¥${deferredTotal} 将在次月补扣`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { comparablePay, minWage, applied, deferredTotal },
|
||||
})
|
||||
} else {
|
||||
checks.push({
|
||||
code: 'BELOW_MIN_WAGE',
|
||||
name: '实发低于最低工资标准',
|
||||
status: 'FAIL',
|
||||
message: `${entry.employee.name} 实发 ¥${entry.netPay} 低于当地最低工资标准 ¥${minWage},请检查社保基数或启用最低工资保护`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { netPay: entry.netPay, minWage, comparablePay },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 12. 上月递延扣款待补扣
|
||||
for (const entry of entries) {
|
||||
const prevDeferred = ((entry as any).prevDeferredSocialEmp || 0) + ((entry as any).prevDeferredHousingEmp || 0) + ((entry as any).prevDeferredMinWage || 0)
|
||||
if (prevDeferred > 0) {
|
||||
checks.push({
|
||||
code: 'PREV_DEFERRED_RECOVERED',
|
||||
name: '上月递延扣款已补扣',
|
||||
status: 'WARNING',
|
||||
message: `${entry.employee.name} 本月补扣上月递延 ¥${prevDeferred}(社保 ¥${(entry as any).prevDeferredSocialEmp || 0} + 公积金 ¥${(entry as any).prevDeferredHousingEmp || 0} + 最低工资补齐 ¥${(entry as any).prevDeferredMinWage || 0})`,
|
||||
employeeId: entry.employeeId,
|
||||
employeeName: entry.employee.name,
|
||||
detail: { prevDeferred },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总
|
||||
const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
|
||||
const failedCount = checks.filter(c => c.status === 'FAIL').length
|
||||
|
||||
@@ -30,6 +30,13 @@ const SEED_DATA: KnowledgeSeed[] = [
|
||||
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
|
||||
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
|
||||
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
|
||||
// ===== 北京地区地方性指引 =====
|
||||
{ title: '北京:规范用人单位单方解除劳动合同工作指引(试行)- 通知工会程序', content: '用人单位单方解除劳动合同的,应当提前五个工作日将理由书面通知本单位工会;尚未建立工会组织的,应当通知上一级工会。上一级工会,原则上是用人单位实际经营地的乡镇、街道、园区、开发区总工会。用人单位隶属区产业工会的,应通知其所属区产业工会。用人单位隶属市产业工会的,应通知其所属市产业工会中的上级工会。用人单位可联系所在地的乡镇、街道、园区、开发区总工会,或拨打12351职工服务热线,咨询本单位对应的上一级工会等事宜。', source: '北京市协调劳动关系三方', category: '解除终止' },
|
||||
{ title: '北京:通知工会函内容要求', content: '用人单位单方解除劳动合同书面通知工会时,通知文本应当写明劳动者基本情况(姓名、性别、年龄、身份证号、工作岗位、工作年限、劳动合同期限、联系方式),解除劳动合同所依据的基本事实,解除劳动合同援引的法律、法规、本单位规章制度的相关条款,以及用人单位的联系人和联系方式等内容。', source: '北京市协调劳动关系三方', category: '解除终止' },
|
||||
{ title: '北京:工会回执要求', content: '用人单位书面通知工会的送达方式为直接送达、邮寄送达等。用人单位工会收到通知后,应及时出具书面回执;上一级工会收到尚未建立工会组织的用人单位通知后,确定用人单位属于联系范围的,应出具书面回执,认为不属于的,应及时提醒用人单位。', source: '北京市协调劳动关系三方', category: '解除终止' },
|
||||
{ title: '北京:工会劳动法律监督提示函', content: '工会认为用人单位违反法律、法规和有关合同的,应当自收到用人单位书面通知五个工作日内,通过发放《工会劳动法律监督提示函》等方式提出意见建议。用人单位应当研究工会的意见,并将处理结果书面通知工会。', source: '北京市协调劳动关系三方', category: '解除终止' },
|
||||
{ title: '北京:仲裁审查通知工会义务', content: '劳动人事争议仲裁委员会在审理解除劳动合同争议案件过程中,依法审查用人单位单方解除劳动合同时是否履行通知工会的义务,了解掌握工会提出的意见建议。未履行通知工会程序的可能被认定为违法解除。', source: '北京市协调劳动关系三方', category: '解除终止' },
|
||||
{ title: '北京市实施《中华人民共和国工会法》办法', content: '北京市实施《中华人民共和国工会法》办法是北京市地方性法规,对工会组织建设、工会权利义务、工会经费等作出规定。用人单位单方解除劳动合同应当遵守该办法关于通知工会的规定。', source: '北京市实施工会法办法', category: '解除终止' },
|
||||
]
|
||||
|
||||
let initialized = false
|
||||
@@ -109,8 +116,8 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
|
||||
{ title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' },
|
||||
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '企业用工专家是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如身份证号都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '安职通是什么', content: '这是一个帮您管理员工、合同、工资和社保的工具,可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||
{ title: '我的数据安全吗', content: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息如证件号码都经过加密存储。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '可以导出数据吗', content: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import crypto from 'crypto'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
// 从身份证号提取出生日期
|
||||
// 从证件号码提取出生日期
|
||||
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
// 18位身份证:7-14位为出生日期 YYYYMMDD
|
||||
if (idCard.length === 18) {
|
||||
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||
// 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||
export function extractGenderFromIdCard(idCard: string): string | null {
|
||||
if (idCard.length === 18) {
|
||||
const genderCode = parseInt(idCard.substring(16, 17))
|
||||
|
||||
@@ -225,12 +225,23 @@ export async function detectContractRisks(orgId: string) {
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 30) {
|
||||
// 30 天内到期:HIGH,需立即处理
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},30天内到期需立即准备续签或终止。`,
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 60) {
|
||||
// 31-60 天到期:MEDIUM,提前预警给 HR 反应时间
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
||||
title: `${emp.name}的合同将于${daysToExpire}天后到期`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},建议提前准备续签或终止。`,
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
@@ -312,8 +323,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=PREGNANCY`,
|
||||
})
|
||||
}
|
||||
if (emp.isInMedicalPeriod) {
|
||||
@@ -322,8 +333,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=MEDICAL_PERIOD`,
|
||||
})
|
||||
}
|
||||
if (emp.isWorkInjured) {
|
||||
@@ -332,8 +343,8 @@ export async function detectTerminationRisks(orgId: string) {
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}工伤期间,解聘受限`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=WORK_INJURY`,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -424,10 +435,10 @@ export async function detectMonthlyTasks(orgId: string) {
|
||||
const today = now.getDate()
|
||||
|
||||
const tasks = [
|
||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
|
||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
|
||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
|
||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
|
||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money?tab=batch' },
|
||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/social?tab=monthly' },
|
||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/social?tab=monthly' },
|
||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money?tab=batch' },
|
||||
]
|
||||
|
||||
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
@@ -535,7 +546,7 @@ export async function runRiskDetection(orgId: string) {
|
||||
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||
const existingRisks = await prisma.riskItem.findMany({
|
||||
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
|
||||
select: { id: true, employeeId: true, type: true, title: true, status: true },
|
||||
select: { id: true, employeeId: true, type: true, title: true, status: true, actionUrl: true },
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`))
|
||||
// PENDING 风险的 employeeId:type → record 映射,用于更新标题
|
||||
@@ -581,18 +592,19 @@ export async function runRiskDetection(orgId: string) {
|
||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
]
|
||||
|
||||
// 更新已存在的 PENDING 风险:标题/量化信息可能因天数变化而变化
|
||||
const toUpdate: { id: string; title: string; description: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
|
||||
// 更新已存在的 PENDING 风险:标题/量化信息/actionUrl 可能因天数变化或代码更新而变化
|
||||
const toUpdate: { id: string; title: string; description: string; actionUrl: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
|
||||
for (const r of nonMonthlyRisks) {
|
||||
const key = `${r.employeeId}:${r.type}`
|
||||
const existing = pendingRiskMap.get(key)
|
||||
if (existing && existing.title !== r.title) {
|
||||
if (existing && (existing.title !== r.title || existing.actionUrl !== r.actionUrl)) {
|
||||
const emp = r.employeeId ? empMap.get(r.employeeId) : null
|
||||
const cost = estimateRiskCost(r, emp)
|
||||
toUpdate.push({
|
||||
id: existing.id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl,
|
||||
estimatedLoss: cost.estimatedLoss,
|
||||
lossRange: cost.lossRange,
|
||||
deadline: cost.deadline,
|
||||
@@ -605,6 +617,7 @@ export async function runRiskDetection(orgId: string) {
|
||||
data: {
|
||||
title: u.title,
|
||||
description: u.description,
|
||||
actionUrl: u.actionUrl,
|
||||
estimatedLoss: u.estimatedLoss,
|
||||
lossRange: u.lossRange,
|
||||
deadline: u.deadline,
|
||||
@@ -612,6 +625,19 @@ export async function runRiskDetection(orgId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 更新 MONTHLY 类型 PENDING 记录的 actionUrl(标题不变但 actionUrl 可能因代码更新而变化)
|
||||
const monthlyPending = existingRisks.filter((r: typeof existingRisks[number]) => r.status === 'PENDING' && r.type === 'MONTHLY')
|
||||
const monthlyActionUrlMap = new Map(monthlyTasks.map((r) => [r.title, r.actionUrl]))
|
||||
for (const r of monthlyPending) {
|
||||
const newUrl = monthlyActionUrlMap.get(r.title)
|
||||
if (newUrl && r.actionUrl !== newUrl) {
|
||||
await prisma.riskItem.update({
|
||||
where: { id: r.id },
|
||||
data: { actionUrl: newUrl },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
// 为每个风险计算量化信息
|
||||
const createData = toCreate.map((r) => {
|
||||
@@ -933,12 +959,17 @@ export async function getDashboardData(orgId: string) {
|
||||
const catKey = getTodoRiskCategoryKey(t.title || '')
|
||||
const dedupKey = `${personKey}:${catKey}`
|
||||
const existing = dedupedTodoMap.get(dedupKey)
|
||||
// 去重保留优先级更高(estimatedLoss 更大或 deadline 更近)的一条
|
||||
if (!existing || t.estimatedLoss > existing.estimatedLoss) {
|
||||
dedupedTodoMap.set(dedupKey, t)
|
||||
}
|
||||
}
|
||||
const dedupedTodos = Array.from(dedupedTodoMap.values())
|
||||
|
||||
// 去重后的待办按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
dedupedTodos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||
|
||||
// 风险分布:使用去重后的数据,与待办列表一致
|
||||
const riskDistribution = {
|
||||
contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length,
|
||||
@@ -946,8 +977,7 @@ export async function getDashboardData(orgId: string) {
|
||||
termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
||||
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW(priorityOrder 已在上方声明)
|
||||
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||
|
||||
const topRisks = todosWithCost
|
||||
@@ -1928,7 +1958,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
}),
|
||||
])
|
||||
|
||||
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
|
||||
// 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
|
||||
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
|
||||
const personBreakdown: Record<string, {
|
||||
personKey: string
|
||||
@@ -1988,7 +2018,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
||||
}
|
||||
|
||||
for (const r of employeeRiskDetails) {
|
||||
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
||||
// 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
||||
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
|
||||
if (!personBreakdown[personKey]) {
|
||||
personBreakdown[personKey] = {
|
||||
|
||||
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
// 合规校验:男职工不可选择三期
|
||||
if (data.type === 'PREGNANCY' && employee.gender === '男') {
|
||||
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
|
||||
}
|
||||
|
||||
// 三期自动计算
|
||||
let pregnancyData: any = {}
|
||||
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
name: '固定期限劳动合同',
|
||||
category: 'CONTRACT',
|
||||
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'monthlySalary', 'socialInsBase'],
|
||||
variables: ['companyName', 'employeeName', 'idCard', 'address', 'phone', 'startDate', 'endDate', 'position', 'workplace', 'probationMonths', 'baseSalary', 'performanceSalary', 'probationSalary', 'socialInsBase'],
|
||||
content: `劳动合同书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
||||
|
||||
第四条 劳动报酬
|
||||
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
|
||||
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元。甲方于每月15日前支付上月工资。
|
||||
|
||||
第五条 社会保险和福利
|
||||
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
||||
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
name: '无固定期限劳动合同',
|
||||
category: 'CONTRACT',
|
||||
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
|
||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
|
||||
content: `无固定期限劳动合同书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
乙方担任{{position}}岗位。
|
||||
|
||||
第三条 劳动报酬
|
||||
乙方月工资为人民币{{monthlySalary}}元。
|
||||
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。
|
||||
|
||||
(其余条款参照固定期限劳动合同模板)
|
||||
|
||||
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
||||
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||
|
||||
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
||||
content: `解除劳动合同协议书
|
||||
|
||||
甲方(用人单位):{{companyName}}
|
||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
||||
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||
|
||||
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
||||
|
||||
@@ -241,7 +241,7 @@ ____年__月__日
|
||||
name: '试用期转正通知书',
|
||||
category: 'NOTICE',
|
||||
description: '试用期考核合格转正通知',
|
||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
|
||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
|
||||
content: `试用期转正通知书
|
||||
|
||||
{{employeeName}}:
|
||||
@@ -249,7 +249,7 @@ ____年__月__日
|
||||
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
||||
|
||||
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
||||
二、转正后月工资为人民币{{monthlySalary}}元。
|
||||
二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月。
|
||||
三、试用期至{{probationEndDate}}结束。
|
||||
|
||||
请继续遵守公司各项规章制度,努力工作。
|
||||
@@ -278,6 +278,100 @@ ____年__月__日
|
||||
|
||||
签收人:____________ 日期:____年__月__日`,
|
||||
},
|
||||
{
|
||||
id: 'tpl_union_termination_notice',
|
||||
name: '拟解除劳动合同通知工会函(北京)',
|
||||
category: 'NOTICE',
|
||||
description: '北京地区单方解除劳动合同前通知工会的函件样式(依据《规范用人单位单方解除劳动合同工作指引》)',
|
||||
variables: ['companyName', 'employeeName', 'employeeGender', 'employeeAge', 'idCard', 'employeePosition', 'workYears', 'contractPeriod', 'employeePhone', 'terminationReason', 'legalBasis', 'contactPerson', 'contactPhone', 'companyAddress'],
|
||||
content: `拟解除劳动合同通知工会函
|
||||
|
||||
{{companyName}}拟解除{{employeeName}}劳动合同的函
|
||||
|
||||
{{companyName}}工会(或上一级工会):
|
||||
|
||||
我单位拟单方解除与{{employeeName}}(劳动者姓名)的劳动合同,具体情况如下:
|
||||
|
||||
一、劳动者基本情况
|
||||
|
||||
姓名:{{employeeName}}
|
||||
性别:{{employeeGender}}
|
||||
年龄:{{employeeAge}}
|
||||
身份证号:{{idCard}}
|
||||
工作岗位:{{employeePosition}}
|
||||
在本单位工作年限:{{workYears}}
|
||||
劳动合同期限:{{contractPeriod}}
|
||||
联系方式:{{employeePhone}}
|
||||
|
||||
二、解除劳动合同的事由
|
||||
|
||||
{{terminationReason}}
|
||||
|
||||
三、解除劳动合同的依据
|
||||
|
||||
{{legalBasis}}
|
||||
|
||||
联系人:{{contactPerson}}
|
||||
电话:{{contactPhone}}
|
||||
地址:{{companyAddress}}
|
||||
|
||||
附件:其他证据材料
|
||||
|
||||
{{companyName}}(盖章)
|
||||
____年__月__日`,
|
||||
},
|
||||
{
|
||||
id: 'tpl_union_receipt',
|
||||
name: '工会回执(北京)',
|
||||
category: 'OTHER',
|
||||
description: '工会收到用人单位单方解除劳动合同通知后的书面回执样式',
|
||||
variables: ['receiptNo', 'companyName', 'employeeName', 'unionContactPerson', 'unionContactPhone', 'unionAddress'],
|
||||
content: `回执
|
||||
|
||||
编号:{{receiptNo}}
|
||||
|
||||
{{companyName}}:
|
||||
|
||||
你单位送交的单方解除与{{employeeName}}劳动合同通知工会的相关材料已收到。
|
||||
|
||||
你单位单方解除劳动合同,应严格遵守《中华人民共和国劳动法》《中华人民共和国劳动合同法》《北京市实施〈中华人民共和国工会法〉办法》等法律法规规定,做到事实清楚、程序合法、依据充分,并依法履行出具解除劳动合同证明、支付经济补偿等法定义务,切实维护职工合法权益。因提供虚假或不完整材料,产生的相关法律后果由你单位自行承担。
|
||||
|
||||
联系人:{{unionContactPerson}}
|
||||
电话:{{unionContactPhone}}
|
||||
地址:{{unionAddress}}
|
||||
|
||||
(盖章)
|
||||
____年__月__日`,
|
||||
},
|
||||
{
|
||||
id: 'tpl_union_supervision_letter',
|
||||
name: '工会劳动法律监督提示函(北京)',
|
||||
category: 'NOTICE',
|
||||
description: '工会认为用人单位违反法律法规时提出的意见建议函件样式',
|
||||
variables: ['letterNo', 'companyName', 'employeeName', 'supervisionIssues', 'supervisionOpinions', 'unionContactPerson', 'unionContactPhone', 'unionAddress'],
|
||||
content: `工会劳动法律监督提示函
|
||||
|
||||
{{letterNo}}
|
||||
|
||||
{{companyName}}:
|
||||
|
||||
你单位送交的单方解除与{{employeeName}}劳动合同通知工会的相关文本材料已收到。经审查,发现你单位存在下列问题:
|
||||
|
||||
{{supervisionIssues}}
|
||||
|
||||
就以上问题,我会提出以下意见:
|
||||
|
||||
{{supervisionOpinions}}
|
||||
|
||||
请你单位收函后认真研究,将处理结果书面通知本会。
|
||||
|
||||
联系人:{{unionContactPerson}}
|
||||
电话:{{unionContactPhone}}
|
||||
地址:{{unionAddress}}
|
||||
|
||||
(盖章)
|
||||
____年__月__日`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { RiskAssessment, TerminationReason } from '@prisma/client'
|
||||
import { autoCreateEsignRecord } from './esign.service'
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
@@ -7,6 +8,13 @@ function dateToMonth(date: Date): string {
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
/** 计算上一个月,格式 YYYY-MM */
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
if (m === 1) return `${y - 1}-12`
|
||||
return `${y}-${String(m - 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface ChecklistItem {
|
||||
key: string
|
||||
label: string
|
||||
@@ -16,7 +24,9 @@ export interface ChecklistItem {
|
||||
suggestionType?: 'info' | 'warning' | 'required'
|
||||
}
|
||||
|
||||
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] {
|
||||
export function getChecklistForReason(reason: string, employee?: any, orgCity?: string): ChecklistItem[] {
|
||||
// 北京地区单方解除须通知工会(依据《规范用人单位单方解除劳动合同工作指引》)
|
||||
const isBeijing = !orgCity || orgCity === '北京' || orgCity === '北京市' || orgCity?.includes('北京')
|
||||
switch (reason) {
|
||||
case 'NEGOTIATED':
|
||||
return [
|
||||
@@ -33,7 +43,14 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
|
||||
return [
|
||||
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
|
||||
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
|
||||
{ key: 'notify_union', label: '是否事先通知工会', autoChecked: null },
|
||||
...(isBeijing ? [{
|
||||
key: 'notify_union',
|
||||
label: '是否提前5个工作日书面通知工会',
|
||||
autoChecked: null as any,
|
||||
suggestion: '北京地区要求:单方解除劳动合同须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会(用人单位实际经营地的乡镇/街道/园区/开发区总工会)。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
|
||||
suggestionType: 'required' as const,
|
||||
}] : []),
|
||||
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
|
||||
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
|
||||
]
|
||||
case 'NONFAULT': {
|
||||
@@ -86,12 +103,29 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
|
||||
suggestionType: 'required',
|
||||
})
|
||||
|
||||
// 通知工会 — 北京地区单方解除必经程序
|
||||
if (isBeijing) {
|
||||
items.push({
|
||||
key: 'notify_union',
|
||||
label: '是否提前5个工作日书面通知工会',
|
||||
autoChecked: null,
|
||||
suggestion: '北京地区要求:单方解除须提前5个工作日将理由书面通知本单位工会;未建立工会的通知上一级工会。可在「文本模板库」中使用《拟解除劳动合同通知工会函》模板。',
|
||||
suggestionType: 'required',
|
||||
})
|
||||
items.push({
|
||||
key: 'union_receipt',
|
||||
label: '是否收到工会书面回执',
|
||||
autoChecked: null,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
case 'LAYOFF':
|
||||
return [
|
||||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
|
||||
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
|
||||
...(isBeijing ? [{ key: 'union_receipt', label: '是否收到工会书面回执', autoChecked: null as any }] : []),
|
||||
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
|
||||
{
|
||||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
||||
@@ -545,6 +579,26 @@ export async function createDraft(orgId: string, userId: string, data: any) {
|
||||
|
||||
const { level } = assessRisk(employee, data.reason || 'NEGOTIATED')
|
||||
|
||||
// 自动推导社保/公积金截止月(基于组织 socialInsCutoffDay 配置)
|
||||
let socialInsEndMonth = data.socialInsEndMonth || null
|
||||
let housingFundEndMonth = data.housingFundEndMonth || null
|
||||
if (data.terminationDate && !socialInsEndMonth) {
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { socialInsCutoffDay: true } })
|
||||
const cutoffDay = org?.socialInsCutoffDay ?? 15
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const termDay = termDate.getDate()
|
||||
const termMonth = dateToMonth(termDate)
|
||||
// cutoffDay 日前离职 → 截止月 = 离职月 - 1;cutoffDay 日后离职 → 截止月 = 离职月
|
||||
if (termDay <= cutoffDay) {
|
||||
const prevMon = prevMonth(termMonth)
|
||||
socialInsEndMonth = prevMon
|
||||
housingFundEndMonth = prevMon
|
||||
} else {
|
||||
socialInsEndMonth = termMonth
|
||||
housingFundEndMonth = termMonth
|
||||
}
|
||||
}
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
@@ -554,8 +608,8 @@ export async function createDraft(orgId: string, userId: string, data: any) {
|
||||
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
|
||||
resignationReason: data.resignationReason || null,
|
||||
compensation: data.compensation || 0,
|
||||
socialInsEndMonth: data.socialInsEndMonth || null,
|
||||
housingFundEndMonth: data.housingFundEndMonth || null,
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark || null,
|
||||
@@ -568,6 +622,18 @@ export async function createDraft(orgId: string, userId: string, data: any) {
|
||||
},
|
||||
})
|
||||
|
||||
// 主动离职时自动创建离职协议电子签署记录
|
||||
if (data.type === 'RESIGNATION') {
|
||||
await autoCreateEsignRecord({
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
scene: 'RESIGNATION',
|
||||
documentTitle: `${employee.name}的离职协议`,
|
||||
remark: '员工主动离职时自动发起',
|
||||
createdBy: userId,
|
||||
})
|
||||
}
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
@@ -723,6 +789,19 @@ export async function executeTermination(orgId: string, recordId: string, userId
|
||||
})
|
||||
})
|
||||
|
||||
// 公司解聘执行完成后自动创建离职协议电子签署记录
|
||||
if (record.type === 'TERMINATION') {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId }, select: { name: true } })
|
||||
await autoCreateEsignRecord({
|
||||
orgId,
|
||||
employeeId: record.employeeId,
|
||||
scene: 'RESIGNATION',
|
||||
documentTitle: `${employee?.name || '员工'}的解除劳动合同协议`,
|
||||
remark: '公司解聘执行完成时自动发起',
|
||||
createdBy: userId,
|
||||
})
|
||||
}
|
||||
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt } from '../lib/crypto'
|
||||
import { encrypt, decrypt } from '../lib/crypto'
|
||||
import { createDraft as createTerminationDraft, executeTermination } from './termination.service'
|
||||
import { createEmployee, addContract } from './contract.service'
|
||||
import { createEmployee, addContract, prevMonth } from './contract.service'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
// 13类流程定义
|
||||
@@ -66,13 +66,51 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
||||
return { employeeId }
|
||||
}
|
||||
case 'CONFIRM': {
|
||||
const { employeeId, regularSalary } = formData
|
||||
const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
|
||||
if (employeeId) {
|
||||
if (regularSalary) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
|
||||
const oldSalary = employee ? Number(decrypt(employee.monthlySalary)) || 0 : 0
|
||||
const newSalary = Number(regularSalary) || 0
|
||||
// 同步更新工资结构
|
||||
const updateData: any = { monthlySalary: encrypt(String(regularSalary)) }
|
||||
if (baseSalary !== undefined) updateData.baseSalary = encrypt(String(Number(baseSalary) || 0))
|
||||
if (performanceSalary !== undefined) updateData.performanceSalary = encrypt(String(Number(performanceSalary) || 0))
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: { monthlySalary: encrypt(String(regularSalary)) },
|
||||
data: updateData,
|
||||
})
|
||||
// 记录薪资变更(试用期薪资 → 转正薪资)
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
const nowMonth = now.toISOString().slice(0, 7)
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId, endMonth: null },
|
||||
data: { endMonth: prevMonth(nowMonth) },
|
||||
})
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId,
|
||||
oldSalary,
|
||||
newSalary,
|
||||
effectiveDate: now,
|
||||
effectiveMonth: nowMonth,
|
||||
endMonth: null,
|
||||
changeType: 'CONFIRM',
|
||||
reason: '试用期转正薪资调整',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
// 校验转正薪资与最新合同试用期薪资是否一致(提示性校验)
|
||||
const latestContract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId, orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (latestContract?.probationSalary && latestContract.probationSalary !== newSalary) {
|
||||
console.warn(`[CONFIRM] 转正薪资 ¥${newSalary} 与合同试用期薪资 ¥${latestContract.probationSalary} 不一致,员工: ${employeeId}`)
|
||||
}
|
||||
await runRiskDetection(orgId)
|
||||
}
|
||||
}
|
||||
@@ -275,13 +313,13 @@ ${body}
|
||||
|
||||
const templates: Record<string, (data: any, org: string) => string> = {
|
||||
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})系我单位员工,自 ${data.hireDate || '___'} 起在我单位工作,现任 ${data.position || '___'} 职务。</div>
|
||||
<div class="body">该员工近一年平均月收入为人民币 ${data.monthlyIncome || '___'} 元(税前)。</div>
|
||||
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(身份证号:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
||||
<div class="body">兹证明 ${data.employeeName || '___'}(证件号码:${data.idCardNumber || '___'})自 ${data.hireDate || '___'} 至 ${data.leaveDate || '___'} 在我单位工作,最后职务为 ${data.position || '___'}。</div>
|
||||
<div class="body">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
|
||||
<div class="body">特此证明。</div>
|
||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# 企业用工专家 一键部署脚本
|
||||
# 安职通 一键部署脚本
|
||||
# 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/
|
||||
#
|
||||
# 用法: bash deploy.sh
|
||||
@@ -342,7 +342,7 @@ verify() {
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " 企业用工专家 部署完成!"
|
||||
echo " 安职通 部署完成!"
|
||||
echo " 访问地址: https://${DOMAIN}/"
|
||||
echo " 后端 API: https://${DOMAIN}/api/v1/"
|
||||
echo " PM2 管理: pm2 list (服务器上)"
|
||||
@@ -357,7 +357,7 @@ if [ "$1" = "--init" ]; then
|
||||
IS_INIT=true
|
||||
fi
|
||||
|
||||
log "开始部署 企业用工专家 到 ${REMOTE_HOST}"
|
||||
log "开始部署 安职通 到 ${REMOTE_HOST}"
|
||||
log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}"
|
||||
echo ""
|
||||
|
||||
|
||||
+147
-292
@@ -1,353 +1,208 @@
|
||||
# TurboHR 系统优化清单(20260811)
|
||||
# 2026年8月11日 优化需求清单
|
||||
|
||||
> 基于用户反馈的 13 项问题,逐条分析合理性并给出优化建议。
|
||||
>
|
||||
> **验证状态**:已逐条对照源码验证,标注 ✅ 确认存在 / ⚠️ 部分存在 / ❌ 不存在或已实现。
|
||||
> 用户反馈共 16 项问题,按模块和优先级整理如下。
|
||||
|
||||
---
|
||||
|
||||
## 一、薪税管理发薪模块
|
||||
## 一、花名册 / 员工详情模块
|
||||
|
||||
### 1.1 发薪数据每月变化需重新导入(反馈 #1)
|
||||
|
||||
**用户反馈**:每个月有变化都要重新导入,需要在好几个模块/系统间来回切换,不便捷。
|
||||
|
||||
**现状分析**:当前发薪流程为:花名册员工数据 → 批量导入薪资数据 → 计算社保公积金 → 生成工资条 → 归档。每月需重新导入薪资批次数据。
|
||||
|
||||
**代码验证**:✅ 确认存在。当前薪税流程需手动导入或手动创建批次,无「复制上月批次」功能。月度导入模板支持考勤/加班/薪资调整/社保变动,但无法自动从花名册拉取固定项。
|
||||
|
||||
**合理性**:✅ 合理。HR 每月薪资数据确实有变化(加班费、绩效奖金、考勤扣款等),但可以优化流程减少重复操作。
|
||||
|
||||
**优化方案**:
|
||||
- 支持「复制上月批次」功能:基于上月归档数据自动生成新月份草稿,仅需修改变化项
|
||||
- 花名册中已维护的社保基数、公积金基数自动同步到薪税模块,无需重复录入
|
||||
- 考勤模块确认的考勤数据自动关联到薪税计算(加班时长、请假扣款等)
|
||||
- 增加「一键取数」功能:从花名册自动拉取基本工资、社保基数等固定项,仅需手动录入变动项
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题1:绩效考核点击员工姓名跳转首页
|
||||
**模块**:花名册 → 绩效考核
|
||||
**优先级**:P1
|
||||
**现象**:在绩效考核列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
|
||||
**可能原因**:员工详情页的路由跳转参数缺失或未正确绑定 onClick 事件。
|
||||
**优化方案**:检查绩效考核列表中员工姓名的点击事件,确保正确跳转到员工详情页(携带 employeeId 参数)。
|
||||
|
||||
---
|
||||
|
||||
## 二、导入错误提示不清晰
|
||||
|
||||
### 2.1 导入提示乱码,无法定位错误(反馈 #2)
|
||||
|
||||
**用户反馈**:按模板填写两人导入后提示一堆乱码,实际模板可能没填错,不知道哪里有问题。
|
||||
|
||||
**现状分析**:`Settings.tsx` 导入预览已有错误展示(`result.errors` 数组),但错误信息可能显示的是后端字段名(英文),对用户来说像"乱码"。且如果用户跳过预览直接导入,错误提示可能不够醒目。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Settings.tsx:967-968` 已有 toast 提示「有 N 条错误」,预览阶段有完整错误表格(行号/姓名/状态/错误信息),且支持「导出错误日志」Excel 下载。但错误信息内容来自后端,可能包含英文字段名导致「乱码」感。用户若跳过预览直接导入,错误提示在结果区域可能不够醒目。
|
||||
|
||||
**合理性**:✅ 合理。导入错误信息需要用户可读、可定位。
|
||||
|
||||
**优化方案**:
|
||||
- 错误信息中文化:将后端返回的字段名映射为中文(如 `name` → `姓名`、`idCardNumber` → `身份证号`)
|
||||
- 错误提示格式:`第 X 行,字段「姓名」:不能为空` 或 `第 X 行,身份证号格式不正确`
|
||||
- 导入失败时增加醒目 toast 提示「有 N 条错误,请查看详情」
|
||||
- 增加「下载错误报告」按钮,导出错误行明细(Excel)
|
||||
- 预览阶段就展示完整校验结果,不通过预览不允许导入
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题3:培训记录点击员工姓名跳转首页
|
||||
**模块**:花名册 → 培训记录
|
||||
**优先级**:P1
|
||||
**现象**:在培训记录列表中点击员工姓名,系统自动跳转到首页,无法进入员工详情页。
|
||||
**可能原因**:与问题1相同,员工姓名点击事件未正确绑定跳转逻辑。
|
||||
**优化方案**:检查培训记录列表中员工姓名的点击事件,确保正确跳转到员工详情页。
|
||||
|
||||
---
|
||||
|
||||
## 三、工资流水导出格式
|
||||
|
||||
### 3.1 导出工资流水是否适配银行格式(反馈 #3)
|
||||
|
||||
**用户反馈**:工资归档后工资条有显示,导出工资流水处是否适合银行直接关联?
|
||||
|
||||
**现状分析**:当前 `PayslipTab.tsx` 无导出功能(已在 20260804 优化清单中列为 P1 待修复项)。工资流水导出需要符合银行代发工资文件格式。
|
||||
|
||||
**代码验证**:✅ 确认存在。`PayslipTab.tsx` 无导出功能,仅有「从批次汇总生成」「税率试算」「删除」操作,无银行代发文件导出。
|
||||
|
||||
**合理性**:✅ 合理。银行代发工资通常需要特定格式(Excel/CSV,包含姓名、身份证号、银行账号、金额等字段)。
|
||||
|
||||
**优化方案**:
|
||||
- 增加「导出银行代发文件」功能,支持常见银行格式(工行、建行、招行等)
|
||||
- 导出字段:姓名、身份证号、银行账号、开户行、实发金额
|
||||
- 支持自定义字段映射,适配不同银行要求
|
||||
- 导出格式支持 Excel 和 CSV
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题14:特殊员工无法点击详情查看
|
||||
**模块**:花名册 → 特殊员工
|
||||
**优先级**:P1
|
||||
**现象**:特殊员工列表中只有编辑和删除按钮,无法点击查看员工详情。
|
||||
**优化方案**:为特殊员工列表行添加点击查看详情功能,或增加"查看"按钮。
|
||||
|
||||
---
|
||||
|
||||
## 四、社保基数与花名册数据不同步
|
||||
## 二、离职管理模块
|
||||
|
||||
### 4.1 社保公积金办理处仍显示工资数而非社保基数(反馈 #4)
|
||||
|
||||
**用户反馈**:花名册中已修改社保基数,但社保公积金办理里还是显示工资数。
|
||||
|
||||
**现状分析**:花名册 `BasicInfo.tsx` 中可编辑社保基数(`socialInsuranceBase`、`housingFundBase`),但社保模块 `SocialInsurance.tsx` 的月度办理可能直接取 `salary` 字段而非社保基数字段。
|
||||
|
||||
**代码验证**:❌ 不存在(已正确实现)。`payroll.service.ts:166-167` 明确代码:`const socialBase = employee.socialInsBase || inputs.baseSalary`,即优先使用花名册中的 `socialInsBase`,未设置时才用基本工资。`contract.service.ts:205-206` 入职时同样 `socialInsBase != null ? socialInsBase : salaryNum`。社保月度办理 `MonthlyRows.tsx` 显示的 `i.base` 来自 `EmployeeSocialInsRecord` 表,该记录在入职和基数调整时已正确写入。**若用户遇到显示工资数而非社保基数,可能是该员工 `socialInsBase` 字段为 null**,需在花名册 BasicInfo 中设置社保基数。
|
||||
|
||||
**合理性**:⚠️ 部分合理。代码逻辑正确(优先用 socialInsBase),但用户若未在花名册设置社保基数,系统会 fallback 到工资数,用户可能不理解这个优先级。建议增加 UI 提示。
|
||||
|
||||
**优化方案**:
|
||||
- 社保公积金月度办理中,基数取值优先级:`socialInsuranceBase` > `salary`(如果社保基数已设置则用社保基数,否则用工资)
|
||||
- 同理公积金基数取值:`housingFundBase` > `salary`
|
||||
- 在社保办理界面显示数据来源标注(「来自花名册社保基数」或「来自月工资」)
|
||||
- 增加「一键同步花名册社保基数」按钮
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题2:离职证明模板是否可自定义,支持员工端下载带印章版本
|
||||
**模块**:离职管理
|
||||
**优先级**:P1
|
||||
**现象**:当前离职证明为固定模板,无法自定义修改。用户希望:
|
||||
1. 离职证明模板可自定义或修改
|
||||
2. 员工可通过小程序(员工端)下载带有企业电子印章的离职证明
|
||||
**优化方案**:
|
||||
- 后端:支持离职证明模板配置(复用文本模板模块,支持变量占位符)
|
||||
- 前端:离职管理中可选择/编辑离职证明模板
|
||||
- 员工端:已完成离职的员工可在线查看和下载离职证明(含电子印章)
|
||||
|
||||
---
|
||||
|
||||
## 五、商险管理添加员工入口缺失
|
||||
## 三、薪税管理模块
|
||||
|
||||
### 5.1 商险方案添加员工无入口(反馈 #5)
|
||||
|
||||
**用户反馈**:添加了商险方案,但不知道如何添加员工,员工办理里也没有商险选项。
|
||||
|
||||
**现状分析**:需确认商险模块是否有员工关联功能。
|
||||
|
||||
**代码验证**:✅ 确认存在。`CommercialInsuranceTab.tsx` 有方案 CRUD 和参保人员查看(`enrollments`),但**无「添加参保员工」按钮**。API 层 `commercialInsuranceApi` 仅有 `plans/enrollments/savePlan/removePlan`,缺少 `addEnrollment` 接口。员工办理 `WorkProcess.tsx` 的 13 种流程类型中也无商险参保类型。
|
||||
|
||||
**合理性**:✅ 合理。商险方案创建后应能关联员工。
|
||||
|
||||
**优化方案**:
|
||||
- 商险方案详情页增加「添加参保员工」按钮,支持批量选择员工
|
||||
- 员工办理(WorkProcess)中增加商险参保/退保流程类型
|
||||
- 花名册员工详情中增加商险参保状态展示
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题4:工资填写后数据自动归零
|
||||
**模块**:薪税管理
|
||||
**优先级**:P0
|
||||
**现象**:在薪税管理中选择一个员工生成工资,进入工资填写页面后录入数据,数据自动返回到 0。
|
||||
**可能原因**:工资条编辑表单的状态管理问题,输入值未正确保存到 state,或被计算逻辑覆盖。
|
||||
**优化方案**:检查工资条编辑表单的 onChange 和 onBlur 逻辑,确保手动输入的值不被自动计算覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 六、员工福利月度汇总无数据
|
||||
## 四、社保公积金模块
|
||||
|
||||
### 6.1 福利添加人员后月度汇总无数(反馈 #6)
|
||||
|
||||
**用户反馈**:员工福利能添加人员,但月度汇总处没有数。
|
||||
|
||||
**现状分析**:需确认福利模块的汇总逻辑是否正确关联了已添加的福利人员数据。
|
||||
|
||||
**代码验证**:❌ 不存在(系统中无福利模块)。搜索全部前端代码无 `benefit`、`福利`、`welfare` 关键词。系统无员工福利管理模块,用户可能指的是其他模块(如社保或商险)的功能。
|
||||
|
||||
**合理性**:⚠️ 需澄清。系统当前无福利模块,需与用户确认具体指的是哪个功能。
|
||||
|
||||
**优化方案**:
|
||||
- 检查月度汇总查询逻辑,确保关联了福利人员表
|
||||
- 汇总维度:按福利类型汇总人数和金额、按部门汇总
|
||||
- 添加人员后自动刷新汇总数据
|
||||
- 汇总页面增加「刷新」按钮
|
||||
|
||||
**优先级**:P1 高
|
||||
### 问题5:社保公积金模块添加员工参保信息列表
|
||||
**模块**:社保公积金
|
||||
**优先级**:P1
|
||||
**现象**:社保公积金模块缺少员工参保信息列表,无法查看各员工各险种的参保状态和参保基数。
|
||||
**优化方案**:
|
||||
- 社保公积金模块新增"员工参保列表"标签页
|
||||
- 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)、缴费基数(养老/医疗/失业/工伤/生育)、公积金基数
|
||||
- 支持按参保状态、城市筛选
|
||||
|
||||
---
|
||||
|
||||
## 七、证据链完整性验证
|
||||
## 五、商业保险模块
|
||||
|
||||
### 7.1 验证全部完整性显示全部异常(反馈 #7)
|
||||
|
||||
**用户反馈**:证据链条中验证全部完整性,显示全部异常,不清楚如何验证。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中有仲裁证据链模块,验证逻辑可能过于严格或验证条件不明确。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Evidence.tsx` 有「验证全部完整性」按钮,调用 `evidenceApi.verifyAll()`,返回 `verifyResult.invalid` 计数。验证逻辑是后端 Hash 校验(SHA256),若全部异常可能是:①数据库中证据链记录的 hash 与实际数据不匹配;②证据链记录为空时验证逻辑有 bug。前端只显示「N 条通过,M 条异常」,**不显示具体异常原因和修复建议**。`EvidenceChain.tsx`(员工维度)有证据列表和风险提醒,但无验证功能。
|
||||
|
||||
**合理性**:✅ 合理。用户不理解验证规则,且全部异常说明验证逻辑可能有问题。
|
||||
|
||||
**优化方案**:
|
||||
- 验证结果中显示具体异常原因(如「缺少劳动合同扫描件」「考勤记录不完整」等)
|
||||
- 每个验证项增加「查看要求」说明,告知用户需要什么材料
|
||||
- 验证标准可配置:区分「必须项」和「建议项」,必须项缺失才标红
|
||||
- 增加「验证说明」帮助文档
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题6:商业保险添加参保方案后无法为员工参保
|
||||
**模块**:商业保险
|
||||
**优先级**:P0
|
||||
**现象**:商业保险模块添加了保险方案后,找不到为员工添加参保信息的入口。
|
||||
**优化方案**:
|
||||
- 在保险方案详情或列表中增加"为员工参保"按钮
|
||||
- 支持批量选择员工参保
|
||||
- 参保后显示参保人员列表
|
||||
|
||||
---
|
||||
|
||||
## 八、员工办理必填项缺失
|
||||
## 六、员工福利模块
|
||||
|
||||
### 8.1 只填姓名也能录入,关键信息未设必填(反馈 #8)
|
||||
|
||||
**用户反馈**:员工办理中只填姓名也能录入,建议身份证号等关键信息设为必填。
|
||||
|
||||
**现状分析**:`WorkProcess.tsx` 的入职登记表单中,`employeeName` 可能是唯一必填项,`idCardNumber` 等字段未设必填。
|
||||
|
||||
**代码验证**:✅ 确认存在。`WorkProcess.tsx:231-242` 的 `handleCreate()` 仅校验 `selectedType` 非空,不校验任何表单字段。`FORM_FIELDS` 配置中无 `required` 标记,所有字段都是选填。后端 `work-process.service.ts` 也未对 formData 做必填校验。HIRE 类型有 `idCardNumber` 字段但非必填,只填姓名即可创建草稿并提交。
|
||||
|
||||
**合理性**:✅ 合理。关键信息缺失会导致后续业务(社保、合同、工资)无法正常办理。
|
||||
|
||||
**优化方案**:
|
||||
- 入职登记必填项:姓名、身份证号、手机号、部门
|
||||
- 用工办理其他流程类型根据类型设置相应必填项
|
||||
- 前端表单增加必填校验提示
|
||||
- 后端 schema 同步增加必填校验
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题7:员工福利添加方案后不知从何处为员工添加福利
|
||||
**模块**:员工福利
|
||||
**优先级**:P0
|
||||
**现象**:员工福利模块添加福利方案后,找不到为员工添加该项福利的入口。
|
||||
**优化方案**:
|
||||
- 在福利方案详情或列表中增加"为员工添加福利"按钮
|
||||
- 支持批量选择员工
|
||||
- 添加后显示享受人员列表
|
||||
|
||||
---
|
||||
|
||||
## 九、员工删除限制
|
||||
|
||||
### 9.1 已办理完毕的员工无法删除(反馈 #9)
|
||||
|
||||
**用户反馈**:员工已办理完毕,想删除无法删除,必须做离职处理。
|
||||
|
||||
**现状分析**:系统设计中员工记录不允许物理删除,只能通过离职流程标记为 `TERMINATED` 状态。这是合理的数据管理设计。
|
||||
|
||||
**代码验证**:✅ 确认存在。`Roster.tsx` 中无删除员工按钮(搜索结果中 Roster.tsx 不含 Trash/delete 相关代码)。花名册仅支持「添加员工」「离职」「重新入职」,不支持物理删除。`WorkProcess.tsx` 中仅可删除草稿记录,不可删除已完成的员工档案。
|
||||
|
||||
**合理性**:⚠️ 部分合理。从数据合规角度,员工记录不应物理删除(需保留人事档案)。但如果是录入错误的测试数据,应提供清理机制。
|
||||
|
||||
**优化方案**:
|
||||
- 保持正式员工不可删除,只能离职处理(合规要求)
|
||||
- 增加「作废」功能:仅限录入错误且无关联业务数据(无合同、无工资记录)的员工可作废
|
||||
- 作废后数据保留但不在花名册显示,管理员可在设置中查看作废记录
|
||||
- 增加「测试数据清理」功能:管理员可一键清除所有测试员工
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题15:员工福利新增方案后员工汇总不显示,月度合计为0
|
||||
**模块**:员工福利
|
||||
**优先级**:P1
|
||||
**现象**:新增福利方案并添加员工后,员工汇总中没有显示该福利,月度合计仍为 0。
|
||||
**可能原因**:福利汇总统计逻辑未包含新增方案的金额,或汇总查询条件未关联到新增方案。
|
||||
**优化方案**:检查福利汇总统计的查询逻辑,确保所有有效福利方案均纳入月度合计计算。
|
||||
|
||||
---
|
||||
|
||||
## 十、离职审批状态无法更改
|
||||
## 七、规章制度管理模块
|
||||
|
||||
### 10.1 待审批状态无法更改或找不到更改入口(反馈 #10)
|
||||
|
||||
**用户反馈**:选择待审批后,再点进去状态无法更改,没找到更改入口。
|
||||
|
||||
**现状分析**:`Termination.tsx` 中离职流程选择「待审批」后,可能缺少审批操作入口或状态流转不完整。
|
||||
|
||||
**代码验证**:❌ 不存在(已实现审批功能)。`Termination.tsx:967-993` 在详情视图中,当 `draftDetail.status === 'PENDING_APPROVAL'` 时,显示审批意见输入框和「审批通过」「驳回」按钮,分别调用 `approveMutation` 和 `rejectMutation`。列表视图中也有快捷审批图标按钮(828-846行)。API 层 `terminationApi.approve/reject` 完整。**用户可能是没找到详情入口**——需点击列表中的审批图标或详情按钮进入详情视图才能操作。
|
||||
|
||||
**合理性**:⚠️ 部分合理。功能已存在,但入口可能不够明显,用户没找到操作位置。建议优化 UI 引导。
|
||||
|
||||
**优化方案**:
|
||||
- 离职详情页增加「审批」按钮(通过/驳回/退回修改)
|
||||
- 待审批状态列表增加批量审批功能
|
||||
- 审批操作记录审批人、审批时间、审批意见
|
||||
- 增加审批通知提醒
|
||||
|
||||
**优先级**:P0 紧急
|
||||
### 问题8:新建规章制度无法进行文本或文档导入
|
||||
**模块**:规章制度
|
||||
**优先级**:P1
|
||||
**现象**:新建规章制度时,只能手动输入正文内容,无法导入 Word 文档或富文本。
|
||||
**优化方案**:
|
||||
- 规章制度编辑器增加"导入 Word 文档"功能(复用文本模板模块的 mammoth 导入方案)
|
||||
- 支持 .docx 格式文档导入并自动转为 HTML
|
||||
|
||||
---
|
||||
|
||||
## 十一、花名册职责边界
|
||||
## 八、文本模板模块
|
||||
|
||||
### 11.1 花名册应只读,修改到相应模块操作(反馈 #11)
|
||||
|
||||
**用户反馈**:花名册应该只能查看,修改应到相应模块,否则太乱。
|
||||
|
||||
**现状分析**:当前花名册 `Roster.tsx` 集成了大量操作:添加员工、薪资调整、部门变更、离职、重新入职等。`EmployeeProfile` 中还能直接编辑基本信息、合同、薪酬等。
|
||||
|
||||
**代码验证**:⚠️ 部分存在。`Roster.tsx` 列表支持添加员工、批量导入、导出,点击进入 `EmployeeProfile` 后可编辑基本信息、合同、薪酬等。操作确实集中在花名册中,但各编辑区域已有模块化分区(BasicInfo/ContractInfo/SalaryInfo 等)。
|
||||
|
||||
**合理性**:⚠️ 部分合理。花名册作为统一查看入口是合理的,但编辑入口确实可以更清晰。
|
||||
|
||||
**优化方案**:
|
||||
- 花名册列表保持只读查看,点击进入员工详情
|
||||
- 员工详情页保留编辑功能,但按模块分区并标注来源模块(如「基本信息 → 员工档案模块」「合同信息 → 合同管理模块」)
|
||||
- 每个编辑区域增加「前往该模块」链接,方便用户到对应模块操作
|
||||
- 花名册列表的操作按钮(薪资调整、离职等)保留,但增加模块跳转提示
|
||||
- 不建议完全移除编辑功能,因为会降低操作效率
|
||||
|
||||
**优先级**:P3 规划(涉及大量 UI 重构)
|
||||
### 问题9:下载的 Word 文档毫无格式
|
||||
**模块**:文本模板
|
||||
**优先级**:P1
|
||||
**现象**:文本模板下载的 Word 文档没有格式,段落、标题、间距等全部丢失,需要手动调整。
|
||||
**可能原因**:当前下载方式可能是纯文本写入 .doc 文件,未使用 Word HTML 格式或样式定义。
|
||||
**优化方案**:
|
||||
- 下载的 Word 文档使用完整的 Word HTML 格式(含 style 定义)
|
||||
- 保留标题、段落间距、字体大小、对齐方式等格式
|
||||
- 参考离职证明下载的 Word HTML 方案
|
||||
|
||||
---
|
||||
|
||||
## 十二、招聘模块缺失
|
||||
|
||||
### 12.1 系统没有招聘模块(反馈 #12)
|
||||
|
||||
**用户反馈**:HR 最重要的招聘环节为什么没有?
|
||||
|
||||
**现状分析**:系统目前覆盖入职→在职→离职全生命周期,但缺少招聘前端的简历管理、面试安排、Offer 管理等环节。
|
||||
|
||||
**代码验证**:✅ 确认存在。搜索全部前端代码无 `recruit`、`resume`、`interview`、`招聘`、`简历`、`面试` 相关页面或路由。系统无招聘模块。
|
||||
|
||||
**合理性**:✅ 合理。招聘是 HR 核心功能之一。
|
||||
|
||||
**优化方案**:
|
||||
- 新建招聘模块,包含:
|
||||
- **简历管理**:简历导入/录入、简历筛选、状态流转(待筛选→初试→复试→Offer→入职/淘汰)
|
||||
- **面试管理**:面试安排、面试评价、面试日历
|
||||
- **Offer 管理**:Offer 模板、Offer 发放、接受/拒绝跟踪
|
||||
- **招聘渠道**:渠道管理、来源统计
|
||||
- **人才库**:未录用候选人归档,未来岗位匹配
|
||||
- 与花名册打通:入职时自动从简历库拉取候选人信息
|
||||
- 招聘数据看板:渠道转化率、招聘周期、录用率等
|
||||
|
||||
**优先级**:P3 规划(大型新功能,建议单独规划版本)
|
||||
### 问题10:无固定期限劳动合同模板下载后不含员工信息
|
||||
**模块**:文本模板
|
||||
**优先级**:P1
|
||||
**现象**:使用"无固定期限劳动合同"模板,已填写了变量占位符,但下载的 Word 文档中变量未被替换为实际员工信息。
|
||||
**可能原因**:下载时未对模板内容进行变量替换处理,直接输出了原始模板。
|
||||
**优化方案**:
|
||||
- 下载 Word 文档时自动替换模板中的变量占位符(如 {{name}}、{{idCardNumber}} 等)
|
||||
- 如果是从员工详情页发起下载,自动填充该员工的信息
|
||||
- 如果是预览下载,提示用户填写变量或选择关联员工
|
||||
|
||||
---
|
||||
|
||||
## 十三、角色权限分工
|
||||
## 九、电子签署模块
|
||||
|
||||
### 13.1 专员和主管的权限分工(反馈 #13)
|
||||
|
||||
**用户反馈**:工作角色分类,是否考虑有专员和主管的权限分工。
|
||||
|
||||
**现状分析**:当前系统角色为 `SUPER_ADMIN`、`ADMIN`、`HR`、`VIEWER` 四级,`HR` 角色拥有全部 HR 操作权限,无专员/主管区分。
|
||||
|
||||
**代码验证**:✅ 确认存在。`authStore.ts` 中 User 角色为 `SUPER_ADMIN | ADMIN | HR | VIEWER` 四级,无专员/主管区分。所有 HR 角色拥有相同权限,无法按模块或操作类型细分。
|
||||
|
||||
**合理性**:✅ 合理。中大型企业需要更细粒度的权限控制。
|
||||
|
||||
**优化方案**:
|
||||
- 角色体系扩展:
|
||||
- `HR_SUPERVISOR`(HR 主管):全部查看 + 审批权限 + 配置权限
|
||||
- `HR_SPECIALIST`(HR 专员):数据录入 + 日常操作,无审批和配置权限
|
||||
- 权限粒度:
|
||||
- 查看权限:全部模块可查看
|
||||
- 操作权限:专员可录入/修改,主管可审批/删除/导出
|
||||
- 配置权限:仅主管/管理员可修改系统配置(社保比例、薪资结构等)
|
||||
- 审批流:离职、薪资调整等关键操作需主管审批
|
||||
- 可按模块设置权限(如考勤专员只能操作考勤模块)
|
||||
|
||||
**优先级**:P2 中
|
||||
### 问题11:发起签署时选择员工没有选项
|
||||
**模块**:电子签署
|
||||
**优先级**:P0
|
||||
**现象**:电子签署中发起签署时,选择员工的下拉框为空,没有任何选项。
|
||||
**可能原因**:员工列表查询接口未调用或返回数据为空,或下拉框数据绑定逻辑有误。
|
||||
**优化方案**:检查电子签署发起页面中员工列表的数据获取逻辑,确保正确加载在职员工列表。
|
||||
|
||||
---
|
||||
|
||||
## 优先级汇总
|
||||
## 十、绩效考核模块
|
||||
|
||||
| 优先级 | 编号 | 问题 | 状态 |
|
||||
|--------|------|------|------|
|
||||
| 优先级 | 编号 | 问题 | 验证状态 | 修复状态 |
|
||||
|--------|------|------|----------|----------|
|
||||
| ~~P0~~ | 2.1 | 导入错误提示乱码 | ⚠️ 部分存在(已有错误展示和导出,但信息可能含英文字段名) | 降级 P1 |
|
||||
| ~~P0~~ | 4.1 | 社保基数不同步 | ❌ 不存在(代码已正确实现优先级 socialInsBase > salary) | 降级 P2(增加 UI 提示) |
|
||||
| P0 紧急 | 8.1 | 员工办理关键信息未设必填 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P0~~ | 10.1 | 离职审批状态无法更改 | ❌ 不存在(已实现完整审批流程) | 无需修复(优化 UI 引导) |
|
||||
| P1 高 | 1.1 | 发薪数据每月需重新导入 | ✅ 确认存在 | 待优化 |
|
||||
| P1 高 | 3.1 | 工资流水导出格式适配银行 | ✅ 确认存在 | 待开发 |
|
||||
| P1 高 | 5.1 | 商险管理添加员工入口缺失 | ✅ 确认存在 | 待修复 |
|
||||
| ~~P1~~ | 6.1 | 员工福利月度汇总无数据 | ❌ 系统无福利模块 | 需澄清需求 |
|
||||
| P2 中 | 7.1 | 证据链验证不清晰 | ⚠️ 部分存在(有验证但不显示具体原因) | 待优化 |
|
||||
| P2 中 | 9.1 | 员工删除限制 | ✅ 确认存在(无删除入口) | 待优化 |
|
||||
| P2 中 | 13.1 | 角色权限分工 | ✅ 确认存在 | 待规划 |
|
||||
| P3 规划 | 11.1 | 花名册职责边界重构 | ⚠️ 部分存在 | 待规划 |
|
||||
| P3 规划 | 12.1 | 招聘模块 | ✅ 确认不存在 | 待规划 |
|
||||
### 问题12:新增绩效记录没有编辑考评人的选项
|
||||
**模块**:绩效考核
|
||||
**优先级**:P2
|
||||
**现象**:绩效考核新增记录时,表单中没有考评人(考核人)的输入字段。
|
||||
**优化方案**:在绩效记录新增表单中增加"考评人"输入框(已有 reviewer 字段,需确认前端是否显示)。
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
## 十一、违纪记录模块
|
||||
|
||||
## 代码验证结论
|
||||
### 问题13:违纪记录缺少处罚执行细节
|
||||
**模块**:违纪记录
|
||||
**优先级**:P2
|
||||
**现象**:违纪记录只有删除功能,缺少处罚执行的具体管理。用户希望支持:
|
||||
1. 处罚类型为扣款/降职时,区分一次性处罚还是持续处罚(如罚一个月后恢复)
|
||||
2. 停薪留职时,可设定留职时间段或灵活安排结束时间
|
||||
**优化方案**:
|
||||
- 违纪记录新增"处罚执行"字段:处罚方式(一次性/持续性)、处罚开始日期、处罚结束日期、处罚金额/降职后岗位
|
||||
- 停薪留职支持设定预计结束日期,到期后提醒确认是否恢复
|
||||
- 违纪记录列表增加处罚状态列(执行中/已结束/已恢复)
|
||||
|
||||
### 已确认存在的问题(需修复)
|
||||
- **#8 员工办理必填项缺失**:`WorkProcess.tsx` 无任何表单必填校验,只填姓名即可提交
|
||||
- **#5 商险添加员工入口缺失**:`CommercialInsuranceTab.tsx` 无添加参保员工按钮和 API
|
||||
- **#1 发薪数据重复导入**:无「复制上月批次」功能
|
||||
- **#3 工资流水无银行格式导出**:`PayslipTab.tsx` 无导出功能
|
||||
- **#9 员工无法删除**:花名册无删除入口
|
||||
- **#12 无招聘模块**:系统中完全不存在
|
||||
- **#13 角色权限无专员/主管分工**:仅四级角色
|
||||
---
|
||||
|
||||
### 不存在或已实现的问题(无需修复)
|
||||
- **#4 社保基数不同步**:`payroll.service.ts:166` 已正确实现 `socialInsBase || salary` 优先级,问题可能是用户未设置 `socialInsBase`
|
||||
- **#10 离职审批无法更改**:`Termination.tsx:967-993` 已实现完整审批操作(通过/驳回),用户可能未找到详情入口
|
||||
- **#6 员工福利汇总无数据**:系统中无福利模块,需澄清用户具体指什么
|
||||
## 十二、合规模块
|
||||
|
||||
### 部分存在的问题(可优化)
|
||||
- **#2 导入错误乱码**:已有错误展示和导出功能,但错误信息可能含英文字段名
|
||||
- **#7 证据链验证**:有验证功能但不显示具体异常原因
|
||||
- **#11 花名册职责边界**:编辑功能已模块化分区但确实集中在花名册中
|
||||
### 问题16:证据链验证完整性全部失败
|
||||
**模块**:合规 → 证据链
|
||||
**优先级**:P0
|
||||
**现象**:合规模块中证据链验证完整性,所有验证项全部失败。
|
||||
**可能原因**:
|
||||
1. 证据链哈希计算逻辑与存储时的哈希不一致(如排序规则、拼接顺序变化)
|
||||
2. 历史数据在升级过程中哈希规则变更,导致旧数据全部校验失败
|
||||
3. orgId 或其他参与哈希计算的参数发生变化
|
||||
**优化方案**:
|
||||
- 检查 verifyAllEvidence 中的哈希计算逻辑,对比存储时的哈希生成逻辑
|
||||
- 如果是哈希规则变更导致,提供"重新生成哈希"功能(仅管理员可用)
|
||||
- 排查是否有数据迁移或 orgId 变更导致的不一致
|
||||
|
||||
### 备注
|
||||
- #4 和 #10 经代码验证不构成 bug,降级处理
|
||||
- #6 需与用户澄清具体需求
|
||||
- #12 招聘模块为大型新功能,建议单独规划版本迭代
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 优先级 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| P0 | 5 | 4, 6, 7, 11, 16 |
|
||||
| P1 | 7 | 1, 2, 3, 5, 8, 9, 10, 15 |
|
||||
| P2 | 2 | 12, 13 |
|
||||
| 待确认 | 1 | 14 |
|
||||
| **合计** | **16** | |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||
<title>企业用工专家</title>
|
||||
<title>安职通</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+4
-4
@@ -16,7 +16,7 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.428.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mammoth": "^1.12.1",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -3348,9 +3348,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
||||
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||
"version": "1.12.1",
|
||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.1.tgz",
|
||||
"integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"file-saver": "^2.0.5",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.428.0",
|
||||
"mammoth": "^1.12.0",
|
||||
"mammoth": "^1.12.1",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -6,7 +6,7 @@
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<title>TurboHR 验收测试清单</title>
|
||||
<title>安职通 验收测试清单</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4f46e5;
|
||||
@@ -280,7 +280,7 @@ body {
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>TurboHR 验收测试清单</h1>
|
||||
<h1>安职通 验收测试清单</h1>
|
||||
<div class="verifier-section">
|
||||
<label>验收人:</label>
|
||||
<div class="autocomplete-wrap">
|
||||
|
||||
+66
-53
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useState, useEffect } from 'react'
|
||||
import { Suspense, useState, useEffect } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'sonner'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
@@ -11,62 +11,66 @@ import PageContainer from './components/layout/PageContainer'
|
||||
import { CommandPalette } from './components/ui/CommandPalette'
|
||||
import { SkeletonPage } from './components/ui/Skeleton'
|
||||
import ErrorBoundary from './components/ui/ErrorBoundary'
|
||||
import { lazyRetry } from './lib/lazyRetry'
|
||||
|
||||
const Login = lazy(() => import('./pages/auth/Login'))
|
||||
const Register = lazy(() => import('./pages/auth/Register'))
|
||||
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Money = lazy(() => import('./pages/Money'))
|
||||
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
|
||||
const Roster = lazy(() => import('./pages/Roster'))
|
||||
const Termination = lazy(() => import('./pages/Termination'))
|
||||
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Evidence = lazy(() => import('./pages/Evidence'))
|
||||
const Policies = lazy(() => import('./pages/Policies'))
|
||||
const Attendance = lazy(() => import('./pages/Attendance'))
|
||||
const Templates = lazy(() => import('./pages/Templates'))
|
||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
||||
const Notifications = lazy(() => import('./pages/Notifications'))
|
||||
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
|
||||
const Payslip = lazy(() => import('./pages/portal/Payslip'))
|
||||
const MyContract = lazy(() => import('./pages/portal/MyContract'))
|
||||
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
|
||||
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
|
||||
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
|
||||
const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
|
||||
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
|
||||
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
|
||||
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
||||
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
|
||||
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
||||
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
||||
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords'))
|
||||
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords'))
|
||||
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords'))
|
||||
const Login = lazyRetry(() => import('./pages/auth/Login'))
|
||||
const Register = lazyRetry(() => import('./pages/auth/Register'))
|
||||
const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword'))
|
||||
const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
|
||||
const Money = lazyRetry(() => import('./pages/Money'))
|
||||
const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
|
||||
const Overtime = lazyRetry(() => import('./pages/Overtime'))
|
||||
const Roster = lazyRetry(() => import('./pages/Roster'))
|
||||
const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
|
||||
const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard'))
|
||||
const Termination = lazyRetry(() => import('./pages/Termination'))
|
||||
const AIAssistant = lazyRetry(() => import('./pages/AIAssistant'))
|
||||
const Settings = lazyRetry(() => import('./pages/Settings'))
|
||||
const Evidence = lazyRetry(() => import('./pages/Evidence'))
|
||||
const Policies = lazyRetry(() => import('./pages/Policies'))
|
||||
const Attendance = lazyRetry(() => import('./pages/Attendance'))
|
||||
const Templates = lazyRetry(() => import('./pages/Templates'))
|
||||
const AuditLog = lazyRetry(() => import('./pages/AuditLog'))
|
||||
const Notifications = lazyRetry(() => import('./pages/Notifications'))
|
||||
const PortalLogin = lazyRetry(() => import('./pages/portal/PortalLogin'))
|
||||
const Payslip = lazyRetry(() => import('./pages/portal/Payslip'))
|
||||
const MyContract = lazyRetry(() => import('./pages/portal/MyContract'))
|
||||
const Onboarding = lazyRetry(() => import('./pages/portal/Onboarding'))
|
||||
const ContractConfirm = lazyRetry(() => import('./pages/portal/ContractConfirm'))
|
||||
const MyPolicies = lazyRetry(() => import('./pages/portal/MyPolicies'))
|
||||
const AutoLogin = lazyRetry(() => import('./pages/portal/AutoLogin'))
|
||||
const MedicalPeriodCalculator = lazyRetry(() => import('./pages/tools/MedicalPeriodCalculator'))
|
||||
const HealthCheck = lazyRetry(() => import('./pages/tools/HealthCheck'))
|
||||
const AnnualValueReport = lazyRetry(() => import('./pages/tools/AnnualValueReport'))
|
||||
const CalendarPage = lazyRetry(() => import('./pages/Calendar'))
|
||||
const MyAttendance = lazyRetry(() => import('./pages/portal/MyAttendance'))
|
||||
const MyLeave = lazyRetry(() => import('./pages/portal/MyLeave'))
|
||||
const SpecialStatus = lazyRetry(() => import('./pages/SpecialStatus'))
|
||||
const CompanyFiles = lazyRetry(() => import('./pages/CompanyFiles'))
|
||||
const LeaveApproval = lazyRetry(() => import('./pages/LeaveApproval'))
|
||||
const TrainingRecords = lazyRetry(() => import('./pages/roster/TrainingRecords'))
|
||||
const PerformanceRecords = lazyRetry(() => import('./pages/roster/PerformanceRecords'))
|
||||
const DisciplinaryRecords = lazyRetry(() => import('./pages/roster/DisciplinaryRecords'))
|
||||
|
||||
// Sprint 4-5 新增页面
|
||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
||||
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
|
||||
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
|
||||
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
|
||||
const MyRecords = lazy(() => import('./pages/portal/MyRecords'))
|
||||
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
|
||||
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
||||
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
||||
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
|
||||
const ESign = lazy(() => import('./pages/ESign'))
|
||||
const EmployeeHome = lazyRetry(() => import('./pages/portal/EmployeeHome'))
|
||||
const OnboardingProgress = lazyRetry(() => import('./pages/portal/OnboardingProgress'))
|
||||
const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply'))
|
||||
const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign'))
|
||||
const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords'))
|
||||
const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter'))
|
||||
const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard'))
|
||||
const CommercialInsurance = lazyRetry(() => import('./pages/CommercialInsurance'))
|
||||
const EmployeeBenefits = lazyRetry(() => import('./pages/EmployeeBenefits'))
|
||||
const ESign = lazyRetry(() => import('./pages/ESign'))
|
||||
const CommissionBonus = lazyRetry(() => import('./pages/CommissionBonus'))
|
||||
|
||||
// 平台管理端
|
||||
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
|
||||
const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard'))
|
||||
const PlatformOrgs = lazy(() => import('./pages/platform/PlatformOrgs'))
|
||||
const PlatformUsers = lazy(() => import('./pages/platform/PlatformUsers'))
|
||||
const PlatformSidebar = lazy(() => import('./components/layout/PlatformSidebar'))
|
||||
const PlatformLogin = lazyRetry(() => import('./pages/platform/PlatformLogin'))
|
||||
const PlatformDashboard = lazyRetry(() => import('./pages/platform/PlatformDashboard'))
|
||||
const PlatformOrgs = lazyRetry(() => import('./pages/platform/PlatformOrgs'))
|
||||
const PlatformUsers = lazyRetry(() => import('./pages/platform/PlatformUsers'))
|
||||
const PlatformSidebar = lazyRetry(() => import('./components/layout/PlatformSidebar'))
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
@@ -138,6 +142,12 @@ function PlatformLayout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
|
||||
// 检查 portal token 是否存在,不存在则跳转登录页
|
||||
const portalToken = localStorage.getItem('portalToken')
|
||||
if (!portalToken) {
|
||||
window.location.href = '/portal/login'
|
||||
return null
|
||||
}
|
||||
if (!showNav) {
|
||||
return (
|
||||
<div className="min-h-screen bg-surface pt-safe pb-safe">
|
||||
@@ -185,8 +195,11 @@ export default function App() {
|
||||
{/* 管理端业务页面 */}
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/org-chart" element={<ProtectedRoute><AdminLayout><OrgChart /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/support" element={<ProtectedRoute><AdminLayout><SupportDashboard /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/overtime" element={<ProtectedRoute><AdminLayout><Overtime /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></AdminLayout></ProtectedRoute>} />
|
||||
@@ -200,8 +213,8 @@ export default function App() {
|
||||
<Route path="/tools/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/work-process" element={<ProtectedRoute><AdminLayout><WorkProcess /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/special-status" element={<ProtectedRoute><AdminLayout><SpecialStatus /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/commission-bonus" element={<ProtectedRoute><AdminLayout><CommissionBonus /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
||||
|
||||
@@ -30,6 +30,101 @@ const categories: HelpCategory[] = [
|
||||
title: '近期更新',
|
||||
icon: Sparkles,
|
||||
articles: [
|
||||
{
|
||||
id: 'update-20260811-overview',
|
||||
question: '2026年8月11日优化:16项问题全面修复',
|
||||
answer: '本次更新覆盖花名册、离职管理、薪税管理、社保公积金、商业保险、员工福利、规章制度、文本模板、电子签署、绩效考核、违纪记录、特殊员工、证据链等全部模块,共修复16项问题(P0紧急5项 + P1重要8项 + P2优化2项 + 待确认1项)。\n以下为各模块主要改进:',
|
||||
},
|
||||
{
|
||||
id: 'update-perf-training-nav',
|
||||
question: '修复:绩效考核/培训记录点击员工姓名可跳转员工详情',
|
||||
answer: '绩效考核和培训记录列表中员工姓名现已支持点击跳转:\n• 点击员工姓名直接跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工\n• 员工姓名显示为链接样式,鼠标悬停有下划线提示',
|
||||
tip: '路径:团队 → 绩效考核/培训记录 → 点击员工姓名',
|
||||
},
|
||||
{
|
||||
id: 'update-resignation-cert',
|
||||
question: '优化:离职证明模板支持自定义+员工端下载',
|
||||
answer: '离职证明功能全面升级:\n• 离职证明模板支持自定义编辑(复用文本模板模块,支持变量占位符)\n• 管理端离职管理中可选择/编辑离职证明模板\n• 员工端已完成离职的员工可在线查看和下载离职证明(含电子印章)',
|
||||
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明 / 员工端 → 我的证明',
|
||||
},
|
||||
{
|
||||
id: 'update-payroll-input',
|
||||
question: '修复(P0):薪税管理工资填写后数据不再归零',
|
||||
answer: '修复工资条编辑表单输入数据后自动归零的问题:\n• 修复 onChange 和 onBlur 逻辑,手动输入的值不再被自动计算覆盖\n• 确保基本工资、津贴等手动输入字段正确保存到状态\n• 自动计算项目(如个税、社保扣款)在手动输入完成后重新计算',
|
||||
tip: '路径:薪税管理 → 选择员工 → 工资填写页面',
|
||||
},
|
||||
{
|
||||
id: 'update-social-enrollment',
|
||||
question: '新增:社保公积金模块员工参保信息列表',
|
||||
answer: '社保公积金模块新增「员工参保」标签页:\n• 列表显示:员工姓名、部门、参保城市、各险种参保状态(已参保/未参保/停缴)\n• 显示缴费基数(养老/医疗/失业/工伤/生育)和公积金基数\n• 支持按参保状态、城市筛选',
|
||||
tip: '路径:社保公积金 → 员工参保 Tab',
|
||||
},
|
||||
{
|
||||
id: 'update-commercial-insurance',
|
||||
question: '修复(P0):商业保险方案支持为员工参保',
|
||||
answer: '商业保险模块新增为员工参保功能:\n• 保险方案详情中增加「为员工参保」按钮\n• 支持批量选择员工参保\n• 参保后显示参保人员列表',
|
||||
tip: '路径:福利保障 → 商业保险 → 点击方案 → 为员工参保',
|
||||
},
|
||||
{
|
||||
id: 'update-benefit-enroll-20260811',
|
||||
question: '修复(P0):员工福利方案支持为员工添加福利',
|
||||
answer: '员工福利模块新增为员工添加福利功能:\n• 福利方案详情中增加「为员工添加福利」按钮\n• 支持批量选择员工\n• 添加后显示享受人员列表和月度合计',
|
||||
tip: '路径:福利保障 → 员工福利 → 点击方案 → 为员工添加福利',
|
||||
},
|
||||
{
|
||||
id: 'update-policy-word-import',
|
||||
question: '新增:规章制度支持导入 Word 文档',
|
||||
answer: '规章制度编辑器新增导入 Word 文档功能:\n• 支持 .docx 格式 Word 文档导入\n• 使用 mammoth 库自动将 Word 内容转为 HTML\n• 导入后可在编辑器中继续修改\n• 保留原有格式(标题、段落、列表等)',
|
||||
tip: '路径:规章制度 → 新建 → 导入 Word 文档按钮',
|
||||
},
|
||||
{
|
||||
id: 'update-template-format',
|
||||
question: '修复:文本模板下载 Word 文档保留完整格式',
|
||||
answer: '文本模板下载的 Word 文档现已保留完整格式:\n• 使用完整的 Word HTML 格式(含 style 定义)\n• 保留标题、段落间距、字体大小、对齐方式等格式\n• 下载文件名使用模板名称,不再使用随机文件名',
|
||||
tip: '路径:文本模板 → 下载 Word',
|
||||
},
|
||||
{
|
||||
id: 'update-template-variables',
|
||||
question: '修复:模板下载时变量占位符正确替换为实际数据',
|
||||
answer: '模板下载时自动替换变量占位符:\n• 下载 Word 文档时自动替换模板中的 {{变量}} 占位符\n• 从员工详情页发起下载时自动填充该员工信息\n• 修复了 token 参数被误当作模板变量的问题',
|
||||
tip: '路径:文本模板 → 填写变量 → 下载 / 员工详情 → 下载模板',
|
||||
},
|
||||
{
|
||||
id: 'update-esign-employees',
|
||||
question: '修复(P0):电子签署发起时员工下拉框正常显示',
|
||||
answer: '修复电子签署发起签署时员工下拉框为空的问题:\n• 发起签署弹窗中员工下拉框正确加载在职员工列表\n• 显示员工姓名和部门信息\n• 修复员工列表数据获取逻辑',
|
||||
tip: '路径:电子签署 → 发起签署 → 选择员工',
|
||||
},
|
||||
{
|
||||
id: 'update-performance-reviewer',
|
||||
question: '优化:新增绩效记录支持填写考评人',
|
||||
answer: '绩效考核新增记录表单增加考评人字段:\n• 新增「考评人」必填输入框\n• 考评人信息保存到 reviewer 字段\n• 列表中显示考评人信息',
|
||||
tip: '路径:团队 → 绩效考核 → 新增 → 考评人',
|
||||
},
|
||||
{
|
||||
id: 'update-disciplinary-detail',
|
||||
question: '优化:违纪记录新增处罚执行细节',
|
||||
answer: '违纪记录新增处罚执行管理功能:\n• 违纪记录新增「执行细节」字段,支持填写处罚方式、处罚日期等\n• 违纪记录列表增加「执行细节」列\n• 表单中增加 textarea 用于详细记录处罚执行情况',
|
||||
tip: '路径:团队 → 违纪记录 → 新增 → 执行细节',
|
||||
},
|
||||
{
|
||||
id: 'update-special-status-detail',
|
||||
question: '修复:特殊员工列表支持查看员工详情',
|
||||
answer: '特殊员工列表新增查看详情功能:\n• 每条记录右上角新增「查看」按钮(眼睛图标)\n• 点击员工姓名也可跳转到员工档案详情页\n• 跳转时携带 employeeId 参数,正确定位到对应员工',
|
||||
tip: '路径:团队 → 特殊员工 → 点击查看按钮或员工姓名',
|
||||
},
|
||||
{
|
||||
id: 'update-benefit-summary',
|
||||
question: '修复:员工福利汇总正确显示参保人员和月度合计',
|
||||
answer: '员工福利汇总数据修复:\n• 新增福利方案并添加员工后,员工汇总正确显示\n• 月度合计金额正确计算所有有效福利方案\n• 汇总Tab显示参保人员姓名、部门、福利项和月度合计',
|
||||
tip: '路径:福利保障 → 员工福利 → 员工汇总 Tab',
|
||||
},
|
||||
{
|
||||
id: 'update-evidence-hash',
|
||||
question: '修复(P0):证据链验证完整性全部通过',
|
||||
answer: '证据链哈希验证逻辑修复:\n• 修复哈希计算中 key 排序问题,改为递归排序所有层级的 key\n• 验证时自动修复因 PostgreSQL JSONB key 重排或哈希算法升级导致的不一致\n• 历史数据验证全部通过,不再出现误报篡改',
|
||||
tip: '路径:合规 → 证据链条 → 验证全部完整性',
|
||||
},
|
||||
{
|
||||
id: 'update-2026-batch-overview',
|
||||
question: '2026年8月批量优化:28项问题一次性修复',
|
||||
@@ -44,13 +139,13 @@ const categories: HelpCategory[] = [
|
||||
{
|
||||
id: 'update-termination-cert',
|
||||
question: '修复:离职证明下载内容为乱码',
|
||||
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、身份证号、入职日期、离职日期、企业名称等',
|
||||
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、证件号码、入职日期、离职日期、企业名称等',
|
||||
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
|
||||
},
|
||||
{
|
||||
id: 'update-attendance-template',
|
||||
question: '优化:考勤导入模板合并Sheet,减少重复录入',
|
||||
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和身份证号',
|
||||
answer: '考勤月度导入模板从多个Sheet合并为单Sheet:\n• 考勤记录和加班记录合并为「考勤与加班」一个Sheet\n• 不再需要分别填写考勤Sheet和加班Sheet\n• 导入时自动识别合并Sheet或独立Sheet,兼容旧模板\n• 减少重复录入姓名和证件号码',
|
||||
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
|
||||
},
|
||||
{
|
||||
@@ -145,14 +240,14 @@ const categories: HelpCategory[] = [
|
||||
},
|
||||
{
|
||||
id: 'update-idcard-dedup',
|
||||
question: '新增:用工办理按身份证号查重',
|
||||
answer: '创建员工时自动按身份证号查重:\n• 后端创建员工前先检查身份证号是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
|
||||
question: '新增:用工办理按证件号码查重',
|
||||
answer: '创建员工时自动按证件号码查重:\n• 后端创建员工前先检查证件号码是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
|
||||
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
|
||||
},
|
||||
{
|
||||
id: 'update-income-cert-select',
|
||||
question: '优化:收入证明支持员工下拉选择并自动填充',
|
||||
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、身份证号、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
|
||||
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、证件号码、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
|
||||
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
|
||||
},
|
||||
{
|
||||
@@ -201,7 +296,7 @@ const categories: HelpCategory[] = [
|
||||
{
|
||||
id: 'system-intro',
|
||||
question: '本系统能帮企业做什么?',
|
||||
answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
|
||||
answer: '「安职通」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
@@ -235,7 +330,7 @@ const categories: HelpCategory[] = [
|
||||
articles: [
|
||||
{
|
||||
id: 'what-is',
|
||||
question: '「企业用工专家」是什么?',
|
||||
question: '「安职通」是什么?',
|
||||
answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。',
|
||||
},
|
||||
{
|
||||
@@ -401,7 +496,7 @@ const categories: HelpCategory[] = [
|
||||
id: 'attendance-import',
|
||||
question: '怎么导入考勤数据?',
|
||||
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
|
||||
tip: '身份证号优先匹配,未填时用姓名匹配。',
|
||||
tip: '证件号码优先匹配,未填时用姓名匹配。',
|
||||
},
|
||||
{
|
||||
id: 'attendance-correct',
|
||||
@@ -518,7 +613,7 @@ const categories: HelpCategory[] = [
|
||||
{
|
||||
id: 'data-safe',
|
||||
question: '我的数据安全吗?',
|
||||
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。',
|
||||
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如证件号码)都经过加密存储。',
|
||||
},
|
||||
{
|
||||
id: 'data-export',
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function OnboardingGuide() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-100">
|
||||
<h2 className="text-base font-semibold">欢迎使用企业用工专家</h2>
|
||||
<h2 className="text-base font-semibold">欢迎使用安职通</h2>
|
||||
<button onClick={close} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||
'/': { group: '工作台', label: '总览' },
|
||||
'/calendar': { group: '工作台', label: '工作日历' },
|
||||
'/roster': { group: '员工管理', label: '花名册' },
|
||||
'/work-process': { group: '员工管理', label: '用工办理' },
|
||||
'/work-process': { group: '员工管理', label: '批量流程' },
|
||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
||||
'/leave-approval': { group: '员工管理', label: '休假审批' },
|
||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-gray-200 pt-safe">
|
||||
<div className="max-w-md mx-auto h-14 flex items-center justify-between px-4">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Logo className="w-6 h-6 text-primary flex-shrink-0" />
|
||||
<span className="text-sm font-bold text-gray-900 truncate">企业用工专家</span>
|
||||
<Logo className="w-6 h-6 object-contain flex-shrink-0" />
|
||||
<span className="text-sm font-bold text-gray-900 truncate">安职通</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{employee.name && (
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
ChevronDown, ChevronRight,
|
||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||
DollarSign, Clock,
|
||||
} from 'lucide-react'
|
||||
import Logo from '../ui/Logo'
|
||||
import { settingsApi } from '../../lib/api-services'
|
||||
|
||||
interface NavItem {
|
||||
@@ -37,14 +37,14 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '团队',
|
||||
items: [
|
||||
{ path: '/roster', label: '花名册', icon: Users },
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/org-chart', label: '组织架构', icon: Building2 },
|
||||
{ path: '/esign', label: '待签合同', icon: PenTool },
|
||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
|
||||
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
||||
@@ -64,6 +64,8 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||
{ path: '/overtime', label: '加班费计算', icon: Clock },
|
||||
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
||||
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
||||
],
|
||||
},
|
||||
@@ -80,6 +82,11 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
|
||||
{ path: '/evidence', label: '证据链条', icon: FileSearch },
|
||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '工具',
|
||||
items: [
|
||||
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
||||
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
|
||||
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
||||
@@ -147,10 +154,9 @@ export default function SidebarNav({ mobileOpen, onClose }: { mobileOpen: boolea
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
)}
|
||||
>
|
||||
{/* Logo 区 */}
|
||||
<div className="h-14 flex items-center gap-2 px-4 border-b border-gray-200 shrink-0">
|
||||
<Logo className="w-5 h-5 text-primary" />
|
||||
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
|
||||
{/* 公司名区 */}
|
||||
<div className="h-14 flex items-center px-4 border-b border-gray-200 shrink-0">
|
||||
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || ''}</span>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 应用 Logo 图标 — 与 favicon.svg 保持一致
|
||||
* 应用 Logo — 嘉驰英才品牌图标
|
||||
*/
|
||||
|
||||
interface LogoProps {
|
||||
@@ -8,12 +8,6 @@ interface LogoProps {
|
||||
|
||||
export default function Logo({ className = 'w-5 h-5' }: LogoProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={className} fill="none">
|
||||
<rect width="32" height="32" rx="6" fill="currentColor" />
|
||||
<path d="M16 6 L24 10 L24 16 L16 20 L8 16 L8 10 Z" stroke="white" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<circle cx="16" cy="13" r="2" fill="white" />
|
||||
<path d="M12 24 L12 20 M20 24 L20 20 M16 22 L16 20" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<rect x="10" y="24" width="12" height="2" rx="1" fill="white" />
|
||||
</svg>
|
||||
<img src="/JClog.png" alt="嘉驰英才" className={className} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 签署方式选择弹窗
|
||||
* 花名册操作(重新入职/合同续签/薪酬变更/调岗调动)成功后,
|
||||
* 弹窗让HR选择是走电子签署还是线下手签。
|
||||
*
|
||||
* - 电子签:自动创建电子签署记录(status=PENDING),员工在员工端签署
|
||||
* - 线下手签:跳转到电子签署页面的线下手签登记
|
||||
* - 跳过:不创建签署记录
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { PenTool, FileCheck, SkipForward, Shield } from 'lucide-react'
|
||||
import { esignApi } from '../../lib/api-services'
|
||||
import Modal from './Modal'
|
||||
import Button from './Button'
|
||||
|
||||
interface SignMethodChoiceProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 员工ID */
|
||||
employeeId: string
|
||||
/** 员工姓名(用于显示) */
|
||||
employeeName: string
|
||||
/** 签署场景 */
|
||||
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
|
||||
/** 文件标题 */
|
||||
documentTitle: string
|
||||
/** 关联合同ID(可选) */
|
||||
contractId?: string
|
||||
/** 备注 */
|
||||
remark?: string
|
||||
/** 操作名称(如"重新入职"、"合同续签") */
|
||||
actionName: string
|
||||
}
|
||||
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
CONTRACT: '劳动合同',
|
||||
RESIGNATION: '离职协议',
|
||||
POLICY: '规章制度',
|
||||
PAYSLIP: '工资条',
|
||||
ONBOARDING: '入职文件',
|
||||
}
|
||||
|
||||
export default function SignMethodChoice({
|
||||
open,
|
||||
onClose,
|
||||
employeeId,
|
||||
employeeName,
|
||||
scene,
|
||||
documentTitle,
|
||||
contractId,
|
||||
remark,
|
||||
actionName,
|
||||
}: SignMethodChoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const createEsignMutation = useMutation({
|
||||
mutationFn: (data: any) => esignApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
toast.success('电子签署已发起,员工可在员工端查看并签署', {
|
||||
action: { label: '查看签署', onClick: () => navigate('/esign') },
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
/** 选择电子签署 */
|
||||
const handleEsign = () => {
|
||||
setCreating(true)
|
||||
createEsignMutation.mutate({
|
||||
employeeId,
|
||||
contractId,
|
||||
scene,
|
||||
documentTitle,
|
||||
remark: remark || `${actionName}时自动发起`,
|
||||
})
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
/** 选择线下手签 → 跳转到签署页面 */
|
||||
const handlePaperSign = () => {
|
||||
onClose()
|
||||
// 通过 URL 参数传递信息,签署页面读取后自动打开线下手签登记
|
||||
const params = new URLSearchParams({
|
||||
action: 'paper-sign',
|
||||
employeeId,
|
||||
scene,
|
||||
documentTitle,
|
||||
})
|
||||
if (contractId) params.set('contractId', contractId)
|
||||
navigate(`/esign?${params.toString()}`)
|
||||
}
|
||||
|
||||
/** 跳过 */
|
||||
const handleSkip = () => {
|
||||
toast.info('已跳过签署,可稍后在「电子签署」页面手动发起')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`${actionName}成功 — 选择签署方式`} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 bg-gray-50 rounded-md p-3">
|
||||
<div className="font-medium text-gray-700">{employeeName} · {SCENE_LABELS[scene] || scene}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{documentTitle}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 flex items-start gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 mt-0.5 shrink-0 text-primary" />
|
||||
<div>根据劳动合同法,{SCENE_LABELS[scene] || '文件'}需双方签署确认。请选择签署方式:</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 电子签署 */}
|
||||
<button
|
||||
onClick={handleEsign}
|
||||
disabled={creating}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50/50 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
|
||||
<PenTool className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">电子签署</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">自动创建签署记录,员工在员工端通过验证码签署</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 线下手签 */}
|
||||
<button
|
||||
onClick={handlePaperSign}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-orange-200 bg-orange-50/50 rounded-lg hover:border-orange-400 hover:bg-orange-50 transition text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center shrink-0">
|
||||
<FileCheck className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">线下手签登记</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">跳转到签署页面,上传签署后的纸质文件扫描件</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 跳过 */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-gray-200 bg-gray-50/50 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
||||
<SkipForward className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-600">稍后处理</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">跳过签署,可稍后在「电子签署」页面手动发起</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ export const surveyPages: SurveyPage[] = [
|
||||
{
|
||||
id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报',
|
||||
features: [
|
||||
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、身份证号、紧急联系人等' },
|
||||
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、证件号码、紧急联系人等' },
|
||||
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
|
||||
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
|
||||
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
|
||||
|
||||
@@ -69,6 +69,9 @@ export const employeeApi = {
|
||||
/** 身份证查重 */
|
||||
checkIdCard: (idCard: string) =>
|
||||
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
|
||||
/** 手机号查重 */
|
||||
checkPhone: (phone: string) =>
|
||||
get('/employees/check-phone', { params: { phone } }).then(unwrap<{ exists: boolean; employee?: any }>()),
|
||||
/** 更新员工 */
|
||||
update: (id: string, data: Record<string, unknown>) =>
|
||||
put(`/employees/${id}`, data),
|
||||
@@ -78,6 +81,9 @@ export const employeeApi = {
|
||||
/** 重新入职 */
|
||||
rehire: (id: string, data: Record<string, unknown>) =>
|
||||
post(`/employees/${id}/rehire`, data),
|
||||
/** 重置员工密码(管理员,重置为手机号后6位) */
|
||||
resetPassword: (id: string) =>
|
||||
post(`/employees/${id}/reset-password`).then(unwrap<any>()),
|
||||
/** 添加合同 */
|
||||
addContract: (data: Record<string, unknown>) =>
|
||||
post('/employees/contracts', data),
|
||||
@@ -288,6 +294,12 @@ export const attendanceApi = {
|
||||
/** 批量确认 */
|
||||
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
||||
post('/attendance/batch-confirm', data).then(unwrap<any>()),
|
||||
/** 批量创建/更新考勤确认记录 */
|
||||
batchCreate: (data: { month: string; items: any[] }) =>
|
||||
post('/attendance/batch', data).then(unwrap<any>()),
|
||||
/** 更新考勤确认扣款金额 */
|
||||
updateDeduction: (id: string, deductionAmount: number) =>
|
||||
put(`/attendance/confirmations/${id}/deduction`, { deductionAmount }).then(unwrap<any>()),
|
||||
/** 单条确认 */
|
||||
confirm: (data: { employeeId: string; month: string }) =>
|
||||
post('/attendance/confirm', data).then(unwrap<any>()),
|
||||
@@ -471,6 +483,18 @@ export const payrollApi = {
|
||||
/** 导入加班费到批次 */
|
||||
importOvertimeToBatch: (batchId: string) =>
|
||||
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
|
||||
/** 获取提成奖金到批次 */
|
||||
fetchBonusToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-bonus`).then(unwrap<any>()),
|
||||
/** 获取绩效工资到批次(按考核系数计算) */
|
||||
fetchPerformanceToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-performance`).then(unwrap<any>()),
|
||||
/** 获取违纪扣款到批次 */
|
||||
fetchDisciplinaryToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-disciplinary`).then(unwrap<any>()),
|
||||
/** 获取考勤扣款到批次 */
|
||||
fetchAttendanceDeductionToBatch: (batchId: string) =>
|
||||
post(`/payroll2/batches/${batchId}/fetch-attendance-deduction`).then(unwrap<any>()),
|
||||
/** 加班费配置 */
|
||||
overtimeConfig: () =>
|
||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
||||
@@ -506,6 +530,55 @@ export const salaryDashboardApi = {
|
||||
|
||||
// ========== 社保公积金相关 ==========
|
||||
|
||||
// 账户管理(新)
|
||||
export const socialAccountApi = {
|
||||
/** 账户列表 */
|
||||
list: (type?: string) =>
|
||||
get('/social/accounts', { params: type ? { type } : {} }).then(unwrap<any[]>()),
|
||||
/** 新建账户 */
|
||||
create: (data: { type: string; name: string; city: string; accountNo?: string; bankName?: string; bankAccount?: string; orgName?: string; orgCode?: string; accountType?: string; isDefault?: boolean; remark?: string }) =>
|
||||
post('/social/accounts', data).then(unwrap<any>()),
|
||||
/** 编辑账户 */
|
||||
update: (id: string, data: Partial<{ type: string; name: string; city: string; accountNo: string; bankName: string; bankAccount: string; orgName: string; orgCode: string; accountType: string; isDefault: boolean; remark: string; status: string }>) =>
|
||||
put(`/social/accounts/${id}`, data).then(unwrap<any>()),
|
||||
/** 删除账户 */
|
||||
remove: (id: string) =>
|
||||
del(`/social/accounts/${id}`).then(unwrap<any>()),
|
||||
/** 设为默认 */
|
||||
setDefault: (id: string) =>
|
||||
put(`/social/accounts/${id}/default`).then(unwrap<any>()),
|
||||
/** 按账户获取年度标准列表 */
|
||||
standards: (accountId: string) =>
|
||||
get(`/social/accounts/${accountId}/standards`).then(unwrap<any[]>()),
|
||||
/** 按账户获取当前生效标准 */
|
||||
currentStandard: (accountId: string) =>
|
||||
get(`/social/accounts/${accountId}/current-standard`).then(unwrap<any>()),
|
||||
/** 按账户获取继承数据(当前标准→旧配置→null),用于新建年度标准初始值 */
|
||||
inheritConfig: (accountId: string) =>
|
||||
get(`/social/accounts/${accountId}/inherit-config`).then(unwrap<any>()),
|
||||
/** 按账户+月份获取适用标准 */
|
||||
standardByMonth: (accountId: string, month: string) =>
|
||||
get(`/social/accounts/${accountId}/standard-by-month/${month}`).then(unwrap<any>()),
|
||||
/** 新建年度标准 */
|
||||
createStandard: (accountId: string, data: any) =>
|
||||
post(`/social/accounts/${accountId}/standards`, data).then(unwrap<any>()),
|
||||
/** 按员工获取适用账户 */
|
||||
employeeAccount: (employeeId: string) =>
|
||||
get(`/social/employee-account/${employeeId}`).then(unwrap<any>()),
|
||||
/** 获取账户已关联的根部门 */
|
||||
accountDepartments: (id: string) =>
|
||||
get(`/social/accounts/${id}/departments`).then(unwrap<any[]>()),
|
||||
/** 账户关联根部门(批量) */
|
||||
linkDepartments: (id: string, departmentIds: string[]) =>
|
||||
put(`/social/accounts/${id}/departments`, { departmentIds }).then(unwrap<any>()),
|
||||
/** 按部门获取适用账户(选部门时自动带出) */
|
||||
departmentAccount: (departmentId: string) =>
|
||||
get(`/social/department-account/${departmentId}`).then(unwrap<any>()),
|
||||
/** 快速更新当前年度标准的最低工资 */
|
||||
updateMinWage: (accountId: string, minWage: number) =>
|
||||
put(`/social/accounts/${accountId}/min-wage`, { minWage }).then(unwrap<any>()),
|
||||
}
|
||||
|
||||
export const socialInsuranceApi = {
|
||||
/** 城市列表 */
|
||||
cities: () =>
|
||||
@@ -585,6 +658,9 @@ export const socialInsuranceApi = {
|
||||
/** 公积金活跃申报 */
|
||||
housingActiveDeclaration: (month: string) =>
|
||||
get('/social/housing/active-declaration', { params: { month } }).then(unwrap<any>()),
|
||||
/** 员工参保信息列表 */
|
||||
employeeEnrollment: (keyword?: string) =>
|
||||
get('/social/employee-enrollment', { params: keyword ? { keyword } : {} }).then(unwrap<any[]>()),
|
||||
}
|
||||
|
||||
// ========== 商业保险 ==========
|
||||
@@ -635,12 +711,60 @@ export const benefitApi = {
|
||||
export const esignApi = {
|
||||
list: (params?: { status?: string; scene?: string }) =>
|
||||
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string }) =>
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
pending: () =>
|
||||
get('/esign/pending').then(unwrap<any[]>()),
|
||||
/** 催办(生成自动登录链接) */
|
||||
remind: (employeeId: string) =>
|
||||
post('/esign/remind', { employeeId }).then(unwrap<any>()),
|
||||
/** 登记线下合同签署日期 */
|
||||
signDate: (contractId: string, signDate: string) =>
|
||||
post('/esign/sign-date', { contractId, signDate }).then(unwrap<any>()),
|
||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
|
||||
post('/esign/create', data),
|
||||
detail: (id: string) =>
|
||||
get(`/esign/${id}`).then(unwrap<any>()),
|
||||
status: (id: string) =>
|
||||
get(`/esign/${id}/status`).then(unwrap<any>()),
|
||||
evidence: (id: string) =>
|
||||
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
|
||||
cancel: (id: string) =>
|
||||
post(`/esign/${id}/cancel`),
|
||||
/** 上传线下签署扫描件 */
|
||||
uploadPaperSign: (files: File[]) => {
|
||||
const formData = new FormData()
|
||||
files.forEach(f => formData.append('files', f))
|
||||
return post('/esign/paper-upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
|
||||
},
|
||||
/** 线下手签登记 */
|
||||
paperSign: (data: Record<string, unknown>) =>
|
||||
post('/esign/paper-sign', data).then(unwrap<any>()),
|
||||
}
|
||||
|
||||
// ========== 提成奖金 ==========
|
||||
|
||||
export const commissionBonusApi = {
|
||||
/** 按月查询列表 + 汇总 */
|
||||
list: (month: string) =>
|
||||
get('/commission-bonus', { params: { month } }).then(unwrap<any>()),
|
||||
/** 新增单条 */
|
||||
create: (data: { employeeId: string; month: string; amount: number; remark?: string }) =>
|
||||
post('/commission-bonus', data).then(unwrap<any>()),
|
||||
/** 更新单条 */
|
||||
update: (id: string, data: { amount?: number; remark?: string }) =>
|
||||
put(`/commission-bonus/${id}`, data).then(unwrap<any>()),
|
||||
/** 删除单条 */
|
||||
remove: (id: string) =>
|
||||
del(`/commission-bonus/${id}`).then(unwrap<any>()),
|
||||
/** 批量导入 Excel */
|
||||
import: (file: File, month: string) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('month', month)
|
||||
return post('/commission-bonus/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
|
||||
},
|
||||
/** 模板下载 URL */
|
||||
templateUrl: '/api/v1/commission-bonus/template',
|
||||
}
|
||||
|
||||
// ========== 离职相关 ==========
|
||||
@@ -691,6 +815,18 @@ export const terminationApi = {
|
||||
/** 批量预览 */
|
||||
batchPreview: (items: Record<string, unknown>[]) =>
|
||||
post('/termination/batch/preview', { items }),
|
||||
/** 上传工会回执文件 */
|
||||
uploadUnionReceipt: (draftId: string, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return post(`/termination/draft/${draftId}/union-receipt/upload`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
|
||||
},
|
||||
/** 保存工会回执信息 */
|
||||
saveUnionReceipt: (draftId: string, data: Record<string, unknown>) =>
|
||||
post(`/termination/draft/${draftId}/union-receipt`, data).then(unwrap<any>()),
|
||||
/** 获取工会回执信息 */
|
||||
getUnionReceipt: (draftId: string) =>
|
||||
get(`/termination/draft/${draftId}/union-receipt`).then(unwrap<any>()),
|
||||
}
|
||||
|
||||
// ========== 制度相关 ==========
|
||||
@@ -974,6 +1110,22 @@ portalAxios.interceptors.request.use((config: any) => {
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
// 与管理端 api 实例一致:response interceptor 返回 response.data(后端 JSON body)
|
||||
// 401 时清除 token 并跳转登录页(避免 token 过期后显示空数据)
|
||||
portalAxios.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
localStorage.removeItem('portalToken')
|
||||
localStorage.removeItem('portalEmployee')
|
||||
// 避免在登录页重复跳转
|
||||
if (!window.location.pathname.includes('/portal/login') && !window.location.pathname.includes('/portal/auto-login')) {
|
||||
window.location.href = '/portal/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
const portalGet = ((url: string, config?: any) => portalAxios.get(url, config)) as any
|
||||
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
|
||||
|
||||
@@ -990,6 +1142,9 @@ export const portalApi = {
|
||||
/** 自动登录 */
|
||||
autoLogin: (token: string) =>
|
||||
portalGet('/auto-login', { params: { token } }).then(unwrap<any>()),
|
||||
/** 修改密码(员工自己) */
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
portalPost('/change-password', { oldPassword, newPassword }).then(unwrap<any>()),
|
||||
/** 生成自动登录令牌(管理端) */
|
||||
generateAutoLoginToken: (employeeId: string) =>
|
||||
post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()),
|
||||
@@ -1053,6 +1208,9 @@ export const portalApi = {
|
||||
/** 撤回离职申请 */
|
||||
resignationWithdraw: (id: string) =>
|
||||
portalPost(`/resignation/${id}/withdraw`).then(unwrap<any>()),
|
||||
/** 下载离职证明 */
|
||||
downloadCertificate: (id: string) =>
|
||||
portalGet(`/resignation/${id}/certificate`, { responseType: 'blob' }) as any,
|
||||
/** 我的休假申请列表 */
|
||||
myLeaves: () =>
|
||||
portalGet('/leaves').then(unwrap<any[]>()),
|
||||
@@ -1068,9 +1226,12 @@ export const portalApi = {
|
||||
/** 电子签署详情 */
|
||||
esignDetail: (id: string) =>
|
||||
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
||||
/** 签署操作 */
|
||||
signEsign: (id: string) =>
|
||||
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
|
||||
/** 发送签署验证码 */
|
||||
esignSendCode: (id: string) =>
|
||||
portalPost(`/esign/${id}/send-code`).then(unwrap<any>()),
|
||||
/** 签署操作(需验证码) */
|
||||
signEsign: (id: string, verifyCode: string) =>
|
||||
portalPost(`/esign/${id}/sign`, { verifyCode }).then(unwrap<any>()),
|
||||
/** 我的培训记录 */
|
||||
myTraining: () =>
|
||||
portalGet('/training').then(unwrap<any[]>()),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { lazy, ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* 带自动重试的懒加载。
|
||||
*
|
||||
* 部署后旧 chunk 文件名(带 hash)被删除,浏览器若缓存了旧 index.html
|
||||
* 仍会请求旧 chunk 导致 "Failed to fetch dynamically imported module"。
|
||||
* 此时自动刷新页面,让浏览器获取新 index.html 中的新 chunk 引用。
|
||||
*/
|
||||
export function lazyRetry<T extends ComponentType<any>>(
|
||||
factory: () => Promise<{ default: T } | { [key: string]: T }>,
|
||||
): React.LazyExoticComponent<T> {
|
||||
return lazy(async () => {
|
||||
try {
|
||||
const m = await factory()
|
||||
// 统一为 { default: T } 格式
|
||||
if ('default' in m) return { default: m.default }
|
||||
// named export 模式:取第一个导出作为 default
|
||||
const firstKey = Object.keys(m)[0]
|
||||
return { default: m[firstKey] }
|
||||
} catch (err: any) {
|
||||
// 仅对动态导入失败自动刷新,避免其他错误误触发
|
||||
if (
|
||||
err &&
|
||||
(err.message?.includes('Failed to fetch dynamically imported module') ||
|
||||
err.message?.includes('Importing a module script failed') ||
|
||||
err.message?.includes('error loading dynamically imported module'))
|
||||
) {
|
||||
// 防止无限刷新:用 sessionStorage 标记,仅刷新一次
|
||||
const key = 'lazyRetry:' + (err.message.match(/[\w-]+\.js/)?.[0] || 'unknown')
|
||||
if (!sessionStorage.getItem(key)) {
|
||||
sessionStorage.setItem(key, '1')
|
||||
window.location.reload()
|
||||
// 返回一个永不 resolve 的 Promise,等待页面刷新
|
||||
return new Promise<{ default: T }>(() => {})
|
||||
}
|
||||
sessionStorage.removeItem(key)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState, lazy, Suspense } from 'react'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { Bot, FileSearch, Scale, Sparkles, BookOpen, TrendingUp, Loader2 } from 'lucide-react'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { lazyRetry } from '../lib/lazyRetry'
|
||||
|
||||
const ChatTab = lazy(() => import('./ai-assistant/ChatTab').then(m => ({ default: m.ChatTab })))
|
||||
const PredictTab = lazy(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab })))
|
||||
const ReviewTab = lazy(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab })))
|
||||
const CaseTab = lazy(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab })))
|
||||
const KnowledgeTab = lazy(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab })))
|
||||
const HRReportTab = lazy(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab })))
|
||||
const ChatTab = lazyRetry(() => import('./ai-assistant/ChatTab').then(m => ({ default: m.ChatTab })))
|
||||
const PredictTab = lazyRetry(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab })))
|
||||
const ReviewTab = lazyRetry(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab })))
|
||||
const CaseTab = lazyRetry(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab })))
|
||||
const KnowledgeTab = lazyRetry(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab })))
|
||||
const HRReportTab = lazyRetry(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab })))
|
||||
|
||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
|
||||
|
||||
@@ -24,6 +26,7 @@ export default function AIAssistant() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>AI 智能助手,提供用工风险预测、案例分析、合规建议等智能服务。支持自然语言提问,系统自动匹配相关法规和案例。关联:风险预测数据来自花名册和合同信息。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
|
||||
@@ -54,6 +54,9 @@ export default function Attendance() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
考勤管理,支持班次设定、排班、出勤查询、月度报表和休假记录。流程:①设置班次 → ②排班 → ③每日打卡或导入考勤 → ④月度汇总。关联:考勤数据影响薪税管理的工资和加班费计算。
|
||||
</PageGuide>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarCheck className="h-5 w-5 text-primary" />
|
||||
@@ -107,7 +110,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [editItem, setEditItem] = useState<any>(null)
|
||||
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0 })
|
||||
const [editForm, setEditForm] = useState({ workDays: 0, lateCount: 0, earlyLeaveCount: 0, absentDays: 0, leaveDays: 0, overtimeHours: 0, overtimePay: 0, deductionAmount: 0 })
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -190,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
return await attendanceApi.manualCorrect(data)
|
||||
// 通过批量创建接口更新考勤确认记录(upsert 语义)
|
||||
return await attendanceApi.batchCreate({
|
||||
month: data.month,
|
||||
items: [{
|
||||
employeeId: data.employeeId,
|
||||
workDays: data.workDays,
|
||||
weekdayHours: 0,
|
||||
weekendHours: 0,
|
||||
holidayHours: 0,
|
||||
overtimePay: data.overtimePay,
|
||||
deductionAmount: data.deductionAmount || 0,
|
||||
}],
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('考勤记录已修改')
|
||||
@@ -418,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
<span>周末加班 {item.weekendHours}h</span>
|
||||
<span>法定加班 {item.holidayHours}h</span>
|
||||
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
||||
{item.deductionAmount > 0 && <span className="text-danger">扣款 ¥{item.deductionAmount?.toFixed(2)}</span>}
|
||||
</div>
|
||||
{item.disputeNote && (
|
||||
<div className="text-xs text-red-600 mt-1">异议说明:{item.disputeNote}</div>
|
||||
@@ -446,6 +462,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
leaveDays: item.leaveDays || 0,
|
||||
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
||||
overtimePay: item.overtimePay || 0,
|
||||
deductionAmount: item.deductionAmount || 0,
|
||||
})
|
||||
}}
|
||||
>
|
||||
@@ -507,6 +524,11 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.overtimePay}
|
||||
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="text-xs text-gray-500">考勤扣款(元)</label>
|
||||
<input type="number" min="0" step="0.01" className="w-full px-2 py-1.5 text-sm border rounded-md" value={editForm.deductionAmount}
|
||||
onChange={(e) => setEditForm({ ...editForm, deductionAmount: Number(e.target.value) })} placeholder="迟到/早退/旷工等扣款金额" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
||||
@@ -555,7 +577,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
|
||||
模板中「考勤与加班」Sheet 包含:姓名、身份证号、日期、考勤状态、上下班时间、各类加班时长。身份证号优先匹配,未填时用姓名匹配。加班时长为0的行只导入考勤。
|
||||
模板中「考勤与加班」Sheet 包含:姓名、证件号码、日期、考勤状态、上下班时间、各类加班时长。证件号码优先匹配,未填时用姓名匹配。加班时长为0的行只导入考勤。
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
CREATE: '创建',
|
||||
@@ -147,6 +148,7 @@ export default function AuditLog() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>审计日志,记录系统中所有关键操作,包括员工档案变更、合同签订、薪酬调整、权限变更等。支持按操作类型、时间范围、操作人筛选查看,用于合规追溯。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">系统操作日志</h1>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dashboardApi, calendarApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
|
||||
@@ -190,6 +191,7 @@ export default function Calendar() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>人事日历,集中展示入职、合同到期、试用期到期、 birthdays 等关键人事事件。支持按月/周视图查看,提前预警重要节点。关联:数据自动来自花名册和合同信息。</PageGuide>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Edit2, Trash2, Upload, Download, Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { commissionBonusApi, employeeApi } from '../lib/api-services'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Button from '../components/ui/Button'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
|
||||
export default function CommissionBonus() {
|
||||
const queryClient = useQueryClient()
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [search, setSearch] = useState('')
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editRecord, setEditRecord] = useState<any>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 列表 + 汇总
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['commission-bonus', month],
|
||||
queryFn: () => commissionBonusApi.list(month),
|
||||
})
|
||||
|
||||
// 在职员工列表(新增用)
|
||||
const { data: employees } = useQuery({
|
||||
queryKey: ['employees-active'],
|
||||
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
|
||||
enabled: showAdd,
|
||||
})
|
||||
|
||||
const records = data?.records || []
|
||||
const summary = data?.summary || { count: 0, totalBonus: 0, totalDeduction: 0, netAmount: 0 }
|
||||
|
||||
// 搜索过滤
|
||||
const filtered = records.filter((r: any) =>
|
||||
!search || r.employee?.name?.includes(search) || r.employee?.department?.includes(search)
|
||||
)
|
||||
|
||||
// 分页
|
||||
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
// 新增
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: { employeeId: string; month: string; amount: number; remark?: string }) =>
|
||||
commissionBonusApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
|
||||
setShowAdd(false)
|
||||
toast.success('已添加')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '添加失败'),
|
||||
})
|
||||
|
||||
// 更新
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: { id: string; amount?: number; remark?: string }) =>
|
||||
commissionBonusApi.update(data.id, { amount: data.amount, remark: data.remark }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
|
||||
setEditRecord(null)
|
||||
toast.success('已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
// 删除
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => commissionBonusApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
|
||||
toast.success('已删除')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
// 导入
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => commissionBonusApi.import(file, month),
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['commission-bonus'] })
|
||||
toast.success(`导入完成:新增 ${data.created},更新 ${data.updated},跳过 ${data.skipped}`)
|
||||
if (data.errors?.length > 0) {
|
||||
toast.error(`错误明细:${data.errors.slice(0, 3).map((e: any) => `第${e.row}行: ${e.message}`).join(';')}`)
|
||||
}
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '导入失败'),
|
||||
})
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) importMutation.mutate(file)
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
|
||||
const fmt = (n: number) => `¥${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
<p>按月管理员工提成奖金/扣款。金额正数为奖金,负数为扣款。数据可用于薪资批次"获取提成奖金"自动填充。</p>
|
||||
</PageGuide>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div>
|
||||
<Label>月份</Label>
|
||||
<Input type="month" value={month} onChange={(e) => { setMonth(e.target.value); setPage(1) }} className="w-40" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Label>搜索</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input value={search} onChange={(e) => { setSearch(e.target.value); setPage(1) }} placeholder="员工姓名/部门" className="pl-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button variant="secondary" onClick={() => window.open(commissionBonusApi.templateUrl, '_blank')}>
|
||||
<Download className="w-4 h-4 mr-1" />模板
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => fileRef.current?.click()} disabled={importMutation.isPending}>
|
||||
<Upload className="w-4 h-4 mr-1" />{importMutation.isPending ? '导入中...' : '批量导入'}
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
|
||||
<Button onClick={() => setShowAdd(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">记录数</div>
|
||||
<div className="text-2xl font-bold text-gray-900 mt-1">{summary.count}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">总奖金</div>
|
||||
<div className="text-2xl font-bold text-safe mt-1">{fmt(summary.totalBonus)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">总扣款</div>
|
||||
<div className="text-2xl font-bold text-danger mt-1">{fmt(summary.totalDeduction)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<div className="text-xs text-gray-500">净额</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${summary.netAmount >= 0 ? 'text-gray-900' : 'text-danger'}`}>{fmt(summary.netAmount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">员工</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">部门</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500">金额</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">备注</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">加载中...</td></tr>
|
||||
) : paged.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">{month} 无提成奖金记录</td></tr>
|
||||
) : paged.map((r: any) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 text-sm text-gray-900">{r.employee?.name || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-500">{r.employee?.department || '-'}</td>
|
||||
<td className={`px-4 py-2.5 text-sm text-right font-medium ${r.amount >= 0 ? 'text-safe' : 'text-danger'}`}>
|
||||
{r.amount >= 0 ? '+' : ''}{fmt(r.amount)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-gray-500">{r.remark || '-'}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<button onClick={() => setEditRecord(r)} className="p-1 text-gray-400 hover:text-primary" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`确认删除 ${r.employee?.name} 的提成奖金记录?`)) deleteMutation.mutate(r.id) }}
|
||||
className="p-1 text-gray-400 hover:text-danger ml-1" title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filtered.length > pageSize && (
|
||||
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={() => setPage(1)} />
|
||||
)}
|
||||
|
||||
{/* 新增弹窗 */}
|
||||
{showAdd && (
|
||||
<AddModal
|
||||
employees={employees || []}
|
||||
month={month}
|
||||
onClose={() => setShowAdd(false)}
|
||||
onSubmit={(data) => addMutation.mutate(data)}
|
||||
saving={addMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editRecord && (
|
||||
<EditModal
|
||||
record={editRecord}
|
||||
onClose={() => setEditRecord(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editRecord.id, ...data })}
|
||||
saving={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddModal({ employees, month, onClose, onSubmit, saving }: {
|
||||
employees: any[]
|
||||
month: string
|
||||
onClose: () => void
|
||||
onSubmit: (data: { employeeId: string; month: string; amount: number; remark?: string }) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [remark, setRemark] = useState('')
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="新增提成奖金" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>员工 *</Label>
|
||||
<Select value={employeeId} onChange={(e) => setEmployeeId(e.target.value)}>
|
||||
<option value="">请选择员工</option>
|
||||
{employees.map((e: any) => (
|
||||
<option key={e.id} value={e.id}>{e.name}({e.department})</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>月份</Label>
|
||||
<Input type="month" value={month} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>金额 *(正=奖金,负=扣款)</Label>
|
||||
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="如 5000 或 -200" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={remark} onChange={(e) => setRemark(e.target.value)} placeholder="选填" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
disabled={!employeeId || !amount || saving}
|
||||
onClick={() => onSubmit({ employeeId, month, amount: parseFloat(amount) || 0, remark: remark || undefined })}
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function EditModal({ record, onClose, onSubmit, saving }: {
|
||||
record: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: { amount?: number; remark?: string }) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [amount, setAmount] = useState(String(record.amount))
|
||||
const [remark, setRemark] = useState(record.remark || '')
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={`编辑 - ${record.employee?.name || ''}`} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>金额 *(正=奖金,负=扣款)</Label>
|
||||
<Input type="number" value={amount} onChange={(e) => setAmount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
disabled={!amount || saving}
|
||||
onClick={() => onSubmit({ amount: parseFloat(amount) || 0, remark: remark || undefined })}
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
const FILE_TYPES = [
|
||||
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
|
||||
@@ -82,6 +83,7 @@ export default function CompanyFiles() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>公司文件管理,集中存储和管理公司制度、合同模板、政策文件等。支持分类管理、在线预览、权限控制。关联:员工可在员工端查看已授权的公司文件。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-semibold">公司备用文件</h1>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Search, Paperclip, Trash2, X, FileText, Download } from 'lucide-react'
|
||||
import { Plus, Search, Paperclip, Trash2, X, FileText, Download, AlertTriangle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -78,7 +79,7 @@ export default function Contracts() {
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">合同管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">集中管理劳动合同、签署状态及到期风险</p>
|
||||
<p className="mt-1 text-sm text-gray-500">集中管理用工关系(劳动合同/劳务协议/实习协议)、签署状态及到期风险</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddModal(true)}>
|
||||
@@ -217,12 +218,30 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
loading: boolean
|
||||
error: any
|
||||
}) {
|
||||
// 拉取组织架构部门列表,用于部门下拉选择
|
||||
const { data: departments = [] } = useQuery({
|
||||
queryKey: ['departments'],
|
||||
queryFn: () => api.get('/departments').then(r => r.data),
|
||||
})
|
||||
// 构建部门树形下拉选项(带层级缩进)
|
||||
const deptOptions: { id: string; label: string; level: number }[] = []
|
||||
const buildDeptOptions = (items: any[], parentId: string | null, level: number) => {
|
||||
items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => {
|
||||
deptOptions.push({ id: d.id, label: d.name, level })
|
||||
buildDeptOptions(items, d.id, level + 1)
|
||||
})
|
||||
}
|
||||
buildDeptOptions(departments, null, 0)
|
||||
// 手机号查重
|
||||
const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
department: '',
|
||||
position: '',
|
||||
hireDate: '',
|
||||
monthlySalary: '',
|
||||
baseSalary: '',
|
||||
performanceSalary: '0',
|
||||
gender: '男' as '男' | '女',
|
||||
phone: '',
|
||||
isPregnant: false,
|
||||
@@ -244,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
position: form.position || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary,
|
||||
baseSalary: form.baseSalary,
|
||||
performanceSalary: form.performanceSalary || '0',
|
||||
gender: form.gender,
|
||||
phone: form.phone || undefined,
|
||||
isPregnant: form.isPregnant,
|
||||
@@ -259,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
contractYears: form.contractYears,
|
||||
probationMonths: form.probationMonths,
|
||||
probationSalary: form.probationSalary,
|
||||
baseSalary: parseFloat(form.baseSalary) || 0,
|
||||
performanceSalary: parseFloat(form.performanceSalary) || 0,
|
||||
}
|
||||
}
|
||||
onSubmit(data)
|
||||
@@ -280,7 +303,12 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>部门 *</Label>
|
||||
<Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" />
|
||||
<Select value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}>
|
||||
<option value="">请选择部门</option>
|
||||
{deptOptions.map(d => (
|
||||
<option key={d.id} value={d.label}>{' '.repeat(d.level)}{d.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>月工资 *</Label>
|
||||
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label>基本工资 *</Label>
|
||||
<Input type="number" value={form.baseSalary} onChange={(e) => {
|
||||
const base = parseFloat(e.target.value) || 0
|
||||
const perf = parseFloat(form.performanceSalary) || 0
|
||||
setForm({ ...form, baseSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
|
||||
}} placeholder="元" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>绩效工资</Label>
|
||||
<Input type="number" value={form.performanceSalary} onChange={(e) => {
|
||||
const perf = parseFloat(e.target.value) || 0
|
||||
const base = parseFloat(form.baseSalary) || 0
|
||||
setForm({ ...form, performanceSalary: e.target.value, monthlySalary: base + perf > 0 ? String(base + perf) : '' })
|
||||
}} placeholder="可为0" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>月薪合计</Label>
|
||||
<div className="text-xs text-gray-600 py-1.5">¥{(parseFloat(form.baseSalary) || 0) + (parseFloat(form.performanceSalary) || 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -310,9 +356,24 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
||||
</div>
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
|
||||
<Input value={form.phone} onChange={(e) => {
|
||||
const phone = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||
setForm({ ...form, phone })
|
||||
setPhoneDuplicate(null)
|
||||
if (phone.length === 11) {
|
||||
employeeApi.checkPhone(phone).then((data: { exists: boolean; employee?: any }) => {
|
||||
setPhoneDuplicate(data)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}} placeholder="选填" maxLength={11} />
|
||||
</div>
|
||||
</div>
|
||||
{phoneDuplicate?.exists && (
|
||||
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>该手机号已存在:{phoneDuplicate.employee?.name}({phoneDuplicate.employee?.department}),请确认是否重复录入</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 特殊状态 */}
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -279,6 +279,9 @@ export default function Dashboard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
工作台展示企业人力全局数据和待办事项,包括在管员工数、当月人力成本、合同风险、审批待办等。可切换概览、人力成本、风险、任务等视图。点击各统计卡片可快速跳转到对应详情页。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1130,7 +1133,9 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => (
|
||||
{filteredTodos.slice((todoPage - 1) * todoPageSize, todoPage * todoPageSize).map((todo) => {
|
||||
const hasValidUrl = todo.actionUrl && todo.actionUrl !== '/' && todo.actionUrl !== ''
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||
@@ -1142,40 +1147,71 @@ export default function Dashboard() {
|
||||
onChange={() => toggleSelect(todo.id)}
|
||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||
/>
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{todo.employeeName && (
|
||||
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
|
||||
)}
|
||||
{todo.employeeDepartment && (
|
||||
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-700">{todo.title}</span>
|
||||
{todo.priority && priorityConfig[todo.priority] && (
|
||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||
{priorityConfig[todo.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{todo.estimatedLoss > 0 && (
|
||||
<span className="text-xs text-red-600 font-medium">损失 {fmt(todo.estimatedLoss)}</span>
|
||||
)}
|
||||
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
|
||||
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`}</span>
|
||||
)}
|
||||
{hasValidUrl ? (
|
||||
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{todo.employeeName && (
|
||||
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
|
||||
)}
|
||||
{todo.employeeDepartment && (
|
||||
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-700">{todo.title}</span>
|
||||
{todo.priority && priorityConfig[todo.priority] && (
|
||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||
{priorityConfig[todo.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{todo.estimatedLoss > 0 && (
|
||||
<span className="text-xs text-red-600 font-medium">损失 {fmt(todo.estimatedLoss)}</span>
|
||||
)}
|
||||
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
|
||||
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2.5 flex-1">
|
||||
<TodoIcon type={todo.type} level={todo.level} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{todo.employeeName && (
|
||||
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
|
||||
)}
|
||||
{todo.employeeDepartment && (
|
||||
<span className="text-xs text-gray-400">{todo.employeeDepartment}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-700">{todo.title}</span>
|
||||
{todo.priority && priorityConfig[todo.priority] && (
|
||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||
{priorityConfig[todo.priority].label}
|
||||
</span>
|
||||
)}
|
||||
{todo.estimatedLoss > 0 && (
|
||||
<span className="text-xs text-red-600 font-medium">损失 {fmt(todo.estimatedLoss)}</span>
|
||||
)}
|
||||
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
|
||||
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}天`}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={todo.actionUrl}
|
||||
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
立刻办理 →
|
||||
</Link>
|
||||
{hasValidUrl && (
|
||||
<Link
|
||||
to={todo.actionUrl}
|
||||
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
立刻办理 →
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => resolveMutation.mutate(todo.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
@@ -1194,7 +1230,7 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
|
||||
</>
|
||||
|
||||
+806
-42
@@ -1,22 +1,33 @@
|
||||
import { useState } from 'react'
|
||||
/**
|
||||
* 待签合同 / 电子签署管理页面
|
||||
* - Tab1 待签合同:按员工聚合展示所有未签署文件,支持催办(生成二维码/链接)
|
||||
* - Tab2 签署记录:全部签署记录列表(状态/场景/签署方式筛选)
|
||||
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
||||
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
|
||||
* - 签署详情(含证据链查看)
|
||||
* - 取消签署
|
||||
*/
|
||||
import { useState, useRef, useEffect, Fragment } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
|
||||
import { esignApi, rosterApi } from '../lib/api-services'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck, Bell, Copy, Check, ChevronDown, ChevronRight, User } from 'lucide-react'
|
||||
import { esignApi, employeeApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import Modal from '../components/ui/Modal'
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
|
||||
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700' },
|
||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
||||
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
|
||||
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
|
||||
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500' },
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700', icon: <Clock className="w-3 h-3" /> },
|
||||
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
|
||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
|
||||
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
|
||||
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -27,17 +38,47 @@ const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
|
||||
}
|
||||
|
||||
const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' },
|
||||
PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' },
|
||||
}
|
||||
|
||||
export default function ESign() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterScene, setFilterScene] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showPaperSign, setShowPaperSign] = useState(false)
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [searchParams] = useSearchParams()
|
||||
const [formData, setFormData] = useState({
|
||||
employeeId: '',
|
||||
scene: 'CONTRACT',
|
||||
documentTitle: '',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 从 URL 参数读取,自动打开线下手签登记
|
||||
useEffect(() => {
|
||||
const action = searchParams.get('action')
|
||||
if (action === 'paper-sign') {
|
||||
setShowPaperSign(true)
|
||||
// 预填表单
|
||||
const employeeId = searchParams.get('employeeId')
|
||||
const scene = searchParams.get('scene') as string
|
||||
const documentTitle = searchParams.get('documentTitle') as string
|
||||
if (employeeId || scene || documentTitle) {
|
||||
setFormData({
|
||||
employeeId: employeeId || '',
|
||||
scene: scene || 'CONTRACT',
|
||||
documentTitle: documentTitle || '',
|
||||
remark: '花名册操作跳转登记',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['esign-records', filterStatus, filterScene],
|
||||
queryFn: async () => {
|
||||
@@ -45,24 +86,46 @@ export default function ESign() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: rosterData } = useQuery<any>({
|
||||
queryKey: ['roster-for-esign'],
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
const { data: pendingList = [], isLoading: pendingLoading } = useQuery<any[]>({
|
||||
queryKey: ['esign-pending'],
|
||||
queryFn: async () => {
|
||||
return await rosterApi.list({ search: '', page: 1, pageSize: 200 } as any) as any
|
||||
return await esignApi.pending()
|
||||
},
|
||||
})
|
||||
|
||||
/** 催办(生成自动登录链接) */
|
||||
const remindMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => esignApi.remind(employeeId) as any,
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
|
||||
toast.success(`催办链接已生成,可复制或展示二维码给员工`, {
|
||||
description: data?.phone ? `员工手机:${data.phone}` : '',
|
||||
})
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '催办失败'),
|
||||
})
|
||||
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-esign'],
|
||||
queryFn: async () => {
|
||||
return await employeeApi.allLite({ status: 'ACTIVE' })
|
||||
},
|
||||
enabled: showCreate,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: { employeeId: string; documentTitle: string; remark?: string }) =>
|
||||
mutationFn: async (data: { employeeId: string; scene: string; documentTitle: string; remark?: string }) =>
|
||||
esignApi.create(data) as any,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
setShowCreate(false)
|
||||
setFormData({ employeeId: '', documentTitle: '', remark: '' })
|
||||
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
|
||||
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
|
||||
toast.success('签署记录已创建,员工可在员工端查看并签署')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error?.message || '创建失败')
|
||||
},
|
||||
onError: () => toast.error('创建失败'),
|
||||
})
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
@@ -87,32 +150,60 @@ export default function ESign() {
|
||||
createMutation.mutate(formData)
|
||||
}
|
||||
|
||||
// ===== 签署详情视图 =====
|
||||
if (detailId) {
|
||||
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
管理员工合同、协议、规章等文件的签署。未签署的按员工聚合在「待签合同」中催办;已签署的记录在「签署记录」中查看。
|
||||
</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenTool className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">电子签署</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">对接易签宝实现在线合同签署</p>
|
||||
<h1 className="text-base font-semibold">待签合同</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">合同签署管理,验证码确认 + 证据链留存</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageGuide>
|
||||
通过易签宝平台发起电子合同签署,员工在线完成签署后自动回传签署状态和PDF文件。
|
||||
<span className="text-amber-600"> 当前为框架预留阶段,对接易签宝API后将自动启用在线签署功能。</span>
|
||||
</PageGuide>
|
||||
|
||||
<InlineAlert type="info" className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">对接状态:框架已就绪,待接入易签宝API</span>
|
||||
<div className="mt-1 text-xs">
|
||||
需要提供易签宝的 AppId、AppSecret 和 API 基础地址。对接后可实现:
|
||||
① HR在系统发起签署 → ② 调用易签宝创建签署流程 → ③ 员工收到签署链接 → ④ 签署完成自动回调更新状态 → ⑤ 合同自动关联电子版PDF
|
||||
</div>
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'pending' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
待签合同
|
||||
{pendingList.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700">{pendingList.length}</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('records')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'records' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
签署记录
|
||||
{records.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">{records.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</InlineAlert>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />发起电子签署
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowPaperSign(true)}>
|
||||
<FileCheck className="w-4 h-4 mr-1" />线下手签登记
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Tab1: 待签合同(按员工聚合) ===== */}
|
||||
{activeTab === 'pending' && (
|
||||
<PendingTab pendingList={pendingList} loading={pendingLoading} onRemind={(empId) => remindMutation.mutate(empId)} remindLoading={remindMutation.isPending} remindData={remindMutation.data} onDetail={(id) => setDetailId(id)} />
|
||||
)}
|
||||
|
||||
{/* ===== Tab2: 签署记录 ===== */}
|
||||
{activeTab === 'records' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
@@ -132,9 +223,6 @@ export default function ESign() {
|
||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />发起签署
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 签署记录列表 */}
|
||||
@@ -151,6 +239,7 @@ export default function ESign() {
|
||||
<th className="py-2 px-3 text-left">文件标题</th>
|
||||
<th className="py-2 px-3 text-left">签署人</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">签署方式</th>
|
||||
<th className="py-2 px-3 text-left">状态</th>
|
||||
<th className="py-2 px-3 text-left">发起时间</th>
|
||||
<th className="py-2 px-3 text-left">完成时间</th>
|
||||
@@ -165,7 +254,9 @@ export default function ESign() {
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="font-medium truncate max-w-[200px]">{r.documentTitle}</span>
|
||||
<button className="font-medium truncate max-w-[200px] text-left hover:text-primary" onClick={() => setDetailId(r.id)}>
|
||||
{r.documentTitle}
|
||||
</button>
|
||||
{r.scene && SCENE_CONFIG[r.scene] && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
|
||||
)}
|
||||
@@ -175,16 +266,32 @@ export default function ESign() {
|
||||
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>{statusCfg.label}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||
{(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
|
||||
{statusCfg.icon}{statusCfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => setDetailId(r.id)} title="查看详情">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{r.status === 'COMPLETED' && r.signedPdfUrl && (
|
||||
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-0.5">
|
||||
<ExternalLink className="w-3 h-3" />查看PDF
|
||||
<ExternalLink className="w-3 h-3" />PDF
|
||||
</a>
|
||||
)}
|
||||
{r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && (
|
||||
<a href={(r as any).scanFileUrls[0]?.url} target="_blank" rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline flex items-center gap-0.5">
|
||||
<ExternalLink className="w-3 h-3" />扫描件
|
||||
</a>
|
||||
)}
|
||||
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
||||
@@ -208,13 +315,15 @@ export default function ESign() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 发起签署 Modal */}
|
||||
{showCreate && (
|
||||
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择员工并填写文件标题,系统将创建签署记录。对接易签宝后,将自动生成签署链接并发送给员工。
|
||||
选择员工和签署场景,系统将自动从模板渲染文件内容,创建签署记录并发起证据链。员工可在员工端查看文件并通过验证码签署。
|
||||
</InlineAlert>
|
||||
<div>
|
||||
<Label>签署员工 *</Label>
|
||||
@@ -224,11 +333,31 @@ export default function ESign() {
|
||||
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
|
||||
>
|
||||
<option value="">请选择员工</option>
|
||||
{rosterData?.items?.filter((e: any) => e.status === 'ACTIVE').map((emp: any) => (
|
||||
{rosterData.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>签署场景 *</Label>
|
||||
<select
|
||||
className="h-9 w-full 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={formData.scene}
|
||||
onChange={(e) => {
|
||||
const scene = e.target.value
|
||||
const defaultTitle: Record<string, string> = {
|
||||
CONTRACT: '劳动合同书',
|
||||
RESIGNATION: '协商解除劳动合同协议书',
|
||||
POLICY: '规章制度签收确认书',
|
||||
PAYSLIP: '工资条确认书',
|
||||
ONBOARDING: '入职文件签署',
|
||||
}
|
||||
setFormData({ ...formData, scene, documentTitle: defaultTitle[scene] || '' })
|
||||
}}
|
||||
>
|
||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>文件标题 *</Label>
|
||||
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
|
||||
@@ -239,6 +368,13 @@ export default function ESign() {
|
||||
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
|
||||
<div className="font-medium text-gray-500 mb-1">签署流程说明</div>
|
||||
<div>1. 创建后员工在员工端「我的电子签署」中看到待签文件</div>
|
||||
<div>2. 员工点击「签署」→ 获取手机验证码 → 输入验证码确认</div>
|
||||
<div>3. 签署完成自动记录证据链(IP/时间戳/验证码),可在此详情页查看</div>
|
||||
{formData.scene === 'CONTRACT' && <div>4. 关联合同的签署完成后自动回写合同签署方式为「电子签署」</div>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
|
||||
@@ -248,6 +384,634 @@ export default function ESign() {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 线下手签登记 Modal */}
|
||||
{showPaperSign && (
|
||||
<PaperSignModal
|
||||
initialEmployeeId={formData.employeeId}
|
||||
initialScene={formData.scene as any}
|
||||
initialDocumentTitle={formData.documentTitle}
|
||||
initialRemark={formData.remark}
|
||||
onClose={() => { setShowPaperSign(false); setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
setShowPaperSign(false)
|
||||
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 线下手签登记 Modal =====
|
||||
function PaperSignModal({ onClose, onSuccess, initialEmployeeId, initialScene, initialDocumentTitle, initialRemark }: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
initialEmployeeId?: string
|
||||
initialScene?: string
|
||||
initialDocumentTitle?: string
|
||||
initialRemark?: string
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [form, setForm] = useState({
|
||||
employeeId: initialEmployeeId || '',
|
||||
scene: initialScene || 'CONTRACT',
|
||||
documentTitle: initialDocumentTitle || '',
|
||||
signedAt: new Date().toISOString().slice(0, 10),
|
||||
signedLocation: '',
|
||||
witnessName: '',
|
||||
witnessPhone: '',
|
||||
remark: initialRemark || '',
|
||||
})
|
||||
const [scanFiles, setScanFiles] = useState<Array<{ name: string; url: string }>>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-paper-sign'],
|
||||
queryFn: () => employeeApi.allLite({ status: 'ACTIVE' }),
|
||||
})
|
||||
|
||||
const paperSignMutation = useMutation({
|
||||
mutationFn: (data: any) => esignApi.paperSign(data),
|
||||
onSuccess: () => {
|
||||
toast.success('线下手签登记成功,证据链已记录')
|
||||
onSuccess()
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '登记失败'),
|
||||
})
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
const allowedExts = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
|
||||
const validFiles: File[] = []
|
||||
for (const file of Array.from(files)) {
|
||||
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||
if (!allowedExts.includes(ext)) {
|
||||
toast.error(`不支持的格式: ${file.name}`)
|
||||
continue
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`文件过大: ${file.name}(最大10MB)`)
|
||||
continue
|
||||
}
|
||||
validFiles.push(file)
|
||||
}
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await esignApi.uploadPaperSign(validFiles)
|
||||
setScanFiles(prev => [...prev, ...res])
|
||||
toast.success(`已上传 ${res.length} 个文件`)
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error?.message || '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.employeeId) { toast.error('请选择员工'); return }
|
||||
if (!form.documentTitle.trim()) { toast.error('请填写文件标题'); return }
|
||||
if (!form.signedAt) { toast.error('请选择签署日期'); return }
|
||||
if (scanFiles.length === 0) { toast.error('请至少上传一份签署扫描件'); return }
|
||||
paperSignMutation.mutate({ ...form, scanFileUrls: scanFiles })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={true} onClose={onClose} title="线下手签登记" size="md">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
登记员工线下手签的合同/协议,上传签署后的纸质文件扫描件。系统自动记录证据链(签署日期、地点、见证人、扫描件),与电子签署具有同等证据效力。
|
||||
</InlineAlert>
|
||||
<div>
|
||||
<Label>签署员工 *</Label>
|
||||
<select
|
||||
className="h-9 w-full 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={form.employeeId}
|
||||
onChange={(e) => setForm({ ...form, employeeId: e.target.value })}
|
||||
>
|
||||
<option value="">请选择员工</option>
|
||||
{rosterData.map((emp: any) => (
|
||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>文件类型 *</Label>
|
||||
<select
|
||||
className="h-9 w-full 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={form.scene}
|
||||
onChange={(e) => {
|
||||
const scene = e.target.value
|
||||
const defaultTitle: Record<string, string> = {
|
||||
CONTRACT: '劳动合同书',
|
||||
RESIGNATION: '解除劳动合同协议书',
|
||||
POLICY: '规章制度签收确认书',
|
||||
PAYSLIP: '工资条确认书',
|
||||
ONBOARDING: '入职文件',
|
||||
}
|
||||
setForm({ ...form, scene, documentTitle: defaultTitle[scene] || '' })
|
||||
}}
|
||||
>
|
||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>文件标题 *</Label>
|
||||
<Input value={form.documentTitle} onChange={(e) => setForm({ ...form, documentTitle: e.target.value })}
|
||||
placeholder="如:2024年度劳动合同" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label>签署日期 *</Label>
|
||||
<Input type="date" max={new Date().toISOString().slice(0, 10)} value={form.signedAt} onChange={(e) => setForm({ ...form, signedAt: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>签署地点</Label>
|
||||
<Input value={form.signedLocation} onChange={(e) => setForm({ ...form, signedLocation: e.target.value })}
|
||||
placeholder="如:公司会议室" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>见证人姓名</Label>
|
||||
<Input value={form.witnessName} onChange={(e) => setForm({ ...form, witnessName: e.target.value })}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>见证人手机号</Label>
|
||||
<Input value={form.witnessPhone} onChange={(e) => setForm({ ...form, witnessPhone: e.target.value })}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>签署扫描件 *</Label>
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-md p-4 text-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jpg,.jpeg,.png,.pdf,.bmp,.webp,.tiff,.tif"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
<Upload className="w-4 h-4 inline mr-1" />
|
||||
{uploading ? '上传中...' : '点击上传扫描件'}
|
||||
</button>
|
||||
<div className="text-xs text-gray-400 mt-1">支持 JPG/PNG/PDF/BMP/WEBP/TIFF,单个最大10MB</div>
|
||||
</div>
|
||||
{scanFiles.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{scanFiles.map((f, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-2 py-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
|
||||
<button
|
||||
onClick={() => setScanFiles(prev => prev.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-danger"
|
||||
>删除</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })}
|
||||
placeholder="可选" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={paperSignMutation.isPending || uploading}>
|
||||
{paperSignMutation.isPending ? '登记中...' : '确认登记'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 签署详情组件 =====
|
||||
function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
const { data: detail, isLoading } = useQuery<any>({
|
||||
queryKey: ['esign-detail', id],
|
||||
queryFn: () => esignApi.detail(id),
|
||||
})
|
||||
const { data: evidence = [] } = useQuery<any[]>({
|
||||
queryKey: ['esign-evidence', id],
|
||||
queryFn: () => esignApi.evidence(id),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
|
||||
<ChevronLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
<Card className="p-6"><div className="text-center text-gray-400">加载中...</div></Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
|
||||
<ChevronLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
<Card className="p-6"><div className="text-center text-gray-400">记录不存在</div></Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusCfg = STATUS_CONFIG[detail.status] || STATUS_CONFIG.PENDING
|
||||
const sceneCfg = SCENE_CONFIG[detail.scene] || SCENE_CONFIG.CONTRACT
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
|
||||
<ChevronLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold">{detail.documentTitle}</h2>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${sceneCfg.color}`}>{sceneCfg.label}</span>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||
{(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">签署人:{detail.employee?.name} · {detail.employee?.department}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs ${statusCfg.color}`}>
|
||||
{statusCfg.icon}{statusCfg.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-400">发起时间</div>
|
||||
<div className="text-gray-700">{new Date(detail.createdAt).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">完成时间</div>
|
||||
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
</div>
|
||||
{detail.signMethod === 'PAPER' ? (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-gray-400">签署日期</div>
|
||||
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">签署地点</div>
|
||||
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">见证人</div>
|
||||
<div className="text-gray-700">{detail.witnessName || '—'} {detail.witnessPhone ? `· ${detail.witnessPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : ''}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-gray-400">过期时间</div>
|
||||
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400">员工手机</div>
|
||||
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{detail.remark && (
|
||||
<div className="col-span-2">
|
||||
<div className="text-gray-400">备注</div>
|
||||
<div className="text-gray-700">{detail.remark}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 线下手签扫描件 */}
|
||||
{detail.signMethod === 'PAPER' && detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<FileCheck className="w-4 h-4 text-orange-500" />签署扫描件
|
||||
<span className="text-xs text-gray-400 font-normal">({(detail.scanFileUrls as any[]).length}份)</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
|
||||
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
|
||||
<ExternalLink className="w-3 h-3 text-gray-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 文件内容预览 */}
|
||||
{detail.documentContent && (
|
||||
<Card className="p-5">
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<FileText className="w-4 h-4 text-gray-400" />文件内容
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 max-h-96 overflow-y-auto bg-gray-50 rounded p-3 whitespace-pre-wrap border">
|
||||
{detail.documentContent}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 证据链 */}
|
||||
<Card className="p-5">
|
||||
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||
<Shield className="w-4 h-4 text-primary" />证据链记录
|
||||
<span className="text-xs text-gray-400 font-normal">({evidence.length}条证据链)</span>
|
||||
</div>
|
||||
{evidence.length === 0 ? (
|
||||
<div className="text-xs text-gray-400 text-center py-4">暂无证据链记录</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{evidence.map((ev: any, idx: number) => {
|
||||
const events = ev.events as any[]
|
||||
return (
|
||||
<div key={ev.id} className="border rounded-md p-3">
|
||||
<div className="text-xs text-gray-400 mb-2">证据链 #{idx + 1} · 创建于 {new Date(ev.createdAt).toLocaleString('zh-CN')}</div>
|
||||
<div className="space-y-2">
|
||||
{events?.map((event: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 text-xs">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="text-gray-700 font-medium">{event.action}</div>
|
||||
<div className="text-gray-400 mt-0.5">
|
||||
{new Date(event.timestamp).toLocaleString('zh-CN')}
|
||||
{event.ip && ` · IP: ${event.ip}`}
|
||||
{event.location && ` · ${event.location}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 mt-2 font-mono">hash: {ev.hash?.slice(0, 32)}...</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 待签合同 Tab — 按员工聚合展示待签文件,支持催办
|
||||
*/
|
||||
function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData, onDetail }: {
|
||||
pendingList: any[]
|
||||
loading: boolean
|
||||
onRemind: (employeeId: string) => void
|
||||
remindLoading: boolean
|
||||
remindData: any
|
||||
onDetail: (id: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
|
||||
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [signDateEditing, setSignDateEditing] = useState<string | null>(null)
|
||||
const [signDateValue, setSignDateValue] = useState('')
|
||||
const [signDateSaving, setSignDateSaving] = useState(false)
|
||||
|
||||
/** 保存签署日期 */
|
||||
const handleSaveSignDate = async (contractId: string) => {
|
||||
if (!signDateValue) { toast.error('请选择签署日期'); return }
|
||||
setSignDateSaving(true)
|
||||
try {
|
||||
await esignApi.signDate(contractId, signDateValue)
|
||||
toast.success('签署日期已登记')
|
||||
setSignDateEditing(null)
|
||||
setSignDateValue('')
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error?.message || '登记失败')
|
||||
} finally {
|
||||
setSignDateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (url: string) => {
|
||||
navigator.clipboard?.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Card><div className="text-center py-8 text-gray-400">加载中...</div></Card>
|
||||
}
|
||||
|
||||
if (pendingList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
||||
所有合同均已签署,暂无待签项
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8"></th>
|
||||
<th className="py-2 px-3 text-left">员工</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">手机号</th>
|
||||
<th className="py-2 px-3 text-left">待签内容</th>
|
||||
<th className="py-2 px-3 text-center">数量</th>
|
||||
<th className="py-2 px-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingList.map((emp: any) => {
|
||||
const expanded = expandedEmp === emp.employeeId
|
||||
return (
|
||||
<Fragment key={emp.employeeId}>
|
||||
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setExpandedEmp(expanded ? null : emp.employeeId)}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{emp.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.department || '—'}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.phone || '—'}</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{emp.pendingItems.map((item: any, idx: number) => {
|
||||
const sceneCfg = SCENE_CONFIG[item.scene]
|
||||
const methodCfg = SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC
|
||||
return (
|
||||
<span key={idx} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${sceneCfg ? sceneCfg.color : 'bg-gray-100 text-gray-600'}`} title={item.title}>
|
||||
{sceneCfg ? sceneCfg.label : item.title}
|
||||
<span className={`px-1 rounded text-[10px] ${methodCfg.color}`}>{methodCfg.label}</span>
|
||||
{item.type === 'contract' && <span className="text-[10px] text-gray-400">未登记</span>}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700 font-medium">
|
||||
{emp.pendingItems.length}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setRemindEmpId(emp.employeeId)
|
||||
onRemind(emp.employeeId)
|
||||
}}
|
||||
disabled={remindLoading && remindEmpId === emp.employeeId}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5 mr-1" />
|
||||
{remindLoading && remindEmpId === emp.employeeId ? '生成中...' : '催办'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* 展开行:待签文件列表 */}
|
||||
{expanded && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td></td>
|
||||
<td colSpan={5} className="py-2 px-3">
|
||||
<div className="space-y-1.5">
|
||||
{emp.pendingItems.map((item: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-white rounded-md border text-xs">
|
||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{SCENE_CONFIG[item.scene] && (
|
||||
<span className={`px-1.5 py-0.5 rounded ${SCENE_CONFIG[item.scene].color}`}>{SCENE_CONFIG[item.scene].label}</span>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 rounded ${(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||
{(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||
</span>
|
||||
<span className="text-gray-400">{new Date(item.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
{item.type === 'esign' && item.recordId && (
|
||||
<button className="text-primary hover:underline ml-auto" onClick={() => onDetail(item.recordId)}>
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
{item.type === 'contract' && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{signDateEditing === item.contractId ? (
|
||||
<>
|
||||
<input
|
||||
type="date"
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
value={signDateValue}
|
||||
onChange={(e) => setSignDateValue(e.target.value)}
|
||||
className="h-7 px-2 text-xs border rounded"
|
||||
/>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
disabled={signDateSaving}
|
||||
onClick={() => handleSaveSignDate(item.contractId)}
|
||||
>
|
||||
{signDateSaving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs text-gray-400 hover:text-gray-600"
|
||||
onClick={() => { setSignDateEditing(null); setSignDateValue('') }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-gray-400">未登记签署日期</span>
|
||||
<button
|
||||
className="text-xs text-primary hover:underline"
|
||||
onClick={() => {
|
||||
setSignDateEditing(item.contractId)
|
||||
setSignDateValue(new Date().toISOString().slice(0, 10))
|
||||
}}
|
||||
>
|
||||
登记签署日期
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 催办结果弹窗:二维码 + 链接 */}
|
||||
{remindData && remindEmpId && (
|
||||
<Modal open onClose={() => { setRemindEmpId(null); }} title="催办 — 发送给员工" size="sm">
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 bg-white rounded-xl border-2 border-gray-100 shadow-sm">
|
||||
<QRCodeSVG value={remindData.url} size={200} level="M" />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||
员工 <span className="font-medium">{remindData.employeeName}</span> 扫码后自动登录员工端签署页面
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">链接 24 小时内有效</p>
|
||||
<div className="mt-3 w-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<span className="text-xs text-gray-500 flex-1 truncate">{remindData.url}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(remindData.url)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function EmployeeBenefits() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">参保人员({enrollments.length}人)</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
|
||||
<Users className="w-3.5 h-3.5 mr-1" />批量参保
|
||||
<Users className="w-3.5 h-3.5 mr-1" />为员工添加福利
|
||||
</Button>
|
||||
</div>
|
||||
{enrollLoading ? (
|
||||
@@ -393,7 +393,7 @@ export default function EmployeeBenefits() {
|
||||
|
||||
{/* 批量参保 Modal */}
|
||||
{showEnrollModal && (
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="批量参保" size="lg">
|
||||
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
|
||||
<div className="space-y-3">
|
||||
<InlineAlert type="info">
|
||||
选择需要参保的员工,设置生效月份后点击「确认参保」。
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function Evidence() {
|
||||
)}
|
||||
<span className="text-sm font-medium">
|
||||
{verifyResult?.invalid === 0
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改`
|
||||
? `全部 ${verifyResult?.total || 0} 条证据链验证通过,数据完整无篡改${verifyResult?.repaired ? `(自动修复 ${verifyResult.repaired} 条历史数据)` : ''}`
|
||||
: `${verifyResult?.valid || 0} 条通过,${verifyResult?.invalid || 0} 条异常,请检查`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
const LEAVE_TYPE_MAP: Record<string, string> = {
|
||||
SICK: '病假',
|
||||
@@ -75,6 +76,7 @@ export default function LeaveApproval() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>请假审批,处理员工请假申请,支持事假、病假、年假、婚假等类型。流程:员工提交申请 → 主管审批 → HR 备案 → 同步考勤。关联:审批通过后自动更新考勤记录。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import { useState, lazy, Suspense } from 'react'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Layers, Wallet, LayoutTemplate, Clock, Receipt, Loader2 } from 'lucide-react'
|
||||
import { Layers, Wallet, LayoutTemplate, Receipt, Loader2 } from 'lucide-react'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { lazyRetry } from '../lib/lazyRetry'
|
||||
|
||||
const BatchManager = lazy(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
||||
const TemplateManager = lazy(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
||||
const OvertimeCalculator = lazy(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
|
||||
const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
|
||||
const BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
||||
const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
||||
const PayslipManager = lazyRetry(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
|
||||
|
||||
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
|
||||
type Tab = 'batch' | 'template' | 'payslip'
|
||||
|
||||
export default function Money() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialEmployeeId = searchParams.get('employeeId') || ''
|
||||
const [tab, setTab] = useState<Tab>(initialEmployeeId ? 'payslip' : 'batch')
|
||||
const initialTab = (searchParams.get('tab') as Tab) || (initialEmployeeId ? 'payslip' : 'batch')
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
||||
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate className="w-4 h-4" /> },
|
||||
{ key: 'overtime', label: '加班费计算', icon: <Clock className="w-4 h-4" /> },
|
||||
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>薪酬管理,支持工资条发放、批量算薪和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理,加班费在「加班费计算」模块单独计算后导入。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及加班薪酬</p>
|
||||
<p className="mt-1 text-sm text-gray-500">统一管理发薪批次、工资条及薪酬模板</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +51,6 @@ export default function Money() {
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
|
||||
{tab === 'batch' && <BatchManager />}
|
||||
{tab === 'template' && <TemplateManager />}
|
||||
{tab === 'overtime' && <OvertimeCalculator />}
|
||||
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import EmptyState from '../components/ui/EmptyState'
|
||||
import Pagination from '../components/ui/Pagination'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
CONTRACT_EXPIRY: '合同到期',
|
||||
@@ -62,6 +63,7 @@ export default function Notifications() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>通知中心,集中管理系统通知消息,包括合同到期提醒、试用期到期、审批通知等。支持已读/未读管理、通知偏好设置。</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 组织架构页面
|
||||
* 树形部门管理 + 岗位字典
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Edit, Trash2, ChevronRight, ChevronDown, Building2, Briefcase, Shield } from 'lucide-react'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import api from '../lib/api'
|
||||
|
||||
export default function OrgChart() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState<'dept' | 'position'>('dept')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [form, setForm] = useState<any>({ name: '', parentId: '', sortOrder: 0, description: '' })
|
||||
|
||||
const { data: departments = [], isLoading } = useQuery({
|
||||
queryKey: ['departments'],
|
||||
queryFn: () => api.get('/departments').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: positions = [] } = useQuery({
|
||||
queryKey: ['positions'],
|
||||
queryFn: () => api.get('/positions').then(r => r.data),
|
||||
enabled: tab === 'position',
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(editing ? `/departments/${editing.id}` : '/departments', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['departments'] })
|
||||
setShowModal(false)
|
||||
toast.success(editing ? '部门已更新' : '部门已创建')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/departments/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['departments'] })
|
||||
toast.success('部门已删除')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpanded(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const renderTree = (items: any[], parentId: string | null, level: number = 0) => {
|
||||
return items.filter(d => d.parentId === parentId).map(d => {
|
||||
// 根部门(level=0 且无父部门)为公司节点,不可编辑/删除
|
||||
const isRoot = d.level === 0 && !d.parentId
|
||||
return (
|
||||
<div key={d.id}>
|
||||
<div
|
||||
className={`flex items-center gap-2 py-2 px-3 rounded-md ${isRoot ? 'bg-primary/5 font-medium' : 'hover:bg-gray-50'}`}
|
||||
style={{ marginLeft: level * 24 }}
|
||||
>
|
||||
{items.some(c => c.parentId === d.id) ? (
|
||||
<button onClick={() => toggleExpand(d.id)} className="text-gray-400 hover:text-gray-600">
|
||||
{expanded.has(d.id) ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4" />
|
||||
)}
|
||||
<Building2 className={`w-4 h-4 ${isRoot ? 'text-primary' : 'text-gray-400'}`} />
|
||||
<span className="flex-1 text-sm">{d.name}</span>
|
||||
{isRoot && <span className="text-xs text-primary">公司</span>}
|
||||
{d.level === 0 && !isRoot && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-gray-400" title="分公司/子公司可在设置-社保公积金账户中单独关联账户">
|
||||
<Shield className="w-3 h-3" />可关联社保账户
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">{d._count?.employees || 0}人</span>
|
||||
{!isRoot && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(d)
|
||||
setForm({ name: d.name, parentId: d.parentId || '', sortOrder: d.sortOrder, description: d.description || '' })
|
||||
setShowModal(true)
|
||||
}}
|
||||
className="text-gray-400 hover:text-primary"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`确认删除部门"${d.name}"?`)) deleteMutation.mutate(d.id) }}
|
||||
className="text-gray-400 hover:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{expanded.has(d.id) && renderTree(items, d.id, level + 1)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>组织架构管理,支持部门树形结构管理和岗位字典维护。可新增、编辑、删除部门和岗位,设置上下级关系和职级。关联:调动员工时使用此处维护的部门和职务。</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">组织架构</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">管理部门、岗位及上下级关系</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-b">
|
||||
<button
|
||||
onClick={() => setTab('dept')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'dept' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>
|
||||
部门管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('position')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 ${tab === 'position' ? 'border-primary text-primary' : 'border-transparent text-gray-500'}`}
|
||||
>
|
||||
岗位字典
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'dept' ? (
|
||||
<Card className="p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium">部门列表</span>
|
||||
<Button size="sm" onClick={() => {
|
||||
setEditing(null)
|
||||
// 默认父部门为根部门(level=0 且 parentId=null)
|
||||
const root = departments.find((d: any) => d.level === 0 && !d.parentId)
|
||||
setForm({ name: '', parentId: root?.id || '', sortOrder: 0, description: '' })
|
||||
setShowModal(true)
|
||||
}}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增部门
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">加载中...</div>
|
||||
) : departments.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无部门数据</div>
|
||||
) : (
|
||||
<div className="space-y-1">{renderTree(departments, null)}</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<PositionTab positions={positions} departments={departments} />
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑部门' : '新增部门'}>
|
||||
<div className="space-y-3">
|
||||
{editing?.level === 0 && (
|
||||
<div className="flex items-start gap-2 p-2 bg-blue-50 rounded text-xs text-blue-700">
|
||||
<Shield className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>此部门为分公司/子公司节点,可在「设置 → 社保公积金账户」中单独关联社保、公积金账户,员工将通过此关联自动继承对应账户。</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>部门名称 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:技术部" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>上级部门</Label>
|
||||
<Select value={form.parentId} onChange={(e) => setForm({ ...form, parentId: e.target.value })}>
|
||||
{departments.filter((d: any) => d.id !== editing?.id).map((d: any) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.level === 0 && !d.parentId ? `${d.name}(公司)` : d.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>排序</Label>
|
||||
<Input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowModal(false)}>取消</Button>
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
|
||||
{editing ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 岗位字典子组件 */
|
||||
function PositionTab({ positions, departments }: { positions: any[]; departments: any[] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<any>(null)
|
||||
const [form, setForm] = useState<any>({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post(editing ? `/positions/${editing.id}` : '/positions', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||
setShowModal(false)
|
||||
toast.success(editing ? '岗位已更新' : '岗位已创建')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '操作失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/positions/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||
toast.success('岗位已删除')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium">岗位列表</span>
|
||||
<Button size="sm" onClick={() => {
|
||||
setEditing(null)
|
||||
setForm({ name: '', departmentId: '', headcount: 0, level: '', description: '' })
|
||||
setShowModal(true)
|
||||
}}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增岗位
|
||||
</Button>
|
||||
</div>
|
||||
{positions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">暂无岗位数据</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{positions.map(p => (
|
||||
<div key={p.id} className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 rounded-md">
|
||||
<Briefcase className="w-4 h-4 text-primary" />
|
||||
<span className="flex-1 text-sm">{p.name}</span>
|
||||
{p.department && <span className="text-xs text-gray-400">{p.department.name}</span>}
|
||||
{p.level && <span className="text-xs text-gray-400">职级:{p.level}</span>}
|
||||
<span className="text-xs text-gray-400">编制:{p.headcount}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditing(p)
|
||||
setForm({ name: p.name, departmentId: p.departmentId || '', headcount: p.headcount, level: p.level || '', description: p.description || '' })
|
||||
setShowModal(true)
|
||||
}}
|
||||
className="text-gray-400 hover:text-primary"
|
||||
>
|
||||
<Edit className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`确认删除岗位"${p.name}"?`)) deleteMutation.mutate(p.id) }}
|
||||
className="text-gray-400 hover:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showModal && (
|
||||
<Modal open onClose={() => setShowModal(false)} title={editing ? '编辑岗位' : '新增岗位'}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>岗位名称 *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如:前端工程师" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>所属部门</Label>
|
||||
<Select value={form.departmentId} onChange={(e) => setForm({ ...form, departmentId: e.target.value })}>
|
||||
<option value="">无</option>
|
||||
{departments.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>编制人数</Label>
|
||||
<Input type="number" value={form.headcount} onChange={(e) => setForm({ ...form, headcount: Number(e.target.value) || 0 })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>职级</Label>
|
||||
<Input value={form.level} onChange={(e) => setForm({ ...form, level: e.target.value })} placeholder="如 P1/M1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="选填" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setShowModal(false)}>取消</Button>
|
||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name}>
|
||||
{editing ? '保存' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { lazyRetry } from '../lib/lazyRetry'
|
||||
|
||||
const OvertimeCalculator = lazyRetry(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
|
||||
|
||||
/**
|
||||
* 加班费计算独立页面
|
||||
* 从薪税管理子标签拆出,作为一级菜单项
|
||||
*/
|
||||
export default function Overtime() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center py-12"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>}>
|
||||
<OvertimeCalculator />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { usePageSize } from '../hooks/usePageSize'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell } from 'lucide-react'
|
||||
import { FileText, Plus, ChevronRight, CheckCircle, Clock, X, Bell, Upload } from 'lucide-react'
|
||||
import mammoth from 'mammoth'
|
||||
import { policiesApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -201,6 +202,7 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [type, setType] = useState('RULES')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => policiesApi.create({ title, content, type }),
|
||||
@@ -230,7 +232,32 @@ function CreatePolicyModal({ onClose, onSuccess }: { onClose: () => void; onSucc
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600">制度内容</label>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文..." />
|
||||
<div className="flex items-center gap-2 mt-1 mb-1">
|
||||
<Button size="sm" variant="secondary" className="!h-7" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="w-3.5 h-3.5 mr-1" />导入 Word 文档
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".docx"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer })
|
||||
setContent(result.value)
|
||||
toast.success('文档导入成功')
|
||||
} catch {
|
||||
toast.error('文档解析失败,请确保为 .docx 格式')
|
||||
}
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-400">支持 .docx 格式,导入后转为 HTML</span>
|
||||
</div>
|
||||
<textarea value={content} onChange={e => setContent(e.target.value)} rows={8} className="w-full mt-1 px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" placeholder="输入制度正文或导入 Word 文档..." />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||
|
||||
+574
-98
@@ -5,8 +5,9 @@ import { toast } from 'sonner'
|
||||
import { toastError } from '../lib/errorToast'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Wallet, Download, Phone, MapPin, Search, Settings2 } from 'lucide-react'
|
||||
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
||||
import { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
|
||||
import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
|
||||
import api from '../lib/api'
|
||||
import { copyToClipboard } from '../lib/clipboard'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||
@@ -19,7 +20,8 @@ import { fmt, terminateReasonMap } from './roster/shared'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import EmployeeProfile from './roster/EmployeeProfile'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal, ConfirmModal } from './roster/modals'
|
||||
import SignMethodChoice from '../components/ui/SignMethodChoice'
|
||||
import { ImportSettings } from './Settings'
|
||||
import QueryError from '../components/ui/QueryError'
|
||||
|
||||
@@ -77,22 +79,54 @@ export default function Roster() {
|
||||
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
|
||||
const [showDeptModal, setShowDeptModal] = useState(false)
|
||||
const [deptEmployee, setDeptEmployee] = useState<any>(null)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const [confirmEmployee, setConfirmEmployee] = useState<any>(null)
|
||||
// 开具证明弹窗
|
||||
const [showCertModal, setShowCertModal] = useState(false)
|
||||
const [certEmployee, setCertEmployee] = useState<any>(null)
|
||||
const [certPurpose, setCertPurpose] = useState('')
|
||||
const [certType, setCertType] = useState('INCOME_CERT')
|
||||
// 续签合同弹窗
|
||||
const [showRenewModal, setShowRenewModal] = useState(false)
|
||||
const [renewEmployee, setRenewEmployee] = useState<any>(null)
|
||||
const [renewNewStartDate, setRenewNewStartDate] = useState('')
|
||||
const [renewNewEndDate, setRenewNewEndDate] = useState('')
|
||||
const [renewNewSalary, setRenewNewSalary] = useState('')
|
||||
const pageSize = usePageSize()
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
|
||||
// 签署方式选择弹窗
|
||||
const [signChoice, setSignChoice] = useState<{
|
||||
open: boolean
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
|
||||
documentTitle: string
|
||||
contractId?: string
|
||||
remark?: string
|
||||
actionName: string
|
||||
} | null>(null)
|
||||
const [batchRenewYears, setBatchRenewYears] = useState(3)
|
||||
const [previewData, setPreviewData] = useState<any>(null)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterContractStatus, setFilterContractStatus] = useState('')
|
||||
const [filterDepartment, setFilterDepartment] = useState('')
|
||||
const [filterPosition, setFilterPosition] = useState('')
|
||||
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
||||
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
||||
// 批量转正弹窗
|
||||
const [showBatchConfirmModal, setShowBatchConfirmModal] = useState(false)
|
||||
const [batchConfirmDate, setBatchConfirmDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [batchConfirmSalary, setBatchConfirmSalary] = useState('')
|
||||
// 批量开具证明弹窗
|
||||
const [showBatchCertModal, setShowBatchCertModal] = useState(false)
|
||||
const [batchCertType, setBatchCertType] = useState('INCOME_CERT')
|
||||
|
||||
const { data: rosterData, isLoading, isError, error, refetch } = useQuery<any>({
|
||||
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment],
|
||||
queryKey: ['roster', page, pageSize, debouncedSearch, filterStatus, filterContractStatus, filterDepartment, filterPosition],
|
||||
queryFn: async () => {
|
||||
const params: any = { page, pageSize }
|
||||
if (debouncedSearch) params.search = debouncedSearch
|
||||
@@ -100,7 +134,8 @@ export default function Roster() {
|
||||
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||
if (filterDepartment) params.department = filterDepartment
|
||||
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined } as any) as any
|
||||
if (filterPosition) params.position = filterPosition
|
||||
const res = await rosterApi.list({ search: debouncedSearch, page, pageSize, status: filterStatus === 'PROBATION' ? 'ACTIVE' : filterStatus || undefined, contractStatus: filterContractStatus || undefined, department: filterDepartment || undefined, position: filterPosition || undefined } as any) as any
|
||||
return res
|
||||
},
|
||||
})
|
||||
@@ -111,9 +146,16 @@ export default function Roster() {
|
||||
})
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
// 从 URL query 参数自动定位员工(从待办跳转时)
|
||||
// 从 URL query 参数自动定位员工(从待办跳转或绩效/培训列表跳转时)
|
||||
const employeeParam = searchParams.get('employee')
|
||||
const employeeIdParam = searchParams.get('employeeId')
|
||||
useEffect(() => {
|
||||
// 优先按 employeeId 直接定位
|
||||
if (employeeIdParam && !selectedId) {
|
||||
setSelectedId(employeeIdParam)
|
||||
setSearchParams({}, { replace: true })
|
||||
return
|
||||
}
|
||||
if (!employeeParam) return
|
||||
setSearch(employeeParam)
|
||||
// 只有当 debouncedSearch 与参数一致时,搜索结果才真正匹配
|
||||
@@ -122,19 +164,20 @@ export default function Roster() {
|
||||
// 清除 URL 参数,避免后续搜索时重复触发
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [employeeParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => employeeApi.create(data),
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await employeeApi.create(data)
|
||||
return res
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
localStorage.removeItem('add-employee-draft')
|
||||
setShowAddModal(false)
|
||||
toast.success('员工已添加', {
|
||||
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
|
||||
})
|
||||
toast.success('员工已添加,请前往员工档案签订合同')
|
||||
},
|
||||
onError: (err: any) => toastError(err, '创建失败'),
|
||||
})
|
||||
@@ -153,8 +196,9 @@ export default function Roster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
|
||||
action: { label: '前往处理', onClick: () => navigate('/termination') },
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
toast.success('已创建离职草稿,离职协议电子签署已自动发起', {
|
||||
action: { label: '查看签署', onClick: () => navigate('/esign') },
|
||||
})
|
||||
setShowResignModal(false)
|
||||
setResignEmployee(null)
|
||||
@@ -177,6 +221,16 @@ export default function Roster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setShowRehireModal(false)
|
||||
// 弹出签署方式选择
|
||||
setSignChoice({
|
||||
open: true,
|
||||
employeeId: rehireEmployee?.id || '',
|
||||
employeeName: rehireEmployee?.name || '',
|
||||
scene: 'CONTRACT',
|
||||
documentTitle: `${rehireEmployee?.name || ''}的劳动合同`,
|
||||
remark: '重新入职时发起',
|
||||
actionName: '重新入职',
|
||||
})
|
||||
setRehireEmployee(null)
|
||||
},
|
||||
})
|
||||
@@ -188,6 +242,16 @@ export default function Roster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setShowSalaryModal(false)
|
||||
// 弹出签署方式选择
|
||||
setSignChoice({
|
||||
open: true,
|
||||
employeeId: salaryEmployee?.id || '',
|
||||
employeeName: salaryEmployee?.name || '',
|
||||
scene: 'POLICY',
|
||||
documentTitle: `${salaryEmployee?.name || ''}的薪酬调整确认书`,
|
||||
remark: '薪酬变更时发起',
|
||||
actionName: '薪酬变更',
|
||||
})
|
||||
setSalaryEmployee(null)
|
||||
},
|
||||
})
|
||||
@@ -199,10 +263,50 @@ export default function Roster() {
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setShowDeptModal(false)
|
||||
// 弹出签署方式选择
|
||||
setSignChoice({
|
||||
open: true,
|
||||
employeeId: deptEmployee?.id || '',
|
||||
employeeName: deptEmployee?.name || '',
|
||||
scene: 'POLICY',
|
||||
documentTitle: `${deptEmployee?.name || ''}的调岗确认书`,
|
||||
remark: '部门/岗位调动时发起',
|
||||
actionName: '调岗调动',
|
||||
})
|
||||
setDeptEmployee(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 转正 mutation
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (data: { employeeId: string; confirmDate: string; regularSalary?: number }) =>
|
||||
workProcessApi.create({ type: 'CONFIRM', title: '转正', employeeId: data.employeeId, formData: { employeeId: data.employeeId, confirmDate: data.confirmDate, regularSalary: data.regularSalary }, status: 'DRAFT' }),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
setShowConfirmModal(false)
|
||||
const originalSalary = confirmEmployee?.monthlySalary ? Number(confirmEmployee.monthlySalary) : null
|
||||
const newSalary = variables.regularSalary
|
||||
// 薪资有变化时弹出签署方式选择
|
||||
if (newSalary && newSalary !== originalSalary) {
|
||||
setSignChoice({
|
||||
open: true,
|
||||
employeeId: variables.employeeId,
|
||||
employeeName: confirmEmployee?.name || '',
|
||||
scene: 'POLICY',
|
||||
documentTitle: `${confirmEmployee?.name || ''}的转正薪酬调整确认书`,
|
||||
remark: `转正薪资调整:¥${originalSalary || 0} → ¥${newSalary}`,
|
||||
actionName: '转正调薪',
|
||||
})
|
||||
} else {
|
||||
toast.success('转正已提交')
|
||||
}
|
||||
setConfirmEmployee(null)
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '转正失败'),
|
||||
})
|
||||
|
||||
const batchRenewMutation = useMutation({
|
||||
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
|
||||
onSuccess: () => {
|
||||
@@ -257,6 +361,142 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
/** 开具证明:直接创建并提交工单 */
|
||||
const certMutation = useMutation({
|
||||
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
|
||||
const certTitleMap: Record<string, string> = {
|
||||
INCOME_CERT: '开具收入证明',
|
||||
EMPLOYMENT_CERT: '开具在职证明',
|
||||
LEAVING_CERT: '开具离职证明',
|
||||
}
|
||||
const created: any = await workProcessApi.create({ type: certType, title: certTitleMap[certType] || '开具证明', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
|
||||
if (created?.id) {
|
||||
await workProcessApi.submit(created.id)
|
||||
}
|
||||
return created
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('证明已开具,可在「证据管理」中查看和下载')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
setShowCertModal(false)
|
||||
setCertEmployee(null)
|
||||
setCertPurpose('')
|
||||
},
|
||||
onError: (err: any) => toastError(err, '开具证明失败'),
|
||||
})
|
||||
|
||||
/** 续签合同:直接创建并提交工单 */
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: async (data: { employeeId: string; formData: Record<string, unknown> }) => {
|
||||
const created: any = await workProcessApi.create({ type: 'RENEW', title: '合同续签', employeeId: data.employeeId, formData: data.formData, status: 'DRAFT' })
|
||||
if (created?.id) {
|
||||
await workProcessApi.submit(created.id)
|
||||
}
|
||||
return created
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('合同续签已完成')
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowRenewModal(false)
|
||||
// 弹出签署方式选择
|
||||
setSignChoice({
|
||||
open: true,
|
||||
employeeId: renewEmployee?.id || '',
|
||||
employeeName: renewEmployee?.name || '',
|
||||
scene: 'CONTRACT',
|
||||
documentTitle: `${renewEmployee?.name || ''}的续签劳动合同`,
|
||||
remark: '合同续签时发起',
|
||||
actionName: '合同续签',
|
||||
})
|
||||
setRenewEmployee(null)
|
||||
setRenewNewStartDate('')
|
||||
setRenewNewEndDate('')
|
||||
setRenewNewSalary('')
|
||||
},
|
||||
onError: (err: any) => toastError(err, '续签合同失败'),
|
||||
})
|
||||
|
||||
/** 批量转正:为选中的试用期员工创建转正草稿 */
|
||||
const handleBatchConfirm = async () => {
|
||||
const probationEmployees = employees.filter((e: any) => selectedIds.has(e.id) && e.probationInfo?.isProbation)
|
||||
if (probationEmployees.length === 0) {
|
||||
toast.error('选中的员工中没有试用期员工')
|
||||
return
|
||||
}
|
||||
const confirmDate = batchConfirmDate
|
||||
let success = 0
|
||||
let failed = 0
|
||||
await Promise.all(
|
||||
probationEmployees.map(async (emp: any) => {
|
||||
try {
|
||||
await workProcessApi.create({
|
||||
type: 'CONFIRM',
|
||||
title: '批量转正',
|
||||
employeeId: emp.id,
|
||||
formData: { employeeId: emp.id, confirmDate, salary: batchConfirmSalary || undefined },
|
||||
status: 'DRAFT',
|
||||
})
|
||||
success++
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
})
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
if (success > 0) toast.success(`已为 ${success} 名员工创建转正草稿`)
|
||||
if (failed > 0) toast.error(`${failed} 名员工转正失败`)
|
||||
setSelectedIds(new Set())
|
||||
setShowBatchConfirmModal(false)
|
||||
}
|
||||
|
||||
/** 批量开具证明:直接为选中员工创建并提交证明 */
|
||||
const handleBatchCert = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.error('请至少选择一名员工')
|
||||
return
|
||||
}
|
||||
const certTitleMap: Record<string, string> = {
|
||||
INCOME_CERT: '开具收入证明',
|
||||
EMPLOYMENT_CERT: '开具在职证明',
|
||||
LEAVING_CERT: '开具离职证明',
|
||||
}
|
||||
let success = 0
|
||||
let failed = 0
|
||||
for (const id of selectedIds) {
|
||||
const emp = employees.find((e: any) => e.id === id)
|
||||
if (!emp) continue
|
||||
try {
|
||||
const created: any = await workProcessApi.create({
|
||||
type: batchCertType,
|
||||
title: certTitleMap[batchCertType] || '开具证明',
|
||||
employeeId: id,
|
||||
formData: {
|
||||
employeeId: id,
|
||||
employeeName: emp.name,
|
||||
idCardNumber: emp.idCardMasked || '',
|
||||
position: emp.position || '',
|
||||
monthlyIncome: emp.monthlySalary ? `¥${emp.monthlySalary}` : '',
|
||||
purpose: '批量开具',
|
||||
},
|
||||
status: 'DRAFT',
|
||||
})
|
||||
if (created?.id) {
|
||||
await workProcessApi.submit(created.id)
|
||||
}
|
||||
success++
|
||||
} catch {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
||||
if (success > 0) toast.success(`已为 ${success} 名员工开具证明`)
|
||||
if (failed > 0) toast.error(`${failed} 名员工开具证明失败`)
|
||||
setSelectedIds(new Set())
|
||||
setShowBatchCertModal(false)
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -279,10 +519,11 @@ export default function Roster() {
|
||||
setFilterStatus('')
|
||||
setFilterContractStatus('')
|
||||
setFilterDepartment('')
|
||||
setFilterPosition('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
|
||||
|
||||
/** 全局合同风险统计(来自后端,与风险中心同口径) */
|
||||
const globalRiskStats = rosterData?.globalRiskStats
|
||||
@@ -311,6 +552,14 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: positionList } = useQuery<string[]>({
|
||||
queryKey: ['roster-positions'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/positions')
|
||||
return res.data as string[]
|
||||
},
|
||||
})
|
||||
|
||||
const { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
|
||||
const [showColSettings, setShowColSettings] = useState(false)
|
||||
|
||||
@@ -375,15 +624,20 @@ export default function Roster() {
|
||||
<option value="">全部部门</option>
|
||||
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterPosition}
|
||||
onChange={(e) => { setFilterPosition(e.target.value); setPage(1) }}
|
||||
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"
|
||||
>
|
||||
<option value="">全部职务</option>
|
||||
{positionList?.map((p: string) => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700">清除筛选</button>
|
||||
)}
|
||||
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => navigate('/work-process')} className="h-9 shrink-0" title="完整的入职办理流程">
|
||||
<UserPlus className="mr-1.5 h-4 w-4" />用工办理
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
|
||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||
</Button>
|
||||
@@ -393,6 +647,7 @@ export default function Roster() {
|
||||
if (debouncedSearch) params.set('search', debouncedSearch)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterDepartment) params.set('department', filterDepartment)
|
||||
if (filterPosition) params.set('position', filterPosition)
|
||||
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
@@ -443,6 +698,12 @@ export default function Roster() {
|
||||
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
||||
<Check className="mr-1 h-4 w-4" />批量续签
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowBatchConfirmModal(true)} className="shrink-0">
|
||||
<CheckCircle className="mr-1 h-4 w-4" />批量转正
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowBatchCertModal(true)} className="shrink-0">
|
||||
<FileText className="mr-1 h-4 w-4" />批量开具证明
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
|
||||
<UserX className="mr-1 h-4 w-4" />批量解聘
|
||||
</Button>
|
||||
@@ -525,23 +786,25 @@ export default function Roster() {
|
||||
{employees.map((e: any) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-b border-gray-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03]"
|
||||
className="border-b border-gray-50 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.03] group"
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
>
|
||||
<td className="px-4 py-3" onClick={(ev) => ev.stopPropagation()}>
|
||||
<td className="px-4 py-2.5" onClick={(ev) => ev.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<div>{e.name}</div>
|
||||
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.idCardNumber) {
|
||||
copyToClipboard(e.idCardNumber, '已复制身份证号')
|
||||
}
|
||||
}}>{e.idCardMasked || '—'}</div>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-gray-900 text-sm leading-tight">{e.name}</span>
|
||||
<span className="text-gray-400 text-[11px] font-mono cursor-pointer hover:text-primary transition-colors leading-tight" title="点击复制完整证件号码" onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (e.idCardNumber) {
|
||||
copyToClipboard(e.idCardNumber, '已复制证件号码')
|
||||
}
|
||||
}}>{e.idCardMasked || '—'}</span>
|
||||
</div>
|
||||
</td>
|
||||
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
|
||||
{colVisible('status') && <td className="px-4 py-3">
|
||||
{colVisible('department') && <td className="px-4 py-2.5 text-gray-500">{e.department}</td>}
|
||||
{colVisible('status') && <td className="px-4 py-2.5">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
||||
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
||||
@@ -559,8 +822,8 @@ export default function Roster() {
|
||||
</span>
|
||||
)}
|
||||
</td>}
|
||||
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
|
||||
<td className="hidden px-4 py-3 text-gray-500">
|
||||
{colVisible('hireDate') && <td className="px-4 py-2.5 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
|
||||
<td className="hidden px-4 py-2.5 text-gray-500">
|
||||
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
|
||||
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
||||
{e.latestTerminationDate.toString().slice(0, 10)}
|
||||
@@ -570,10 +833,10 @@ export default function Roster() {
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
|
||||
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
|
||||
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</td>}
|
||||
{colVisible('contractType') && <td className="px-4 py-3">
|
||||
{colVisible('position') && <td className="px-4 py-2.5 text-gray-500">{e.position || '—'}</td>}
|
||||
{colVisible('phone') && <td className="px-4 py-2.5 text-gray-500">{e.phone || '—'}</td>}
|
||||
{colVisible('gender') && <td className="px-4 py-2.5 text-center text-gray-500">{e.gender || '—'}</td>}
|
||||
{colVisible('contractType') && <td className="px-4 py-2.5">
|
||||
{(() => {
|
||||
const typeConfig: Record<string, { label: string; style: string }> = {
|
||||
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
||||
@@ -590,7 +853,7 @@ export default function Roster() {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('contractStatus') && <td className="px-4 py-3">
|
||||
{colVisible('contractStatus') && <td className="px-4 py-2.5">
|
||||
{(() => {
|
||||
const tagStyles: Record<string, string> = {
|
||||
expired: 'bg-red-50 text-danger',
|
||||
@@ -598,6 +861,7 @@ export default function Roster() {
|
||||
unsigned_over_30: 'bg-red-50 text-danger',
|
||||
unsigned: 'bg-yellow-50 text-yellow-700',
|
||||
expiring: 'bg-yellow-50 text-yellow-700',
|
||||
pending_sign: 'bg-yellow-50 text-yellow-700',
|
||||
active: 'bg-green-50 text-safe',
|
||||
unfixed: 'bg-green-50 text-safe',
|
||||
}
|
||||
@@ -607,6 +871,7 @@ export default function Roster() {
|
||||
unsigned_over_30: '未签署(超30天)',
|
||||
unsigned: '未签署',
|
||||
expiring: '即将到期',
|
||||
pending_sign: '待签署',
|
||||
active: '正常',
|
||||
unfixed: '正常',
|
||||
}
|
||||
@@ -615,7 +880,7 @@ export default function Roster() {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('contractExpiry') && <td className="px-4 py-3 text-gray-500">
|
||||
{colVisible('contractExpiry') && <td className="px-4 py-2.5 text-gray-500">
|
||||
{(() => {
|
||||
const endDate = e.latestContract?.endDate
|
||||
if (!endDate) {
|
||||
@@ -635,7 +900,7 @@ export default function Roster() {
|
||||
return <span className="text-xs">{dateStr}</span>
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('socialStatus') && <td className="px-4 py-3">
|
||||
{colVisible('socialStatus') && <td className="px-4 py-2.5">
|
||||
{(() => {
|
||||
const status = e.socialInsuranceStatus
|
||||
if (!status) return <span className="text-gray-300 text-xs">—</span>
|
||||
@@ -649,12 +914,12 @@ export default function Roster() {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('socialInsBase') && <td className="px-4 py-3 text-right text-xs">
|
||||
{colVisible('socialInsBase') && <td className="px-4 py-2.5 text-right text-xs">
|
||||
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300">—</span>}
|
||||
</td>}
|
||||
{colVisible('socialInsAmount') && <td className="px-4 py-3 text-right text-xs">
|
||||
{colVisible('socialInsAmount') && <td className="px-4 py-2.5 text-right">
|
||||
{(() => {
|
||||
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300">—</span>
|
||||
if (!e.socialInsCalc && !e.housingFundCalc) return <span className="text-gray-300 text-xs">—</span>
|
||||
const socialEmp = e.socialInsCalc?.socialEmp || 0
|
||||
const socialOrg = e.socialInsCalc?.socialOrg || 0
|
||||
const housingEmp = e.housingFundCalc?.housingEmp || 0
|
||||
@@ -662,14 +927,14 @@ export default function Roster() {
|
||||
const totalEmp = socialEmp + housingEmp
|
||||
const totalOrg = socialOrg + housingOrg
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span>个人: {totalEmp.toFixed(2)}</span>
|
||||
<span className="text-gray-400">企业: {totalOrg.toFixed(2)}</span>
|
||||
<div className="flex flex-col gap-0.5 items-end">
|
||||
<span className="text-xs text-gray-700 leading-tight">个人 {totalEmp.toFixed(2)}</span>
|
||||
<span className="text-[11px] text-gray-400 leading-tight">企业 {totalOrg.toFixed(2)}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</td>}
|
||||
{colVisible('records') && <td className="px-4 py-3 text-center">
|
||||
{colVisible('records') && <td className="px-4 py-2.5 text-center">
|
||||
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||
{(() => {
|
||||
const items = [
|
||||
@@ -690,60 +955,109 @@ export default function Roster() {
|
||||
})()}
|
||||
</div>
|
||||
</td>}
|
||||
<td className="px-4 py-3 text-center">
|
||||
<td className="px-3 py-2 text-center">
|
||||
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<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()
|
||||
setSalaryEmployee(e)
|
||||
setShowSalaryModal(true)
|
||||
}}
|
||||
>
|
||||
<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()
|
||||
navigate('/money')
|
||||
}}
|
||||
>
|
||||
<Wallet 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()
|
||||
setDeptEmployee(e)
|
||||
setShowDeptModal(true)
|
||||
}}
|
||||
>
|
||||
<Building2 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-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setResignEmployee(e)
|
||||
setShowResignModal(true)
|
||||
}}
|
||||
>
|
||||
<UserX className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<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()
|
||||
setSalaryEmployee(e)
|
||||
setShowSalaryModal(true)
|
||||
}}
|
||||
>
|
||||
<DollarSign className="h-4 w-4" />
|
||||
</button>
|
||||
{/* 试用期员工显示转正按钮 */}
|
||||
{e.probationInfo?.isProbation && (
|
||||
<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()
|
||||
setConfirmEmployee(e)
|
||||
setShowConfirmModal(true)
|
||||
}}
|
||||
>
|
||||
<CheckCircle 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()
|
||||
setDeptEmployee(e)
|
||||
setShowDeptModal(true)
|
||||
}}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-0.5 border-t border-gray-100 pt-1.5">
|
||||
<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()
|
||||
setCertEmployee(e)
|
||||
setCertPurpose('')
|
||||
setCertType('INCOME_CERT')
|
||||
setShowCertModal(true)
|
||||
}}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</button>
|
||||
{(e.contractStatus === 'expiring' || e.contractStatus === 'expired') && (
|
||||
<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()
|
||||
// 自动推导新合同开始日期:原合同结束日 + 1天
|
||||
const oldEndDate = e.latestContract?.endDate
|
||||
let newStart = new Date().toISOString().slice(0, 10)
|
||||
if (oldEndDate) {
|
||||
const d = new Date(oldEndDate)
|
||||
d.setDate(d.getDate() + 1)
|
||||
newStart = d.toISOString().slice(0, 10)
|
||||
}
|
||||
setRenewEmployee(e)
|
||||
setRenewNewStartDate(newStart)
|
||||
setRenewNewEndDate('')
|
||||
setRenewNewSalary(e.monthlySalary ? String(e.monthlySalary) : '')
|
||||
setShowRenewModal(true)
|
||||
}}
|
||||
>
|
||||
<RotateCcw 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-danger/10 hover:text-danger"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setResignEmployee(e)
|
||||
setShowResignModal(true)
|
||||
}}
|
||||
>
|
||||
<UserX className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
|
||||
@@ -858,6 +1172,153 @@ export default function Roster() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showConfirmModal && confirmEmployee && (
|
||||
<ConfirmModal
|
||||
employee={confirmEmployee}
|
||||
onClose={() => { setShowConfirmModal(false); setConfirmEmployee(null) }}
|
||||
onSubmit={(data) => confirmMutation.mutate(data)}
|
||||
loading={confirmMutation.isPending}
|
||||
error={confirmMutation.error as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 开具证明弹窗 */}
|
||||
{showCertModal && certEmployee && (
|
||||
<Modal open={showCertModal} onClose={() => { setShowCertModal(false); setCertEmployee(null) }} title={`为 ${certEmployee.name} 开具证明`}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>证明类型</Label>
|
||||
<Select value={certType} onChange={(e) => setCertType(e.target.value)}>
|
||||
<option value="INCOME_CERT">收入证明</option>
|
||||
<option value="EMPLOYMENT_CERT">在职证明</option>
|
||||
<option value="LEAVING_CERT">离职证明</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
将自动填充员工姓名、证件号码、职务等信息,并直接生成证明文书。
|
||||
</div>
|
||||
<div>
|
||||
<Label>用途(选填)</Label>
|
||||
<Input value={certPurpose} onChange={(e) => setCertPurpose(e.target.value)} placeholder="如:办理签证、租房、贷款等" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowCertModal(false); setCertEmployee(null) }}>取消</Button>
|
||||
<Button
|
||||
disabled={certMutation.isPending}
|
||||
onClick={() => {
|
||||
certMutation.mutate({
|
||||
employeeId: certEmployee.id,
|
||||
formData: {
|
||||
employeeId: certEmployee.id,
|
||||
employeeName: certEmployee.name,
|
||||
idCardNumber: certEmployee.idCardMasked || '',
|
||||
position: certEmployee.position || '',
|
||||
monthlyIncome: certEmployee.monthlySalary ? `¥${certEmployee.monthlySalary}` : '',
|
||||
purpose: certPurpose || '通用',
|
||||
},
|
||||
})
|
||||
}}
|
||||
>确认开具</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 续签合同弹窗 */}
|
||||
{showRenewModal && renewEmployee && (
|
||||
<Modal open={showRenewModal} onClose={() => { setShowRenewModal(false); setRenewEmployee(null) }} title={`为 ${renewEmployee.name} 续签合同`}>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
原合同到期日:{renewEmployee.latestContract?.endDate ? new Date(renewEmployee.latestContract.endDate).toISOString().slice(0, 10) : '无'}
|
||||
</div>
|
||||
<div>
|
||||
<Label>新合同开始日期 *</Label>
|
||||
<Input type="date" value={renewNewStartDate} onChange={(e) => setRenewNewStartDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>新合同结束日期</Label>
|
||||
<Input type="date" value={renewNewEndDate} onChange={(e) => setRenewNewEndDate(e.target.value)} placeholder="留空表示无固定期限" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>新薪资(选填)</Label>
|
||||
<Input type="number" value={renewNewSalary} onChange={(e) => setRenewNewSalary(e.target.value)} placeholder="续签后月薪" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowRenewModal(false); setRenewEmployee(null) }}>取消</Button>
|
||||
<Button
|
||||
disabled={renewMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!renewNewStartDate) {
|
||||
toast.error('请填写新合同开始日期')
|
||||
return
|
||||
}
|
||||
if (renewNewEndDate && renewNewEndDate < renewNewStartDate) {
|
||||
toast.error('结束日期不能早于开始日期')
|
||||
return
|
||||
}
|
||||
renewMutation.mutate({
|
||||
employeeId: renewEmployee.id,
|
||||
formData: {
|
||||
employeeId: renewEmployee.id,
|
||||
oldContractId: renewEmployee.latestContract?.id || '',
|
||||
newStartDate: renewNewStartDate,
|
||||
newEndDate: renewNewEndDate || undefined,
|
||||
newSalary: renewNewSalary || undefined,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>确认续签</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 批量转正弹窗 */}
|
||||
{showBatchConfirmModal && (
|
||||
<Modal open={showBatchConfirmModal} onClose={() => setShowBatchConfirmModal(false)} title="批量转正">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
将为选中的 {selectedIds.size} 名员工中的试用期员工创建转正草稿。
|
||||
</div>
|
||||
<div>
|
||||
<Label>转正日期</Label>
|
||||
<Input type="date" value={batchConfirmDate} onChange={(e) => setBatchConfirmDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>转正薪资(选填,留空则不调整)</Label>
|
||||
<Input type="number" value={batchConfirmSalary} onChange={(e) => setBatchConfirmSalary(e.target.value)} placeholder="转正后月薪" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowBatchConfirmModal(false)}>取消</Button>
|
||||
<Button onClick={handleBatchConfirm}>确认转正</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 批量开具证明弹窗 */}
|
||||
{showBatchCertModal && (
|
||||
<Modal open={showBatchCertModal} onClose={() => setShowBatchCertModal(false)} title="批量开具证明">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-gray-500">
|
||||
将为选中的 {selectedIds.size} 名员工批量生成证明文书,可在「证据管理」中查看和下载。
|
||||
</div>
|
||||
<div>
|
||||
<Label>证明类型</Label>
|
||||
<Select value={batchCertType} onChange={(e) => setBatchCertType(e.target.value)}>
|
||||
<option value="INCOME_CERT">收入证明</option>
|
||||
<option value="EMPLOYMENT_CERT">在职证明</option>
|
||||
<option value="LEAVING_CERT">离职证明</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setShowBatchCertModal(false)}>取消</Button>
|
||||
<Button onClick={handleBatchCert}>确认开具</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showBatchRenewModal && (
|
||||
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
|
||||
<div className="space-y-3">
|
||||
@@ -1052,6 +1513,21 @@ export default function Roster() {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 签署方式选择弹窗 */}
|
||||
{signChoice?.open && (
|
||||
<SignMethodChoice
|
||||
open={signChoice.open}
|
||||
onClose={() => setSignChoice(null)}
|
||||
employeeId={signChoice.employeeId}
|
||||
employeeName={signChoice.employeeName}
|
||||
scene={signChoice.scene}
|
||||
documentTitle={signChoice.documentTitle}
|
||||
contractId={signChoice.contractId}
|
||||
remark={signChoice.remark}
|
||||
actionName={signChoice.actionName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ export default function SalaryDashboard() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
||||
</PageGuide>
|
||||
{/* 页头 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -70,11 +73,6 @@ export default function SalaryDashboard() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 操作说明 — 紧跟标题下方 */}
|
||||
<PageGuide>
|
||||
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
||||
</PageGuide>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : isError ? (
|
||||
|
||||
+309
-17
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2 } from 'lucide-react'
|
||||
import { settingsApi, notificationsApi } from '../lib/api-services'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Edit2 } from 'lucide-react'
|
||||
import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -10,6 +11,7 @@ import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
import Modal from '../components/ui/Modal'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
|
||||
|
||||
export default function Settings() {
|
||||
@@ -48,6 +50,7 @@ export default function Settings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>系统设置,管理组织信息、用户权限、审批流程、系统参数等。支持多角色配置、审批节点设置、通知模板管理。</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
@@ -653,7 +656,7 @@ function handleAcceptanceExport() {
|
||||
}
|
||||
|
||||
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
|
||||
let md = `# TurboHR 验收测试结果\n\n`
|
||||
let md = `# 安职通 验收测试结果\n\n`
|
||||
md += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
|
||||
md += `> **验收人数:** ${keys.length}\n\n`
|
||||
|
||||
@@ -702,7 +705,7 @@ function handleAcceptanceExport() {
|
||||
md += `\n`
|
||||
}
|
||||
|
||||
md += `\n---\n*由企业用工专家系统自动生成*\n`
|
||||
md += `\n---\n*由安职通系统自动生成*\n`
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -1148,7 +1151,7 @@ function InitImport() {
|
||||
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
||||
)}
|
||||
{result.duplicates > 0 && (
|
||||
<div className="text-amber-600">重复 {result.duplicates} 条(身份证号已存在)</div>
|
||||
<div className="text-amber-600">重复 {result.duplicates} 条(证件号码已存在)</div>
|
||||
)}
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
@@ -1214,12 +1217,12 @@ function InitImport() {
|
||||
<Card>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||||
<div>- <b>员工信息</b>:姓名、部门、性别、手机号、身份证号、入职日期、月工资、社保基数、公积金基数等</div>
|
||||
<div>- <b>劳动合同</b>:身份证号(优先匹配)、姓名(备选)、合同类型、签订日期、起止日期、试用期等</div>
|
||||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型</div>
|
||||
<div>- <b>违纪记录</b>:身份证号(优先匹配)、姓名(备选)、日期、违纪类型、描述、处罚</div>
|
||||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间</div>
|
||||
<div className="mt-2 text-gray-500">各 Sheet 优先用「身份证号」精确匹配员工,未填身份证号时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
<div>- <b>员工信息</b>:姓名、部门、性别、手机号、证件号码、入职日期、月工资、社保基数、公积金基数等</div>
|
||||
<div>- <b>劳动合同</b>:证件号码(优先匹配)、姓名(备选)、合同类型、签订日期、起止日期、试用期等</div>
|
||||
<div>- <b>加班记录</b>:证件号码(优先匹配)、姓名(备选)、日期、加班时长、加班类型</div>
|
||||
<div>- <b>违纪记录</b>:证件号码(优先匹配)、姓名(备选)、日期、违纪类型、描述、处罚</div>
|
||||
<div>- <b>考勤记录</b>:证件号码(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间</div>
|
||||
<div className="mt-2 text-gray-500">各 Sheet 优先用「证件号码」精确匹配员工,未填证件号码时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1377,12 +1380,12 @@ function MonthlyImport() {
|
||||
<Card>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间(同日重复导入会覆盖)</div>
|
||||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型(同月重复导入会累加)</div>
|
||||
<div>- <b>薪资调整</b>:身份证号(优先匹配)、姓名(备选)、调整后月薪、生效日期、调薪原因(自动关闭旧薪资记录)</div>
|
||||
<div>- <b>社保变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div>- <b>公积金变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div className="mt-2 text-gray-500">所有 Sheet 优先用「身份证号」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
<div>- <b>考勤记录</b>:证件号码(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间(同日重复导入会覆盖)</div>
|
||||
<div>- <b>加班记录</b>:证件号码(优先匹配)、姓名(备选)、日期、加班时长、加班类型(同月重复导入会累加)</div>
|
||||
<div>- <b>薪资调整</b>:证件号码(优先匹配)、姓名(备选)、调整后月薪、生效日期、调薪原因(自动关闭旧薪资记录)</div>
|
||||
<div>- <b>社保变动</b>:证件号码(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div>- <b>公积金变动</b>:证件号码(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div className="mt-2 text-gray-500">所有 Sheet 优先用「证件号码」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -1572,3 +1575,292 @@ function MedicalPolicyForm({ policy, onSave, onClose }: { policy: any; onSave: (
|
||||
)
|
||||
}
|
||||
|
||||
// ========== 社保公积金账户管理 ==========
|
||||
function SocialAccountSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [accountType, setAccountType] = useState<'SOCIAL' | 'HOUSING'>('SOCIAL')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editAccount, setEditAccount] = useState<any>(null)
|
||||
|
||||
const { data: accounts, isLoading } = useQuery({
|
||||
queryKey: ['social-accounts', accountType],
|
||||
queryFn: () => socialAccountApi.list(accountType),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => socialAccountApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowForm(false)
|
||||
toast.success('账户已创建')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setEditAccount(null)
|
||||
setShowForm(false)
|
||||
toast.success('已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => socialAccountApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('已删除')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
const setDefaultMutation = useMutation({
|
||||
mutationFn: (id: string) => socialAccountApi.setDefault(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('已设为默认')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">社保公积金账户管理</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">管理社保、公积金账户,关联公司/分公司/子公司。员工通过所属根部门自动继承账户。</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => { setEditAccount(null); setShowForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建账户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 类型切换 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{(['SOCIAL', 'HOUSING'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setAccountType(t)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
accountType === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t === 'SOCIAL' ? '社保账户' : '公积金账户'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 账户列表 */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : !accounts || accounts.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
暂无{accountType === 'SOCIAL' ? '社保' : '公积金'}账户,点击"新建账户"创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((a: any) => (
|
||||
<div key={a.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{a.name}</span>
|
||||
{a.isDefault && <span className="px-1.5 py-0.5 rounded text-xs bg-green-50 text-safe">默认</span>}
|
||||
{a.status === 'SUSPENDED' && <span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已停用</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-0.5">
|
||||
<div>城市:{a.city} {a.accountNo && `· 账号:${a.accountNo}`}</div>
|
||||
{a.orgName && <div>缴费主体:{a.orgName} {a.orgCode && `(${a.orgCode})`}</div>}
|
||||
{a.type === 'HOUSING' && a.bankName && <div>开户行:{a.bankName} {a.bankAccount && `· ${a.bankAccount}`}</div>}
|
||||
{a.accountType && a.type === 'HOUSING' && <div>账户类型:{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</div>}
|
||||
<div>关联:{(a._count?.deptSocialAccounts || 0) + (a._count?.deptHousingAccounts || 0)} 个根部门 · {(a._count?.socialRecords || 0) + (a._count?.housingRecords || 0)} 条参保记录</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!a.isDefault && a.status === 'ACTIVE' && (
|
||||
<button onClick={() => setDefaultMutation.mutate(a.id)} className="text-xs text-primary hover:underline" title="设为默认">设为默认</button>
|
||||
)}
|
||||
<button onClick={() => { setEditAccount(a); setShowForm(true) }} className="p-1 text-gray-400 hover:text-primary" title="编辑">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '删除确认', message: `确认删除账户"${a.name}"?关联的参保记录不会被删除。`, variant: 'danger' })) {
|
||||
deleteMutation.mutate(a.id)
|
||||
}
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-danger" title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新建/编辑弹窗 */}
|
||||
{showForm && (
|
||||
<AccountFormModal
|
||||
type={accountType}
|
||||
account={editAccount}
|
||||
onClose={() => { setShowForm(false); setEditAccount(null) }}
|
||||
onSubmit={async (data, departmentIds) => {
|
||||
if (editAccount) {
|
||||
updateMutation.mutate({ id: editAccount.id, data })
|
||||
if (departmentIds) {
|
||||
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
|
||||
toast.success('账户已更新,部门关联已同步')
|
||||
}
|
||||
} else {
|
||||
createMutation.mutate(
|
||||
{ ...data, type: accountType },
|
||||
{
|
||||
onSuccess: async (created: any) => {
|
||||
if (departmentIds && departmentIds.length > 0) {
|
||||
await socialAccountApi.linkDepartments(created.id, departmentIds)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
}}
|
||||
saving={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
|
||||
type: string
|
||||
account: any
|
||||
onClose: () => void
|
||||
onSubmit: (data: any, departmentIds?: string[]) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [name, setName] = useState(account?.name || '')
|
||||
const [city, setCity] = useState(account?.city || '')
|
||||
const [accountNo, setAccountNo] = useState(account?.accountNo || '')
|
||||
const [orgName, setOrgName] = useState(account?.orgName || '')
|
||||
const [orgCode, setOrgCode] = useState(account?.orgCode || '')
|
||||
const [bankName, setBankName] = useState(account?.bankName || '')
|
||||
const [bankAccount, setBankAccount] = useState(account?.bankAccount || '')
|
||||
const [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC')
|
||||
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
|
||||
const [remark, setRemark] = useState(account?.remark || '')
|
||||
const [selectedDeptIds, setSelectedDeptIds] = useState<string[]>([])
|
||||
|
||||
// 加载 level=0 根部门列表
|
||||
const { data: rootDepartments = [] } = useQuery<any[]>({
|
||||
queryKey: ['departments'],
|
||||
queryFn: () => api.get('/departments').then(r => r.data),
|
||||
})
|
||||
const rootDepts = rootDepartments.filter((d: any) => d.level === 0)
|
||||
|
||||
// 编辑时加载已关联部门
|
||||
useEffect(() => {
|
||||
if (account?.id) {
|
||||
socialAccountApi.accountDepartments(account.id).then((depts: any[]) => {
|
||||
setSelectedDeptIds(depts.map((d: any) => d.id))
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [account?.id])
|
||||
|
||||
const toggleDept = (id: string) => {
|
||||
setSelectedDeptIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>账户名称 *</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:北京总公司社保账户" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>参保城市 *</Label>
|
||||
<Input value={city} onChange={(e) => setCity(e.target.value)} placeholder="如:北京" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{type === 'SOCIAL' ? '社保登记号' : '公积金单位账号'}</Label>
|
||||
<Input value={accountNo} onChange={(e) => setAccountNo(e.target.value)} />
|
||||
</div>
|
||||
{type === 'HOUSING' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>账户类型</Label>
|
||||
<Select value={accountTypeVal} onChange={(e) => setAccountTypeVal(e.target.value)}>
|
||||
<option value="BASIC">基本公积金</option>
|
||||
<option value="SUPPLEMENTARY">补充公积金</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>开户行</Label>
|
||||
<Input value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="如:工商银行北京分行" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>银行账号</Label>
|
||||
<Input value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<Label>缴费主体名称</Label>
|
||||
<Input value={orgName} onChange={(e) => setOrgName(e.target.value)} placeholder="子公司/分公司名称" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>缴费主体统一社会信用代码</Label>
|
||||
<Input value={orgCode} onChange={(e) => setOrgCode(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>备注</Label>
|
||||
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* 关联根部门 */}
|
||||
<div>
|
||||
<Label>关联根部门(公司/分公司/子公司)</Label>
|
||||
<p className="text-xs text-gray-400 mb-2">勾选后,这些根部门下的员工将自动继承此账户。未勾选则使用公司默认账户。</p>
|
||||
{rootDepts.length === 0 ? (
|
||||
<div className="text-xs text-gray-400">暂无根部门</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{rootDepts.map((d: any) => (
|
||||
<label key={d.id} className="flex items-center gap-2 text-sm py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeptIds.includes(d.id)}
|
||||
onChange={() => toggleDept(d.id)}
|
||||
/>
|
||||
<span>{d.name}</span>
|
||||
{d._count?.employees > 0 && <span className="text-xs text-gray-400">({d._count.employees}人)</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||
设为默认账户(同类型下仅一个默认)
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
disabled={!name || !city || saving}
|
||||
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark }, selectedDeptIds)}
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,72 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useConfirm } from '../hooks/useConfirm'
|
||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
||||
import { Calculator, Check, Plus, Download, AlertCircle, Clock } from 'lucide-react'
|
||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import { socialInsuranceApi } from '../lib/api-services'
|
||||
import { socialInsuranceApi, socialAccountApi } from '../lib/api-services'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label } from '../components/ui/Input'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
|
||||
import { AccountCard } from './social-insurance/AccountCard'
|
||||
import { AccountFormModal } from './social-insurance/AccountFormModal'
|
||||
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
export default function SocialInsurance() {
|
||||
const queryClient = useQueryClient()
|
||||
const confirm = useConfirm()
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
|
||||
const [city, setCity] = useState<string>('北京')
|
||||
const [showAddCity, setShowAddCity] = useState(false)
|
||||
const [newCityName, setNewCityName] = useState('')
|
||||
const [base, setBase] = useState(8000)
|
||||
const [searchParams] = useSearchParams()
|
||||
const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
|
||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
|
||||
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)
|
||||
const [adjustData, setAdjustData] = useState<any>(null)
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
||||
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: '北京',
|
||||
pensionOrg: 16, pensionEmp: 8,
|
||||
medicalOrg: 9.8, medicalEmp: 2,
|
||||
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),
|
||||
city: '北京',
|
||||
accountType: 'BASIC',
|
||||
housingOrg: 12, housingEmp: 12,
|
||||
baseMin: 6326, baseMax: 33891,
|
||||
// 账户管理相关 state
|
||||
const [showAccountForm, setShowAccountForm] = useState(false)
|
||||
const [editAccount, setEditAccount] = useState<any>(null)
|
||||
|
||||
// 获取账户列表(按当前 tab 类型)
|
||||
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
|
||||
const { data: accounts = [] } = useQuery<any[]>({
|
||||
queryKey: ['social-accounts', accountType],
|
||||
queryFn: () => socialAccountApi.list(accountType),
|
||||
})
|
||||
|
||||
// 获取城市列表
|
||||
const { data: cities = [] } = useQuery<string[]>({
|
||||
queryKey: ['social-config-cities'],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.cities()
|
||||
// 账户 CRUD mutations
|
||||
const createAccountMutation = useMutation({
|
||||
mutationFn: (data: any) => socialAccountApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setShowAccountForm(false)
|
||||
toast.success('账户已创建')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
const { data: config, isLoading: configLoading } = useQuery<any>({
|
||||
queryKey: ['social-config', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.config(city)
|
||||
const updateAccountMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
setEditAccount(null)
|
||||
setShowAccountForm(false)
|
||||
toast.success('已更新')
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '更新失败'),
|
||||
})
|
||||
|
||||
const { data: housingConfig, isLoading: housingLoading } = useQuery<any>({
|
||||
queryKey: ['housing-config', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.housingConfig(city)
|
||||
const deleteAccountMutation = useMutation({
|
||||
mutationFn: (id: string) => socialAccountApi.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
toast.success('已删除')
|
||||
},
|
||||
})
|
||||
const { data: housingAllAccounts } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-all-accounts', city],
|
||||
queryFn: async () => {
|
||||
const res = await socialInsuranceApi.housingConfigVersions(city) as any
|
||||
const current = (res || []).filter((v: any) => v.isCurrent)
|
||||
return current
|
||||
},
|
||||
})
|
||||
|
||||
const { data: versions } = useQuery<any[]>({
|
||||
queryKey: ['social-config-versions', city],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.configVersions(city)
|
||||
},
|
||||
enabled: showVersions && tab === 'social',
|
||||
})
|
||||
|
||||
const { data: housingVersions } = useQuery<any[]>({
|
||||
queryKey: ['housing-config-versions'],
|
||||
queryFn: async () => {
|
||||
return await socialInsuranceApi.housingConfigVersions()
|
||||
},
|
||||
enabled: showVersions && tab === 'housing',
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||
})
|
||||
|
||||
// 已办理月份列表(进入月度办理Tab时自动加载)
|
||||
@@ -168,154 +139,6 @@ export default function SocialInsurance() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: result, mutate: calcMutate, isPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.calculate(base, city)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: housingResult, mutate: calcHousingMutate, isPending: housingCalcPending } = useMutation<any>({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.housingCalculate(base, city)
|
||||
},
|
||||
})
|
||||
|
||||
const createVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => socialInsuranceApi.createConfigVersion(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || err?.message || '创建失败'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const createHousingVersionMutation = useMutation({
|
||||
mutationFn: (data: any) => socialInsuranceApi.createHousingConfigVersion(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
setShowNewVersion(false)
|
||||
toast.success('公积金新版本已创建,旧版本已自动归档')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.message || err?.message || '创建失败'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const aiSuggestMut = useMutation<any, any, { city: string; effectiveFrom: string; type: 'social' | 'housing' }>({
|
||||
mutationFn: async (vars: { city: string; effectiveFrom: string; type: 'social' | 'housing' }) => {
|
||||
return await socialInsuranceApi.aiSuggest(vars)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (isHousing) {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
housingOrg: data.housingOrg ?? activeNewVersion.housingOrg,
|
||||
housingEmp: data.housingEmp ?? activeNewVersion.housingEmp,
|
||||
})
|
||||
} else {
|
||||
activeSetNewVersion({
|
||||
...activeNewVersion,
|
||||
baseMin: data.baseMin ?? activeNewVersion.baseMin,
|
||||
baseMax: data.baseMax ?? activeNewVersion.baseMax,
|
||||
medicalBaseMin: data.medicalBaseMin ?? 0,
|
||||
medicalBaseMax: data.medicalBaseMax ?? 0,
|
||||
pensionOrg: data.pensionOrg ?? activeNewVersion.pensionOrg,
|
||||
pensionEmp: data.pensionEmp ?? activeNewVersion.pensionEmp,
|
||||
medicalOrg: data.medicalOrg ?? activeNewVersion.medicalOrg,
|
||||
medicalEmp: data.medicalEmp ?? activeNewVersion.medicalEmp,
|
||||
unemploymentOrg: data.unemploymentOrg ?? activeNewVersion.unemploymentOrg,
|
||||
unemploymentEmp: data.unemploymentEmp ?? activeNewVersion.unemploymentEmp,
|
||||
injuryOrg: data.injuryOrg ?? activeNewVersion.injuryOrg,
|
||||
maternityOrg: data.maternityOrg ?? activeNewVersion.maternityOrg,
|
||||
extraInsurances: data.extraInsurances ?? [],
|
||||
})
|
||||
}
|
||||
toast.success('AI建议已填入,请核对后保存')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('AI建议获取失败,请手动填写')
|
||||
},
|
||||
})
|
||||
|
||||
const previewAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.adjustPreview(config?.id)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const previewHousingAdjustMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await socialInsuranceApi.housingAdjustPreview(housingConfig?.id)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setAdjustData(data)
|
||||
setShowAdjust(true)
|
||||
},
|
||||
})
|
||||
|
||||
const applyAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
socialInsuranceApi.applyAdjust(config?.id, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const applyHousingAdjustMutation = useMutation({
|
||||
mutationFn: (data: { items: { employeeId: string; newBase: number }[] }) =>
|
||||
socialInsuranceApi.applyHousingAdjust(housingConfig?.id, data),
|
||||
onSuccess: (res: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
toast.success(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => socialInsuranceApi.resetAdjust(config?.id, city),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions', city] })
|
||||
toast.success('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => socialInsuranceApi.resetHousingAdjust(housingConfig?.id, city),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config', city] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions', city] })
|
||||
toast.success('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||
if (!data?.items?.length) return
|
||||
const headers = type === 'social'
|
||||
@@ -338,17 +161,12 @@ export default function SocialInsurance() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const isHousing = tab === 'housing'
|
||||
const activeConfig = isHousing ? housingConfig : config
|
||||
const activeVersions = isHousing ? housingVersions : versions
|
||||
const activePreviewMut = isHousing ? previewHousingAdjustMutation : previewAdjustMutation
|
||||
const activeApplyMut = isHousing ? applyHousingAdjustMutation : applyAdjustMutation
|
||||
const activeCreateMut = isHousing ? createHousingVersionMutation : createVersionMutation
|
||||
const activeNewVersion = isHousing ? newHousingVersion : newVersion
|
||||
const activeSetNewVersion = isHousing ? setNewHousingVersion : setNewVersion
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<PageGuide>
|
||||
社保公积金管理,维护社保、公积金缴费基数和比例,按月办理增减员。流程:①设置缴费基数和比例 → ②按月获取增减员名单 → ③完成办理。关联:社保基数影响薪税管理的工资计算;离职员工的社保截止请在离职管理中确认。
|
||||
</PageGuide>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-5 w-5 text-primary" />
|
||||
@@ -357,570 +175,83 @@ export default function SocialInsurance() {
|
||||
<p className="mt-1 text-sm text-gray-500">维护社保、公积金、商业保险缴费基数、版本及月度记录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setShowVersions(!showVersions)}>
|
||||
<History className="w-4 h-4 mr-1" />
|
||||
{showVersions ? '收起历史' : '版本历史'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowNewVersion(!showNewVersion)}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建版本
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 + 城市选择 */}
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center gap-4 border-b">
|
||||
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
|
||||
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
onClick={() => { setTab(t); setMonthlyProcessed(false); setProcessStatus(null) }}
|
||||
>
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
|
||||
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
||||
</button>
|
||||
))}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<label className="text-sm text-gray-500">城市:</label>
|
||||
{showAddCity ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
className="h-9 w-24 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={newCityName}
|
||||
onChange={(e) => setNewCityName(e.target.value)}
|
||||
placeholder="城市名"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newCityName.trim()) {
|
||||
setCity(newCityName.trim())
|
||||
setNewCityName('')
|
||||
setShowAddCity(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
|
||||
}
|
||||
if (e.key === 'Escape') { setShowAddCity(false); setNewCityName('') }
|
||||
}}
|
||||
/>
|
||||
<button className="h-9 px-2 text-xs text-primary" onClick={() => {
|
||||
if (newCityName.trim()) {
|
||||
setCity(newCityName.trim())
|
||||
setNewCityName('')
|
||||
setShowAddCity(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-cities'] })
|
||||
}
|
||||
}}>确定</button>
|
||||
<button className="h-9 px-2 text-xs text-gray-400" onClick={() => { setShowAddCity(false); setNewCityName('') }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
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.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<button
|
||||
className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-400 hover:text-primary hover:border-primary transition"
|
||||
title="添加新城市"
|
||||
onClick={() => setShowAddCity(true)}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
||||
{/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
(isHousing ? housingLoading : configLoading) ? (
|
||||
<Card><div className="text-center py-8 text-gray-500">加载中...</div></Card>
|
||||
) : activeConfig ? (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe">当前生效</span>
|
||||
<span className="text-sm text-gray-500">生效月份:{activeConfig.effectiveFrom}</span>
|
||||
<span className="text-sm text-gray-500">· {activeConfig.city}</span>
|
||||
{activeConfig.adjustmentDone && (
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-500">已调整员工基数</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeConfig.adjustmentDone && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (await confirm({ title: '重置确认', message: `确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`, variant: 'primary' })) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => activePreviewMut.mutate()}
|
||||
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* 新建对应类型账户按钮 */}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => { setEditAccount(null); setShowAccountForm(true) }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建{tab === 'housing' ? '公积金' : '社保'}账户
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4 shrink-0" />
|
||||
<span>批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。</span>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<>
|
||||
{(housingAllAccounts || []).length > 1 && (
|
||||
<div className="flex gap-2 mb-3">
|
||||
{(housingAllAccounts || []).map((a: any) => (
|
||||
<span key={a.id} className={`px-2 py-0.5 rounded text-xs ${a.accountType === 'SUPPLEMENTARY' ? 'bg-purple-50 text-purple-700' : 'bg-blue-50 text-blue-700'}`}>
|
||||
{a.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'} {a.housingOrg}%/{a.housingEmp}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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">{activeConfig.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</span></div>
|
||||
<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>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(企业)</span><span className="font-medium">{activeConfig.housingOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">公积金(个人)</span><span className="font-medium">{activeConfig.housingEmp}%</span></div>
|
||||
{accounts.length === 0 ? (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
暂无{tab === 'housing' ? '公积金' : '社保'}账户,点击上方「新建{tab === 'housing' ? '公积金' : '社保'}账户」创建
|
||||
</div>
|
||||
</>
|
||||
</Card>
|
||||
) : (
|
||||
<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>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">工伤(企业)</span><span className="font-medium">{activeConfig.injuryOrg}%</span></div>
|
||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
|
||||
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
|
||||
<div key={idx} className="flex justify-between border-b pb-1.5">
|
||||
<span className="text-gray-500">{ins.name}(企业/个人)</span>
|
||||
<span className="font-medium">
|
||||
{ins.baseType === 'fixed'
|
||||
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
|
||||
: `${ins.orgRate}% / ${ins.empRate}%`
|
||||
accounts.map((a: any) => (
|
||||
<AccountCard
|
||||
key={a.id}
|
||||
account={a}
|
||||
isHousing={tab === 'housing'}
|
||||
onEdit={(acc) => { setEditAccount(acc); setShowAccountForm(true) }}
|
||||
onDelete={(acc) => deleteAccountMutation.mutate(acc.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* 账户新建/编辑弹窗 */}
|
||||
{showAccountForm && (
|
||||
<AccountFormModal
|
||||
type={accountType}
|
||||
account={editAccount}
|
||||
onClose={() => { setShowAccountForm(false); setEditAccount(null) }}
|
||||
onSubmit={async (data, departmentIds) => {
|
||||
if (editAccount) {
|
||||
updateAccountMutation.mutate({ id: editAccount.id, data })
|
||||
if (departmentIds) {
|
||||
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
|
||||
toast.success('账户已更新,部门关联已同步')
|
||||
}
|
||||
} else {
|
||||
createAccountMutation.mutate(
|
||||
{ ...data, type: accountType },
|
||||
{
|
||||
onSuccess: async (created: any) => {
|
||||
if (departmentIds && departmentIds.length > 0) {
|
||||
await socialAccountApi.linkDepartments(created.id, departmentIds)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||
},
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card><div className="text-center py-8 text-gray-500">该城市暂无{isHousing ? '公积金' : '社保'}配置,请点击「新建版本」创建</div></Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 调整预览 */}
|
||||
{(tab === 'social' || tab === 'housing') && showAdjust && adjustData && (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />员工{isHousing ? '公积金' : '社保'}基数调整
|
||||
</h3>
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2 mb-3">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
按当前版本基数上下限(¥{fmt(adjustData.baseMin)} ~ ¥{fmt(adjustData.baseMax)})调整全部在职员工{isHousing ? '公积金' : '社保'}缴费基数。
|
||||
建议基数=上年月均工资按上下限裁剪。您可逐行修改,也可点击「采用建议值」或「保持原基数」。确认后保存,此操作只能执行一次。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.suggestedBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
<Check className="w-3.5 h-3.5 mr-1" />全部采用建议值
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const newEdits: Record<string, number> = {}
|
||||
adjustData.items.forEach((i: any) => { newEdits[i.employeeId] = i.oldBase })
|
||||
setEditItems(newEdits)
|
||||
}}>
|
||||
全部保持原基数
|
||||
</Button>
|
||||
<span className="text-xs text-gray-400">共 {adjustData.total} 名员工</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">姓名</th>
|
||||
<th className="py-2 text-left">部门</th>
|
||||
<th className="py-2 text-right">上年月均</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(当前)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(建议)</th>
|
||||
<th className="py-2 text-right">{isHousing ? '公积金' : '社保'}基数(新)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{adjustData.items.map((item: any) => {
|
||||
const edit = editItems[item.employeeId]
|
||||
const newBase = edit ?? item.suggestedBase
|
||||
const changed = newBase !== item.oldBase
|
||||
return (
|
||||
<tr key={item.employeeId} className="border-b last:border-0">
|
||||
<td className="py-1.5">{item.name}</td>
|
||||
<td className="py-1.5 text-gray-500">{item.department}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.avgSalary)}</td>
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{editingId === item.employeeId ? (
|
||||
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
|
||||
onBlur={() => setEditingId(null)}
|
||||
autoFocus />
|
||||
) : (
|
||||
<span className="cursor-text inline-block !w-28 text-right"
|
||||
onClick={() => setEditingId(item.employeeId)}>
|
||||
¥{fmt(newBase)}
|
||||
</span>
|
||||
)}
|
||||
{changed && <span className="text-warning ml-1">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => {
|
||||
const items = adjustData.items.map((i: any) => ({ employeeId: i.employeeId, newBase: editItems[i.employeeId] ?? i.suggestedBase }))
|
||||
activeApplyMut.mutate({ items })
|
||||
}} disabled={activeApplyMut.isPending}>
|
||||
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 版本历史 */}
|
||||
{(tab === 'social' || tab === 'housing') && showVersions && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><History className="w-4 h-4" />{isHousing ? '公积金' : '社保'}版本历史</h3>
|
||||
{!activeVersions || activeVersions.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-400 text-xs">暂无版本记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 text-left">生效月份</th>
|
||||
<th className="py-2 text-left">失效月份</th>
|
||||
<th className="py-2 text-left">城市</th>
|
||||
{isHousing && <th className="py-2 text-left">账户类型</th>}
|
||||
<th className="py-2 text-right">基数下限</th>
|
||||
<th className="py-2 text-right">基数上限</th>
|
||||
{isHousing ? (
|
||||
<th className="py-2 text-right">公积金%</th>
|
||||
) : (
|
||||
<>
|
||||
<th className="py-2 text-right">养老%</th>
|
||||
<th className="py-2 text-right">医疗%</th>
|
||||
</>
|
||||
)}
|
||||
<th className="py-2 text-center">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeVersions.map((v: any) => (
|
||||
<tr key={v.id} className="border-b last:border-0 hover:bg-gray-50">
|
||||
<td className="py-2">{v.effectiveFrom}</td>
|
||||
<td className="py-2 text-gray-400">{v.effectiveTo || '—'}</td>
|
||||
<td className="py-2">{v.city}</td>
|
||||
{isHousing && <td className="py-2">{v.accountType === 'SUPPLEMENTARY' ? '补充公积金' : '基本公积金'}</td>}
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMin)}</td>
|
||||
<td className="py-2 text-right">¥{fmt(v.baseMax)}</td>
|
||||
{isHousing ? (
|
||||
<td className="py-2 text-right text-gray-500">{v.housingOrg}/{v.housingEmp}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-2 text-right text-gray-500">{v.pensionOrg}/{v.pensionEmp}</td>
|
||||
<td className="py-2 text-right text-gray-500">{v.medicalOrg}/{v.medicalEmp}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="py-2 text-center">
|
||||
{v.isCurrent ? <span className="px-2 py-0.5 rounded bg-green-50 text-safe">当前</span> : <span className="px-2 py-0.5 rounded bg-gray-100 text-gray-400">历史</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 新建版本 */}
|
||||
{(tab === 'social' || tab === 'housing') && showNewVersion && (
|
||||
<Card>
|
||||
<h3 className="text-xs font-medium mb-3 flex items-center gap-2"><Plus className="w-4 h-4" />新建{isHousing ? '公积金' : '社保'}配置版本</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 font-medium"
|
||||
onClick={() => aiSuggestMut.mutate({ city: activeNewVersion.city, effectiveFrom: activeNewVersion.effectiveFrom, type: isHousing ? 'housing' : 'social' })}
|
||||
disabled={aiSuggestMut.isPending}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{aiSuggestMut.isPending ? 'AI获取中...' : 'AI建议 — 根据城市和年份自动填充最新政策'}
|
||||
</button>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<div><Label>生效月份</Label><Input type="month" value={activeNewVersion.effectiveFrom} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} /></div>
|
||||
<div><Label>城市</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.city} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, city: e.target.value })}>
|
||||
{[...new Set([city, ...cities])].map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<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-3 gap-3">
|
||||
<div>
|
||||
<Label>账户类型</Label>
|
||||
<select className="w-full h-9 rounded-md border border-input bg-background px-3 text-sm" value={activeNewVersion.accountType || 'BASIC'} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, accountType: e.target.value })}>
|
||||
<option value="BASIC">基本公积金</option>
|
||||
<option value="SUPPLEMENTARY">补充公积金</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><Label>公积金(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>公积金(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.housingEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} /></div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<div><Label>养老(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>养老(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.pensionEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>医疗(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.medicalEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} /></div>
|
||||
<div><Label>失业(个人%)</Label><Input type="number" step="0.1" value={activeNewVersion.unemploymentEmp} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} /></div>
|
||||
<div><Label>工伤(企业%)</Label><Input type="number" step="0.1" value={activeNewVersion.injuryOrg} onChange={(e) => activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} /></div>
|
||||
<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 ? '保存中...' : '创建版本'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setShowNewVersion(false)}>取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 试算工具 */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3">{isHousing ? '公积金' : '社保'}试算</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>缴费基数(月工资)</Label>
|
||||
<Input type="number" value={base} onChange={(e) => setBase(Number(e.target.value) || 0)} />
|
||||
</div>
|
||||
<Button onClick={() => isHousing ? calcHousingMutate() : calcMutate()} disabled={isHousing ? housingCalcPending : isPending}>
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
{(isHousing ? housingCalcPending : isPending) ? '计算中...' : '开始计算'}
|
||||
</Button>
|
||||
{activeConfig && (
|
||||
<div className="text-sm text-gray-400">
|
||||
当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-3 flex items-center gap-2"><Calculator className="w-4 h-4" />计算结果</h2>
|
||||
{(() => {
|
||||
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">
|
||||
缴费基数:<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="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-gray-500">
|
||||
<th className="py-1.5">险种</th>
|
||||
<th className="py-1.5 text-right">企业%</th>
|
||||
<th className="py-1.5 text-right">个人%</th>
|
||||
<th className="py-1.5 text-right">企业缴纳</th>
|
||||
<th className="py-1.5 text-right">个人缴纳</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
<td className="py-1.5 text-right text-gray-500">{item.empRate}%</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.orgAmount)}</td>
|
||||
<td className="py-1.5 text-right">¥{fmt(item.empAmount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-bold">
|
||||
<td className="py-2" colSpan={3}>合计</td>
|
||||
<td className="py-2 text-right text-danger">¥{fmt(r.totalOrg)}</td>
|
||||
<td className="py-2 text-right text-warning">¥{fmt(r.totalEmp)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</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.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</Card>
|
||||
}}
|
||||
saving={createAccountMutation.isPending || updateAccountMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 基数说明(仅社保/公积金Tab显示) */}
|
||||
{(tab === 'social' || tab === 'housing') && (
|
||||
<p className="text-sm text-gray-400">
|
||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ========== 月度办理 Tab ========== */}
|
||||
{tab === 'monthly' && (
|
||||
<div className="space-y-3">
|
||||
@@ -1150,6 +481,11 @@ export default function SocialInsurance() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 员工参保信息 Tab ========== */}
|
||||
{tab === 'enrollment' && (
|
||||
<EmployeeEnrollmentTab />
|
||||
)}
|
||||
|
||||
{/* ========== 专项附加扣除 Tab ========== */}
|
||||
{tab === 'deduction' && (
|
||||
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user