Compare commits
87 Commits
8cfdd566af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aea88fd385 | |||
| 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 |
@@ -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
|
ADMIN
|
||||||
HR
|
HR
|
||||||
VIEWER
|
VIEWER
|
||||||
|
SUPPORT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EmployeeStatus {
|
enum EmployeeStatus {
|
||||||
@@ -139,6 +140,7 @@ model Organization {
|
|||||||
esignTrainingEnabled Boolean @default(false) // 培训记录电子签
|
esignTrainingEnabled Boolean @default(false) // 培训记录电子签
|
||||||
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
|
esignPerformanceEnabled Boolean @default(false) // 绩效考核电子签
|
||||||
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
|
esignDisciplinaryEnabled Boolean @default(false) // 违纪记录电子签
|
||||||
|
socialInsCutoffDay Int @default(15) // 社保/公积金截止日:每月该日前离职→截止月=离职月-1,该日后→截止月=离职月
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -196,7 +198,18 @@ model Organization {
|
|||||||
benefitPlans EmployeeBenefitPlan[]
|
benefitPlans EmployeeBenefitPlan[]
|
||||||
benefitEnrollments EmployeeBenefitEnrollment[]
|
benefitEnrollments EmployeeBenefitEnrollment[]
|
||||||
eSignRecords ESignRecord[]
|
eSignRecords ESignRecord[]
|
||||||
|
commissionBonuses CommissionBonus[]
|
||||||
medicalPeriodPolicies MedicalPeriodPolicy[]
|
medicalPeriodPolicies MedicalPeriodPolicy[]
|
||||||
|
socialAccounts SocialAccount[]
|
||||||
|
socialYearStandards SocialYearStandard[]
|
||||||
|
// 组织架构 + 审批流
|
||||||
|
departments Department[]
|
||||||
|
positions Position[]
|
||||||
|
approvalFlows ApprovalFlow[]
|
||||||
|
approvalInstances ApprovalInstance[]
|
||||||
|
// 客服工作台
|
||||||
|
tickets Ticket[]
|
||||||
|
chatSessions ChatSession[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -224,11 +237,17 @@ model Employee {
|
|||||||
department String
|
department String
|
||||||
position String? // 岗位
|
position String? // 岗位
|
||||||
hireDate DateTime
|
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)
|
status EmployeeStatus @default(ACTIVE)
|
||||||
gender String?
|
gender String?
|
||||||
phone String?
|
phone String?
|
||||||
idCardNumber String? // AES-256 加密存储
|
idCardNumber String? // AES-256 加密存储
|
||||||
|
idType String? // 证件类型:ID_CARD(身份证,默认)/PASSPORT/HK_MACAO_TAIWAN/OTHER
|
||||||
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
|
idCardHash String? // SHA-256 哈希,用于按身份证号查询匹配
|
||||||
emergencyContact String?
|
emergencyContact String?
|
||||||
emergencyPhone String?
|
emergencyPhone String?
|
||||||
@@ -246,6 +265,8 @@ model Employee {
|
|||||||
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
|
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
|
||||||
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
|
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
|
||||||
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
|
||||||
|
supplementaryHousingAccountId String? // 补充公积金账户(可选,高管才有)
|
||||||
|
supplementaryHousingAccount SocialAccount? @relation("EmpSupplementaryHousingAccount", fields: [supplementaryHousingAccountId], references: [id], onDelete: SetNull)
|
||||||
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
|
||||||
city String? // 员工社保参保城市
|
city String? // 员工社保参保城市
|
||||||
birthDate DateTime? // 出生日期(从身份证号提取)
|
birthDate DateTime? // 出生日期(从身份证号提取)
|
||||||
@@ -285,10 +306,35 @@ model Employee {
|
|||||||
commercialInsEnrollments CommercialInsuranceEnrollment[]
|
commercialInsEnrollments CommercialInsuranceEnrollment[]
|
||||||
benefitEnrollments EmployeeBenefitEnrollment[]
|
benefitEnrollments EmployeeBenefitEnrollment[]
|
||||||
eSignRecords ESignRecord[]
|
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])
|
@@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 {
|
model LaborContract {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
@@ -299,10 +345,13 @@ model LaborContract {
|
|||||||
startDate DateTime
|
startDate DateTime
|
||||||
endDate DateTime?
|
endDate DateTime?
|
||||||
contractType ContractType
|
contractType ContractType
|
||||||
|
contractCategory String? // 合同分类:LABOR_CONTRACT(劳动合同,默认)/LABOR_AGREEMENT(劳务协议)/INTERNSHIP(实习协议)/FLEXIBLE(灵活用工)
|
||||||
signMethod SignMethod @default(PAPER)
|
signMethod SignMethod @default(PAPER)
|
||||||
contractYears Int @default(3)
|
contractYears Int @default(3)
|
||||||
probationMonths Int @default(0)
|
probationMonths Int @default(0)
|
||||||
probationSalary Int @default(0)
|
probationSalary Int @default(0)
|
||||||
|
baseSalary Int @default(0) // 基本工资(转正后)
|
||||||
|
performanceSalary Int @default(0) // 绩效工资(转正后,可为0)
|
||||||
renewalCount Int @default(0)
|
renewalCount Int @default(0)
|
||||||
attachmentName String?
|
attachmentName String?
|
||||||
attachmentUrl 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 {
|
model SocialInsuranceConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
@@ -427,6 +558,8 @@ model SocialInsuranceConfig {
|
|||||||
pensionEmp Float @default(8) // 养老保险 个人比例 %
|
pensionEmp Float @default(8) // 养老保险 个人比例 %
|
||||||
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
|
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
|
||||||
medicalEmp Float @default(2) // 医疗保险 个人比例 %
|
medicalEmp Float @default(2) // 医疗保险 个人比例 %
|
||||||
|
medicalOrgExtra Float @default(0) // 医疗企业附加(固定金额)
|
||||||
|
medicalEmpExtra Float @default(0) // 医疗个人附加(固定金额,如北京3元)
|
||||||
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
|
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
|
||||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||||
@@ -436,6 +569,7 @@ model SocialInsuranceConfig {
|
|||||||
medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin)
|
medicalBaseMin Float @default(0) // 医疗/生育保险基数下限(0 时 fallback 到 baseMin)
|
||||||
medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax)
|
medicalBaseMax Float @default(0) // 医疗/生育保险基数上限(0 时 fallback 到 baseMax)
|
||||||
extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }]
|
extraInsurances Json? // 附加险种配置 JSON: [{ name, orgRate, empRate, baseType: 'pension'|'medical'|'fixed', fixedAmount }]
|
||||||
|
minWage Float @default(0) // 当地月最低工资标准(0=不检查)
|
||||||
effectiveFrom String // 生效月份 YYYY-MM
|
effectiveFrom String // 生效月份 YYYY-MM
|
||||||
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
effectiveTo String? // 失效月份 YYYY-MM(null=当前有效)
|
||||||
isCurrent Boolean @default(true) // 是否当前生效版本
|
isCurrent Boolean @default(true) // 是否当前生效版本
|
||||||
@@ -583,6 +717,7 @@ model DisciplinaryRecord {
|
|||||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||||
actionDetail String?
|
actionDetail String?
|
||||||
|
deductionAmount Float @default(0) // 扣款金额(action=DEDUCTION 时填写,用于薪资批次导入)
|
||||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||||
ackDate DateTime?
|
ackDate DateTime?
|
||||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||||
@@ -690,6 +825,7 @@ model Payslip {
|
|||||||
month String // YYYY-MM
|
month String // YYYY-MM
|
||||||
// 薪酬构成
|
// 薪酬构成
|
||||||
baseSalary Float @default(0)
|
baseSalary Float @default(0)
|
||||||
|
performanceSalary Float @default(0) // 绩效工资
|
||||||
overtimePay Float @default(0)
|
overtimePay Float @default(0)
|
||||||
weekdayOvertimePay Float @default(0)
|
weekdayOvertimePay Float @default(0)
|
||||||
weekendOvertimePay Float @default(0)
|
weekendOvertimePay Float @default(0)
|
||||||
@@ -728,7 +864,8 @@ model PayrollBatch {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
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...)
|
batchNo Int // 批次序号(1, 2, 3...)
|
||||||
name String // 批次名称
|
name String // 批次名称
|
||||||
type PayrollBatchType @default(REGULAR)
|
type PayrollBatchType @default(REGULAR)
|
||||||
@@ -781,9 +918,20 @@ model BatchEntry {
|
|||||||
socialOrg Float @default(0)
|
socialOrg Float @default(0)
|
||||||
housingEmp Float @default(0)
|
housingEmp Float @default(0)
|
||||||
housingOrg Float @default(0)
|
housingOrg Float @default(0)
|
||||||
|
supplementaryHousingEmp Float @default(0) // 补充公积金个人部分
|
||||||
|
supplementaryHousingOrg Float @default(0) // 补充公积金单位部分
|
||||||
tax Float @default(0)
|
tax Float @default(0)
|
||||||
totalPay Float @default(0) // 应发合计
|
totalPay Float @default(0) // 应发合计
|
||||||
netPay 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?
|
riskWarnings Json?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -836,7 +984,9 @@ model EmployeeSocialInsRecord {
|
|||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
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
|
startMonth String // 开始缴费年月 YYYY-MM
|
||||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||||
base Float // 缴费基数
|
base Float // 缴费基数
|
||||||
@@ -849,6 +999,7 @@ model EmployeeSocialInsRecord {
|
|||||||
@@index([orgId, employeeId])
|
@@index([orgId, employeeId])
|
||||||
@@index([employeeId, startMonth, endMonth])
|
@@index([employeeId, startMonth, endMonth])
|
||||||
@@index([orgId, city])
|
@@index([orgId, city])
|
||||||
|
@@index([accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model EmployeeHousingFundRecord {
|
model EmployeeHousingFundRecord {
|
||||||
@@ -857,7 +1008,9 @@ model EmployeeHousingFundRecord {
|
|||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
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
|
startMonth String // 开始缴费年月 YYYY-MM
|
||||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||||
base Float // 缴费基数
|
base Float // 缴费基数
|
||||||
@@ -869,6 +1022,7 @@ model EmployeeHousingFundRecord {
|
|||||||
|
|
||||||
@@index([orgId, employeeId])
|
@@index([orgId, employeeId])
|
||||||
@@index([employeeId, startMonth, endMonth])
|
@@index([employeeId, startMonth, endMonth])
|
||||||
|
@@index([accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
||||||
@@ -876,6 +1030,8 @@ model SocialMonthlyProcess {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
accountId String? // 关联到账户(迁移后非空)
|
||||||
|
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||||
month String // 办理月份 YYYY-MM
|
month String // 办理月份 YYYY-MM
|
||||||
type String // SOCIAL=社保, HOUSING=公积金
|
type String // SOCIAL=社保, HOUSING=公积金
|
||||||
status String @default("COMPLETED") // COMPLETED=已办理
|
status String @default("COMPLETED") // COMPLETED=已办理
|
||||||
@@ -887,6 +1043,7 @@ model SocialMonthlyProcess {
|
|||||||
|
|
||||||
@@unique([orgId, month, type])
|
@@unique([orgId, month, type])
|
||||||
@@index([orgId, month])
|
@@index([orgId, month])
|
||||||
|
@@index([accountId, month])
|
||||||
}
|
}
|
||||||
|
|
||||||
model EmployeeDepartmentRecord {
|
model EmployeeDepartmentRecord {
|
||||||
@@ -897,10 +1054,12 @@ model EmployeeDepartmentRecord {
|
|||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
oldDepartment String // 调整前部门
|
oldDepartment String // 调整前部门
|
||||||
newDepartment String // 调整后部门
|
newDepartment String // 调整后部门
|
||||||
|
oldPosition String? // 调整前职务
|
||||||
|
newPosition String? // 调整后职务
|
||||||
effectiveMonth String // 生效年月 YYYY-MM
|
effectiveMonth String // 生效年月 YYYY-MM
|
||||||
endMonth String? // 失效年月 YYYY-MM(null=至今有效)
|
endMonth String? // 失效年月 YYYY-MM(null=至今有效)
|
||||||
reason String? // 调部门原因
|
reason String? // 调动原因
|
||||||
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
|
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调动
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -1041,6 +1200,7 @@ model AttendanceConfirmation {
|
|||||||
weekendHours Float @default(0)
|
weekendHours Float @default(0)
|
||||||
holidayHours Float @default(0)
|
holidayHours Float @default(0)
|
||||||
overtimePay Float @default(0)
|
overtimePay Float @default(0)
|
||||||
|
deductionAmount Float @default(0) // 考勤扣款金额(迟到/早退/旷工等,手动填写,用于薪资批次导入)
|
||||||
confirmedAt DateTime?
|
confirmedAt DateTime?
|
||||||
confirmedBy String?
|
confirmedBy String?
|
||||||
confirmIp String?
|
confirmIp String?
|
||||||
@@ -1509,6 +1669,7 @@ model ESignRecord {
|
|||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
|
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
|
||||||
|
signMethod String @default("ELECTRONIC") // ELECTRONIC=电子签署 | PAPER=线下手签
|
||||||
flowId String? // 易签宝流程ID
|
flowId String? // 易签宝流程ID
|
||||||
documentTitle String // 文件标题
|
documentTitle String // 文件标题
|
||||||
documentContent String? // 文件内容(HTML/PDF base64)
|
documentContent String? // 文件内容(HTML/PDF base64)
|
||||||
@@ -1518,13 +1679,183 @@ model ESignRecord {
|
|||||||
initiatedBy String // 发起人(HR用户ID)
|
initiatedBy String // 发起人(HR用户ID)
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
expiredAt DateTime?
|
expiredAt DateTime?
|
||||||
callbackData Json? // 易签宝回调数据
|
callbackData Json? // 易签宝回调数据 / 线下手签证据
|
||||||
remark String?
|
remark String?
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// 线下手签专用字段
|
||||||
|
signedAt DateTime? // 线下签署日期
|
||||||
|
signedLocation String? // 签署地点
|
||||||
|
witnessName String? // 见证人姓名
|
||||||
|
witnessPhone String? // 见证人手机号
|
||||||
|
scanFileUrls Json? // 线下签署扫描件URL列表 [{name, url}]
|
||||||
|
|
||||||
@@index([orgId, status])
|
@@index([orgId, status])
|
||||||
@@index([employeeId])
|
@@index([employeeId])
|
||||||
@@index([contractId])
|
@@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()
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
|
import path from 'path'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import helmet from 'helmet'
|
import helmet from 'helmet'
|
||||||
import morgan from 'morgan'
|
import morgan from 'morgan'
|
||||||
@@ -35,6 +36,11 @@ app.get('/health', (_req, res) => {
|
|||||||
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
|
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)
|
app.use('/api/v1', apiLimiter)
|
||||||
|
|
||||||
// 路由挂载
|
// 路由挂载
|
||||||
@@ -61,6 +67,10 @@ import auditRoutes from './routes/audit.routes'
|
|||||||
import calendarRoutes from './routes/calendar.routes'
|
import calendarRoutes from './routes/calendar.routes'
|
||||||
import platformRoutes from './routes/platform.routes'
|
import platformRoutes from './routes/platform.routes'
|
||||||
import workProcessRoutes from './routes/work-process.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 enterpriseTemplateRoutes from './routes/enterprise-template.routes'
|
||||||
import specialStatusRoutes from './routes/special-status.routes'
|
import specialStatusRoutes from './routes/special-status.routes'
|
||||||
import companyFileRoutes from './routes/company-file.routes'
|
import companyFileRoutes from './routes/company-file.routes'
|
||||||
@@ -70,6 +80,7 @@ import salaryRoutes from './routes/salary.routes'
|
|||||||
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
|
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
|
||||||
import benefitRoutes from './routes/benefit.routes'
|
import benefitRoutes from './routes/benefit.routes'
|
||||||
import esignRoutes from './routes/esign.routes'
|
import esignRoutes from './routes/esign.routes'
|
||||||
|
import commissionBonusRoutes from './routes/commission-bonus.routes'
|
||||||
app.use('/api/v1/auth', authRoutes)
|
app.use('/api/v1/auth', authRoutes)
|
||||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||||
app.use('/api/v1/employees', employeeRoutes)
|
app.use('/api/v1/employees', employeeRoutes)
|
||||||
@@ -93,6 +104,10 @@ app.use('/api/v1/audit', auditRoutes)
|
|||||||
app.use('/api/v1/calendar', calendarRoutes)
|
app.use('/api/v1/calendar', calendarRoutes)
|
||||||
app.use('/api/v1/platform', platformRoutes)
|
app.use('/api/v1/platform', platformRoutes)
|
||||||
app.use('/api/v1/work-processes', workProcessRoutes)
|
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/enterprise-templates', enterpriseTemplateRoutes)
|
||||||
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
app.use('/api/v1/special-statuses', specialStatusRoutes)
|
||||||
app.use('/api/v1/company-files', companyFileRoutes)
|
app.use('/api/v1/company-files', companyFileRoutes)
|
||||||
@@ -102,6 +117,10 @@ app.use('/api/v1/salary', salaryRoutes)
|
|||||||
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
|
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
|
||||||
app.use('/api/v1/benefits', benefitRoutes)
|
app.use('/api/v1/benefits', benefitRoutes)
|
||||||
app.use('/api/v1/esign', esignRoutes)
|
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)
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,23 @@ function daysFromNow(date: Date): number {
|
|||||||
return Math.floor((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
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> {
|
async function buildOrgContext(orgId: string): Promise<string> {
|
||||||
const [employees, risks] = await Promise.all([
|
const [employees, risks] = await Promise.all([
|
||||||
prisma.employee.findMany({
|
prisma.employee.findMany({
|
||||||
@@ -98,7 +115,7 @@ async function buildOrgContext(orgId: string): Promise<string> {
|
|||||||
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
|
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
|
||||||
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
|
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
|
||||||
if (e.isWorkInjured) 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')
|
}).join('\n')
|
||||||
|
|
||||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level}):${r.description || '无详细描述'}`).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)}
|
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
|
||||||
员工详情:
|
员工详情:
|
||||||
- 姓名:${emp.name}
|
- 姓名:${emp.name}
|
||||||
|
- 身份证号:${maskIdCard(emp.idCardNumber)}
|
||||||
- 部门:${emp.department}
|
- 部门:${emp.department}
|
||||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||||
- 状态:${emp.status}
|
- 状态:${emp.status}
|
||||||
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
|
- 特殊状态:${[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 ? '已签署解除协议' : '未签署解除协议'}` : ''}`
|
- 合同:${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) {
|
} 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 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}`
|
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
|
||||||
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
|
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
|
||||||
}
|
}
|
||||||
@@ -306,16 +324,17 @@ router.get('/predict-stream', authMiddleware, async (req: AuthRequest, res, next
|
|||||||
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
|
orgContext = `当前日期:${new Date().toISOString().slice(0, 10)}
|
||||||
员工详情:
|
员工详情:
|
||||||
- 姓名:${emp.name}
|
- 姓名:${emp.name}
|
||||||
|
- 身份证号:${maskIdCard(emp.idCardNumber)}
|
||||||
- 部门:${emp.department}
|
- 部门:${emp.department}
|
||||||
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}(已工作${tenure})
|
||||||
- 状态:${emp.status}
|
- 状态:${emp.status}
|
||||||
- 特殊状态:${[emp.isPregnant && '孕期/哺乳期', emp.isInMedicalPeriod && '医疗期', emp.isWorkInjured && '工伤'].filter(Boolean).join('、') || '无'}
|
- 特殊状态:${[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 ? '已签署解除协议' : '未签署解除协议'}` : ''}`
|
- 合同:${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) {
|
} 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 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}`
|
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}`
|
||||||
scopeHint = `请只针对【${department}】部门的员工进行风险预测,不要分析其他部门的员工。`
|
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),
|
weekendHours: z.number().min(0),
|
||||||
holidayHours: z.number().min(0),
|
holidayHours: z.number().min(0),
|
||||||
overtimePay: z.number().min(0),
|
overtimePay: z.number().min(0),
|
||||||
|
deductionAmount: z.number().min(0).optional(),
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
const { month, items } = schema.parse(req.body)
|
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) }
|
} 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) => {
|
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@@ -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 { Router } from 'express'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { auditLog } from '../middleware/auditLog'
|
import { auditLog } from '../middleware/auditLog'
|
||||||
import { createEvidence } from '../services/evidence.service'
|
import { createEvidence } from '../services/evidence.service'
|
||||||
|
import { autoCreateEsignRecord } from '../services/esign.service'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { sha256 } from '../lib/crypto'
|
import { sha256, decrypt } from '../lib/crypto'
|
||||||
import {
|
import {
|
||||||
createEmployeeSchema,
|
createEmployeeSchema,
|
||||||
updateEmployeeSchema,
|
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) => {
|
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const data = createEmployeeSchema.parse(req.body)
|
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) => {
|
router.post('/contracts/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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'] }],
|
events: [{ action: '合同签订', timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }],
|
||||||
createdBy: req.user!.id,
|
createdBy: req.user!.id,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// 注意:电子签署记录由前端根据 signMethod 决定是否创建,避免重复
|
||||||
res.json({ success: true, data: result })
|
res.json({ success: true, data: result })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* 电子签署路由 — 管理端
|
||||||
|
*
|
||||||
|
* 完整签署流程:
|
||||||
|
* 1. HR 发起签署(校验组织电子签开关 + 自动渲染模板文件内容 + 创建证据链)
|
||||||
|
* 2. 员工在 portal 端查看待签文件 → 发送验证码 → 验证码确认签署
|
||||||
|
* 3. 签署完成 → 回写合同 + 追加证据链 + 生成签署确认文件
|
||||||
|
* 4. 易签宝回调(对接后启用):更新状态 + 回写PDF
|
||||||
|
*
|
||||||
|
* 证据链事件:发起签署 / 验证码发送 / 签署完成 / 取消签署
|
||||||
|
*/
|
||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { z } from 'zod'
|
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 prisma from '../lib/prisma'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
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()
|
const router = Router()
|
||||||
router.use(authMiddleware)
|
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({
|
const createSignSchema = z.object({
|
||||||
contractId: z.string().optional(),
|
contractId: z.string().optional(),
|
||||||
employeeId: z.string().min(1),
|
employeeId: z.string().min(1),
|
||||||
@@ -13,9 +51,13 @@ const createSignSchema = z.object({
|
|||||||
documentContent: z.string().optional(),
|
documentContent: z.string().optional(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
scene: 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) => {
|
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const status = req.query.status as string | undefined
|
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) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 发起签署
|
/**
|
||||||
// TODO: 对接易签宝 API — 此处为框架预留
|
* 待签合同列表(按员工聚合)
|
||||||
// 1. 调用易签宝创建签署流程
|
* GET /esign/pending
|
||||||
// 2. 获取签署链接
|
*
|
||||||
// 3. 保存 flowId 和 signUrl
|
* 汇总两类未签署记录,按员工聚合:
|
||||||
|
* 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) => {
|
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const data = createSignSchema.parse(req.body)
|
const data = createSignSchema.parse(req.body)
|
||||||
|
const scene = data.scene || 'CONTRACT'
|
||||||
|
|
||||||
// 预留:调用易签宝 API 创建签署流程
|
// 1. 校验组织电子签开关
|
||||||
// const esignResult = await esignApi.createFlow({
|
const orgFlag = SCENE_ORG_FLAG_MAP[scene]
|
||||||
// title: data.documentTitle,
|
if (orgFlag) {
|
||||||
// signerPhone: employee.phone,
|
const org = await prisma.organization.findUnique({
|
||||||
// signerName: employee.name,
|
where: { id: req.user!.orgId },
|
||||||
// content: data.documentContent,
|
select: { [orgFlag]: true, name: true } as any,
|
||||||
// })
|
})
|
||||||
// const flowId = esignResult.flowId
|
if (org && !(org as any)[orgFlag]) {
|
||||||
// const signUrl = esignResult.signUrl
|
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({
|
const record = await prisma.eSignRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
contractId: data.contractId || null,
|
contractId: data.contractId || null,
|
||||||
employeeId: data.employeeId,
|
employeeId: data.employeeId,
|
||||||
scene: data.scene || 'CONTRACT',
|
scene,
|
||||||
documentTitle: data.documentTitle,
|
documentTitle: data.documentTitle,
|
||||||
documentContent: data.documentContent || null,
|
documentContent: documentContent || null,
|
||||||
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
|
|
||||||
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
|
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
initiatedBy: req.user!.id,
|
initiatedBy: req.user!.id,
|
||||||
createdBy: req.user!.id,
|
createdBy: req.user!.id,
|
||||||
remark: data.remark || null,
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: record,
|
data: record,
|
||||||
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
|
message: '签署记录已创建,员工可在员工端查看并签署',
|
||||||
})
|
})
|
||||||
} catch (err) { next(err) }
|
} 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) => {
|
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await prisma.eSignRecord.findFirst({
|
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: '记录不存在' } })
|
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||||
|
|
||||||
// 预留:调用易签宝 API 查询流程状态
|
// 检查是否已过期但状态仍为 PENDING
|
||||||
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
|
if (record.status === 'PENDING' && record.expiredAt && record.expiredAt < new Date()) {
|
||||||
// if (esignStatus !== record.status) {
|
const updated = await prisma.eSignRecord.update({
|
||||||
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
|
where: { id: record.id },
|
||||||
// }
|
data: { status: 'EXPIRED' },
|
||||||
|
})
|
||||||
|
return res.json({ success: true, data: updated })
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ success: true, data: record })
|
res.json({ success: true, data: record })
|
||||||
} catch (err) { next(err) }
|
} 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) => {
|
router.post('/callback', async (req, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
const { flowId, status, signedPdfUrl, ...rest } = req.body
|
||||||
|
|
||||||
// TODO: 验证易签宝回调签名
|
// TODO: 对接易签宝后验证回调签名
|
||||||
// if (!verifyEsignCallback(req.headers, req.body)) {
|
// if (!verifyEsignCallback(req.headers, req.body)) {
|
||||||
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
|
// 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') {
|
if (status === 'COMPLETED') {
|
||||||
await prisma.laborContract.update({
|
if (record.contractId) {
|
||||||
where: { id: record.contractId },
|
await prisma.laborContract.update({
|
||||||
data: {
|
where: { id: record.contractId },
|
||||||
signMethod: 'ELECTRONIC',
|
data: {
|
||||||
electronicContractUrl: signedPdfUrl || null,
|
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) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 取消签署
|
/**
|
||||||
|
* 取消签署
|
||||||
|
* - 更新状态为 CANCELLED
|
||||||
|
* - 追加证据链
|
||||||
|
*/
|
||||||
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const record = await prisma.eSignRecord.update({
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
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' },
|
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) }
|
} 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: 'status', width: 8 },
|
||||||
{ header: '入职日期', key: 'hireDate', width: 12 },
|
{ header: '入职日期', key: 'hireDate', width: 12 },
|
||||||
{ header: '手机号', key: 'phone', width: 13 },
|
{ header: '手机号', key: 'phone', width: 13 },
|
||||||
{ header: '身份证号', key: 'idCardNumber', width: 20 },
|
{ header: '证件号码', key: 'idCardNumber', width: 20 },
|
||||||
{ header: '月工资', key: 'monthlySalary', width: 10 },
|
{ header: '月工资', key: 'monthlySalary', width: 10 },
|
||||||
{ header: '社保基数', key: 'socialInsBase', width: 10 },
|
{ header: '社保基数', key: 'socialInsBase', width: 10 },
|
||||||
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
|
{ header: '公积金基数', key: 'housingFundBase', width: 10 },
|
||||||
@@ -478,7 +478,7 @@ router.get('/tax-declaration', authMiddleware, requireAdmin, async (req: AuthReq
|
|||||||
let seq = 0
|
let seq = 0
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
seq++
|
seq++
|
||||||
// 解密身份证号
|
// 解密证件号码
|
||||||
let idCard: string = ''
|
let idCard: string = ''
|
||||||
try { if (e.employee.idCardNumber) idCard = decrypt(e.employee.idCardNumber) || '' } catch { idCard = e.employee.idCardNumber || '' }
|
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 { Router, Response, NextFunction } from 'express'
|
||||||
import multer from 'multer'
|
import multer from 'multer'
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { requireAdmin } from '../middleware/rbac'
|
import { requireAdmin } from '../middleware/rbac'
|
||||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||||
@@ -18,17 +19,17 @@ function contentDisposition(filename: string): string {
|
|||||||
const router = Router()
|
const router = Router()
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||||
|
|
||||||
// 身份证号格式校验(18位正则 + 校验位算法)
|
// 证件号码格式校验(18位正则 + 校验位算法)
|
||||||
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
|
||||||
if (!idCard) return { valid: true }
|
if (!idCard) return { valid: true }
|
||||||
const s = idCard.trim()
|
const s = idCard.trim()
|
||||||
// 15位身份证号升级为18位
|
// 15位证件号码升级为18位
|
||||||
if (/^\d{15}$/.test(s)) {
|
if (/^\d{15}$/.test(s)) {
|
||||||
const upgraded = upgrade15To18(s)
|
const upgraded = upgrade15To18(s)
|
||||||
return { valid: true, upgraded }
|
return { valid: true, upgraded }
|
||||||
}
|
}
|
||||||
if (!/^\d{17}[\dXx]$/.test(s)) {
|
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]
|
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 sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
|
||||||
const expected = checkCodes[sum % 11]
|
const expected = checkCodes[sum % 11]
|
||||||
if (s.charAt(17).toUpperCase() !== expected) {
|
if (s.charAt(17).toUpperCase() !== expected) {
|
||||||
return { valid: false, error: '身份证号校验位错误' }
|
return { valid: false, error: '证件号码校验位错误' }
|
||||||
}
|
}
|
||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
|
|||||||
const rows = XLSX.utils.sheet_to_json(empSheet)
|
const rows = XLSX.utils.sheet_to_json(empSheet)
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
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('姓名为空') }
|
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
|
||||||
const hireDate = parseDate(getField(r, '入职日期'))
|
const hireDate = parseDate(getField(r, '入职日期'))
|
||||||
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
|
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)
|
const rows = XLSX.utils.sheet_to_json(contractSheet)
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
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[] }
|
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('姓名和身份证号都为空') }
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||||
const sd = parseDate(getField(r, '合同开始日期'))
|
const sd = parseDate(getField(r, '合同开始日期'))
|
||||||
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
|
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 })
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
const otType = val(getField(r, '加班类型')) || '工作日加班'
|
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[] }
|
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('姓名和身份证号都为空') }
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||||
const dt = parseDate(getField(r, '日期'))
|
const dt = parseDate(getField(r, '日期'))
|
||||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
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 })
|
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)
|
const rows = XLSX.utils.sheet_to_json(discSheet)
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
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[] }
|
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.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 })
|
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
|
||||||
preview.disciplinary.push(row)
|
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)
|
const rows = XLSX.utils.sheet_to_json(attSheet)
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
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[] }
|
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('姓名和身份证号都为空') }
|
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和证件号码都为空') }
|
||||||
const dt = parseDate(getField(r, '日期'))
|
const dt = parseDate(getField(r, '日期'))
|
||||||
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
|
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 })
|
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, '月工资')))
|
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 }
|
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, '身份证号'))
|
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) { result.skipped++; result.errors.push(`员工第${i + 2}行:证件号码为空,跳过`); result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'skipped', message: '证件号码为空' }); continue }
|
||||||
if (idCard) {
|
if (idCard) {
|
||||||
const idCheck = validateIdCard(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.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 (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 或「无」表示不参保
|
// 社保基数:值为 0 或「无」表示不参保
|
||||||
const socialInsBaseVal = num(getField(r, '社保基数'))
|
const socialInsBaseVal = num(getField(r, '社保基数'))
|
||||||
const socialInsOptOut = val(getField(r, '社保基数')) === '无' || val(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 housingFundOptOut = val(getField(r, '公积金基数')) === '无' || val(getField(r, '公积金基数')) === '不缴'
|
||||||
const housingFundBase = housingFundOptOut ? 0 : (housingFundBaseVal || num(salary))
|
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({
|
const emp = await prisma.employee.create({
|
||||||
data: {
|
data: {
|
||||||
orgId, name, department: dept, hireDate,
|
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),
|
gender: val(getField(r, '性别')) || (idCard ? extractGenderFromIdCard(idCard) : null),
|
||||||
femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER'
|
femaleWorkerType: (val(getField(r, '女性岗位类型')) === '工人' || val(getField(r, '女性岗位类型')) === 'WORKER') ? 'WORKER'
|
||||||
: (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null,
|
: (val(getField(r, '女性岗位类型')) === '干部' || val(getField(r, '女性岗位类型')) === 'CADRE') ? 'CADRE' : null,
|
||||||
phone: val(getField(r, '手机号')) || null,
|
phone: importPhoneVal,
|
||||||
|
passwordHash: importPasswordHash,
|
||||||
idCardNumber: idCard ? encrypt(idCard) : null,
|
idCardNumber: idCard ? encrypt(idCard) : null,
|
||||||
idCardHash: idCard ? sha256(idCard) : null,
|
idCardHash: idCard ? sha256(idCard) : null,
|
||||||
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
|
birthDate: idCard ? extractBirthDateFromIdCard(idCard) : null,
|
||||||
@@ -329,8 +364,8 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
|
|||||||
const msg = e?.message || ''
|
const msg = e?.message || ''
|
||||||
if (msg.includes('Unique constraint')) {
|
if (msg.includes('Unique constraint')) {
|
||||||
result.duplicates++
|
result.duplicates++
|
||||||
result.errors.push(`员工第${i + 2}行:该员工已存在(身份证号重复),跳过`)
|
result.errors.push(`员工第${i + 2}行:该员工已存在(证件号码重复),跳过`)
|
||||||
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '身份证号重复' })
|
result.details.push({ sheet: '员工信息', row: i + 2, name, status: 'duplicate', message: '证件号码重复' })
|
||||||
} else if (msg.includes('invalid') || msg.includes('validation')) {
|
} else if (msg.includes('invalid') || msg.includes('validation')) {
|
||||||
result.skipped++
|
result.skipped++
|
||||||
result.errors.push(`员工第${i + 2}行:数据格式不正确,请检查各项填写`)
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(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 }
|
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||||
const startDate = parseDate(getField(r, '合同开始日期'))
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(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 }
|
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||||
const date = parseDate(getField(r, '日期'))
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(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 }
|
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||||
const date = parseDate(getField(r, '日期'))
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(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 }
|
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||||
const date = parseDate(getField(r, '日期'))
|
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 wb = XLSX.utils.book_new()
|
||||||
|
|
||||||
const empData = [
|
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 }]
|
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, '员工信息')
|
XLSX.utils.book_append_sheet(wb, empWs, '员工信息')
|
||||||
|
|
||||||
const contractData = [
|
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), '劳动合同')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
|
||||||
|
|
||||||
const otData = [
|
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), '加班记录')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
|
||||||
|
|
||||||
const discData = [
|
const discData = [
|
||||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
{ '姓名*': '张三', '证件号码': '110101199001011234', '日期*': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
|
||||||
]
|
]
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||||
|
|
||||||
const attData = [
|
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), '考勤记录')
|
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) {
|
function findEmp(r: any) {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
if (idCard) {
|
if (idCard) {
|
||||||
const emp = empByHash.get(sha256(idCard))
|
const emp = empByHash.get(sha256(idCard))
|
||||||
if (emp) return emp
|
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) => {
|
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
|
||||||
const wb = XLSX.utils.book_new()
|
const wb = XLSX.utils.book_new()
|
||||||
|
|
||||||
// 合并考勤+加班为一个Sheet,减少重复录入姓名身份证号
|
// 合并考勤+加班为一个Sheet,减少重复录入姓名证件号码
|
||||||
const attOtData = [{
|
const attOtData = [{
|
||||||
'姓名': '张三',
|
'姓名': '张三',
|
||||||
'身份证号': '110101199001011234',
|
'证件号码': '110101199001011234',
|
||||||
'日期': '2024-06-01',
|
'日期': '2024-06-01',
|
||||||
'考勤状态': '正常',
|
'考勤状态': '正常',
|
||||||
'上班时间': '09:00',
|
'上班时间': '09:00',
|
||||||
@@ -798,16 +833,16 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
|
|||||||
]
|
]
|
||||||
XLSX.utils.book_append_sheet(wb, attOtWs, '考勤与加班')
|
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), '薪资调整')
|
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), '社保变动')
|
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), '公积金变动')
|
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), '违纪记录')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
|
||||||
|
|
||||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
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++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const idCard = val(getField(r, '身份证号'))
|
const idCard = val(getField(r, '证件号码'))
|
||||||
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(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 }
|
if (!empId) { result.errors.push(`第${i + 2}行:找不到员工「${val(getField(r, '姓名'))}」`); continue }
|
||||||
const entryId = entryByEmp.get(empId)
|
const entryId = entryByEmp.get(empId)
|
||||||
@@ -906,8 +941,8 @@ router.post('/payroll', authMiddleware, upload.single('file'), async (req: AuthR
|
|||||||
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||||
const wb = XLSX.utils.book_new()
|
const wb = XLSX.utils.book_new()
|
||||||
const data = [
|
const data = [
|
||||||
{ '姓名*': '张三', '身份证号*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
|
{ '姓名*': '张三', '证件号码*': '110101199001011234', '基本工资': 10000, '加班费': 500, '津贴': 800, '扣款': 0, '奖金': 2000 },
|
||||||
{ '姓名*': '李四', '身份证号*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
|
{ '姓名*': '李四', '证件号码*': '110101199002021234', '基本工资': 12000, '加班费': 0, '津贴': 600, '扣款': 100, '奖金': 0 },
|
||||||
]
|
]
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
|
||||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||||
@@ -921,8 +956,8 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
|
|||||||
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
router.get('/special-deduction/template', authMiddleware, (_req: AuthRequest, res: Response) => {
|
||||||
const wb = XLSX.utils.book_new()
|
const wb = XLSX.utils.book_new()
|
||||||
const data = [
|
const data = [
|
||||||
{ '姓名*': '张三', '身份证号': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
{ '姓名*': '张三', '证件号码': '110101199001011234', '子女教育': 1000, '赡养老人': 2000, '住房': 1500, '继续教育': 0, '婴幼儿照护': 0, '备注': '' },
|
||||||
{ '姓名*': '李四', '身份证号': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
{ '姓名*': '李四', '证件号码': '110101199002021234', '子女教育': 0, '赡养老人': 1000, '住房': 0, '继续教育': 400, '婴幼儿照护': 1000, '备注': '继续教育证书' },
|
||||||
]
|
]
|
||||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '专项附加扣除')
|
||||||
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
|
||||||
@@ -953,7 +988,7 @@ router.post('/special-deduction', authMiddleware, requireAdmin, upload.single('f
|
|||||||
const r = rows[i] as any
|
const r = rows[i] as any
|
||||||
try {
|
try {
|
||||||
const name = val(getField(r, '姓名'))
|
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)
|
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 }
|
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 { decrypt } from '../lib/crypto'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { isInProbation } from '../services/contract.service'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(authMiddleware)
|
router.use(authMiddleware)
|
||||||
@@ -377,8 +378,11 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
|
|||||||
const deduction = deductions[emp.id] || 0
|
const deduction = deductions[emp.id] || 0
|
||||||
|
|
||||||
let baseSalary = 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) {
|
} else if (emp.monthlySalary) {
|
||||||
try {
|
try {
|
||||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
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 emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
|
||||||
const socialBase = emp.socialInsBase || baseSalary
|
const socialBase = emp.socialInsBase != null ? emp.socialInsBase : baseSalary
|
||||||
const housingBase = emp.housingFundBase || baseSalary
|
const housingBase = emp.housingFundBase != null ? emp.housingFundBase : baseSalary
|
||||||
|
|
||||||
// 计算社保公积金
|
// 计算社保公积金
|
||||||
let socialEmp = 0, housingEmp = 0
|
let socialEmp = 0, housingEmp = 0
|
||||||
|
|||||||
@@ -10,6 +10,42 @@ import {
|
|||||||
generatePayslipFromBatches,
|
generatePayslipFromBatches,
|
||||||
prePayrollCheck,
|
prePayrollCheck,
|
||||||
} from '../services/payroll.service'
|
} 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 编码中文文件名
|
// RFC 5987 编码中文文件名
|
||||||
function contentDisposition(filename: string): string {
|
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({
|
const createBatchSchema = z.object({
|
||||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
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'),
|
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
|
||||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch', 'custom']).default('copy_last'),
|
||||||
sourceBatchId: z.string().optional(),
|
sourceBatchId: z.string().optional(),
|
||||||
@@ -228,7 +265,7 @@ const createBatchSchema = z.object({
|
|||||||
|
|
||||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
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
|
const orgId = req.user!.orgId
|
||||||
|
|
||||||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||||||
@@ -283,7 +320,18 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
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 {
|
} else {
|
||||||
employees = await prisma.employee.findMany({
|
employees = await prisma.employee.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -303,6 +351,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
data: {
|
data: {
|
||||||
orgId,
|
orgId,
|
||||||
month,
|
month,
|
||||||
|
payMonth: payMonth || null,
|
||||||
batchNo,
|
batchNo,
|
||||||
name: batchName,
|
name: batchName,
|
||||||
type,
|
type,
|
||||||
@@ -317,16 +366,28 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||||||
for (const emp of employees) {
|
for (const emp of employees) {
|
||||||
let baseSalary = 0
|
let baseSalary = 0
|
||||||
|
let performanceSalary = 0
|
||||||
let overtimePay = 0
|
let overtimePay = 0
|
||||||
let allowance = 0
|
let allowance = 0
|
||||||
let deduction = 0
|
let deduction = 0
|
||||||
let bonus = 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) {
|
if (mode === 'copy_batch' && sourceEntries) {
|
||||||
// 复制指定批次:从源条目复制数据
|
// 复制指定批次:从源条目复制数据
|
||||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||||
if (srcEntry) {
|
if (srcEntry) {
|
||||||
baseSalary = srcEntry.baseSalary
|
baseSalary = srcEntry.baseSalary
|
||||||
|
performanceSalary = srcEntry.performanceSalary || 0
|
||||||
overtimePay = srcEntry.overtimePay
|
overtimePay = srcEntry.overtimePay
|
||||||
allowance = srcEntry.allowance
|
allowance = srcEntry.allowance
|
||||||
deduction = srcEntry.deduction
|
deduction = srcEntry.deduction
|
||||||
@@ -341,30 +402,88 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
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) {
|
} else if (emp.monthlySalary) {
|
||||||
|
// 旧数据兼容:回退到 monthlySalary
|
||||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||||
}
|
}
|
||||||
if (prevPayslip) baseSalary = prevPayslip.baseSalary
|
|
||||||
overtimePay = overtime?.totalPay || 0
|
overtimePay = overtime?.totalPay || 0
|
||||||
allowance = prevPayslip?.allowance || 0
|
allowance = prevPayslip?.allowance || 0
|
||||||
deduction = prevPayslip?.deduction || 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_all: 所有金额默认 0
|
||||||
// blank_employees 模式下尝试从员工记录获取基本工资
|
|
||||||
if (mode === 'blank_employees' && emp.monthlySalary) {
|
// 统一试用期判定(仅 copy_last / copy_batch / custom 模式适用,SEVERANCE 除外)
|
||||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
// 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 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||||||
|
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
|
||||||
let calcResult: any
|
let calcResult: any
|
||||||
try {
|
if (type === 'SEVERANCE' && severanceAmount > 0) {
|
||||||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
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 }
|
||||||
} catch (calcErr: any) {
|
} else {
|
||||||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
try {
|
||||||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
// 查询上月递延数据(上月已归档批次中该员工的递延金额)
|
||||||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
|
const 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,
|
orgId,
|
||||||
employeeId: emp.id,
|
employeeId: emp.id,
|
||||||
baseSalary,
|
baseSalary,
|
||||||
|
performanceSalary,
|
||||||
overtimePay,
|
overtimePay,
|
||||||
allowance,
|
allowance,
|
||||||
deduction,
|
deduction,
|
||||||
@@ -384,9 +504,20 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
socialOrg: calcResult.socialOrg,
|
socialOrg: calcResult.socialOrg,
|
||||||
housingEmp: calcResult.housingEmp,
|
housingEmp: calcResult.housingEmp,
|
||||||
housingOrg: calcResult.housingOrg,
|
housingOrg: calcResult.housingOrg,
|
||||||
|
supplementaryHousingEmp: calcResult.supplementaryHousingEmp || 0,
|
||||||
|
supplementaryHousingOrg: calcResult.supplementaryHousingOrg || 0,
|
||||||
tax: calcResult.tax,
|
tax: calcResult.tax,
|
||||||
totalPay: calcResult.totalPay,
|
totalPay: calcResult.totalPay,
|
||||||
netPay: calcResult.netPay,
|
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,
|
riskWarnings,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -426,15 +557,15 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
|
|
||||||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||||||
const updateEntrySchema = z.object({
|
const updateEntrySchema = z.object({
|
||||||
baseSalary: z.number().min(0).optional(),
|
baseSalary: z.number().optional(),
|
||||||
overtimePay: z.number().min(0).optional(),
|
overtimePay: z.number().optional(),
|
||||||
allowance: z.number().min(0).optional(),
|
allowance: z.number().optional(),
|
||||||
deduction: z.number().min(0).optional(),
|
deduction: z.number().optional(),
|
||||||
bonus: z.number().min(0).optional(),
|
bonus: z.number().optional(),
|
||||||
socialEmp: z.number().min(0).optional(),
|
socialEmp: z.number().optional(),
|
||||||
socialOrg: z.number().min(0).optional(),
|
socialOrg: z.number().optional(),
|
||||||
housingEmp: z.number().min(0).optional(),
|
housingEmp: z.number().optional(),
|
||||||
housingOrg: z.number().min(0).optional(),
|
housingOrg: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
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: '条目不存在' } })
|
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
|
||||||
|
|
||||||
// 合并输入项
|
// 合并输入项(包含细化薪资字段,避免编辑时丢失岗位工资/绩效工资等)
|
||||||
const inputs = {
|
const inputs = {
|
||||||
baseSalary: data.baseSalary ?? entry.baseSalary,
|
baseSalary: data.baseSalary ?? entry.baseSalary,
|
||||||
overtimePay: data.overtimePay ?? entry.overtimePay,
|
overtimePay: data.overtimePay ?? entry.overtimePay,
|
||||||
allowance: data.allowance ?? entry.allowance,
|
allowance: data.allowance ?? entry.allowance,
|
||||||
deduction: data.deduction ?? entry.deduction,
|
deduction: data.deduction ?? entry.deduction,
|
||||||
bonus: data.bonus ?? entry.bonus,
|
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,15 +607,17 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
|||||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||||
|
|
||||||
|
// 查询上月递延数据(与创建批次时一致)
|
||||||
|
const prevDeferred = await getPrevDeferred(orgId, employeeId, batch.month)
|
||||||
|
|
||||||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
|
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
|
||||||
const options = Object.keys(overrideSocial).length > 0
|
const options: any = { prevDeferred }
|
||||||
? { overrideSocial }
|
if (Object.keys(overrideSocial).length > 0) options.overrideSocial = overrideSocial
|
||||||
: undefined
|
|
||||||
|
|
||||||
// 重新计算
|
// 重新计算
|
||||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||||
|
|
||||||
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
|
const { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, systemSupplementaryHousingEmp, systemSupplementaryHousingOrg, taxBreakdown, ...entryData } = calcResult
|
||||||
const updated = await prisma.batchEntry.update({
|
const updated = await prisma.batchEntry.update({
|
||||||
where: { id: entry.id },
|
where: { id: entry.id },
|
||||||
data: { ...inputs, ...entryData },
|
data: { ...inputs, ...entryData },
|
||||||
@@ -513,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) => {
|
router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -541,10 +1050,14 @@ router.get('/batches/:batchId/entries/:employeeId/tax-detail', async (req: AuthR
|
|||||||
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
|
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
|
||||||
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
|
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
|
||||||
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
|
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
|
||||||
|
taxBreakdown.systemSupplementaryHousingEmp = calcResult.systemSupplementaryHousingEmp
|
||||||
|
taxBreakdown.systemSupplementaryHousingOrg = calcResult.systemSupplementaryHousingOrg
|
||||||
taxBreakdown.actualSocialEmp = entry.socialEmp
|
taxBreakdown.actualSocialEmp = entry.socialEmp
|
||||||
taxBreakdown.actualSocialOrg = entry.socialOrg
|
taxBreakdown.actualSocialOrg = entry.socialOrg
|
||||||
taxBreakdown.actualHousingEmp = entry.housingEmp
|
taxBreakdown.actualHousingEmp = entry.housingEmp
|
||||||
taxBreakdown.actualHousingOrg = entry.housingOrg
|
taxBreakdown.actualHousingOrg = entry.housingOrg
|
||||||
|
taxBreakdown.actualSupplementaryHousingEmp = entry.supplementaryHousingEmp || 0
|
||||||
|
taxBreakdown.actualSupplementaryHousingOrg = entry.supplementaryHousingOrg || 0
|
||||||
res.json({ success: true, data: taxBreakdown })
|
res.json({ success: true, data: taxBreakdown })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err)
|
next(err)
|
||||||
@@ -577,8 +1090,11 @@ router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Respons
|
|||||||
if (!emp) continue
|
if (!emp) continue
|
||||||
|
|
||||||
let baseSalary = 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 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) {
|
} else if (emp.monthlySalary) {
|
||||||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||||||
}
|
}
|
||||||
@@ -592,7 +1108,7 @@ 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 calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||||||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||||||
|
|
||||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||||
const entry = await prisma.batchEntry.create({
|
const entry = await prisma.batchEntry.create({
|
||||||
data: {
|
data: {
|
||||||
batchId, orgId, employeeId,
|
batchId, orgId, employeeId,
|
||||||
@@ -727,15 +1243,15 @@ router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response,
|
|||||||
otherDeduction: entry.otherDeduction || undefined,
|
otherDeduction: entry.otherDeduction || undefined,
|
||||||
}
|
}
|
||||||
// 社保如被手动覆盖,保留覆盖值
|
// 社保如被手动覆盖,保留覆盖值
|
||||||
|
// 社保如被手动覆盖,保留覆盖值(通过比较系统值和实际值判断是否覆盖过)
|
||||||
const overrideSocial: any = {}
|
const overrideSocial: any = {}
|
||||||
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp
|
// 归档重算时不强制覆盖社保,让系统重新计算(除非之前有手动覆盖)
|
||||||
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg
|
// 这里简化处理:不传 overrideSocial,让系统重算
|
||||||
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp
|
const prevDeferred = await getPrevDeferred(orgId, entry.employeeId, batch.month)
|
||||||
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg
|
const options: any = { prevDeferred }
|
||||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
|
||||||
|
|
||||||
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
|
||||||
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
|
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, systemSupplementaryHousingEmp: _sshe, systemSupplementaryHousingOrg: _ssho, taxBreakdown: _tb, ...entryData } = calcResult
|
||||||
await prisma.batchEntry.update({
|
await prisma.batchEntry.update({
|
||||||
where: { id: entry.id },
|
where: { id: entry.id },
|
||||||
data: { ...entryData },
|
data: { ...entryData },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
|||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
|
||||||
import { setCode, getCode, deleteCode, updateCode, checkRateLimit } from '../lib/codeStore'
|
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()
|
const router = Router()
|
||||||
|
|
||||||
@@ -36,18 +36,30 @@ function portalAuth(req: Request, res: Response, next: NextFunction) {
|
|||||||
router.post('/login', async (req, res, next) => {
|
router.post('/login', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const data = portalLoginSchema.parse(req.body)
|
const data = portalLoginSchema.parse(req.body)
|
||||||
const employee = await prisma.employee.findFirst({
|
// 查找所有匹配手机号的在职员工(可能跨组织)
|
||||||
|
const employees = await prisma.employee.findMany({
|
||||||
where: { phone: data.phone, status: 'ACTIVE' },
|
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: '手机号或密码错误' } })
|
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: '手机号或密码错误' } })
|
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||||
}
|
}
|
||||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
const token = signAccessToken({ id: matchedEmployee.id, orgId: matchedEmployee.orgId, role: 'EMPLOYEE' })
|
||||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
res.json({ success: true, data: { token, employee: { id: matchedEmployee.id, name: matchedEmployee.name, department: matchedEmployee.department } } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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}次机会)` } })
|
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount - 1}次机会)` } })
|
||||||
}
|
}
|
||||||
await deleteCode(data.phone)
|
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: '员工不存在' } })
|
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' })
|
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 } } })
|
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||||
} catch (err) {
|
} 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) => {
|
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -940,7 +990,7 @@ router.get('/resignation/:id/certificate', portalAuth, async (req: any, res, nex
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content = `<h1>解除/终止劳动合同证明书</h1>
|
content = `<h1>解除/终止劳动合同证明书</h1>
|
||||||
<p>兹证明 ${variables.employeeName}(身份证号:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
<p>兹证明 ${variables.employeeName}(证件号码:${variables.idCardNumber}),原系我单位 ${variables.department} 部门员工,于 ${variables.leaveDate} 因 ${reason} 原因,正式解除/终止劳动合同。</p>
|
||||||
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
<p>经济补偿金已结清:¥${variables.compensation}。社保截止月份:${variables.socialInsEndMonth || '—'},公积金截止月份:${variables.housingFundEndMonth || '—'}。</p>
|
||||||
<p>特此证明。</p>
|
<p>特此证明。</p>
|
||||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>`
|
||||||
@@ -1029,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) => {
|
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
@@ -1036,11 +1092,30 @@ router.get('/esign', portalAuth, async (req: any, res, next) => {
|
|||||||
where: { employeeId, orgId },
|
where: { employeeId, orgId },
|
||||||
orderBy: { createdAt: 'desc' },
|
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 })
|
res.json({ success: true, data: records })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 查看签署详情
|
/**
|
||||||
|
* 查看签署详情(含文件内容)
|
||||||
|
*/
|
||||||
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
@@ -1048,37 +1123,179 @@ router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
|||||||
where: { id: req.params.id, employeeId, orgId },
|
where: { id: req.params.id, employeeId, orgId },
|
||||||
})
|
})
|
||||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
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 })
|
res.json({ success: true, data: record })
|
||||||
} catch (err) { next(err) }
|
} 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 {
|
try {
|
||||||
const { id: employeeId, orgId } = req.employee
|
const { id: employeeId, orgId } = req.employee
|
||||||
const record = await prisma.eSignRecord.findFirst({
|
const record = await prisma.eSignRecord.findFirst({
|
||||||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
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) 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({
|
const updated = await prisma.eSignRecord.update({
|
||||||
where: { id: record.id },
|
where: { id: record.id },
|
||||||
data: {
|
data: {
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
|
// 保存签署证据到 callbackData(作为签署凭证)
|
||||||
|
callbackData: { signEvidence, signedAt: new Date().toISOString() } as any,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果关联了合同,更新合同签署信息
|
// 回写合同
|
||||||
if (record.contractId) {
|
if (record.contractId) {
|
||||||
await prisma.laborContract.update({
|
await prisma.laborContract.update({
|
||||||
where: { id: record.contractId },
|
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: '签署成功' })
|
res.json({ success: true, data: updated, message: '签署成功' })
|
||||||
} catch (err) { next(err) }
|
} 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) => {
|
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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 status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
|
||||||
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
|
||||||
const department = req.query.department as string
|
const department = req.query.department as string
|
||||||
|
const position = req.query.position as string
|
||||||
const skip = (page - 1) * pageSize
|
const skip = (page - 1) * pageSize
|
||||||
|
|
||||||
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
|
||||||
@@ -71,6 +87,9 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
if (department) {
|
if (department) {
|
||||||
whereBase.department = department
|
whereBase.department = department
|
||||||
}
|
}
|
||||||
|
if (position) {
|
||||||
|
whereBase.position = position
|
||||||
|
}
|
||||||
if (search && !isIdCardSearch) {
|
if (search && !isIdCardSearch) {
|
||||||
whereBase.OR = [
|
whereBase.OR = [
|
||||||
{ name: { contains: search } },
|
{ name: { contains: search } },
|
||||||
@@ -81,7 +100,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
// 状态过滤在 DB 层完成(contractStatus 需要后处理计算,仍需内存过滤)
|
||||||
if (status === 'RESIGNED') {
|
if (status === 'RESIGNED') {
|
||||||
whereBase.status = 'RESIGNED'
|
whereBase.status = 'RESIGNED'
|
||||||
} else if (status === 'PRE_HIRE') {
|
} else if (status === 'PRE_HIRE' || status === 'PRE_ONBOARD') {
|
||||||
|
// 预入职:ACTIVE 状态但入职日期在未来(按入职日期自动判定)
|
||||||
whereBase.status = 'ACTIVE'
|
whereBase.status = 'ACTIVE'
|
||||||
whereBase.hireDate = { gt: todayEnd }
|
whereBase.hireDate = { gt: todayEnd }
|
||||||
} else if (status === 'ACTIVE') {
|
} else if (status === 'ACTIVE') {
|
||||||
@@ -94,7 +114,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
whereBase.contracts = { none: {} }
|
whereBase.contracts = { none: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
|
// 当有 contractStatus(非 unsigned)筛选或证件号码搜索时,需要先查全部再过滤后分页
|
||||||
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
|
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
|
||||||
|
|
||||||
const [dbTotal, employees] = await Promise.all([
|
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 isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
|
||||||
const isPreHire = !isResigned && e.hireDate > todayEnd
|
const isPreHire = !isResigned && e.hireDate > todayEnd
|
||||||
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
|
||||||
// 身份证号脱敏显示
|
// 证件号码脱敏显示
|
||||||
let idCardMasked: string | null = null
|
let idCardMasked: string | null = null
|
||||||
if (e.idCardNumber) {
|
if (e.idCardNumber) {
|
||||||
try {
|
try {
|
||||||
@@ -200,6 +220,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
idCardMasked,
|
idCardMasked,
|
||||||
idCardNumber: safeDecryptStr(e.idCardNumber),
|
idCardNumber: safeDecryptStr(e.idCardNumber),
|
||||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||||
|
baseSalary: e.baseSalary ? safeDecrypt(e.baseSalary) : null,
|
||||||
|
performanceSalary: e.performanceSalary ? safeDecrypt(e.performanceSalary) : null,
|
||||||
socialInsBase: e.socialInsBase,
|
socialInsBase: e.socialInsBase,
|
||||||
housingFundBase: e.housingFundBase,
|
housingFundBase: e.housingFundBase,
|
||||||
socialInsCalc: (() => {
|
socialInsCalc: (() => {
|
||||||
@@ -223,7 +245,13 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
riskLevel: contractInfo.riskLevel,
|
riskLevel: contractInfo.riskLevel,
|
||||||
socialInsuranceStatus: (() => {
|
socialInsuranceStatus: (() => {
|
||||||
const sr = (e as any).socialInsRecords?.[0]
|
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 表示在保,否则已停保
|
// endMonth 为 null 表示在保,否则已停保
|
||||||
if (sr.endMonth) return 'SUSPENDED'
|
if (sr.endMonth) return 'SUSPENDED'
|
||||||
return 'ACTIVE'
|
return 'ACTIVE'
|
||||||
@@ -250,7 +278,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
|||||||
result = result.filter((e) => e.contractStatus === contractStatus)
|
result = result.filter((e) => e.contractStatus === contractStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 身份证号后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
// 证件号码后N位搜索:在内存中过滤(idCardNumber 已解密为明文)
|
||||||
if (isIdCardSearch) {
|
if (isIdCardSearch) {
|
||||||
result = result.filter((e: any) => {
|
result = result.filter((e: any) => {
|
||||||
if (!e.idCardNumber) return false
|
if (!e.idCardNumber) return false
|
||||||
@@ -347,7 +375,7 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
|||||||
if (found) monthlyProcessRecords.push(recordData)
|
if (found) monthlyProcessRecords.push(recordData)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
const { monthlySalary, baseSalary, performanceSalary, bankAccount, idCardNumber, ...rest } = employee
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
const dynamicStatus = employee.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
|
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,
|
...rest,
|
||||||
status: dynamicStatus,
|
status: dynamicStatus,
|
||||||
monthlySalary: safeDecrypt(monthlySalary),
|
monthlySalary: safeDecrypt(monthlySalary),
|
||||||
|
baseSalary: baseSalary ? safeDecrypt(baseSalary) : null,
|
||||||
|
performanceSalary: performanceSalary ? safeDecrypt(performanceSalary) : null,
|
||||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||||
idCardNumber: safeDecryptStr(idCardNumber),
|
idCardNumber: safeDecryptStr(idCardNumber),
|
||||||
monthlyProcessRecords,
|
monthlyProcessRecords,
|
||||||
@@ -935,7 +965,7 @@ router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest,
|
|||||||
|
|
||||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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({
|
const record = await prisma.disciplinaryRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
@@ -946,6 +976,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
|||||||
severity: severity || 'WARNING',
|
severity: severity || 'WARNING',
|
||||||
action: action || 'ORAL_WARNING',
|
action: action || 'ORAL_WARNING',
|
||||||
actionDetail,
|
actionDetail,
|
||||||
|
deductionAmount: Number(deductionAmount) || 0,
|
||||||
employeeAck: employeeAck || false,
|
employeeAck: employeeAck || false,
|
||||||
ackDate: ackDate ? new Date(ackDate) : null,
|
ackDate: ackDate ? new Date(ackDate) : null,
|
||||||
ackMethod,
|
ackMethod,
|
||||||
@@ -969,7 +1000,7 @@ router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest
|
|||||||
|
|
||||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
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({
|
const record = await prisma.disciplinaryRecord.findFirst({
|
||||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||||
})
|
})
|
||||||
@@ -983,6 +1014,7 @@ router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: Au
|
|||||||
severity,
|
severity,
|
||||||
action,
|
action,
|
||||||
actionDetail,
|
actionDetail,
|
||||||
|
deductionAmount: deductionAmount !== undefined ? Number(deductionAmount) || 0 : undefined,
|
||||||
employeeAck,
|
employeeAck,
|
||||||
ackDate: ackDate ? new Date(ackDate) : null,
|
ackDate: ackDate ? new Date(ackDate) : null,
|
||||||
ackMethod,
|
ackMethod,
|
||||||
@@ -1025,7 +1057,7 @@ router.get('/:employeeId/disciplinary/:recordId/certificate', authMiddleware, as
|
|||||||
|
|
||||||
const content = `违纪确认证明
|
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}
|
违纪类型:${typeMap[record.violationType] || record.violationType}
|
||||||
严重程度:${severityMap[record.severity] || record.severity}
|
严重程度:${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) => {
|
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { newSalary, effectiveMonth, reason } = req.body
|
const { newSalary, baseSalary, performanceSalary, effectiveMonth, reason } = req.body
|
||||||
const employee = await prisma.employee.findFirst({
|
const employee = await prisma.employee.findFirst({
|
||||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
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({
|
await prisma.employee.update({
|
||||||
where: { id: req.params.id },
|
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 })
|
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) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// 调部门
|
// 调动(部门+职务变动)
|
||||||
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { newDepartment, effectiveMonth, reason } = req.body
|
const { newDepartment, newPosition, departmentId, effectiveMonth, reason } = req.body
|
||||||
const employee = await prisma.employee.findFirst({
|
const employee = await prisma.employee.findFirst({
|
||||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
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 oldDepartment = employee.department
|
||||||
|
const oldPosition = employee.position || null
|
||||||
const effMonth = effectiveMonth || dateToMonth(new Date())
|
const effMonth = effectiveMonth || dateToMonth(new Date())
|
||||||
const prevEffMonth = prevMonth(effMonth)
|
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({
|
await prisma.employeeDepartmentRecord.updateMany({
|
||||||
where: { employeeId: req.params.id, endMonth: null },
|
where: { employeeId: req.params.id, endMonth: null },
|
||||||
data: { endMonth: prevEffMonth },
|
data: { endMonth: prevEffMonth },
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建新部门记录
|
// 创建新调动记录
|
||||||
const record = await prisma.employeeDepartmentRecord.create({
|
const record = await prisma.employeeDepartmentRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
employeeId: req.params.id,
|
employeeId: req.params.id,
|
||||||
oldDepartment,
|
oldDepartment,
|
||||||
newDepartment,
|
newDepartment: deptName,
|
||||||
|
oldPosition,
|
||||||
|
newPosition: newPosition || null,
|
||||||
effectiveMonth: effMonth,
|
effectiveMonth: effMonth,
|
||||||
endMonth: null,
|
endMonth: null,
|
||||||
changeType: 'TRANSFER',
|
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({
|
await prisma.employee.update({
|
||||||
where: { id: req.params.id },
|
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 })
|
res.json({ success: true, data: record })
|
||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const socialConfigFields = {
|
|||||||
pensionEmp: z.number().optional(),
|
pensionEmp: z.number().optional(),
|
||||||
medicalOrg: z.number().optional(),
|
medicalOrg: z.number().optional(),
|
||||||
medicalEmp: z.number().optional(),
|
medicalEmp: z.number().optional(),
|
||||||
|
medicalOrgExtra: z.number().min(0).optional(),
|
||||||
|
medicalEmpExtra: z.number().min(0).optional(),
|
||||||
unemploymentOrg: z.number().optional(),
|
unemploymentOrg: z.number().optional(),
|
||||||
unemploymentEmp: z.number().optional(),
|
unemploymentEmp: z.number().optional(),
|
||||||
injuryOrg: z.number().optional(),
|
injuryOrg: z.number().optional(),
|
||||||
@@ -23,6 +25,7 @@ const socialConfigFields = {
|
|||||||
medicalBaseMin: z.number().optional(),
|
medicalBaseMin: z.number().optional(),
|
||||||
medicalBaseMax: z.number().optional(),
|
medicalBaseMax: z.number().optional(),
|
||||||
extraInsurances: z.any().optional(),
|
extraInsurances: z.any().optional(),
|
||||||
|
minWage: z.number().min(0).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const housingConfigFields = {
|
const housingConfigFields = {
|
||||||
@@ -34,6 +37,426 @@ const housingConfigFields = {
|
|||||||
baseMax: z.number().optional(),
|
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) => {
|
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@@ -398,8 +821,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
|||||||
|
|
||||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||||
const medicalOrg = medicalBase * config.medicalOrg / 100
|
const medicalOrg = medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0)
|
||||||
const medicalEmp = medicalBase * config.medicalEmp / 100
|
const medicalEmp = medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
|
||||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||||
const injuryOrg = actualBase * config.injuryOrg / 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 medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||||
const items = [
|
const items = [
|
||||||
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
|
{ 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.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.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
|
||||||
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
|
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,7 +4,10 @@ import { auditLog } from '../middleware/auditLog'
|
|||||||
import { terminationChecklistSchema, resignationSchema, batchTerminatePreviewSchema, batchTerminateSchema, createTerminationDraftSchema, updateTerminationDraftSchema } from '../schemas/termination.schema'
|
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 { 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 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()
|
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 })
|
res.json({ success: true, data: checklist })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(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
|
export default router
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const REQUIRED_FIELDS: Record<string, string[]> = {
|
|||||||
// 必填字段中文标签映射
|
// 必填字段中文标签映射
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
|
name: '员工姓名', department: '部门', hireDate: '入职日期', phone: '手机号',
|
||||||
idCardNumber: '身份证号', employeeId: '员工ID', contractId: '合同ID',
|
idCardNumber: '证件号码', employeeId: '员工ID', contractId: '合同ID',
|
||||||
contractStartDate: '合同开始日期', confirmDate: '转正日期',
|
contractStartDate: '合同开始日期', confirmDate: '转正日期',
|
||||||
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
|
newEndDate: '新到期日期', newStartDate: '新合同开始日期',
|
||||||
suspendDate: '中止日期', employeeName: '员工姓名',
|
suspendDate: '中止日期', employeeName: '员工姓名',
|
||||||
@@ -35,11 +35,38 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
leaveDate: '离职日期', agreementStartDate: '协议开始日期',
|
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) => {
|
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const data = createWorkProcessSchema.parse(req.body)
|
const data = createWorkProcessSchema.parse(req.body)
|
||||||
const { type, title, employeeId, formData, status, remark } = data
|
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({
|
const process = await (prisma as any).workProcess.create({
|
||||||
data: {
|
data: {
|
||||||
orgId: req.user!.orgId,
|
orgId: req.user!.orgId,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const createEmployeeSchema = z.object({
|
|||||||
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
||||||
hireDate: z.string().datetime(),
|
hireDate: z.string().datetime(),
|
||||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
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(),
|
gender: z.enum(['男', '女']).optional(),
|
||||||
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
femaleWorkerType: z.enum(['CADRE', 'WORKER']).optional(),
|
||||||
phone: z.string().regex(/^1[3-9]\d{9}$/).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(),
|
city: z.string().max(20).optional(),
|
||||||
education: z.string().max(20).optional(),
|
education: z.string().max(20).optional(),
|
||||||
position: z.string().max(50).optional(),
|
position: z.string().max(50).optional(),
|
||||||
|
supplementaryHousingAccountId: z.string().nullable().optional(),
|
||||||
contract: z.object({
|
contract: z.object({
|
||||||
signDate: z.string().datetime().nullable(),
|
signDate: z.string().datetime().nullable(),
|
||||||
startDate: z.string().datetime(),
|
startDate: z.string().datetime(),
|
||||||
endDate: z.string().datetime().nullable(),
|
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'),
|
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||||
contractYears: z.number().int().min(1).max(10).default(3),
|
contractYears: z.number().int().min(1).max(10).default(3),
|
||||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
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(),
|
city: z.string().max(20).optional(),
|
||||||
education: z.string().max(20).optional(),
|
education: z.string().max(20).optional(),
|
||||||
position: z.string().max(50).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({
|
export const batchRenewSchema = z.object({
|
||||||
@@ -62,7 +66,7 @@ export const addContractSchema = z.object({
|
|||||||
signDate: z.string().datetime().nullable(),
|
signDate: z.string().datetime().nullable(),
|
||||||
startDate: z.string().datetime(),
|
startDate: z.string().datetime(),
|
||||||
endDate: z.string().datetime().nullable(),
|
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'),
|
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||||
contractYears: z.number().int().min(1).max(10).default(3),
|
contractYears: z.number().int().min(1).max(10).default(3),
|
||||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
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(),
|
reason: z.string().max(200).optional(),
|
||||||
terminationDate: z.string().optional(),
|
terminationDate: z.string().optional(),
|
||||||
compensation: z.number().min(0).optional(),
|
compensation: z.number().min(0).optional(),
|
||||||
|
compensationBreakdown: z.any().optional(),
|
||||||
handoverItems: z.array(z.any()).optional(),
|
handoverItems: z.array(z.any()).optional(),
|
||||||
|
checklistOverrides: z.any().optional(),
|
||||||
remark: z.string().max(500).optional(),
|
remark: z.string().max(500).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ export const updateTerminationDraftSchema = z.object({
|
|||||||
reason: z.string().max(200).optional(),
|
reason: z.string().max(200).optional(),
|
||||||
terminationDate: z.string().optional(),
|
terminationDate: z.string().optional(),
|
||||||
compensation: z.number().min(0).optional(),
|
compensation: z.number().min(0).optional(),
|
||||||
|
compensationBreakdown: z.any().optional(),
|
||||||
handoverItems: z.array(z.any()).optional(),
|
handoverItems: z.array(z.any()).optional(),
|
||||||
|
checklistOverrides: z.any().optional(),
|
||||||
remark: z.string().max(500).optional(),
|
remark: z.string().max(500).optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -181,10 +181,12 @@ ${orgContext}
|
|||||||
|
|
||||||
请按以下格式输出:
|
请按以下格式输出:
|
||||||
【未来30天预计风险】
|
【未来30天预计风险】
|
||||||
- [员工姓名/风险描述] → [建议措施]
|
- [员工身份证号(脱敏)/姓名/风险描述] → [建议措施]
|
||||||
|
|
||||||
【优先级建议】
|
【优先级建议】
|
||||||
[先处理什么,再处理什么]`
|
[先处理什么,再处理什么]
|
||||||
|
|
||||||
|
注意:员工唯一标识为身份证号(脱敏),姓名仅作辅助参考,同名员工以身份证号区分。`
|
||||||
|
|
||||||
const response = await client.chat.completions.create({
|
const response = await client.chat.completions.create({
|
||||||
model: 'qwen-plus',
|
model: 'qwen-plus',
|
||||||
@@ -208,8 +210,8 @@ export async function* predictRisksStream(orgContext: string) {
|
|||||||
|
|
||||||
用表格列出每个风险项:
|
用表格列出每个风险项:
|
||||||
|
|
||||||
| 优先级 | 员工/范围 | 风险描述 | 法律依据 | 建议措施 | 截止日期 |
|
| 优先级 | 员工(身份证号/姓名) | 风险描述 | 法律依据 | 建议措施 | 截止日期 |
|
||||||
|--------|-----------|----------|----------|----------|----------|
|
|--------|---------------------|----------|----------|----------|----------|
|
||||||
|
|
||||||
## 优先级建议
|
## 优先级建议
|
||||||
|
|
||||||
@@ -219,6 +221,8 @@ export async function* predictRisksStream(orgContext: string) {
|
|||||||
|
|
||||||
简要总结企业当前用工合规状况和改进方向。
|
简要总结企业当前用工合规状况和改进方向。
|
||||||
|
|
||||||
|
注意:员工唯一标识为身份证号(脱敏),姓名仅作辅助参考,同名员工以身份证号区分。
|
||||||
|
|
||||||
企业数据:
|
企业数据:
|
||||||
${orgContext}`
|
${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
|
weekendHours: number
|
||||||
holidayHours: number
|
holidayHours: number
|
||||||
overtimePay: number
|
overtimePay: number
|
||||||
|
deductionAmount?: number
|
||||||
}>) {
|
}>) {
|
||||||
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
const results: Array<{ employeeId: string; success: boolean; error?: string }> = []
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
|||||||
weekendHours: item.weekendHours,
|
weekendHours: item.weekendHours,
|
||||||
holidayHours: item.holidayHours,
|
holidayHours: item.holidayHours,
|
||||||
overtimePay: item.overtimePay,
|
overtimePay: item.overtimePay,
|
||||||
|
deductionAmount: item.deductionAmount || 0,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -80,6 +82,7 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
|||||||
weekendHours: item.weekendHours,
|
weekendHours: item.weekendHours,
|
||||||
holidayHours: item.holidayHours,
|
holidayHours: item.holidayHours,
|
||||||
overtimePay: item.overtimePay,
|
overtimePay: item.overtimePay,
|
||||||
|
deductionAmount: item.deductionAmount || 0,
|
||||||
createdBy: userId,
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { lastLoginAt: new Date() },
|
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 { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||||
import { runRiskDetection } from './risk.service'
|
import { runRiskDetection } from './risk.service'
|
||||||
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service'
|
import { extractBirthDateFromIdCard, extractGenderFromIdCard } from './retirement.service'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
function daysBetween(a: Date, b: Date): number {
|
function daysBetween(a: Date, b: Date): number {
|
||||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
@@ -13,7 +14,25 @@ function dateToMonth(date: Date): string {
|
|||||||
return `${y}-${m}`
|
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({
|
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||||
where: { orgId, ...(city ? { city } : {}) },
|
where: { orgId, ...(city ? { city } : {}) },
|
||||||
orderBy: { effectiveFrom: 'desc' },
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
@@ -22,7 +41,21 @@ async function clampSocialInsBase(orgId: string, base: number, city?: string): P
|
|||||||
return base
|
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({
|
const config = await prisma.housingFundConfig.findFirst({
|
||||||
where: { orgId, ...(city ? { city } : {}) },
|
where: { orgId, ...(city ? { city } : {}) },
|
||||||
orderBy: { effectiveFrom: 'desc' },
|
orderBy: { effectiveFrom: 'desc' },
|
||||||
@@ -31,12 +64,59 @@ async function clampHousingFundBase(orgId: string, base: number, city?: string):
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevMonth(month: string): string {
|
export function prevMonth(month: string): string {
|
||||||
const [y, m] = month.split('-').map(Number)
|
const [y, m] = month.split('-').map(Number)
|
||||||
const d = new Date(y, m - 2, 1)
|
const d = new Date(y, m - 2, 1)
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
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: {
|
export function getContractStatus(contract: {
|
||||||
signDate: Date | null
|
signDate: Date | null
|
||||||
startDate: Date
|
startDate: Date
|
||||||
@@ -70,7 +150,21 @@ export function getContractStatus(contract: {
|
|||||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
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) {
|
if (contract.endDate) {
|
||||||
const daysToExpire = daysBetween(contract.endDate, today)
|
const daysToExpire = daysBetween(contract.endDate, today)
|
||||||
if (daysToExpire < 0) {
|
if (daysToExpire < 0) {
|
||||||
@@ -106,6 +200,31 @@ export function validateProbation(contractMonths: number, probationMonths: numbe
|
|||||||
return { valid: true, max }
|
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 }) {
|
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
|
||||||
const page = params.page || 1
|
const page = params.page || 1
|
||||||
const pageSize = params.pageSize || 20
|
const pageSize = params.pageSize || 20
|
||||||
@@ -161,6 +280,9 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
|||||||
} catch {
|
} catch {
|
||||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||||
}
|
}
|
||||||
|
// 解密工资结构
|
||||||
|
const decryptedBaseSalary = emp.baseSalary ? safeDecryptNum(emp.baseSalary) : null
|
||||||
|
const decryptedPerformanceSalary = emp.performanceSalary ? safeDecryptNum(emp.performanceSalary) : null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: emp.id,
|
id: emp.id,
|
||||||
@@ -169,6 +291,8 @@ export async function getEmployees(orgId: string, params: { page?: number; pageS
|
|||||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||||
status: emp.status,
|
status: emp.status,
|
||||||
monthlySalary: decryptedSalary,
|
monthlySalary: decryptedSalary,
|
||||||
|
baseSalary: decryptedBaseSalary,
|
||||||
|
performanceSalary: decryptedPerformanceSalary,
|
||||||
contractStatus: contractInfo.status,
|
contractStatus: contractInfo.status,
|
||||||
contractStatusText: contractInfo.statusText,
|
contractStatusText: contractInfo.statusText,
|
||||||
riskLevel: contractInfo.riskLevel,
|
riskLevel: contractInfo.riskLevel,
|
||||||
@@ -205,18 +329,47 @@ export async function getEmployeeDetail(orgId: string, id: string) {
|
|||||||
return {
|
return {
|
||||||
...employee,
|
...employee,
|
||||||
monthlySalary: decryptedSalary,
|
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) {
|
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||||
// 身份证号查重
|
// 证件号码有效性校验
|
||||||
if (data.idCardNumber) {
|
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({
|
const existing = await prisma.employee.findFirst({
|
||||||
where: { orgId, idCardHash: sha256(data.idCardNumber) },
|
where: { orgId, idCardHash: sha256(data.idCardNumber) },
|
||||||
select: { id: true, name: true, department: true, status: true },
|
select: { id: true, name: true, department: true, status: true },
|
||||||
})
|
})
|
||||||
if (existing) {
|
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 hireDate = new Date(data.hireDate)
|
||||||
const hireMonth = dateToMonth(hireDate)
|
const hireMonth = dateToMonth(hireDate)
|
||||||
const salaryNum = Number(data.monthlySalary) || 0
|
const salaryNum = Number(data.monthlySalary) || 0
|
||||||
|
// 工资结构:基本工资 + 绩效工资 = 月薪总额
|
||||||
|
const baseSalaryNum = Number(data.baseSalary) || 0
|
||||||
|
const performanceSalaryNum = Number(data.performanceSalary) || 0
|
||||||
const city = data.city || '北京'
|
const city = data.city || '北京'
|
||||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
// 劳务协议/实习协议:不缴纳社保公积金,基数强制 0
|
||||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
|
||||||
const socialInsBase = await clampSocialInsBase(orgId, rawSocialInsBase, city)
|
const rawSocialInsBase = isNoSocialContract ? 0 : (data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum)
|
||||||
const housingFundBase = await clampHousingFundBase(orgId, rawHousingFundBase, city)
|
const rawHousingFundBase = isNoSocialContract ? 0 : (data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum)
|
||||||
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
|
const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city, data.socialAccountId)
|
||||||
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
|
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) => {
|
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({
|
const emp = await tx.employee.create({
|
||||||
data: {
|
data: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -247,9 +408,12 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
department: data.department,
|
department: data.department,
|
||||||
hireDate,
|
hireDate,
|
||||||
monthlySalary: encrypt(data.monthlySalary),
|
monthlySalary: encrypt(data.monthlySalary),
|
||||||
|
baseSalary: encrypt(String(baseSalaryNum)),
|
||||||
|
performanceSalary: encrypt(String(performanceSalaryNum)),
|
||||||
gender: data.gender,
|
gender: data.gender,
|
||||||
femaleWorkerType: data.femaleWorkerType,
|
femaleWorkerType: data.femaleWorkerType,
|
||||||
phone: data.phone,
|
phone: data.phone,
|
||||||
|
passwordHash,
|
||||||
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
|
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
|
||||||
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
|
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
|
||||||
isPregnant: data.isPregnant || false,
|
isPregnant: data.isPregnant || false,
|
||||||
@@ -263,6 +427,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
city: data.city || '北京',
|
city: data.city || '北京',
|
||||||
education: data.education || null,
|
education: data.education || null,
|
||||||
position: data.position || 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',
|
changeType: 'ONBOARDING',
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
city: data.city || '北京',
|
city: data.city || '北京',
|
||||||
|
accountId: data.socialAccountId || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,6 +456,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
changeType: 'ONBOARDING',
|
changeType: 'ONBOARDING',
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
city: data.city || '北京',
|
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,
|
contractYears: data.contract.contractYears || 3,
|
||||||
probationMonths: data.contract.probationMonths || 0,
|
probationMonths: data.contract.probationMonths || 0,
|
||||||
probationSalary: data.contract.probationSalary || 0,
|
probationSalary: data.contract.probationSalary || 0,
|
||||||
|
baseSalary: baseSalaryNum,
|
||||||
|
performanceSalary: performanceSalaryNum,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -379,6 +549,10 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
|
|||||||
|
|
||||||
const newHireMonth = dateToMonth(newHireDate)
|
const newHireMonth = dateToMonth(newHireDate)
|
||||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
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 city = data.city || employee.city || '北京'
|
||||||
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
const rawSocialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||||
const rawHousingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : 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,
|
housingFundStartMonth,
|
||||||
housingFundEndMonth: null,
|
housingFundEndMonth: null,
|
||||||
city: data.city || employee.city || '北京',
|
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,
|
contractYears: data.contract.contractYears || 3,
|
||||||
probationMonths: data.contract.probationMonths || 0,
|
probationMonths: data.contract.probationMonths || 0,
|
||||||
probationSalary: data.contract.probationSalary || 0,
|
probationSalary: data.contract.probationSalary || 0,
|
||||||
|
baseSalary: baseSalaryNum,
|
||||||
|
performanceSalary: performanceSalaryNum,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -526,6 +708,27 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
|||||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
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 = {}
|
const updateData: any = {}
|
||||||
if (data.name !== undefined) updateData.name = data.name
|
if (data.name !== undefined) updateData.name = data.name
|
||||||
if (data.department !== undefined) updateData.department = data.department
|
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 oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||||
const newSalary = Number(data.monthlySalary) || 0
|
const newSalary = Number(data.monthlySalary) || 0
|
||||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
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) {
|
if (oldSalary !== newSalary) {
|
||||||
const now = new Date()
|
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.city !== undefined) updateData.city = data.city
|
||||||
if (data.education !== undefined) updateData.education = data.education
|
if (data.education !== undefined) updateData.education = data.education
|
||||||
if (data.position !== undefined) updateData.position = data.position
|
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) {
|
if (data.city !== undefined && data.city !== employee.city) {
|
||||||
@@ -742,6 +954,8 @@ export async function batchRenew(orgId: string, userId: string, contractIds: str
|
|||||||
contractYears: years,
|
contractYears: years,
|
||||||
probationMonths: 0,
|
probationMonths: 0,
|
||||||
probationSalary: 0,
|
probationSalary: 0,
|
||||||
|
baseSalary: contract.baseSalary || 0,
|
||||||
|
performanceSalary: contract.performanceSalary || 0,
|
||||||
renewalCount: contract.renewalCount + 1,
|
renewalCount: contract.renewalCount + 1,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
@@ -793,6 +1007,8 @@ export async function addContract(orgId: string, userId: string, data: any) {
|
|||||||
contractYears: data.contractYears || 3,
|
contractYears: data.contractYears || 3,
|
||||||
probationMonths: data.probationMonths || 0,
|
probationMonths: data.probationMonths || 0,
|
||||||
probationSalary: data.probationSalary || 0,
|
probationSalary: data.probationSalary || 0,
|
||||||
|
baseSalary: data.baseSalary || 0,
|
||||||
|
performanceSalary: data.performanceSalary || 0,
|
||||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||||
attachmentUrl: data.attachmentUrl || null,
|
attachmentUrl: data.attachmentUrl || null,
|
||||||
electronicContractNo: data.electronicContractNo || 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,5 +1,69 @@
|
|||||||
import prisma from '../lib/prisma'
|
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 }[] = [
|
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 medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
|
||||||
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
|
||||||
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
const medicalBase = Math.min(Math.max(base, medMin), medMax)
|
||||||
let socialEmp = actualBase * (config.pensionEmp + config.unemploymentEmp) / 100 + medicalBase * config.medicalEmp / 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
|
let socialOrg = actualBase * (config.pensionOrg + config.unemploymentOrg + config.injuryOrg) / 100 + medicalBase * (config.medicalOrg + config.maternityOrg) / 100 + (config.medicalOrgExtra || 0)
|
||||||
// 附加险种(大病险/长护险等)
|
// 附加险种(大病险/长护险等)
|
||||||
const extraItems: any[] = []
|
const extraItems: any[] = []
|
||||||
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
|
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) {
|
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 housingEmp = actualBase * config.housingEmp / 100
|
||||||
const housingOrg = actualBase * config.housingOrg / 100
|
const housingOrg = actualBase * config.housingOrg / 100
|
||||||
return { actualBase, housingEmp, housingOrg }
|
return { actualBase, housingEmp, housingOrg }
|
||||||
@@ -137,42 +204,63 @@ export async function calcBatchEntry(
|
|||||||
month: string,
|
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 },
|
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',
|
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: '员工不存在' }
|
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||||
|
|
||||||
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
|
// 劳务协议/实习协议人员:不缴纳社保公积金,基数强制 0
|
||||||
const [socialConfig, housingConfig] = await Promise.all([
|
const latestContract = employee.contracts[0]
|
||||||
prisma.socialInsuranceConfig.findFirst({
|
const isNoSocialContract = latestContract && ['LABOR', 'INTERNSHIP'].includes(latestContract.contractType)
|
||||||
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' },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
// 社保基数:优先用员工核定基数,否则用基本工资
|
// 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
|
||||||
const socialBase = employee.socialInsBase || inputs.baseSalary
|
const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
|
||||||
const housingBase = employee.housingFundBase || inputs.baseSalary
|
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 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. 计算当月应缴社保公积金全额
|
// 1. 计算当月应缴社保公积金全额
|
||||||
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
|
let fullSocialEmp = 0, fullSocialOrg = 0, fullHousingEmp = 0, fullHousingOrg = 0
|
||||||
|
let fullSuppHousingEmp = 0, fullSuppHousingOrg = 0
|
||||||
if (socialConfig) {
|
if (socialConfig) {
|
||||||
const social = calcSocialInsurance(socialBase, socialConfig)
|
const social = calcSocialInsurance(socialBase, socialConfig)
|
||||||
fullSocialEmp = social.socialEmp
|
fullSocialEmp = social.socialEmp
|
||||||
@@ -183,6 +271,12 @@ export async function calcBatchEntry(
|
|||||||
fullHousingEmp = housing.housingEmp
|
fullHousingEmp = housing.housingEmp
|
||||||
fullHousingOrg = housing.housingOrg
|
fullHousingOrg = housing.housingOrg
|
||||||
}
|
}
|
||||||
|
// 补充公积金(用同样的公积金基数)
|
||||||
|
if (supplementaryHousingConfig) {
|
||||||
|
const suppHousing = calcHousingFund(housingBase, supplementaryHousingConfig)
|
||||||
|
fullSuppHousingEmp = suppHousing.housingEmp
|
||||||
|
fullSuppHousingOrg = suppHousing.housingOrg
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额
|
// 2. 查询该员工当月已归档批次中已扣的社保公积金金额
|
||||||
const archivedEntries = await prisma.batchEntry.findMany({
|
const archivedEntries = await prisma.batchEntry.findMany({
|
||||||
@@ -191,25 +285,37 @@ export async function calcBatchEntry(
|
|||||||
employeeId,
|
employeeId,
|
||||||
batch: { month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
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 deductedSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0)
|
||||||
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
|
const deductedSocialOrg = archivedEntries.reduce((s, e) => s + e.socialOrg, 0)
|
||||||
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
|
const deductedHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0)
|
||||||
const deductedHousingOrg = archivedEntries.reduce((s, e) => s + e.housingOrg, 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)
|
// 3. 本批次应扣 = 应缴全额 - 已扣金额(不足额补扣,足额为0)
|
||||||
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
|
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
|
||||||
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
|
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
|
||||||
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
|
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
|
||||||
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
|
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 systemSocialEmp = socialEmp
|
||||||
const systemSocialOrg = socialOrg
|
const systemSocialOrg = socialOrg
|
||||||
const systemHousingEmp = housingEmp
|
const systemHousingEmp = housingEmp
|
||||||
const systemHousingOrg = housingOrg
|
const systemHousingOrg = housingOrg
|
||||||
|
const systemSupplementaryHousingEmp = suppHousingEmp
|
||||||
|
const systemSupplementaryHousingOrg = suppHousingOrg
|
||||||
|
|
||||||
// 手动覆盖社保值
|
// 手动覆盖社保值
|
||||||
if (options?.overrideSocial) {
|
if (options?.overrideSocial) {
|
||||||
@@ -253,12 +359,27 @@ export async function calcBatchEntry(
|
|||||||
status: 'ARCHIVED',
|
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 ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
|
||||||
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
||||||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp + (e.supplementaryHousingEmp || 0), 0) + housingEmp + suppHousingEmp
|
||||||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
|
||||||
|
// 专项附加扣除:按月读取 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 ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
||||||
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
||||||
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||||||
@@ -271,6 +392,8 @@ export async function calcBatchEntry(
|
|||||||
ytdSocialEmp,
|
ytdSocialEmp,
|
||||||
ytdHousingEmp,
|
ytdHousingEmp,
|
||||||
ytdSpecialDeduction,
|
ytdSpecialDeduction,
|
||||||
|
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
|
||||||
|
specialDeductionRecords: deductionRecords.length,
|
||||||
ytdTaxableIncome,
|
ytdTaxableIncome,
|
||||||
ytdTaxDeducted,
|
ytdTaxDeducted,
|
||||||
currentMonthTax: tax,
|
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 {
|
return {
|
||||||
socialEmp: Math.round(socialEmp * 100) / 100,
|
socialEmp: Math.round(socialEmp * 100) / 100,
|
||||||
socialOrg: Math.round(socialOrg * 100) / 100,
|
socialOrg: Math.round(socialOrg * 100) / 100,
|
||||||
housingEmp: Math.round(housingEmp * 100) / 100,
|
housingEmp: Math.round(housingEmp * 100) / 100,
|
||||||
housingOrg: Math.round(housingOrg * 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,
|
systemSocialEmp: Math.round(systemSocialEmp * 100) / 100,
|
||||||
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
|
systemSocialOrg: Math.round(systemSocialOrg * 100) / 100,
|
||||||
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
|
systemHousingEmp: Math.round(systemHousingEmp * 100) / 100,
|
||||||
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
|
systemHousingOrg: Math.round(systemHousingOrg * 100) / 100,
|
||||||
|
systemSupplementaryHousingEmp: Math.round(systemSupplementaryHousingEmp * 100) / 100,
|
||||||
|
systemSupplementaryHousingOrg: Math.round(systemSupplementaryHousingOrg * 100) / 100,
|
||||||
tax,
|
tax,
|
||||||
taxBreakdown,
|
taxBreakdown,
|
||||||
totalPay: Math.round(totalPay * 100) / 100,
|
totalPay: Math.round(totalPay * 100) / 100,
|
||||||
netPay: Math.round(netPay * 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 } },
|
where: { employeeId_month: { employeeId, month } },
|
||||||
update: {
|
update: {
|
||||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||||
|
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||||
allowance: Math.round(summary.allowance * 100) / 100,
|
allowance: Math.round(summary.allowance * 100) / 100,
|
||||||
deduction: Math.round(summary.deduction * 100) / 100,
|
deduction: Math.round(summary.deduction * 100) / 100,
|
||||||
@@ -441,6 +660,7 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
|||||||
employeeId,
|
employeeId,
|
||||||
month,
|
month,
|
||||||
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
baseSalary: Math.round(summary.baseSalary * 100) / 100,
|
||||||
|
performanceSalary: Math.round(summary.performanceSalary * 100) / 100,
|
||||||
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
overtimePay: Math.round(summary.overtimePay * 100) / 100,
|
||||||
allowance: Math.round(summary.allowance * 100) / 100,
|
allowance: Math.round(summary.allowance * 100) / 100,
|
||||||
deduction: Math.round(summary.deduction * 100) / 100,
|
deduction: Math.round(summary.deduction * 100) / 100,
|
||||||
@@ -514,7 +734,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
|
|||||||
// 1. 社保基数是否在上下限范围内
|
// 1. 社保基数是否在上下限范围内
|
||||||
if (socialConfig) {
|
if (socialConfig) {
|
||||||
for (const entry of entries) {
|
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) {
|
if (base < socialConfig.baseMin || base > socialConfig.baseMax) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: 'SOCIAL_BASE_OUT_OF_RANGE',
|
code: 'SOCIAL_BASE_OUT_OF_RANGE',
|
||||||
@@ -532,7 +752,7 @@ export async function prePayrollCheck(orgId: string, batchId: string): Promise<P
|
|||||||
// 2. 公积金基数是否在上下限范围内
|
// 2. 公积金基数是否在上下限范围内
|
||||||
if (housingConfig) {
|
if (housingConfig) {
|
||||||
for (const entry of entries) {
|
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) {
|
if (base < housingConfig.baseMin || base > housingConfig.baseMax) {
|
||||||
checks.push({
|
checks.push({
|
||||||
code: 'HOUSING_BASE_OUT_OF_RANGE',
|
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 passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0
|
||||||
const failedCount = checks.filter(c => c.status === 'FAIL').length
|
const 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: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
|
||||||
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
|
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', 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
|
let initialized = false
|
||||||
@@ -109,8 +116,8 @@ const HELP_SEED_DATA: KnowledgeSeed[] = [
|
|||||||
{ title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' },
|
{ title: '怎么修改公司信息', content: '在设置页面可以修改公司名称、行业、规模等基本信息。这些信息会影响风险检测的准确性,请如实填写。', source: '使用帮助', category: '系统帮助-通知设置' },
|
||||||
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
|
{ title: '可以在手机上使用吗', content: '可以。用手机浏览器打开本网站即可,手机版会自动显示底部导航栏。建议添加到手机桌面像App一样使用。苹果手机Safari打开点击底部分享按钮选择添加到主屏幕。安卓手机Chrome打开点击右上角菜单选择添加到主屏幕。', source: '使用帮助', category: '系统帮助-快速入门' },
|
||||||
{ title: '第一次使用该从哪里开始', content: '建议按以下顺序:1添加员工信息,2填写合同信息,3设置社保基数和比例,4创建发薪批次,5有不懂的随时点帮助图标查看。不用担心填错,所有信息都可以随时修改。', 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: '可以。在员工管理页面可以导出员工名单为Excel文件。工资批次也可以导出为Excel方便财务对账。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||||
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
|
{ title: '可以多人同时使用吗', content: '可以。在设置页面可以添加多个HR账号,不同账号可以设置不同权限。比如一个管理员、几个普通HR。', source: '使用帮助', category: '系统帮助-常见问题' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
|
|
||||||
// 从身份证号提取出生日期
|
// 从证件号码提取出生日期
|
||||||
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
||||||
// 18位身份证:7-14位为出生日期 YYYYMMDD
|
// 18位身份证:7-14位为出生日期 YYYYMMDD
|
||||||
if (idCard.length === 18) {
|
if (idCard.length === 18) {
|
||||||
@@ -24,7 +24,7 @@ export function extractBirthDateFromIdCard(idCard: string): Date | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从身份证号提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
// 从证件号码提取性别(18位:第17位奇数为男,偶数为女;15位:第15位)
|
||||||
export function extractGenderFromIdCard(idCard: string): string | null {
|
export function extractGenderFromIdCard(idCard: string): string | null {
|
||||||
if (idCard.length === 18) {
|
if (idCard.length === 18) {
|
||||||
const genderCode = parseInt(idCard.substring(16, 17))
|
const genderCode = parseInt(idCard.substring(16, 17))
|
||||||
|
|||||||
@@ -225,12 +225,23 @@ export async function detectContractRisks(orgId: string) {
|
|||||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||||
})
|
})
|
||||||
} else if (daysToExpire <= 30) {
|
} 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({
|
risks.push({
|
||||||
employeeId: emp.id,
|
employeeId: emp.id,
|
||||||
type: 'CONTRACT',
|
type: 'CONTRACT',
|
||||||
level: 'MEDIUM',
|
level: 'MEDIUM',
|
||||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
title: `${emp.name}的合同将于${daysToExpire}天后到期`,
|
||||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},建议提前准备续签或终止。`,
|
||||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -312,8 +323,8 @@ export async function detectTerminationRisks(orgId: string) {
|
|||||||
type: 'TERMINATION',
|
type: 'TERMINATION',
|
||||||
level: 'HIGH',
|
level: 'HIGH',
|
||||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=PREGNANCY`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (emp.isInMedicalPeriod) {
|
if (emp.isInMedicalPeriod) {
|
||||||
@@ -322,8 +333,8 @@ export async function detectTerminationRisks(orgId: string) {
|
|||||||
type: 'TERMINATION',
|
type: 'TERMINATION',
|
||||||
level: 'MEDIUM',
|
level: 'MEDIUM',
|
||||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
actionUrl: `/special-status?employee=${encodeURIComponent(emp.name)}&type=MEDICAL_PERIOD`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (emp.isWorkInjured) {
|
if (emp.isWorkInjured) {
|
||||||
@@ -332,8 +343,8 @@ export async function detectTerminationRisks(orgId: string) {
|
|||||||
type: 'TERMINATION',
|
type: 'TERMINATION',
|
||||||
level: 'HIGH',
|
level: 'HIGH',
|
||||||
title: `${emp.name}工伤期间,解聘受限`,
|
title: `${emp.name}工伤期间,解聘受限`,
|
||||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
description: '工伤职工在停工留薪期内不得解除劳动合同。此为合规提示,请勿发起解聘;如需确认员工状态请查看特殊状态。',
|
||||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
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 today = now.getDate()
|
||||||
|
|
||||||
const tasks = [
|
const tasks = [
|
||||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
|
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money?tab=batch' },
|
||||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
|
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/social?tab=monthly' },
|
||||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
|
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/social?tab=monthly' },
|
||||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
|
{ 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 }[] = []
|
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 可能因天数变化而不同)
|
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||||
const existingRisks = await prisma.riskItem.findMany({
|
const existingRisks = await prisma.riskItem.findMany({
|
||||||
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
|
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}`))
|
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`))
|
||||||
// PENDING 风险的 employeeId:type → record 映射,用于更新标题
|
// PENDING 风险的 employeeId:type → record 映射,用于更新标题
|
||||||
@@ -581,18 +592,19 @@ export async function runRiskDetection(orgId: string) {
|
|||||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||||
]
|
]
|
||||||
|
|
||||||
// 更新已存在的 PENDING 风险:标题/量化信息可能因天数变化而变化
|
// 更新已存在的 PENDING 风险:标题/量化信息/actionUrl 可能因天数变化或代码更新而变化
|
||||||
const toUpdate: { id: string; title: string; description: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
|
const toUpdate: { id: string; title: string; description: string; actionUrl: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = []
|
||||||
for (const r of nonMonthlyRisks) {
|
for (const r of nonMonthlyRisks) {
|
||||||
const key = `${r.employeeId}:${r.type}`
|
const key = `${r.employeeId}:${r.type}`
|
||||||
const existing = pendingRiskMap.get(key)
|
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 emp = r.employeeId ? empMap.get(r.employeeId) : null
|
||||||
const cost = estimateRiskCost(r, emp)
|
const cost = estimateRiskCost(r, emp)
|
||||||
toUpdate.push({
|
toUpdate.push({
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
|
actionUrl: r.actionUrl,
|
||||||
estimatedLoss: cost.estimatedLoss,
|
estimatedLoss: cost.estimatedLoss,
|
||||||
lossRange: cost.lossRange,
|
lossRange: cost.lossRange,
|
||||||
deadline: cost.deadline,
|
deadline: cost.deadline,
|
||||||
@@ -605,6 +617,7 @@ export async function runRiskDetection(orgId: string) {
|
|||||||
data: {
|
data: {
|
||||||
title: u.title,
|
title: u.title,
|
||||||
description: u.description,
|
description: u.description,
|
||||||
|
actionUrl: u.actionUrl,
|
||||||
estimatedLoss: u.estimatedLoss,
|
estimatedLoss: u.estimatedLoss,
|
||||||
lossRange: u.lossRange,
|
lossRange: u.lossRange,
|
||||||
deadline: u.deadline,
|
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) {
|
if (toCreate.length > 0) {
|
||||||
// 为每个风险计算量化信息
|
// 为每个风险计算量化信息
|
||||||
const createData = toCreate.map((r) => {
|
const createData = toCreate.map((r) => {
|
||||||
@@ -933,12 +959,17 @@ export async function getDashboardData(orgId: string) {
|
|||||||
const catKey = getTodoRiskCategoryKey(t.title || '')
|
const catKey = getTodoRiskCategoryKey(t.title || '')
|
||||||
const dedupKey = `${personKey}:${catKey}`
|
const dedupKey = `${personKey}:${catKey}`
|
||||||
const existing = dedupedTodoMap.get(dedupKey)
|
const existing = dedupedTodoMap.get(dedupKey)
|
||||||
|
// 去重保留优先级更高(estimatedLoss 更大或 deadline 更近)的一条
|
||||||
if (!existing || t.estimatedLoss > existing.estimatedLoss) {
|
if (!existing || t.estimatedLoss > existing.estimatedLoss) {
|
||||||
dedupedTodoMap.set(dedupKey, t)
|
dedupedTodoMap.set(dedupKey, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dedupedTodos = Array.from(dedupedTodoMap.values())
|
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 = {
|
const riskDistribution = {
|
||||||
contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length,
|
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,
|
termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW
|
// 按优先级排序:URGENT > HIGH > MEDIUM > LOW(priorityOrder 已在上方声明)
|
||||||
const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }
|
|
||||||
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||||
|
|
||||||
const topRisks = todosWithCost
|
const topRisks = todosWithCost
|
||||||
@@ -1411,6 +1441,7 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
const [
|
const [
|
||||||
totalEmployees,
|
totalEmployees,
|
||||||
unsignedContracts,
|
unsignedContracts,
|
||||||
|
pendingSignContracts,
|
||||||
expiringContracts,
|
expiringContracts,
|
||||||
pendingRisks,
|
pendingRisks,
|
||||||
highRisks,
|
highRisks,
|
||||||
@@ -1426,6 +1457,14 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||||
// 未签合同:完全无合同记录的在职员工
|
// 未签合同:完全无合同记录的在职员工
|
||||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
|
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
|
||||||
|
// 待签署:有合同记录但 signDate 为 null(合同已创建但未完成签署)
|
||||||
|
prisma.employee.count({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
contracts: { some: { signDate: null } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
prisma.laborContract.count({
|
prisma.laborContract.count({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -1453,8 +1492,8 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 5 维度评分计算
|
// 5 维度评分计算(未签合同 + 待签署都扣分)
|
||||||
const contractScore = Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5)
|
const contractScore = Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5)
|
||||||
const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12)
|
const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12)
|
||||||
const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8)
|
const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8)
|
||||||
const salaryScore = totalPayslips > 0
|
const salaryScore = totalPayslips > 0
|
||||||
@@ -1463,7 +1502,7 @@ export async function getComplianceScore(orgId: string) {
|
|||||||
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
|
const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0)
|
||||||
|
|
||||||
const dimensions = [
|
const dimensions = [
|
||||||
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + expiringContracts },
|
{ key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + pendingSignContracts + expiringContracts },
|
||||||
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
|
{ key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish },
|
||||||
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
|
{ key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed },
|
||||||
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
|
{ key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips },
|
||||||
@@ -1583,6 +1622,7 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
const [
|
const [
|
||||||
totalEmployees,
|
totalEmployees,
|
||||||
unsignedContracts,
|
unsignedContracts,
|
||||||
|
pendingSignContracts,
|
||||||
expiringContracts,
|
expiringContracts,
|
||||||
policiesWithoutPublish,
|
policiesWithoutPublish,
|
||||||
totalPolicies,
|
totalPolicies,
|
||||||
@@ -1602,6 +1642,14 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||||
// 未签合同:完全无合同记录的在职员工
|
// 未签合同:完全无合同记录的在职员工
|
||||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
|
prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }),
|
||||||
|
// 待签署:有合同记录但 signDate 为 null(合同已创建但未完成签署)
|
||||||
|
prisma.employee.count({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
contracts: { some: { signDate: null } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
prisma.laborContract.count({
|
prisma.laborContract.count({
|
||||||
where: {
|
where: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -1635,37 +1683,49 @@ export async function getHealthCheck(orgId: string) {
|
|||||||
{
|
{
|
||||||
key: 'contract',
|
key: 'contract',
|
||||||
name: '合同管理',
|
name: '合同管理',
|
||||||
score: Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5),
|
score: Math.max(0, 100 - unsignedContracts * 15 - pendingSignContracts * 10 - expiringContracts * 5),
|
||||||
findings: [
|
findings: [
|
||||||
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已签书面合同',
|
unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已有合同记录',
|
||||||
|
pendingSignContracts > 0 ? `${pendingSignContracts} 份合同待签署(已创建未完成签署)` : '',
|
||||||
expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同',
|
expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同',
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
recommendations: [
|
recommendations: [
|
||||||
unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '',
|
unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '',
|
||||||
|
pendingSignContracts > 0 ? '尽快完成待签署合同的签署流程,未签署合同在仲裁中可能无效' : '',
|
||||||
expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '',
|
expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '',
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'salary_social',
|
key: 'salary',
|
||||||
name: '薪酬社保',
|
name: '薪酬工资',
|
||||||
|
score: totalPayslips > 0
|
||||||
|
? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100))
|
||||||
|
: 100,
|
||||||
|
findings: [
|
||||||
|
totalPayslips > 0
|
||||||
|
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
|
||||||
|
: '暂无工资条记录',
|
||||||
|
].filter(Boolean),
|
||||||
|
recommendations: [
|
||||||
|
unconfirmedPayslips > 0 ? '提醒员工及时确认工资条,保留薪酬合规证据' : '',
|
||||||
|
].filter(Boolean),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'social',
|
||||||
|
name: '社保公积金',
|
||||||
score: Math.round(
|
score: Math.round(
|
||||||
(socialConfig ? 40 : 0) +
|
(socialConfig ? 50 : 0) +
|
||||||
(housingConfig ? 20 : 0) +
|
(housingConfig ? 30 : 0) +
|
||||||
(totalPayslips > 0 ? Math.max(0, 20 - Math.round((unconfirmedPayslips / totalPayslips) * 20)) : 20) +
|
|
||||||
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
|
(employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2))
|
||||||
),
|
),
|
||||||
findings: [
|
findings: [
|
||||||
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
|
socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置',
|
||||||
housingConfig ? '公积金配置已设置' : '未配置公积金比例',
|
housingConfig ? '公积金配置已设置' : '未配置公积金比例',
|
||||||
totalPayslips > 0
|
|
||||||
? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认`
|
|
||||||
: '暂无工资条记录',
|
|
||||||
employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常',
|
employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常',
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
recommendations: [
|
recommendations: [
|
||||||
!socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '',
|
!socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '',
|
||||||
!housingConfig ? '根据当地政策配置公积金比例' : '',
|
!housingConfig ? '根据当地政策配置公积金比例' : '',
|
||||||
unconfirmedPayslips > 0 ? '提醒员工及时确认工资条,保留薪酬合规证据' : '',
|
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1928,7 +1988,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
|
// 按证件号码去重(同一人可能有多条 Employee 记录),无证件号码时回退到 employeeId
|
||||||
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
|
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
|
||||||
const personBreakdown: Record<string, {
|
const personBreakdown: Record<string, {
|
||||||
personKey: string
|
personKey: string
|
||||||
@@ -1988,7 +2048,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const r of employeeRiskDetails) {
|
for (const r of employeeRiskDetails) {
|
||||||
// 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
// 去重优先级:证件号码 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算
|
||||||
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
|
const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown'
|
||||||
if (!personBreakdown[personKey]) {
|
if (!personBreakdown[personKey]) {
|
||||||
personBreakdown[personKey] = {
|
personBreakdown[personKey] = {
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ export async function createSpecialStatus(orgId: string, userId: string, data: a
|
|||||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合规校验:男职工不可选择三期
|
||||||
|
if (data.type === 'PREGNANCY' && employee.gender === '男') {
|
||||||
|
throw { code: 'VALIDATION_ERROR', message: '三期仅适用于女性员工,男职工不可选择三期' }
|
||||||
|
}
|
||||||
|
|
||||||
// 三期自动计算
|
// 三期自动计算
|
||||||
let pregnancyData: any = {}
|
let pregnancyData: any = {}
|
||||||
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
|
if (data.type === 'PREGNANCY' && data.expectedDueDate) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
name: '固定期限劳动合同',
|
name: '固定期限劳动合同',
|
||||||
category: 'CONTRACT',
|
category: 'CONTRACT',
|
||||||
description: '标准固定期限劳动合同模板,适用于大多数正式员工',
|
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: `劳动合同书
|
content: `劳动合同书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
@@ -36,7 +36,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
甲方执行标准工时制度,乙方每日工作时间不超过8小时,每周不超过40小时。
|
||||||
|
|
||||||
第四条 劳动报酬
|
第四条 劳动报酬
|
||||||
乙方试用期月工资为人民币{{monthlySalary}}元,转正后月工资为人民币{{monthlySalary}}元,甲方于每月15日前支付上月工资。
|
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。试用期月工资为人民币{{probationSalary}}元。甲方于每月15日前支付上月工资。
|
||||||
|
|
||||||
第五条 社会保险和福利
|
第五条 社会保险和福利
|
||||||
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
甲方依法为乙方缴纳社会保险,缴费基数为{{socialInsBase}}元。
|
||||||
@@ -64,7 +64,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
name: '无固定期限劳动合同',
|
name: '无固定期限劳动合同',
|
||||||
category: 'CONTRACT',
|
category: 'CONTRACT',
|
||||||
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
description: '无固定期限劳动合同模板,适用于符合签订条件的情况',
|
||||||
variables: ['companyName', 'employeeName', 'startDate', 'position', 'monthlySalary'],
|
variables: ['companyName', 'employeeName', 'startDate', 'position', 'baseSalary', 'performanceSalary'],
|
||||||
content: `无固定期限劳动合同书
|
content: `无固定期限劳动合同书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
@@ -79,7 +79,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
乙方担任{{position}}岗位。
|
乙方担任{{position}}岗位。
|
||||||
|
|
||||||
第三条 劳动报酬
|
第三条 劳动报酬
|
||||||
乙方月工资为人民币{{monthlySalary}}元。
|
乙方基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月(绩效工资根据考核结果发放)。
|
||||||
|
|
||||||
(其余条款参照固定期限劳动合同模板)
|
(其余条款参照固定期限劳动合同模板)
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
content: `解除劳动合同协议书
|
content: `解除劳动合同协议书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||||
|
|
||||||
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
|
甲乙双方经协商一致,就解除劳动合同事宜达成如下协议:
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export const documentTemplates: DocumentTemplate[] = [
|
|||||||
content: `解除劳动合同协议书
|
content: `解除劳动合同协议书
|
||||||
|
|
||||||
甲方(用人单位):{{companyName}}
|
甲方(用人单位):{{companyName}}
|
||||||
乙方(劳动者):{{employeeName}},身份证号:{{idCard}}
|
乙方(劳动者):{{employeeName}},证件号码:{{idCard}}
|
||||||
|
|
||||||
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
乙方因个人原因主动提出离职,经甲乙双方友好协商,就解除劳动合同事宜达成如下协议:
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ ____年__月__日
|
|||||||
name: '试用期转正通知书',
|
name: '试用期转正通知书',
|
||||||
category: 'NOTICE',
|
category: 'NOTICE',
|
||||||
description: '试用期考核合格转正通知',
|
description: '试用期考核合格转正通知',
|
||||||
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'monthlySalary', 'companyName'],
|
variables: ['employeeName', 'position', 'probationEndDate', 'regularDate', 'baseSalary', 'performanceSalary', 'companyName'],
|
||||||
content: `试用期转正通知书
|
content: `试用期转正通知书
|
||||||
|
|
||||||
{{employeeName}}:
|
{{employeeName}}:
|
||||||
@@ -249,7 +249,7 @@ ____年__月__日
|
|||||||
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
经考核,你在试用期内表现合格,符合岗位要求。现通知你:
|
||||||
|
|
||||||
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
一、自{{regularDate}}起正式转正,担任{{position}}岗位。
|
||||||
二、转正后月工资为人民币{{monthlySalary}}元。
|
二、转正后基本工资为人民币{{baseSalary}}元/月,绩效工资为人民币{{performanceSalary}}元/月。
|
||||||
三、试用期至{{probationEndDate}}结束。
|
三、试用期至{{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 prisma from '../lib/prisma'
|
||||||
import { RiskAssessment, TerminationReason } from '@prisma/client'
|
import { RiskAssessment, TerminationReason } from '@prisma/client'
|
||||||
|
import { autoCreateEsignRecord } from './esign.service'
|
||||||
|
|
||||||
function dateToMonth(date: Date): string {
|
function dateToMonth(date: Date): string {
|
||||||
const y = date.getFullYear()
|
const y = date.getFullYear()
|
||||||
@@ -7,6 +8,13 @@ function dateToMonth(date: Date): string {
|
|||||||
return `${y}-${m}`
|
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 {
|
export interface ChecklistItem {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
@@ -16,7 +24,9 @@ export interface ChecklistItem {
|
|||||||
suggestionType?: 'info' | 'warning' | 'required'
|
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) {
|
switch (reason) {
|
||||||
case 'NEGOTIATED':
|
case 'NEGOTIATED':
|
||||||
return [
|
return [
|
||||||
@@ -33,7 +43,14 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
|
|||||||
return [
|
return [
|
||||||
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
|
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
|
||||||
{ key: 'has_evidence', 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 },
|
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
|
||||||
]
|
]
|
||||||
case 'NONFAULT': {
|
case 'NONFAULT': {
|
||||||
@@ -86,12 +103,29 @@ export function getChecklistForReason(reason: string, employee?: any): Checklist
|
|||||||
suggestionType: 'required',
|
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
|
return items
|
||||||
}
|
}
|
||||||
case 'LAYOFF':
|
case 'LAYOFF':
|
||||||
return [
|
return [
|
||||||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
|
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
|
||||||
{ key: 'listen_opinions', label: '是否听取工会或职工意见', 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: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
|
||||||
{
|
{
|
||||||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
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')
|
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({
|
const record = await prisma.terminationRecord.create({
|
||||||
data: {
|
data: {
|
||||||
orgId,
|
orgId,
|
||||||
@@ -554,8 +608,8 @@ export async function createDraft(orgId: string, userId: string, data: any) {
|
|||||||
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
|
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
|
||||||
resignationReason: data.resignationReason || null,
|
resignationReason: data.resignationReason || null,
|
||||||
compensation: data.compensation || 0,
|
compensation: data.compensation || 0,
|
||||||
socialInsEndMonth: data.socialInsEndMonth || null,
|
socialInsEndMonth,
|
||||||
housingFundEndMonth: data.housingFundEndMonth || null,
|
housingFundEndMonth,
|
||||||
riskLevel: level,
|
riskLevel: level,
|
||||||
checklist: data.checklist || {},
|
checklist: data.checklist || {},
|
||||||
remark: data.remark || null,
|
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 }
|
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 }
|
return { id: recordId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import prisma from '../lib/prisma'
|
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 { 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'
|
import { runRiskDetection } from './risk.service'
|
||||||
|
|
||||||
// 13类流程定义
|
// 13类流程定义
|
||||||
@@ -66,13 +66,51 @@ export async function executeWorkProcess(processId: string, type: string, formDa
|
|||||||
return { employeeId }
|
return { employeeId }
|
||||||
}
|
}
|
||||||
case 'CONFIRM': {
|
case 'CONFIRM': {
|
||||||
const { employeeId, regularSalary } = formData
|
const { employeeId, regularSalary, baseSalary, performanceSalary } = formData
|
||||||
if (employeeId) {
|
if (employeeId) {
|
||||||
if (regularSalary) {
|
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({
|
await prisma.employee.update({
|
||||||
where: { id: employeeId },
|
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)
|
await runRiskDetection(orgId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,13 +313,13 @@ ${body}
|
|||||||
|
|
||||||
const templates: Record<string, (data: any, org: string) => string> = {
|
const templates: Record<string, (data: any, org: string) => string> = {
|
||||||
INCOME_CERT: (data, org) => wrapHtml('收入证明', `
|
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.monthlyIncome || '___'} 元(税前)。</div>
|
||||||
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
<div class="body">本证明仅用于 ${data.purpose || '___'},不作其他用途。</div>
|
||||||
<div class="body">特此证明。</div>
|
<div class="body">特此证明。</div>
|
||||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||||
LEAVING_CERT: (data, org) => wrapHtml('离职证明', `
|
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">该员工已于 ${data.leaveDate || '___'} 与我单位解除劳动关系,双方已办妥交接手续。</div>
|
||||||
<div class="body">特此证明。</div>
|
<div class="body">特此证明。</div>
|
||||||
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
<div class="sign">${org}<br/>${new Date().toLocaleDateString('zh-CN')}</div>`),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 企业用工专家 一键部署脚本
|
# 安职通 一键部署脚本
|
||||||
# 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/
|
# 部署到 154.8.162.18 -> /var/www/turbohr/ -> https://on.hr8ai.top/
|
||||||
#
|
#
|
||||||
# 用法: bash deploy.sh
|
# 用法: bash deploy.sh
|
||||||
@@ -342,7 +342,7 @@ verify() {
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo " 企业用工专家 部署完成!"
|
echo " 安职通 部署完成!"
|
||||||
echo " 访问地址: https://${DOMAIN}/"
|
echo " 访问地址: https://${DOMAIN}/"
|
||||||
echo " 后端 API: https://${DOMAIN}/api/v1/"
|
echo " 后端 API: https://${DOMAIN}/api/v1/"
|
||||||
echo " PM2 管理: pm2 list (服务器上)"
|
echo " PM2 管理: pm2 list (服务器上)"
|
||||||
@@ -357,7 +357,7 @@ if [ "$1" = "--init" ]; then
|
|||||||
IS_INIT=true
|
IS_INIT=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "开始部署 企业用工专家 到 ${REMOTE_HOST}"
|
log "开始部署 安职通 到 ${REMOTE_HOST}"
|
||||||
log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}"
|
log "域名: ${DOMAIN} 目录: ${REMOTE_DIR}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover" />
|
||||||
<title>企业用工专家</title>
|
<title>安职通</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+4
-4
@@ -16,7 +16,7 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.428.0",
|
"lucide-react": "^0.428.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.1",
|
||||||
"qrcode.react": "^4.0.1",
|
"qrcode.react": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -3348,9 +3348,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mammoth": {
|
"node_modules/mammoth": {
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.1.tgz",
|
||||||
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
"integrity": "sha512-nCH9KKjWi3jQ+i8bUKs7k1yrXtSEGpWgF8IYkzsFMcbn+5S6l4bZEBbyx2hOQErFiXPuAs9RPa6qjXVxhyx/8g==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xmldom/xmldom": "^0.8.6",
|
"@xmldom/xmldom": "^0.8.6",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"lucide-react": "^0.428.0",
|
"lucide-react": "^0.428.0",
|
||||||
"mammoth": "^1.12.0",
|
"mammoth": "^1.12.1",
|
||||||
"qrcode.react": "^4.0.1",
|
"qrcode.react": "^4.0.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^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="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<title>TurboHR 验收测试清单</title>
|
<title>安职通 验收测试清单</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--primary: #4f46e5;
|
--primary: #4f46e5;
|
||||||
@@ -280,7 +280,7 @@ body {
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>TurboHR 验收测试清单</h1>
|
<h1>安职通 验收测试清单</h1>
|
||||||
<div class="verifier-section">
|
<div class="verifier-section">
|
||||||
<label>验收人:</label>
|
<label>验收人:</label>
|
||||||
<div class="autocomplete-wrap">
|
<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 { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
@@ -11,62 +11,66 @@ import PageContainer from './components/layout/PageContainer'
|
|||||||
import { CommandPalette } from './components/ui/CommandPalette'
|
import { CommandPalette } from './components/ui/CommandPalette'
|
||||||
import { SkeletonPage } from './components/ui/Skeleton'
|
import { SkeletonPage } from './components/ui/Skeleton'
|
||||||
import ErrorBoundary from './components/ui/ErrorBoundary'
|
import ErrorBoundary from './components/ui/ErrorBoundary'
|
||||||
|
import { lazyRetry } from './lib/lazyRetry'
|
||||||
|
|
||||||
const Login = lazy(() => import('./pages/auth/Login'))
|
const Login = lazyRetry(() => import('./pages/auth/Login'))
|
||||||
const Register = lazy(() => import('./pages/auth/Register'))
|
const Register = lazyRetry(() => import('./pages/auth/Register'))
|
||||||
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
|
const ForgotPassword = lazyRetry(() => import('./pages/auth/ForgotPassword'))
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
const Dashboard = lazyRetry(() => import('./pages/Dashboard'))
|
||||||
const Money = lazy(() => import('./pages/Money'))
|
const Money = lazyRetry(() => import('./pages/Money'))
|
||||||
const SocialInsurance = lazy(() => import('./pages/SocialInsurance'))
|
const SocialInsurance = lazyRetry(() => import('./pages/SocialInsurance'))
|
||||||
const Roster = lazy(() => import('./pages/Roster'))
|
const Overtime = lazyRetry(() => import('./pages/Overtime'))
|
||||||
const Termination = lazy(() => import('./pages/Termination'))
|
const Roster = lazyRetry(() => import('./pages/Roster'))
|
||||||
const AIAssistant = lazy(() => import('./pages/AIAssistant'))
|
const OrgChart = lazyRetry(() => import('./pages/OrgChart'))
|
||||||
const Settings = lazy(() => import('./pages/Settings'))
|
const SupportDashboard = lazyRetry(() => import('./pages/support/SupportDashboard'))
|
||||||
const Evidence = lazy(() => import('./pages/Evidence'))
|
const Termination = lazyRetry(() => import('./pages/Termination'))
|
||||||
const Policies = lazy(() => import('./pages/Policies'))
|
const AIAssistant = lazyRetry(() => import('./pages/AIAssistant'))
|
||||||
const Attendance = lazy(() => import('./pages/Attendance'))
|
const Settings = lazyRetry(() => import('./pages/Settings'))
|
||||||
const Templates = lazy(() => import('./pages/Templates'))
|
const Evidence = lazyRetry(() => import('./pages/Evidence'))
|
||||||
const AuditLog = lazy(() => import('./pages/AuditLog'))
|
const Policies = lazyRetry(() => import('./pages/Policies'))
|
||||||
const Notifications = lazy(() => import('./pages/Notifications'))
|
const Attendance = lazyRetry(() => import('./pages/Attendance'))
|
||||||
const PortalLogin = lazy(() => import('./pages/portal/PortalLogin'))
|
const Templates = lazyRetry(() => import('./pages/Templates'))
|
||||||
const Payslip = lazy(() => import('./pages/portal/Payslip'))
|
const AuditLog = lazyRetry(() => import('./pages/AuditLog'))
|
||||||
const MyContract = lazy(() => import('./pages/portal/MyContract'))
|
const Notifications = lazyRetry(() => import('./pages/Notifications'))
|
||||||
const Onboarding = lazy(() => import('./pages/portal/Onboarding'))
|
const PortalLogin = lazyRetry(() => import('./pages/portal/PortalLogin'))
|
||||||
const ContractConfirm = lazy(() => import('./pages/portal/ContractConfirm'))
|
const Payslip = lazyRetry(() => import('./pages/portal/Payslip'))
|
||||||
const MyPolicies = lazy(() => import('./pages/portal/MyPolicies'))
|
const MyContract = lazyRetry(() => import('./pages/portal/MyContract'))
|
||||||
const AutoLogin = lazy(() => import('./pages/portal/AutoLogin'))
|
const Onboarding = lazyRetry(() => import('./pages/portal/Onboarding'))
|
||||||
const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator'))
|
const ContractConfirm = lazyRetry(() => import('./pages/portal/ContractConfirm'))
|
||||||
const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
|
const MyPolicies = lazyRetry(() => import('./pages/portal/MyPolicies'))
|
||||||
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
|
const AutoLogin = lazyRetry(() => import('./pages/portal/AutoLogin'))
|
||||||
const CalendarPage = lazy(() => import('./pages/Calendar'))
|
const MedicalPeriodCalculator = lazyRetry(() => import('./pages/tools/MedicalPeriodCalculator'))
|
||||||
const WorkProcess = lazy(() => import('./pages/WorkProcess'))
|
const HealthCheck = lazyRetry(() => import('./pages/tools/HealthCheck'))
|
||||||
const MyAttendance = lazy(() => import('./pages/portal/MyAttendance'))
|
const AnnualValueReport = lazyRetry(() => import('./pages/tools/AnnualValueReport'))
|
||||||
const MyLeave = lazy(() => import('./pages/portal/MyLeave'))
|
const CalendarPage = lazyRetry(() => import('./pages/Calendar'))
|
||||||
const SpecialStatus = lazy(() => import('./pages/SpecialStatus'))
|
const MyAttendance = lazyRetry(() => import('./pages/portal/MyAttendance'))
|
||||||
const CompanyFiles = lazy(() => import('./pages/CompanyFiles'))
|
const MyLeave = lazyRetry(() => import('./pages/portal/MyLeave'))
|
||||||
const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
const SpecialStatus = lazyRetry(() => import('./pages/SpecialStatus'))
|
||||||
const TrainingRecords = lazy(() => import('./pages/roster/TrainingRecords'))
|
const CompanyFiles = lazyRetry(() => import('./pages/CompanyFiles'))
|
||||||
const PerformanceRecords = lazy(() => import('./pages/roster/PerformanceRecords'))
|
const LeaveApproval = lazyRetry(() => import('./pages/LeaveApproval'))
|
||||||
const DisciplinaryRecords = lazy(() => import('./pages/roster/DisciplinaryRecords'))
|
const TrainingRecords = lazyRetry(() => import('./pages/roster/TrainingRecords'))
|
||||||
|
const PerformanceRecords = lazyRetry(() => import('./pages/roster/PerformanceRecords'))
|
||||||
|
const DisciplinaryRecords = lazyRetry(() => import('./pages/roster/DisciplinaryRecords'))
|
||||||
|
|
||||||
// Sprint 4-5 新增页面
|
// Sprint 4-5 新增页面
|
||||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
const EmployeeHome = lazyRetry(() => import('./pages/portal/EmployeeHome'))
|
||||||
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
|
const OnboardingProgress = lazyRetry(() => import('./pages/portal/OnboardingProgress'))
|
||||||
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
|
const ResignationApply = lazyRetry(() => import('./pages/portal/ResignationApply'))
|
||||||
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
|
const MyEsign = lazyRetry(() => import('./pages/portal/MyEsign'))
|
||||||
const MyRecords = lazy(() => import('./pages/portal/MyRecords'))
|
const MyRecords = lazyRetry(() => import('./pages/portal/MyRecords'))
|
||||||
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
|
const RiskCenter = lazyRetry(() => import('./pages/compliance/RiskCenter'))
|
||||||
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
const SalaryDashboard = lazyRetry(() => import('./pages/SalaryDashboard'))
|
||||||
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
const CommercialInsurance = lazyRetry(() => import('./pages/CommercialInsurance'))
|
||||||
const EmployeeBenefits = lazy(() => import('./pages/EmployeeBenefits'))
|
const EmployeeBenefits = lazyRetry(() => import('./pages/EmployeeBenefits'))
|
||||||
const ESign = lazy(() => import('./pages/ESign'))
|
const ESign = lazyRetry(() => import('./pages/ESign'))
|
||||||
|
const CommissionBonus = lazyRetry(() => import('./pages/CommissionBonus'))
|
||||||
|
|
||||||
// 平台管理端
|
// 平台管理端
|
||||||
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
|
const PlatformLogin = lazyRetry(() => import('./pages/platform/PlatformLogin'))
|
||||||
const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard'))
|
const PlatformDashboard = lazyRetry(() => import('./pages/platform/PlatformDashboard'))
|
||||||
const PlatformOrgs = lazy(() => import('./pages/platform/PlatformOrgs'))
|
const PlatformOrgs = lazyRetry(() => import('./pages/platform/PlatformOrgs'))
|
||||||
const PlatformUsers = lazy(() => import('./pages/platform/PlatformUsers'))
|
const PlatformUsers = lazyRetry(() => import('./pages/platform/PlatformUsers'))
|
||||||
const PlatformSidebar = lazy(() => import('./components/layout/PlatformSidebar'))
|
const PlatformSidebar = lazyRetry(() => import('./components/layout/PlatformSidebar'))
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
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 }) {
|
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) {
|
if (!showNav) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-surface pt-safe pb-safe">
|
<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="/" element={<ProtectedRoute><AdminLayout><Dashboard /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/roster" element={<ProtectedRoute><AdminLayout><Roster /></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="/money" element={<ProtectedRoute><AdminLayout><Money /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/social" element={<ProtectedRoute><AdminLayout><SocialInsurance /></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="/termination" element={<ProtectedRoute><AdminLayout><Termination /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
<Route path="/ai-assistant" element={<ProtectedRoute><AdminLayout><AIAssistant /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/settings" element={<ProtectedRoute><AdminLayout><Settings /></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/medical-period" element={<ProtectedRoute><AdminLayout><MedicalPeriodCalculator /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></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="/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="/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="/company-files" element={<ProtectedRoute><AdminLayout><CompanyFiles /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
<Route path="/leave-approval" element={<ProtectedRoute><AdminLayout><LeaveApproval /></AdminLayout></ProtectedRoute>} />
|
||||||
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
<Route path="/training-records" element={<ProtectedRoute><AdminLayout><TrainingRecords /></AdminLayout></ProtectedRoute>} />
|
||||||
|
|||||||
@@ -139,13 +139,13 @@ const categories: HelpCategory[] = [
|
|||||||
{
|
{
|
||||||
id: 'update-termination-cert',
|
id: 'update-termination-cert',
|
||||||
question: '修复:离职证明下载内容为乱码',
|
question: '修复:离职证明下载内容为乱码',
|
||||||
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、身份证号、入职日期、离职日期、企业名称等',
|
answer: '修复离职证明下载后打开为乱码的问题:\n• 使用 Word HTML 格式生成 .doc 文件,设置 charset=utf-8\n• 指定 SimSun(宋体)字体,确保中文正常显示\n• 离职证明内容包含:员工姓名、证件号码、入职日期、离职日期、企业名称等',
|
||||||
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
|
tip: '路径:离职管理 → 已完成解聘 → 下载离职证明',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'update-attendance-template',
|
id: 'update-attendance-template',
|
||||||
question: '优化:考勤导入模板合并Sheet,减少重复录入',
|
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: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
|
tip: '路径:考勤管理 → 考勤确认 → 导入考勤 → 下载模板',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -240,14 +240,14 @@ const categories: HelpCategory[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'update-idcard-dedup',
|
id: 'update-idcard-dedup',
|
||||||
question: '新增:用工办理按身份证号查重',
|
question: '新增:用工办理按证件号码查重',
|
||||||
answer: '创建员工时自动按身份证号查重:\n• 后端创建员工前先检查身份证号是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
|
answer: '创建员工时自动按证件号码查重:\n• 后端创建员工前先检查证件号码是否已存在\n• 如果已存在,返回已有员工姓名、部门、在职状态\n• 前端显示明确的重复提示信息,避免重复录入',
|
||||||
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
|
tip: '路径:用工办理 → 入职办理 / 花名册 → 添加员工',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'update-income-cert-select',
|
id: 'update-income-cert-select',
|
||||||
question: '优化:收入证明支持员工下拉选择并自动填充',
|
question: '优化:收入证明支持员工下拉选择并自动填充',
|
||||||
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、身份证号、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
|
answer: '用工办理中收入证明和离职证明支持员工下拉选择:\n• 员工字段从手动输入改为下拉搜索选择\n• 选择员工后自动填充:姓名、证件号码、职务、月收入、入职日期、部门、手机号\n• 减少手动输入,避免信息不一致',
|
||||||
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
|
tip: '路径:用工办理 → 收入证明/离职证明 → 选择员工',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -296,7 +296,7 @@ const categories: HelpCategory[] = [
|
|||||||
{
|
{
|
||||||
id: 'system-intro',
|
id: 'system-intro',
|
||||||
question: '本系统能帮企业做什么?',
|
question: '本系统能帮企业做什么?',
|
||||||
answer: '「企业用工专家」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
|
answer: '「安职通」是一站式人力资源管理平台,覆盖员工全生命周期管理,帮助企业高效管理人事业务的同时确保合规运营:\n• 员工管理:入职登记、合同签订、转正调岗、离职解聘\n• 薪税管理:工资计算、个税申报、社保公积金缴纳\n• 考勤管理:排班打卡、加班统计、休假记录、月度报表\n• 合同管理:电子合同、到期提醒、续签流程\n• 风险管控:自动扫描法律风险、合规预警、判赔预测\n• AI 助手:劳动法咨询、智能问答、文档生成',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compliance',
|
id: 'compliance',
|
||||||
@@ -330,7 +330,7 @@ const categories: HelpCategory[] = [
|
|||||||
articles: [
|
articles: [
|
||||||
{
|
{
|
||||||
id: 'what-is',
|
id: 'what-is',
|
||||||
question: '「企业用工专家」是什么?',
|
question: '「安职通」是什么?',
|
||||||
answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。',
|
answer: '这是一个帮您管理员工、合同、工资和社保的工具。您可以把它理解为一个「人事小助手」,帮您把繁琐的人事工作变得简单。比如:记录员工信息、提醒合同到期、计算工资社保、生成法律文档等。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -496,7 +496,7 @@ const categories: HelpCategory[] = [
|
|||||||
id: 'attendance-import',
|
id: 'attendance-import',
|
||||||
question: '怎么导入考勤数据?',
|
question: '怎么导入考勤数据?',
|
||||||
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
|
answer: '在考勤管理「考勤确认」标签页中,点击「导入考勤」按钮,下载模板填写后上传。系统会自动匹配员工并生成考勤记录。',
|
||||||
tip: '身份证号优先匹配,未填时用姓名匹配。',
|
tip: '证件号码优先匹配,未填时用姓名匹配。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'attendance-correct',
|
id: 'attendance-correct',
|
||||||
@@ -613,7 +613,7 @@ const categories: HelpCategory[] = [
|
|||||||
{
|
{
|
||||||
id: 'data-safe',
|
id: 'data-safe',
|
||||||
question: '我的数据安全吗?',
|
question: '我的数据安全吗?',
|
||||||
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如身份证号)都经过加密存储。',
|
answer: '您的数据存储在加密的云端服务器上,只有您本人登录后才能查看。我们不会将您的数据分享给任何第三方。所有敏感信息(如证件号码)都经过加密存储。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'data-export',
|
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="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="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">
|
<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">
|
<button onClick={close} className="text-gray-400 hover:text-gray-600">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -12,29 +12,46 @@ interface BreadcrumbItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
const ROUTE_MAP: Record<string, BreadcrumbItem> = {
|
||||||
'/': { group: '工作台', label: '总览' },
|
// 首页
|
||||||
'/calendar': { group: '工作台', label: '工作日历' },
|
'/': { group: '首页', label: '工作台' },
|
||||||
'/roster': { group: '员工管理', label: '花名册' },
|
'/calendar': { group: '首页', label: '工作日历' },
|
||||||
'/work-process': { group: '员工管理', label: '用工办理' },
|
// 团队
|
||||||
'/attendance': { group: '员工管理', label: '考勤确认' },
|
'/roster': { group: '团队', label: '花名册' },
|
||||||
'/leave-approval': { group: '员工管理', label: '休假审批' },
|
'/org-chart': { group: '团队', label: '组织架构' },
|
||||||
'/termination': { group: '员工管理', label: '解聘补偿' },
|
'/esign': { group: '团队', label: '待签合同' },
|
||||||
'/special-status': { group: '员工管理', label: '特殊状态' },
|
'/termination': { group: '团队', label: '离职管理' },
|
||||||
'/training-records': { group: '员工管理', label: '培训记录' },
|
'/training-records': { group: '团队', label: '培训记录' },
|
||||||
'/performance-records': { group: '员工管理', label: '绩效考核' },
|
'/performance-records': { group: '团队', label: '绩效考核' },
|
||||||
'/disciplinary-records': { group: '员工管理', label: '违纪记录' },
|
'/disciplinary-records': { group: '团队', label: '违纪记录' },
|
||||||
'/money': { group: '薪税社保', label: '薪税管理' },
|
'/special-status': { group: '团队', label: '特殊员工' },
|
||||||
'/social': { group: '薪税社保', label: '社保公积金' },
|
// 时间
|
||||||
'/evidence': { group: '合规风控', label: '证据链' },
|
'/attendance': { group: '时间', label: '考勤排班' },
|
||||||
'/policies': { group: '合规风控', label: '规章制度' },
|
'/leave-approval': { group: '时间', label: '休假审批' },
|
||||||
'/tools/health-check': { group: '合规风控', label: '用工体检' },
|
// 薪酬
|
||||||
'/tools/medical-period': { group: '合规风控', label: '医疗期计算' },
|
'/money': { group: '薪酬', label: '薪税管理' },
|
||||||
'/tools/annual-value': { group: '合规风控', label: '年度价值' },
|
'/social': { group: '薪酬', label: '社保公积金' },
|
||||||
'/ai-assistant': { group: 'AI 辅助', label: 'AI 顾问' },
|
'/overtime': { group: '薪酬', label: '加班费计算' },
|
||||||
'/templates': { group: 'AI 辅助', label: '文本模板' },
|
'/commission-bonus': { group: '薪酬', label: '提成奖金' },
|
||||||
'/notifications': { group: '系统', label: '通知管理' },
|
'/salary-dashboard': { group: '薪酬', label: '薪酬分析' },
|
||||||
'/audit': { group: '系统', label: '操作日志' },
|
// 福利保障
|
||||||
'/settings': { group: '系统', label: '设置' },
|
'/commercial-insurance': { group: '福利保障', label: '商业保险' },
|
||||||
|
'/benefits': { group: '福利保障', label: '员工福利' },
|
||||||
|
// 合规
|
||||||
|
'/risk-center': { group: '合规', label: '风险中心' },
|
||||||
|
'/evidence': { group: '合规', label: '证据链条' },
|
||||||
|
'/policies': { group: '合规', label: '规章制度' },
|
||||||
|
// 工具
|
||||||
|
'/tools/health-check': { group: '工具', label: '用工体检' },
|
||||||
|
'/tools/medical-period': { group: '工具', label: '医疗期' },
|
||||||
|
'/tools/annual-value': { group: '工具', label: '年度价值' },
|
||||||
|
// 更多
|
||||||
|
'/ai-assistant': { group: '更多', label: 'AI顾问' },
|
||||||
|
'/templates': { group: '更多', label: '文本模板' },
|
||||||
|
'/notifications': { group: '更多', label: '通知管理' },
|
||||||
|
'/audit': { group: '更多', label: '操作日志' },
|
||||||
|
'/company-files': { group: '更多', label: '公司文件' },
|
||||||
|
'/settings': { group: '更多', label: '系统设置' },
|
||||||
|
'/support': { group: '更多', label: '客服支持' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Breadcrumb() {
|
export default function Breadcrumb() {
|
||||||
@@ -50,7 +67,7 @@ export default function Breadcrumb() {
|
|||||||
<Home className="w-3 h-3" />
|
<Home className="w-3 h-3" />
|
||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
</Link>
|
</Link>
|
||||||
{item.group !== '工作台' && (
|
{item.group !== '首页' && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-3 h-3 text-gray-300" />
|
<ChevronRight className="w-3 h-3 text-gray-300" />
|
||||||
<span className="text-gray-400">{item.group}</span>
|
<span className="text-gray-400">{item.group}</span>
|
||||||
|
|||||||
@@ -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">
|
<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="max-w-md mx-auto h-14 flex items-center justify-between px-4">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Logo className="w-6 h-6 text-primary flex-shrink-0" />
|
<Logo className="w-6 h-6 object-contain flex-shrink-0" />
|
||||||
<span className="text-sm font-bold text-gray-900 truncate">企业用工专家</span>
|
<span className="text-sm font-bold text-gray-900 truncate">安职通</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{employee.name && (
|
{employee.name && (
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
Building2, CalendarDays, ClipboardList, Heart, CalendarClock,
|
||||||
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
Gift, PenTool, Umbrella, GraduationCap, TrendingUp, AlertTriangle,
|
||||||
|
DollarSign, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Logo from '../ui/Logo'
|
|
||||||
import { settingsApi } from '../../lib/api-services'
|
import { settingsApi } from '../../lib/api-services'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -37,14 +37,14 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '团队',
|
title: '团队',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/roster', label: '花名册', icon: Users },
|
{ 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: '/termination', label: '离职管理', icon: UserX },
|
||||||
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
|
{ path: '/training-records', label: '培训记录', icon: GraduationCap },
|
||||||
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
{ path: '/performance-records', label: '绩效考核', icon: TrendingUp },
|
||||||
@@ -64,6 +64,8 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/money', label: '薪税管理', icon: Calculator },
|
{ path: '/money', label: '薪税管理', icon: Calculator },
|
||||||
{ path: '/social', label: '社保公积金', icon: Shield },
|
{ path: '/social', label: '社保公积金', icon: Shield },
|
||||||
|
{ path: '/overtime', label: '加班费计算', icon: Clock },
|
||||||
|
{ path: '/commission-bonus', label: '提成奖金', icon: DollarSign },
|
||||||
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
{ path: '/salary-dashboard', label: '薪酬分析', icon: BarChart3 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -80,6 +82,11 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
|
{ path: '/risk-center', label: '风险中心', icon: ShieldAlert },
|
||||||
{ path: '/evidence', label: '证据链条', icon: FileSearch },
|
{ path: '/evidence', label: '证据链条', icon: FileSearch },
|
||||||
{ path: '/policies', label: '规章制度', icon: FileText },
|
{ path: '/policies', label: '规章制度', icon: FileText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '工具',
|
||||||
|
items: [
|
||||||
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
{ path: '/tools/health-check', label: '用工体检', icon: Stethoscope },
|
||||||
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
|
{ path: '/tools/medical-period', label: '医疗期', icon: HeartPulse },
|
||||||
{ path: '/tools/annual-value', label: '年度价值', icon: Award },
|
{ 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',
|
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">
|
<div className="h-14 flex items-center 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>
|
||||||
<span className="font-bold text-sm text-gray-900 truncate">{orgData?.name || '企业用工专家'}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航菜单 */}
|
{/* 导航菜单 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 应用 Logo 图标 — 与 favicon.svg 保持一致
|
* 应用 Logo — 嘉驰英才品牌图标
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
@@ -8,12 +8,6 @@ interface LogoProps {
|
|||||||
|
|
||||||
export default function Logo({ className = 'w-5 h-5' }: LogoProps) {
|
export default function Logo({ className = 'w-5 h-5' }: LogoProps) {
|
||||||
return (
|
return (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className={className} fill="none">
|
<img src="/JClog.png" alt="嘉驰英才" className={className} />
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '员工端 > 入职填报',
|
id: 24, name: 'Onboarding(入职信息填报)', file: 'frontend/src/pages/portal/Onboarding.tsx', menu: '员工端 > 入职填报',
|
||||||
features: [
|
features: [
|
||||||
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、身份证号、紧急联系人等' },
|
{ id: '24.1', name: '入职表单填写', desc: '填写姓名、手机号、证件号码、紧急联系人等' },
|
||||||
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
|
{ id: '24.2', name: '入职材料上传', desc: '上传身份证、学历证明、银行卡等材料' },
|
||||||
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
|
{ id: '24.3', name: '文件类型选择', desc: '选择上传文件的类型(身份证正反面、学历、银行卡、其他)' },
|
||||||
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
|
{ id: '24.4', name: '已传文件管理', desc: '查看和删除已上传文件' },
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ export const employeeApi = {
|
|||||||
/** 身份证查重 */
|
/** 身份证查重 */
|
||||||
checkIdCard: (idCard: string) =>
|
checkIdCard: (idCard: string) =>
|
||||||
get('/employees/check-id-card', { params: { idCard } }).then(unwrap<{ exists: boolean; employee?: any }>()),
|
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>) =>
|
update: (id: string, data: Record<string, unknown>) =>
|
||||||
put(`/employees/${id}`, data),
|
put(`/employees/${id}`, data),
|
||||||
@@ -78,6 +81,9 @@ export const employeeApi = {
|
|||||||
/** 重新入职 */
|
/** 重新入职 */
|
||||||
rehire: (id: string, data: Record<string, unknown>) =>
|
rehire: (id: string, data: Record<string, unknown>) =>
|
||||||
post(`/employees/${id}/rehire`, data),
|
post(`/employees/${id}/rehire`, data),
|
||||||
|
/** 重置员工密码(管理员,重置为手机号后6位) */
|
||||||
|
resetPassword: (id: string) =>
|
||||||
|
post(`/employees/${id}/reset-password`).then(unwrap<any>()),
|
||||||
/** 添加合同 */
|
/** 添加合同 */
|
||||||
addContract: (data: Record<string, unknown>) =>
|
addContract: (data: Record<string, unknown>) =>
|
||||||
post('/employees/contracts', data),
|
post('/employees/contracts', data),
|
||||||
@@ -288,6 +294,12 @@ export const attendanceApi = {
|
|||||||
/** 批量确认 */
|
/** 批量确认 */
|
||||||
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
batchConfirm: (data: { month: string; all?: boolean; ids?: string[] }) =>
|
||||||
post('/attendance/batch-confirm', data).then(unwrap<any>()),
|
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 }) =>
|
confirm: (data: { employeeId: string; month: string }) =>
|
||||||
post('/attendance/confirm', data).then(unwrap<any>()),
|
post('/attendance/confirm', data).then(unwrap<any>()),
|
||||||
@@ -471,6 +483,18 @@ export const payrollApi = {
|
|||||||
/** 导入加班费到批次 */
|
/** 导入加班费到批次 */
|
||||||
importOvertimeToBatch: (batchId: string) =>
|
importOvertimeToBatch: (batchId: string) =>
|
||||||
post(`/payroll/overtime/import-to-batch/${batchId}`).then(unwrap<any>()),
|
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: () =>
|
overtimeConfig: () =>
|
||||||
get('/payroll/overtime/config').then(unwrap<any>()),
|
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 = {
|
export const socialInsuranceApi = {
|
||||||
/** 城市列表 */
|
/** 城市列表 */
|
||||||
cities: () =>
|
cities: () =>
|
||||||
@@ -638,12 +711,60 @@ export const benefitApi = {
|
|||||||
export const esignApi = {
|
export const esignApi = {
|
||||||
list: (params?: { status?: string; scene?: string }) =>
|
list: (params?: { status?: string; scene?: string }) =>
|
||||||
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
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),
|
post('/esign/create', data),
|
||||||
|
detail: (id: string) =>
|
||||||
|
get(`/esign/${id}`).then(unwrap<any>()),
|
||||||
status: (id: string) =>
|
status: (id: string) =>
|
||||||
get(`/esign/${id}/status`).then(unwrap<any>()),
|
get(`/esign/${id}/status`).then(unwrap<any>()),
|
||||||
|
evidence: (id: string) =>
|
||||||
|
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
|
||||||
cancel: (id: string) =>
|
cancel: (id: string) =>
|
||||||
post(`/esign/${id}/cancel`),
|
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',
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 离职相关 ==========
|
// ========== 离职相关 ==========
|
||||||
@@ -694,6 +815,18 @@ export const terminationApi = {
|
|||||||
/** 批量预览 */
|
/** 批量预览 */
|
||||||
batchPreview: (items: Record<string, unknown>[]) =>
|
batchPreview: (items: Record<string, unknown>[]) =>
|
||||||
post('/termination/batch/preview', { items }),
|
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>()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 制度相关 ==========
|
// ========== 制度相关 ==========
|
||||||
@@ -977,6 +1110,22 @@ portalAxios.interceptors.request.use((config: any) => {
|
|||||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config
|
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 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
|
const portalPost = ((url: string, data?: any, config?: any) => portalAxios.post(url, data, config)) as any
|
||||||
|
|
||||||
@@ -993,6 +1142,9 @@ export const portalApi = {
|
|||||||
/** 自动登录 */
|
/** 自动登录 */
|
||||||
autoLogin: (token: string) =>
|
autoLogin: (token: string) =>
|
||||||
portalGet('/auto-login', { params: { token } }).then(unwrap<any>()),
|
portalGet('/auto-login', { params: { token } }).then(unwrap<any>()),
|
||||||
|
/** 修改密码(员工自己) */
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) =>
|
||||||
|
portalPost('/change-password', { oldPassword, newPassword }).then(unwrap<any>()),
|
||||||
/** 生成自动登录令牌(管理端) */
|
/** 生成自动登录令牌(管理端) */
|
||||||
generateAutoLoginToken: (employeeId: string) =>
|
generateAutoLoginToken: (employeeId: string) =>
|
||||||
post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()),
|
post('/portal/auto-login-token', { employeeId }).then(unwrap<any>()),
|
||||||
@@ -1074,9 +1226,12 @@ export const portalApi = {
|
|||||||
/** 电子签署详情 */
|
/** 电子签署详情 */
|
||||||
esignDetail: (id: string) =>
|
esignDetail: (id: string) =>
|
||||||
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
||||||
/** 签署操作 */
|
/** 发送签署验证码 */
|
||||||
signEsign: (id: string) =>
|
esignSendCode: (id: string) =>
|
||||||
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
|
portalPost(`/esign/${id}/send-code`).then(unwrap<any>()),
|
||||||
|
/** 签署操作(需验证码) */
|
||||||
|
signEsign: (id: string, verifyCode: string) =>
|
||||||
|
portalPost(`/esign/${id}/sign`, { verifyCode }).then(unwrap<any>()),
|
||||||
/** 我的培训记录 */
|
/** 我的培训记录 */
|
||||||
myTraining: () =>
|
myTraining: () =>
|
||||||
portalGet('/training').then(unwrap<any[]>()),
|
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 { 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 ChatTab = lazyRetry(() => import('./ai-assistant/ChatTab').then(m => ({ default: m.ChatTab })))
|
||||||
const PredictTab = lazy(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab })))
|
const PredictTab = lazyRetry(() => import('./ai-assistant/PredictTab').then(m => ({ default: m.PredictTab })))
|
||||||
const ReviewTab = lazy(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab })))
|
const ReviewTab = lazyRetry(() => import('./ai-assistant/ReviewTab').then(m => ({ default: m.ReviewTab })))
|
||||||
const CaseTab = lazy(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab })))
|
const CaseTab = lazyRetry(() => import('./ai-assistant/CaseTab').then(m => ({ default: m.CaseTab })))
|
||||||
const KnowledgeTab = lazy(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab })))
|
const KnowledgeTab = lazyRetry(() => import('./ai-assistant/KnowledgeTab').then(m => ({ default: m.KnowledgeTab })))
|
||||||
const HRReportTab = lazy(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab })))
|
const HRReportTab = lazyRetry(() => import('./ai-assistant/HRReportTab').then(m => ({ default: m.HRReportTab })))
|
||||||
|
|
||||||
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
|
type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report'
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ export default function AIAssistant() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>AI 智能助手,提供用工风险预测、案例分析、合规建议等智能服务。支持自然语言提问,系统自动匹配相关法规和案例。关联:风险预测数据来自花名册和合同信息。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Bot className="h-5 w-5 text-primary" />
|
<Bot className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export default function Attendance() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
考勤管理,支持班次设定、排班、出勤查询、月度报表和休假记录。流程:①设置班次 → ②排班 → ③每日打卡或导入考勤 → ④月度汇总。关联:考勤数据影响薪税管理的工资和加班费计算。
|
||||||
|
</PageGuide>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarCheck className="h-5 w-5 text-primary" />
|
<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 [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [editItem, setEditItem] = useState<any>(null)
|
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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const pageSize = usePageSize()
|
const pageSize = usePageSize()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
@@ -190,7 +193,19 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
|
|
||||||
const editMutation = useMutation({
|
const editMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
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: () => {
|
onSuccess: () => {
|
||||||
toast.success('考勤记录已修改')
|
toast.success('考勤记录已修改')
|
||||||
@@ -418,6 +433,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
<span>周末加班 {item.weekendHours}h</span>
|
<span>周末加班 {item.weekendHours}h</span>
|
||||||
<span>法定加班 {item.holidayHours}h</span>
|
<span>法定加班 {item.holidayHours}h</span>
|
||||||
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
<span className="text-gray-700">加班费 ¥{item.overtimePay?.toFixed(2)}</span>
|
||||||
|
{item.deductionAmount > 0 && <span className="text-danger">扣款 ¥{item.deductionAmount?.toFixed(2)}</span>}
|
||||||
</div>
|
</div>
|
||||||
{item.disputeNote && (
|
{item.disputeNote && (
|
||||||
<div className="text-xs text-red-600 mt-1">异议说明:{item.disputeNote}</div>
|
<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,
|
leaveDays: item.leaveDays || 0,
|
||||||
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
overtimeHours: (item.weekdayHours || 0) + (item.weekendHours || 0) + (item.holidayHours || 0),
|
||||||
overtimePay: item.overtimePay || 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}
|
<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) })} />
|
onChange={(e) => setEditForm({ ...editForm, overtimePay: Number(e.target.value) })} />
|
||||||
</div>
|
</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>
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
<Button size="sm" variant="secondary" onClick={() => setEditItem(null)}>取消</Button>
|
||||||
@@ -555,7 +577,7 @@ function ConfirmTab({ onGoToTab }: { onGoToTab?: (tab: string) => void }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
|
<div className="text-xs text-gray-500 bg-blue-50/50 rounded-md p-2">
|
||||||
模板中「考勤与加班」Sheet 包含:姓名、身份证号、日期、考勤状态、上下班时间、各类加班时长。身份证号优先匹配,未填时用姓名匹配。加班时长为0的行只导入考勤。
|
模板中「考勤与加班」Sheet 包含:姓名、证件号码、日期、考勤状态、上下班时间、各类加班时长。证件号码优先匹配,未填时用姓名匹配。加班时长为0的行只导入考勤。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||||
@@ -670,9 +692,6 @@ function ShiftsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
班次管理用于定义考勤班次(如早班、晚班、全天班)。设置班次名称、上下班时间、弹性时间等。排班时引用此处定义的班次。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
<Button onClick={() => { setEditShift(null); setForm({ name: '', startTime: '09:00', endTime: '18:00', flexibleMinutes: 0, restMinutes: 60, color: '#3b82f6' }); setShowAdd(true) }}>
|
||||||
<Plus className="w-4 h-4 mr-1" />新增班次
|
<Plus className="w-4 h-4 mr-1" />新增班次
|
||||||
@@ -836,9 +855,6 @@ function ScheduleTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
排班用于按日期为员工分配班次。选择日期后可查看当日排班情况,点击「排班」按钮为员工分配班次。支持批量排班和复制排班。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1029,9 +1045,6 @@ function DailyTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
每日出勤记录展示当日所有员工的打卡情况,包括上班/下班时间、迟到/早退/缺卡状态。可手动补卡或修正异常记录。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1257,9 +1270,6 @@ function MonthlyTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
月度报表汇总当月各员工的出勤天数、迟到次数、加班时长等统计数据。可导出月度考勤表用于发薪参考。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -1409,9 +1419,6 @@ function LeavesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
休假记录管理员工的请假信息,包括事假、病假、年假等。可手动录入休假记录,系统自动计算天数并关联考勤数据。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex justify-end items-center gap-3">
|
<div className="flex justify-end items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Card from '../components/ui/Card'
|
|||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
CREATE: '创建',
|
CREATE: '创建',
|
||||||
@@ -147,6 +148,7 @@ export default function AuditLog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>审计日志,记录系统中所有关键操作,包括员工档案变更、合同签订、薪酬调整、权限变更等。支持按操作类型、时间范围、操作人筛选查看,用于合规追溯。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ScrollText className="h-5 w-5 text-primary" />
|
<ScrollText className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-base font-semibold">系统操作日志</h1>
|
<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 Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label } from '../components/ui/Input'
|
import { Input, Label } from '../components/ui/Input'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const EVENT_TYPE_COLORS: Record<string, string> = {
|
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||||
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
|
CONTRACT_EXPIRY: 'bg-red-100 text-red-700 border-red-200',
|
||||||
@@ -190,6 +191,7 @@ export default function Calendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>人事日历,集中展示入职、合同到期、试用期到期、 birthdays 等关键人事事件。支持按月/周视图查看,提前预警重要节点。关联:数据自动来自花名册和合同信息。</PageGuide>
|
||||||
{/* 顶部工具栏 */}
|
{/* 顶部工具栏 */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex items-center 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 Button from '../components/ui/Button'
|
||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const FILE_TYPES = [
|
const FILE_TYPES = [
|
||||||
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
|
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
|
||||||
@@ -82,6 +83,7 @@ export default function CompanyFiles() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>公司文件管理,集中存储和管理公司制度、合同模板、政策文件等。支持分类管理、在线预览、权限控制。关联:员工可在员工端查看已授权的公司文件。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-lg font-semibold">公司备用文件</h1>
|
<h1 className="text-lg font-semibold">公司备用文件</h1>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { usePageSize } from '../hooks/usePageSize'
|
import { usePageSize } from '../hooks/usePageSize'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { toast } from 'sonner'
|
||||||
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services'
|
||||||
|
import api from '../lib/api'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
@@ -78,7 +79,7 @@ export default function Contracts() {
|
|||||||
<FileText className="h-5 w-5 text-primary" />
|
<FileText className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-semibold">合同管理</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setShowAddModal(true)}>
|
<Button onClick={() => setShowAddModal(true)}>
|
||||||
@@ -217,12 +218,30 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
error: any
|
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({
|
const [form, setForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
hireDate: '',
|
hireDate: '',
|
||||||
monthlySalary: '',
|
monthlySalary: '',
|
||||||
|
baseSalary: '',
|
||||||
|
performanceSalary: '0',
|
||||||
gender: '男' as '男' | '女',
|
gender: '男' as '男' | '女',
|
||||||
phone: '',
|
phone: '',
|
||||||
isPregnant: false,
|
isPregnant: false,
|
||||||
@@ -244,6 +263,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
position: form.position || undefined,
|
position: form.position || undefined,
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
hireDate: new Date(form.hireDate).toISOString(),
|
||||||
monthlySalary: form.monthlySalary,
|
monthlySalary: form.monthlySalary,
|
||||||
|
baseSalary: form.baseSalary,
|
||||||
|
performanceSalary: form.performanceSalary || '0',
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
phone: form.phone || undefined,
|
phone: form.phone || undefined,
|
||||||
isPregnant: form.isPregnant,
|
isPregnant: form.isPregnant,
|
||||||
@@ -259,6 +280,8 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
contractYears: form.contractYears,
|
contractYears: form.contractYears,
|
||||||
probationMonths: form.probationMonths,
|
probationMonths: form.probationMonths,
|
||||||
probationSalary: form.probationSalary,
|
probationSalary: form.probationSalary,
|
||||||
|
baseSalary: parseFloat(form.baseSalary) || 0,
|
||||||
|
performanceSalary: parseFloat(form.performanceSalary) || 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onSubmit(data)
|
onSubmit(data)
|
||||||
@@ -280,7 +303,12 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>部门 *</Label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -295,9 +323,27 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Label>月工资 *</Label>
|
<div>
|
||||||
<Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -310,9 +356,24 @@ function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>手机号</Label>
|
<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>
|
||||||
</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">
|
<div className="flex gap-4">
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ export default function Dashboard() {
|
|||||||
const yearCost = data.yearCostSummary
|
const yearCost = data.yearCostSummary
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary' },
|
{ label: '在管员工', value: data.stats.employeeCount, icon: Users, color: 'text-primary', to: '/roster' },
|
||||||
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger' },
|
{ label: '高风险', value: data.stats.highRiskCount, icon: AlertTriangle, color: 'text-danger', to: '/risk-center' },
|
||||||
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning' },
|
{ label: '待办事项', value: data.stats.todoCount, icon: CheckSquare, color: 'text-warning', to: '', tab: 'risk' as const },
|
||||||
{ label: '工资条', value: payroll?.payslipCount ?? 0, icon: Receipt, color: 'text-safe' },
|
{ label: '工资条', value: payroll?.payslipCount ?? 0, icon: Receipt, color: 'text-safe', to: '/money' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// 当月人力成本
|
// 当月人力成本
|
||||||
@@ -279,6 +279,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
工作台展示企业人力全局数据和待办事项,包括在管员工数、当月人力成本、合同风险、审批待办等。可切换概览、人力成本、风险、任务等视图。点击各统计卡片可快速跳转到对应详情页。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -382,16 +385,13 @@ export default function Dashboard() {
|
|||||||
{/* 概览 Tab */}
|
{/* 概览 Tab */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
概览页展示企业人力全局数据:在管员工数、待办事项、当月人力成本及年度累计成本。可点击各统计卡片快速跳转到对应详情页。右上角可自定义显示/隐藏模块。
|
|
||||||
</PageGuide>
|
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
{isSectionVisible('overview_stats') && (
|
{isSectionVisible('overview_stats') && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
{stats.map((stat) => {
|
{stats.map((stat) => {
|
||||||
const Icon = stat.icon
|
const Icon = stat.icon
|
||||||
return (
|
const inner = (
|
||||||
<Card key={stat.label} className="flex items-center gap-2.5">
|
<Card key={stat.label} className="flex items-center gap-2.5 hover:shadow-md transition-shadow cursor-pointer">
|
||||||
<Icon className={`w-6 h-6 ${stat.color}`} />
|
<Icon className={`w-6 h-6 ${stat.color}`} />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-base font-bold">{stat.value}</div>
|
<div className="text-base font-bold">{stat.value}</div>
|
||||||
@@ -399,6 +399,10 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
if (stat.tab) {
|
||||||
|
return <div key={stat.label} onClick={() => setActiveTab(stat.tab)}>{inner}</div>
|
||||||
|
}
|
||||||
|
return <Link key={stat.label} to={stat.to}>{inner}</Link>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -425,18 +429,46 @@ export default function Dashboard() {
|
|||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center px-2 overflow-hidden">
|
<div className="absolute inset-0 flex flex-col items-center justify-center px-2 overflow-hidden">
|
||||||
<span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
|
<span className={`text-2xl font-bold leading-none ${complianceScore?.level === 'safe' ? 'text-safe' : complianceScore?.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore?.overallScore ?? complianceScore?.totalScore ?? 0}</span>
|
||||||
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? ''}</span>
|
<span className="text-xs text-gray-500 mt-1 truncate max-w-full">{complianceScore?.levelLabel ?? complianceScore?.summary ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 维度评分 */}
|
{/* 维度评分 */}
|
||||||
<div className="flex-1 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
|
<div className="flex-1 grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2">
|
||||||
{complianceScore?.dimensions?.map((dim: any) => (
|
{complianceScore?.dimensions?.map((dim: any) => {
|
||||||
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors">
|
const dimRouteMap: Record<string, string> = {
|
||||||
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
|
contract: '/roster',
|
||||||
<span className="text-xs text-gray-600">{dim.name}</span>
|
salary: '/money',
|
||||||
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}待办</span>}
|
social: '/social',
|
||||||
</Link>
|
attendance: '/attendance',
|
||||||
))}
|
policy: '/policies',
|
||||||
|
termination: '/termination',
|
||||||
|
evidence: '/evidence',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link key={dim.key} to={dimRouteMap[dim.key] || '/roster'} className="flex flex-col items-center p-1.5 rounded-lg hover:bg-gray-50 transition-colors group relative">
|
||||||
|
<span className={`text-lg font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
|
||||||
|
<span className="text-xs text-gray-600">{dim.name}</span>
|
||||||
|
{dim.todoCount > 0 && <span className="text-[10px] text-gray-400">{dim.todoCount}待办</span>}
|
||||||
|
{/* hover 显示得分依据 */}
|
||||||
|
{dim.findings && dim.findings.length > 0 && (
|
||||||
|
<div className="hidden group-hover:block absolute top-full left-1/2 -translate-x-1/2 z-20 mt-1 w-56 bg-gray-900 text-white text-xs rounded-lg p-2.5 shadow-lg whitespace-normal">
|
||||||
|
<div className="font-medium mb-1">得分依据</div>
|
||||||
|
{dim.findings.map((f: string, i: number) => (
|
||||||
|
<div key={i} className="text-gray-300 leading-relaxed mb-0.5">• {f}</div>
|
||||||
|
))}
|
||||||
|
{dim.recommendations && dim.recommendations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="font-medium mt-1.5 mb-0.5 text-amber-300">建议</div>
|
||||||
|
{dim.recommendations.map((r: string, i: number) => (
|
||||||
|
<div key={i} className="text-gray-300 leading-relaxed mb-0.5">• {r}</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 合同到期预警内联 */}
|
{/* 合同到期预警内联 */}
|
||||||
@@ -572,9 +604,6 @@ export default function Dashboard() {
|
|||||||
{/* 人力成本 Tab */}
|
{/* 人力成本 Tab */}
|
||||||
{activeTab === 'cost' && (
|
{activeTab === 'cost' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
人力成本页展示当月及年度累计的工资、社保、公积金、加班费、补偿金等各项成本明细。可导出薪税汇总表,点击月份卡片可查看历史趋势。
|
|
||||||
</PageGuide>
|
|
||||||
{/* 成本专属指标卡片 */}
|
{/* 成本专属指标卡片 */}
|
||||||
{isSectionVisible('cost_metrics') && (
|
{isSectionVisible('cost_metrics') && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
@@ -839,9 +868,6 @@ export default function Dashboard() {
|
|||||||
{/* 人员分析 Tab */}
|
{/* 人员分析 Tab */}
|
||||||
{activeTab === 'workforce' && (
|
{activeTab === 'workforce' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
人员分析页展示本月入离职动态、员工分布及绩效统计。可按部门筛选查看人员构成,洞察团队变化趋势。
|
|
||||||
</PageGuide>
|
|
||||||
{/* 本月工作动态 */}
|
{/* 本月工作动态 */}
|
||||||
{isSectionVisible('workforce_activities') && (
|
{isSectionVisible('workforce_activities') && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -974,11 +1000,6 @@ export default function Dashboard() {
|
|||||||
{/* 风险提醒 Tab */}
|
{/* 风险提醒 Tab */}
|
||||||
{(activeTab === 'risk' || activeTab === 'task') && (
|
{(activeTab === 'risk' || activeTab === 'task') && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
{activeTab === 'risk'
|
|
||||||
? '风险提醒页汇总合同到期、未签合同、离职风险、退休预警等高风险待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'
|
|
||||||
: '月度任务页汇总社保办理、发薪批次等周期性待办事项。可批量处理或逐项解决,处理完成后自动归档至已办事项。'}
|
|
||||||
</PageGuide>
|
|
||||||
{/* 风险分布饼图(仅风险提醒Tab) */}
|
{/* 风险分布饼图(仅风险提醒Tab) */}
|
||||||
{activeTab === 'risk' && isSectionVisible('risk_distribution') && (
|
{activeTab === 'risk' && isSectionVisible('risk_distribution') && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1130,7 +1151,9 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<div
|
||||||
key={todo.id}
|
key={todo.id}
|
||||||
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
className="flex items-center justify-between px-2.5 py-2 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
@@ -1142,40 +1165,71 @@ export default function Dashboard() {
|
|||||||
onChange={() => toggleSelect(todo.id)}
|
onChange={() => toggleSelect(todo.id)}
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
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">
|
{hasValidUrl ? (
|
||||||
<TodoIcon type={todo.type} level={todo.level} />
|
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
|
||||||
<div className="flex flex-col">
|
<TodoIcon type={todo.type} level={todo.level} />
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex flex-col">
|
||||||
{todo.employeeName && (
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-xs font-bold text-gray-900">{todo.employeeName}</span>
|
{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>
|
{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="text-xs text-gray-700">{todo.title}</span>
|
||||||
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
{todo.priority && priorityConfig[todo.priority] && (
|
||||||
{priorityConfig[todo.priority].label}
|
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
|
||||||
</span>
|
{priorityConfig[todo.priority].label}
|
||||||
)}
|
</span>
|
||||||
{todo.estimatedLoss > 0 && (
|
)}
|
||||||
<span className="text-xs text-red-600 font-medium">损失 {fmt(todo.estimatedLoss)}</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>
|
{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>
|
</div>
|
||||||
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Link
|
{hasValidUrl && (
|
||||||
to={todo.actionUrl}
|
<Link
|
||||||
className="px-2 py-1 rounded text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
|
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>
|
立刻办理 →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => resolveMutation.mutate(todo.id)}
|
onClick={() => resolveMutation.mutate(todo.id)}
|
||||||
disabled={resolveMutation.isPending}
|
disabled={resolveMutation.isPending}
|
||||||
@@ -1194,7 +1248,7 @@ export default function Dashboard() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
|
<Pagination page={todoPage} pageSize={todoPageSize} total={filteredTodos.length} onPageChange={setTodoPage} onPageSizeChange={() => setTodoPage(1)} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+801
-37
@@ -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 { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { PenTool, Plus, X, RefreshCw, ExternalLink, FileText, AlertCircle } from 'lucide-react'
|
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 { esignApi, employeeApi } from '../lib/api-services'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
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 { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||||
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700' },
|
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' },
|
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
|
||||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||||
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger' },
|
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
|
||||||
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500' },
|
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' },
|
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 }> = {
|
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' },
|
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() {
|
export default function ESign() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterScene, setFilterScene] = useState('')
|
const [filterScene, setFilterScene] = useState('')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showPaperSign, setShowPaperSign] = useState(false)
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
employeeId: '',
|
employeeId: '',
|
||||||
|
scene: 'CONTRACT',
|
||||||
documentTitle: '',
|
documentTitle: '',
|
||||||
remark: '',
|
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[]>({
|
const { data: records = [], isLoading } = useQuery<any[]>({
|
||||||
queryKey: ['esign-records', filterStatus, filterScene],
|
queryKey: ['esign-records', filterStatus, filterScene],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -45,6 +86,26 @@ export default function ESign() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 待签合同列表(按员工聚合) */
|
||||||
|
const { data: pendingList = [], isLoading: pendingLoading } = useQuery<any[]>({
|
||||||
|
queryKey: ['esign-pending'],
|
||||||
|
queryFn: async () => {
|
||||||
|
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[]>({
|
const { data: rosterData = [] } = useQuery<any[]>({
|
||||||
queryKey: ['employees-for-esign'],
|
queryKey: ['employees-for-esign'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -54,15 +115,17 @@ export default function ESign() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createMutation = useMutation({
|
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,
|
esignApi.create(data) as any,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
setFormData({ employeeId: '', documentTitle: '', remark: '' })
|
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
|
||||||
toast.success('签署记录已创建,待对接易签宝后将自动发送签署链接')
|
toast.success('签署记录已创建,员工可在员工端查看并签署')
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '创建失败')
|
||||||
},
|
},
|
||||||
onError: () => toast.error('创建失败'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
@@ -87,32 +150,60 @@ export default function ESign() {
|
|||||||
createMutation.mutate(formData)
|
createMutation.mutate(formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 签署详情视图 =====
|
||||||
|
if (detailId) {
|
||||||
|
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
管理员工合同、协议、规章等文件的签署。未签署的按员工聚合在「待签合同」中催办;已签署的记录在「签署记录」中查看。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PenTool className="h-5 w-5 text-primary" />
|
<PenTool className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-semibold">电子签署</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PageGuide>
|
{/* Tab 切换 */}
|
||||||
通过易签宝平台发起电子合同签署,员工在线完成签署后自动回传签署状态和PDF文件。
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-amber-600"> 当前为框架预留阶段,对接易签宝API后将自动启用在线签署功能。</span>
|
<div className="flex gap-1 border-b">
|
||||||
</PageGuide>
|
<button
|
||||||
|
onClick={() => setActiveTab('pending')}
|
||||||
<InlineAlert type="info" className="flex items-start gap-2">
|
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'}`}
|
||||||
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
>
|
||||||
<div>
|
待签合同
|
||||||
<span className="font-medium">对接状态:框架已就绪,待接入易签宝API</span>
|
{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>}
|
||||||
<div className="mt-1 text-xs">
|
</button>
|
||||||
需要提供易签宝的 AppId、AppSecret 和 API 基础地址。对接后可实现:
|
<button
|
||||||
① HR在系统发起签署 → ② 调用易签宝创建签署流程 → ③ 员工收到签署链接 → ④ 签署完成自动回调更新状态 → ⑤ 合同自动关联电子版PDF
|
onClick={() => setActiveTab('records')}
|
||||||
</div>
|
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>
|
</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 items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -132,9 +223,6 @@ export default function ESign() {
|
|||||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />发起签署
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<td className="py-2 px-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
<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] && (
|
{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>
|
<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">{r.employee?.name || '—'}</td>
|
||||||
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
||||||
<td className="py-2 px-3">
|
<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>
|
||||||
<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">{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-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
|
||||||
<td className="py-2 px-3 text-right">
|
<td className="py-2 px-3 text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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 && (
|
{r.status === 'COMPLETED' && r.signedPdfUrl && (
|
||||||
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
|
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
|
||||||
className="text-xs text-primary hover:underline flex items-center gap-0.5">
|
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>
|
</a>
|
||||||
)}
|
)}
|
||||||
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
||||||
@@ -208,13 +315,15 @@ export default function ESign() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 发起签署 Modal */}
|
{/* 发起签署 Modal */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
|
<Modal open={true} onClose={() => setShowCreate(false)} title="发起电子签署" size="md">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InlineAlert type="info">
|
<InlineAlert type="info">
|
||||||
选择员工并填写文件标题,系统将创建签署记录。对接易签宝后,将自动生成签署链接并发送给员工。
|
选择员工和签署场景,系统将自动从模板渲染文件内容,创建签署记录并发起证据链。员工可在员工端查看文件并通过验证码签署。
|
||||||
</InlineAlert>
|
</InlineAlert>
|
||||||
<div>
|
<div>
|
||||||
<Label>签署员工 *</Label>
|
<Label>签署员工 *</Label>
|
||||||
@@ -229,6 +338,26 @@ export default function ESign() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label>文件标题 *</Label>
|
<Label>文件标题 *</Label>
|
||||||
<Input value={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
|
<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 })}
|
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||||
placeholder="可选" />
|
placeholder="可选" />
|
||||||
</div>
|
</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">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||||
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
|
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
|
||||||
@@ -248,6 +384,634 @@ export default function ESign() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Button from '../components/ui/Button'
|
|||||||
import { Input, Label, Select } from '../components/ui/Input'
|
import { Input, Label, Select } from '../components/ui/Input'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const LEAVE_TYPE_MAP: Record<string, string> = {
|
const LEAVE_TYPE_MAP: Record<string, string> = {
|
||||||
SICK: '病假',
|
SICK: '病假',
|
||||||
@@ -75,6 +76,7 @@ export default function LeaveApproval() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>请假审批,处理员工请假申请,支持事假、病假、年假、婚假等类型。流程:员工提交申请 → 主管审批 → HR 备案 → 同步考勤。关联:审批通过后自动更新考勤记录。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarClock className="w-5 h-5 text-primary" />
|
<CalendarClock className="w-5 h-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
import { useState, lazy, Suspense } from 'react'
|
import { useState, Suspense } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
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 BatchManager = lazyRetry(() => import('./money/BatchTab').then(m => ({ default: m.BatchManager })))
|
||||||
const TemplateManager = lazy(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
const TemplateManager = lazyRetry(() => import('./money/TemplateTab').then(m => ({ default: m.TemplateManager })))
|
||||||
const OvertimeCalculator = lazy(() => import('./money/OvertimeTab').then(m => ({ default: m.OvertimeCalculator })))
|
const PayslipManager = lazyRetry(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
|
||||||
const PayslipManager = lazy(() => import('./money/PayslipTab').then(m => ({ default: m.PayslipManager })))
|
|
||||||
|
|
||||||
type Tab = 'batch' | 'template' | 'overtime' | 'payslip'
|
type Tab = 'batch' | 'template' | 'payslip'
|
||||||
|
|
||||||
export default function Money() {
|
export default function Money() {
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const initialEmployeeId = searchParams.get('employeeId') || ''
|
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 }[] = [
|
const tabs: { key: Tab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
{ key: 'batch', label: '发薪批次', icon: <Layers className="w-4 h-4" /> },
|
||||||
{ key: 'template', label: '薪酬模版', icon: <LayoutTemplate 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" /> },
|
{ key: 'payslip', label: '工资条管理', icon: <Receipt className="w-4 h-4" /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>薪酬管理,支持工资条发放、批量算薪和工资模板配置。流程:①配置模板 → ②导入考勤数据 → ③批量算薪 → ④生成工资条。关联:工资数据来自花名册薪酬信息,考勤数据来自考勤管理,加班费在「加班费计算」模块单独计算后导入。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Wallet className="w-5 h-5 text-primary" />
|
<Wallet className="w-5 h-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-semibold">薪税管理</h1>
|
<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>
|
||||||
</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>}>
|
<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 === 'batch' && <BatchManager />}
|
||||||
{tab === 'template' && <TemplateManager />}
|
{tab === 'template' && <TemplateManager />}
|
||||||
{tab === 'overtime' && <OvertimeCalculator />}
|
|
||||||
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
{tab === 'payslip' && <PayslipManager filterEmployeeId={initialEmployeeId} />}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Card from '../components/ui/Card'
|
|||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
CONTRACT_EXPIRY: '合同到期',
|
CONTRACT_EXPIRY: '合同到期',
|
||||||
@@ -62,6 +63,7 @@ export default function Notifications() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>通知中心,集中管理系统通知消息,包括合同到期提醒、试用期到期、审批通知等。支持已读/未读管理、通知偏好设置。</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+566
-97
@@ -5,8 +5,9 @@ import { toast } from 'sonner'
|
|||||||
import { toastError } from '../lib/errorToast'
|
import { toastError } from '../lib/errorToast'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
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 { Users, Plus, Check, UserX, UserPlus, DollarSign, Building2, RotateCcw, Upload, Download, Phone, MapPin, Search, Settings2, CheckCircle, FileText } from 'lucide-react'
|
||||||
import { rosterApi, employeeApi, terminationApi } from '../lib/api-services'
|
import { rosterApi, employeeApi, terminationApi, workProcessApi } from '../lib/api-services'
|
||||||
|
import api from '../lib/api'
|
||||||
import { copyToClipboard } from '../lib/clipboard'
|
import { copyToClipboard } from '../lib/clipboard'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
import { useDebouncedValue } from '../hooks/useDebouncedValue'
|
||||||
@@ -19,7 +20,8 @@ import { fmt, terminateReasonMap } from './roster/shared'
|
|||||||
import PageGuide from '../components/ui/PageGuide'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import EmployeeProfile from './roster/EmployeeProfile'
|
import EmployeeProfile from './roster/EmployeeProfile'
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
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 { ImportSettings } from './Settings'
|
||||||
import QueryError from '../components/ui/QueryError'
|
import QueryError from '../components/ui/QueryError'
|
||||||
|
|
||||||
@@ -77,22 +79,54 @@ export default function Roster() {
|
|||||||
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
|
const [salaryEmployee, setSalaryEmployee] = useState<any>(null)
|
||||||
const [showDeptModal, setShowDeptModal] = useState(false)
|
const [showDeptModal, setShowDeptModal] = useState(false)
|
||||||
const [deptEmployee, setDeptEmployee] = useState<any>(null)
|
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 pageSize = usePageSize()
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
|
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 [batchRenewYears, setBatchRenewYears] = useState(3)
|
||||||
const [previewData, setPreviewData] = useState<any>(null)
|
const [previewData, setPreviewData] = useState<any>(null)
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterContractStatus, setFilterContractStatus] = useState('')
|
const [filterContractStatus, setFilterContractStatus] = useState('')
|
||||||
const [filterDepartment, setFilterDepartment] = useState('')
|
const [filterDepartment, setFilterDepartment] = useState('')
|
||||||
|
const [filterPosition, setFilterPosition] = useState('')
|
||||||
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
||||||
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
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>({
|
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 () => {
|
queryFn: async () => {
|
||||||
const params: any = { page, pageSize }
|
const params: any = { page, pageSize }
|
||||||
if (debouncedSearch) params.search = debouncedSearch
|
if (debouncedSearch) params.search = debouncedSearch
|
||||||
@@ -100,7 +134,8 @@ export default function Roster() {
|
|||||||
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
if (filterStatus === 'PROBATION') params.status = 'ACTIVE'
|
||||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||||
if (filterDepartment) params.department = filterDepartment
|
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
|
return res
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -132,16 +167,17 @@ export default function Roster() {
|
|||||||
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
}, [employeeParam, employeeIdParam, debouncedSearch, isLoading, employees, selectedId, setSearchParams])
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (data: any) => employeeApi.create(data),
|
mutationFn: async (data: any) => {
|
||||||
|
const res = await employeeApi.create(data)
|
||||||
|
return res
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||||
localStorage.removeItem('add-employee-draft')
|
localStorage.removeItem('add-employee-draft')
|
||||||
setShowAddModal(false)
|
setShowAddModal(false)
|
||||||
toast.success('员工已添加', {
|
toast.success('员工已添加,请前往员工档案签订合同')
|
||||||
action: { label: '前往用工办理', onClick: () => navigate('/work-process') },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: (err: any) => toastError(err, '创建失败'),
|
onError: (err: any) => toastError(err, '创建失败'),
|
||||||
})
|
})
|
||||||
@@ -160,8 +196,9 @@ export default function Roster() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
queryClient.invalidateQueries({ queryKey: ['termination-drafts'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||||
toast.success('已创建离职草稿,请前往「解聘补偿」页面完成流程', {
|
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||||
action: { label: '前往处理', onClick: () => navigate('/termination') },
|
toast.success('已创建离职草稿,离职协议电子签署已自动发起', {
|
||||||
|
action: { label: '查看签署', onClick: () => navigate('/esign') },
|
||||||
})
|
})
|
||||||
setShowResignModal(false)
|
setShowResignModal(false)
|
||||||
setResignEmployee(null)
|
setResignEmployee(null)
|
||||||
@@ -184,6 +221,16 @@ export default function Roster() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||||
setShowRehireModal(false)
|
setShowRehireModal(false)
|
||||||
|
// 弹出签署方式选择
|
||||||
|
setSignChoice({
|
||||||
|
open: true,
|
||||||
|
employeeId: rehireEmployee?.id || '',
|
||||||
|
employeeName: rehireEmployee?.name || '',
|
||||||
|
scene: 'CONTRACT',
|
||||||
|
documentTitle: `${rehireEmployee?.name || ''}的劳动合同`,
|
||||||
|
remark: '重新入职时发起',
|
||||||
|
actionName: '重新入职',
|
||||||
|
})
|
||||||
setRehireEmployee(null)
|
setRehireEmployee(null)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -195,6 +242,16 @@ export default function Roster() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||||
setShowSalaryModal(false)
|
setShowSalaryModal(false)
|
||||||
|
// 弹出签署方式选择
|
||||||
|
setSignChoice({
|
||||||
|
open: true,
|
||||||
|
employeeId: salaryEmployee?.id || '',
|
||||||
|
employeeName: salaryEmployee?.name || '',
|
||||||
|
scene: 'POLICY',
|
||||||
|
documentTitle: `${salaryEmployee?.name || ''}的薪酬调整确认书`,
|
||||||
|
remark: '薪酬变更时发起',
|
||||||
|
actionName: '薪酬变更',
|
||||||
|
})
|
||||||
setSalaryEmployee(null)
|
setSalaryEmployee(null)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -206,10 +263,50 @@ export default function Roster() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||||||
setShowDeptModal(false)
|
setShowDeptModal(false)
|
||||||
|
// 弹出签署方式选择
|
||||||
|
setSignChoice({
|
||||||
|
open: true,
|
||||||
|
employeeId: deptEmployee?.id || '',
|
||||||
|
employeeName: deptEmployee?.name || '',
|
||||||
|
scene: 'POLICY',
|
||||||
|
documentTitle: `${deptEmployee?.name || ''}的调岗确认书`,
|
||||||
|
remark: '部门/岗位调动时发起',
|
||||||
|
actionName: '调岗调动',
|
||||||
|
})
|
||||||
setDeptEmployee(null)
|
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({
|
const batchRenewMutation = useMutation({
|
||||||
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
|
mutationFn: (data: { contractIds: string[]; years: number }) => employeeApi.batchRenew(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -264,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) => {
|
const toggleSelect = (id: string) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -286,10 +519,11 @@ export default function Roster() {
|
|||||||
setFilterStatus('')
|
setFilterStatus('')
|
||||||
setFilterContractStatus('')
|
setFilterContractStatus('')
|
||||||
setFilterDepartment('')
|
setFilterDepartment('')
|
||||||
|
setFilterPosition('')
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment || filterPosition
|
||||||
|
|
||||||
/** 全局合同风险统计(来自后端,与风险中心同口径) */
|
/** 全局合同风险统计(来自后端,与风险中心同口径) */
|
||||||
const globalRiskStats = rosterData?.globalRiskStats
|
const globalRiskStats = rosterData?.globalRiskStats
|
||||||
@@ -318,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 { isVisible: colVisible, toggle: colToggle } = useRosterColumns()
|
||||||
const [showColSettings, setShowColSettings] = useState(false)
|
const [showColSettings, setShowColSettings] = useState(false)
|
||||||
|
|
||||||
@@ -382,15 +624,20 @@ export default function Roster() {
|
|||||||
<option value="">全部部门</option>
|
<option value="">全部部门</option>
|
||||||
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
{departmentList?.map((d: string) => <option key={d} value={d}>{d}</option>)}
|
||||||
</select>
|
</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 && (
|
{hasActiveFilters && (
|
||||||
<button onClick={clearFilters} className="h-9 px-2 text-sm text-gray-400 transition hover:text-gray-700">清除筛选</button>
|
<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">
|
<Button onClick={() => setShowAddModal(true)} className="h-9 shrink-0">
|
||||||
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
<Plus className="mr-1.5 h-4 w-4" />添加员工
|
||||||
</Button>
|
</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">
|
<Button variant="secondary" onClick={() => setShowImportModal(true)} className="h-9 shrink-0">
|
||||||
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
<Upload className="mr-1.5 h-4 w-4" />批量导入
|
||||||
</Button>
|
</Button>
|
||||||
@@ -400,6 +647,7 @@ export default function Roster() {
|
|||||||
if (debouncedSearch) params.set('search', debouncedSearch)
|
if (debouncedSearch) params.set('search', debouncedSearch)
|
||||||
if (filterStatus) params.set('status', filterStatus)
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
if (filterDepartment) params.set('department', filterDepartment)
|
if (filterDepartment) params.set('department', filterDepartment)
|
||||||
|
if (filterPosition) params.set('position', filterPosition)
|
||||||
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
if (filterContractStatus) params.set('contractStatus', filterContractStatus)
|
||||||
const token = useAuthStore.getState().accessToken
|
const token = useAuthStore.getState().accessToken
|
||||||
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
const baseURL = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||||
@@ -450,6 +698,12 @@ export default function Roster() {
|
|||||||
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
||||||
<Check className="mr-1 h-4 w-4" />批量续签
|
<Check className="mr-1 h-4 w-4" />批量续签
|
||||||
</Button>
|
</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">
|
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
|
||||||
<UserX className="mr-1 h-4 w-4" />批量解聘
|
<UserX className="mr-1 h-4 w-4" />批量解聘
|
||||||
</Button>
|
</Button>
|
||||||
@@ -529,26 +783,28 @@ export default function Roster() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{employees.map((e: any) => (
|
{employees.map((e: any, idx: number) => (
|
||||||
<tr
|
<tr
|
||||||
key={e.id}
|
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-100 last:border-0 cursor-pointer transition-colors hover:bg-primary/[0.05] group ${idx % 2 === 1 ? 'bg-gray-100/60' : ''}`}
|
||||||
onClick={() => setSelectedId(e.id)}
|
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)} />
|
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium">
|
<td className="px-4 py-2.5">
|
||||||
<div>{e.name}</div>
|
<div className="flex flex-col gap-0.5">
|
||||||
<div className="text-gray-400 text-xs font-mono cursor-pointer hover:text-primary transition-colors" title="点击复制完整身份证号" onClick={(ev) => {
|
<span className="font-medium text-gray-900 text-sm leading-tight">{e.name}</span>
|
||||||
ev.stopPropagation()
|
<span className="text-gray-400 text-[11px] font-mono cursor-pointer hover:text-primary transition-colors leading-tight" title="点击复制完整证件号码" onClick={(ev) => {
|
||||||
if (e.idCardNumber) {
|
ev.stopPropagation()
|
||||||
copyToClipboard(e.idCardNumber, '已复制身份证号')
|
if (e.idCardNumber) {
|
||||||
}
|
copyToClipboard(e.idCardNumber, '已复制证件号码')
|
||||||
}}>{e.idCardMasked || '—'}</div>
|
}
|
||||||
|
}}>{e.idCardMasked || '—'}</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{colVisible('department') && <td className="px-4 py-3 text-gray-500">{e.department}</td>}
|
{colVisible('department') && <td className="px-4 py-2.5 text-gray-500">{e.department}</td>}
|
||||||
{colVisible('status') && <td className="px-4 py-3">
|
{colVisible('status') && <td className="px-4 py-2.5">
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
e.status === 'ACTIVE' ? 'bg-green-50 text-safe'
|
||||||
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
: e.status === 'PRE_HIRE' ? 'bg-blue-50 text-blue-600'
|
||||||
@@ -566,8 +822,8 @@ export default function Roster() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>}
|
</td>}
|
||||||
{colVisible('hireDate') && <td className="px-4 py-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>}
|
{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-3 text-gray-500">
|
<td className="hidden px-4 py-2.5 text-gray-500">
|
||||||
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
|
{e.hasTermination && e.latestTerminationDate && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' ? (
|
||||||
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
||||||
{e.latestTerminationDate.toString().slice(0, 10)}
|
{e.latestTerminationDate.toString().slice(0, 10)}
|
||||||
@@ -577,10 +833,10 @@ export default function Roster() {
|
|||||||
<span className="text-gray-300">—</span>
|
<span className="text-gray-300">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
{colVisible('position') && <td className="px-4 py-3 text-gray-500">{e.position || '—'}</td>}
|
{colVisible('position') && <td className="px-4 py-2.5 text-gray-500">{e.position || '—'}</td>}
|
||||||
{colVisible('phone') && <td className="px-4 py-3 text-gray-500">{e.phone || '—'}</td>}
|
{colVisible('phone') && <td className="px-4 py-2.5 text-gray-500">{e.phone || '—'}</td>}
|
||||||
{colVisible('gender') && <td className="px-4 py-3 text-center text-gray-500">{e.gender || '—'}</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-3">
|
{colVisible('contractType') && <td className="px-4 py-2.5">
|
||||||
{(() => {
|
{(() => {
|
||||||
const typeConfig: Record<string, { label: string; style: string }> = {
|
const typeConfig: Record<string, { label: string; style: string }> = {
|
||||||
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
FIXED: { label: '劳动合同-固定期', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
||||||
@@ -597,7 +853,7 @@ export default function Roster() {
|
|||||||
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
|
return <span className={`px-2 py-0.5 rounded text-xs ${cfg.style}`}>{cfg.label}</span>
|
||||||
})()}
|
})()}
|
||||||
</td>}
|
</td>}
|
||||||
{colVisible('contractStatus') && <td className="px-4 py-3">
|
{colVisible('contractStatus') && <td className="px-4 py-2.5">
|
||||||
{(() => {
|
{(() => {
|
||||||
const tagStyles: Record<string, string> = {
|
const tagStyles: Record<string, string> = {
|
||||||
expired: 'bg-red-50 text-danger',
|
expired: 'bg-red-50 text-danger',
|
||||||
@@ -605,6 +861,7 @@ export default function Roster() {
|
|||||||
unsigned_over_30: 'bg-red-50 text-danger',
|
unsigned_over_30: 'bg-red-50 text-danger',
|
||||||
unsigned: 'bg-yellow-50 text-yellow-700',
|
unsigned: 'bg-yellow-50 text-yellow-700',
|
||||||
expiring: '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',
|
active: 'bg-green-50 text-safe',
|
||||||
unfixed: 'bg-green-50 text-safe',
|
unfixed: 'bg-green-50 text-safe',
|
||||||
}
|
}
|
||||||
@@ -614,6 +871,7 @@ export default function Roster() {
|
|||||||
unsigned_over_30: '未签署(超30天)',
|
unsigned_over_30: '未签署(超30天)',
|
||||||
unsigned: '未签署',
|
unsigned: '未签署',
|
||||||
expiring: '即将到期',
|
expiring: '即将到期',
|
||||||
|
pending_sign: '待签署',
|
||||||
active: '正常',
|
active: '正常',
|
||||||
unfixed: '正常',
|
unfixed: '正常',
|
||||||
}
|
}
|
||||||
@@ -622,7 +880,7 @@ export default function Roster() {
|
|||||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
|
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{text}</span>
|
||||||
})()}
|
})()}
|
||||||
</td>}
|
</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
|
const endDate = e.latestContract?.endDate
|
||||||
if (!endDate) {
|
if (!endDate) {
|
||||||
@@ -642,7 +900,7 @@ export default function Roster() {
|
|||||||
return <span className="text-xs">{dateStr}</span>
|
return <span className="text-xs">{dateStr}</span>
|
||||||
})()}
|
})()}
|
||||||
</td>}
|
</td>}
|
||||||
{colVisible('socialStatus') && <td className="px-4 py-3">
|
{colVisible('socialStatus') && <td className="px-4 py-2.5">
|
||||||
{(() => {
|
{(() => {
|
||||||
const status = e.socialInsuranceStatus
|
const status = e.socialInsuranceStatus
|
||||||
if (!status) return <span className="text-gray-300 text-xs">—</span>
|
if (!status) return <span className="text-gray-300 text-xs">—</span>
|
||||||
@@ -656,12 +914,12 @@ export default function Roster() {
|
|||||||
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
|
return <span className={`px-2 py-0.5 rounded text-xs ${c.style}`}>{c.label}</span>
|
||||||
})()}
|
})()}
|
||||||
</td>}
|
</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>}
|
{e.socialInsBase ? e.socialInsBase.toLocaleString() : <span className="text-gray-300">—</span>}
|
||||||
</td>}
|
</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 socialEmp = e.socialInsCalc?.socialEmp || 0
|
||||||
const socialOrg = e.socialInsCalc?.socialOrg || 0
|
const socialOrg = e.socialInsCalc?.socialOrg || 0
|
||||||
const housingEmp = e.housingFundCalc?.housingEmp || 0
|
const housingEmp = e.housingFundCalc?.housingEmp || 0
|
||||||
@@ -669,14 +927,14 @@ export default function Roster() {
|
|||||||
const totalEmp = socialEmp + housingEmp
|
const totalEmp = socialEmp + housingEmp
|
||||||
const totalOrg = socialOrg + housingOrg
|
const totalOrg = socialOrg + housingOrg
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-0.5 items-end">
|
||||||
<span>个人: {totalEmp.toFixed(2)}</span>
|
<span className="text-xs text-gray-700 leading-tight">个人 {totalEmp.toFixed(2)}</span>
|
||||||
<span className="text-gray-400">企业: {totalOrg.toFixed(2)}</span>
|
<span className="text-[11px] text-gray-400 leading-tight">企业 {totalOrg.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</td>}
|
</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">
|
<div className="flex items-center justify-center gap-1 flex-wrap">
|
||||||
{(() => {
|
{(() => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -697,60 +955,109 @@ export default function Roster() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</td>}
|
</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') && (
|
{e.status === 'ACTIVE' && (!e.hasTermination || e.latestTerminationStatus === 'CANCELLED' || e.latestTerminationStatus === 'COMPLETED') && (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<button
|
<div className="flex items-center justify-center gap-0.5">
|
||||||
type="button"
|
<button
|
||||||
title="调薪"
|
type="button"
|
||||||
aria-label={`为${e.name}调薪`}
|
title="调薪"
|
||||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
aria-label={`为${e.name}调薪`}
|
||||||
onClick={(ev) => {
|
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||||
ev.stopPropagation()
|
onClick={(ev) => {
|
||||||
setSalaryEmployee(e)
|
ev.stopPropagation()
|
||||||
setShowSalaryModal(true)
|
setSalaryEmployee(e)
|
||||||
}}
|
setShowSalaryModal(true)
|
||||||
>
|
}}
|
||||||
<DollarSign className="h-4 w-4" />
|
>
|
||||||
</button>
|
<DollarSign className="h-4 w-4" />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
{/* 试用期员工显示转正按钮 */}
|
||||||
title="发薪"
|
{e.probationInfo?.isProbation && (
|
||||||
aria-label={`为${e.name}发薪`}
|
<button
|
||||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
type="button"
|
||||||
onClick={(ev) => {
|
title="转正"
|
||||||
ev.stopPropagation()
|
aria-label={`为${e.name}办理转正`}
|
||||||
navigate('/money')
|
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||||
}}
|
onClick={(ev) => {
|
||||||
>
|
ev.stopPropagation()
|
||||||
<Wallet className="h-4 w-4" />
|
setConfirmEmployee(e)
|
||||||
</button>
|
setShowConfirmModal(true)
|
||||||
<button
|
}}
|
||||||
type="button"
|
>
|
||||||
title="调部门"
|
<CheckCircle className="h-4 w-4" />
|
||||||
aria-label={`为${e.name}调整部门`}
|
</button>
|
||||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
)}
|
||||||
onClick={(ev) => {
|
<button
|
||||||
ev.stopPropagation()
|
type="button"
|
||||||
setDeptEmployee(e)
|
title="调动"
|
||||||
setShowDeptModal(true)
|
aria-label={`为${e.name}调动`}
|
||||||
}}
|
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||||
>
|
onClick={(ev) => {
|
||||||
<Building2 className="h-4 w-4" />
|
ev.stopPropagation()
|
||||||
</button>
|
setDeptEmployee(e)
|
||||||
<button
|
setShowDeptModal(true)
|
||||||
type="button"
|
}}
|
||||||
title="离职"
|
>
|
||||||
aria-label={`为${e.name}办理离职`}
|
<Building2 className="h-4 w-4" />
|
||||||
className="rounded-md p-1.5 text-gray-500 transition hover:bg-danger/10 hover:text-danger"
|
</button>
|
||||||
onClick={(ev) => {
|
</div>
|
||||||
ev.stopPropagation()
|
<div className="flex items-center justify-center gap-0.5 border-t border-gray-100 pt-1.5">
|
||||||
setResignEmployee(e)
|
<button
|
||||||
setShowResignModal(true)
|
type="button"
|
||||||
}}
|
title="开具证明"
|
||||||
>
|
aria-label={`为${e.name}开具证明`}
|
||||||
<UserX className="h-4 w-4" />
|
className="rounded-md p-1.5 text-gray-500 transition hover:bg-primary/10 hover:text-primary"
|
||||||
</button>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
|
{e.hasTermination && e.status === 'ACTIVE' && e.latestTerminationStatus !== 'CANCELLED' && e.latestTerminationStatus !== 'COMPLETED' && (
|
||||||
@@ -865,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 && (
|
{showBatchRenewModal && (
|
||||||
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
|
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1059,6 +1513,21 @@ export default function Roster() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export default function SalaryDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<PageGuide>
|
||||||
|
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
||||||
|
</PageGuide>
|
||||||
{/* 页头 */}
|
{/* 页头 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -70,11 +73,6 @@ export default function SalaryDashboard() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 操作说明 — 紧跟标题下方 */}
|
|
||||||
<PageGuide>
|
|
||||||
薪酬分析看板展示企业薪酬分布、部门间薪酬对比及年度趋势变化。选择年份后查看各部门薪酬数据、同比环比变化。用于辅助薪酬决策和成本控制。
|
|
||||||
</PageGuide>
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
|
|||||||
+309
-17
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Edit2 } from 'lucide-react'
|
||||||
import { settingsApi, notificationsApi } from '../lib/api-services'
|
import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
|
||||||
|
import api from '../lib/api'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||||||
import Card from '../components/ui/Card'
|
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 { Input, Label, Select } from '../components/ui/Input'
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -48,6 +50,7 @@ export default function Settings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>系统设置,管理组织信息、用户权限、审批流程、系统参数等。支持多角色配置、审批节点设置、通知模板管理。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="h-5 w-5 text-primary" />
|
<Building2 className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
@@ -653,7 +656,7 @@ function handleAcceptanceExport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultMap: Record<string, string> = { pass: '✅通过', fail: '❌失败', partial: '⚠️部分通过', untested: '🚫未测试' }
|
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 += `> **导出时间:** ${new Date().toLocaleString('zh-CN')}\n`
|
||||||
md += `> **验收人数:** ${keys.length}\n\n`
|
md += `> **验收人数:** ${keys.length}\n\n`
|
||||||
|
|
||||||
@@ -702,7 +705,7 @@ function handleAcceptanceExport() {
|
|||||||
md += `\n`
|
md += `\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
md += `\n---\n*由企业用工专家系统自动生成*\n`
|
md += `\n---\n*由安职通系统自动生成*\n`
|
||||||
|
|
||||||
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -1148,7 +1151,7 @@ function InitImport() {
|
|||||||
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
<div className="text-amber-600">跳过 {result.skipped} 条(数据不完整或格式错误)</div>
|
||||||
)}
|
)}
|
||||||
{result.duplicates > 0 && (
|
{result.duplicates > 0 && (
|
||||||
<div className="text-amber-600">重复 {result.duplicates} 条(身份证号已存在)</div>
|
<div className="text-amber-600">重复 {result.duplicates} 条(证件号码已存在)</div>
|
||||||
)}
|
)}
|
||||||
{result.errors?.length > 0 && (
|
{result.errors?.length > 0 && (
|
||||||
<div className="mt-2 pt-2 border-t border-green-200">
|
<div className="mt-2 pt-2 border-t border-green-200">
|
||||||
@@ -1214,12 +1217,12 @@ function InitImport() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
<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>- <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 className="mt-2 text-gray-500">各 Sheet 优先用「证件号码」精确匹配员工,未填证件号码时用「姓名」兑底(重名可能匹配错误)</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1377,12 +1380,12 @@ function MonthlyImport() {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
<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>- <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 className="mt-2 text-gray-500">所有 Sheet 优先用「证件号码」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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,102 +1,72 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { Calculator, Check, Plus, Download, AlertCircle, Clock } from 'lucide-react'
|
||||||
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles } from 'lucide-react'
|
|
||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
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 Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
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 { MonthlyRow, MonthlyHousingRow } from './social-insurance/MonthlyRows'
|
||||||
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
import SpecialDeductionTab from './social-insurance/SpecialDeductionTab'
|
||||||
import EmployeeEnrollmentTab from './social-insurance/EmployeeEnrollmentTab'
|
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 })
|
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
|
||||||
export default function SocialInsurance() {
|
export default function SocialInsurance() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const confirm = useConfirm()
|
const [searchParams] = useSearchParams()
|
||||||
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>('monthly')
|
const initialTab = (searchParams.get('tab') as 'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment') || 'monthly'
|
||||||
const [city, setCity] = useState<string>('北京')
|
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'enrollment'>(initialTab)
|
||||||
const [showAddCity, setShowAddCity] = useState(false)
|
|
||||||
const [newCityName, setNewCityName] = useState('')
|
|
||||||
const [base, setBase] = useState(8000)
|
|
||||||
const [deductionMonth, setDeductionMonth] = useState(new Date().toISOString().slice(0, 7))
|
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 [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||||
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
const [monthlyProcessed, setMonthlyProcessed] = useState(false)
|
||||||
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
const [processStatus, setProcessStatus] = useState<{ social: any; housing: any } | null>(null)
|
||||||
const [newVersion, setNewVersion] = useState<any>({
|
// 账户管理相关 state
|
||||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
const [showAccountForm, setShowAccountForm] = useState(false)
|
||||||
city: '北京',
|
const [editAccount, setEditAccount] = useState<any>(null)
|
||||||
pensionOrg: 16, pensionEmp: 8,
|
|
||||||
medicalOrg: 9.8, medicalEmp: 2,
|
// 获取账户列表(按当前 tab 类型)
|
||||||
unemploymentOrg: 0.5, unemploymentEmp: 0.5,
|
const accountType = tab === 'housing' ? 'HOUSING' : 'SOCIAL'
|
||||||
injuryOrg: 0.2, maternityOrg: 0.8,
|
const { data: accounts = [] } = useQuery<any[]>({
|
||||||
baseMin: 6326, baseMax: 33891,
|
queryKey: ['social-accounts', accountType],
|
||||||
medicalBaseMin: 0, medicalBaseMax: 0,
|
queryFn: () => socialAccountApi.list(accountType),
|
||||||
extraInsurances: [],
|
|
||||||
})
|
|
||||||
const [newHousingVersion, setNewHousingVersion] = useState<any>({
|
|
||||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
|
||||||
city: '北京',
|
|
||||||
accountType: 'BASIC',
|
|
||||||
housingOrg: 12, housingEmp: 12,
|
|
||||||
baseMin: 6326, baseMax: 33891,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取城市列表
|
// 账户 CRUD mutations
|
||||||
const { data: cities = [] } = useQuery<string[]>({
|
const createAccountMutation = useMutation({
|
||||||
queryKey: ['social-config-cities'],
|
mutationFn: (data: any) => socialAccountApi.create(data),
|
||||||
queryFn: async () => {
|
onSuccess: () => {
|
||||||
return await socialInsuranceApi.cities()
|
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>({
|
const updateAccountMutation = useMutation({
|
||||||
queryKey: ['social-config', city],
|
mutationFn: ({ id, data }: { id: string; data: any }) => socialAccountApi.update(id, data),
|
||||||
queryFn: async () => {
|
onSuccess: () => {
|
||||||
return await socialInsuranceApi.config(city)
|
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>({
|
const deleteAccountMutation = useMutation({
|
||||||
queryKey: ['housing-config', city],
|
mutationFn: (id: string) => socialAccountApi.remove(id),
|
||||||
queryFn: async () => {
|
onSuccess: () => {
|
||||||
return await socialInsuranceApi.housingConfig(city)
|
queryClient.invalidateQueries({ queryKey: ['social-accounts'] })
|
||||||
|
toast.success('已删除')
|
||||||
},
|
},
|
||||||
})
|
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '删除失败'),
|
||||||
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',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 已办理月份列表(进入月度办理Tab时自动加载)
|
// 已办理月份列表(进入月度办理Tab时自动加载)
|
||||||
@@ -169,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) => {
|
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||||
if (!data?.items?.length) return
|
if (!data?.items?.length) return
|
||||||
const headers = type === 'social'
|
const headers = type === 'social'
|
||||||
@@ -339,17 +161,12 @@ export default function SocialInsurance() {
|
|||||||
URL.revokeObjectURL(url)
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
社保公积金管理,维护社保、公积金缴费基数和比例,按月办理增减员。流程:①设置缴费基数和比例 → ②按月获取增减员名单 → ③完成办理。关联:社保基数影响薪税管理的工资计算;离职员工的社保截止请在离职管理中确认。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calculator className="h-5 w-5 text-primary" />
|
<Calculator className="h-5 w-5 text-primary" />
|
||||||
@@ -358,22 +175,9 @@ export default function SocialInsurance() {
|
|||||||
<p className="mt-1 text-sm text-gray-500">维护社保、公积金、商业保险缴费基数、版本及月度记录</p>
|
<p className="mt-1 text-sm text-gray-500">维护社保、公积金、商业保险缴费基数、版本及月度记录</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Tab 切换 + 城市选择 */}
|
{/* Tab 切换 */}
|
||||||
<div className="flex items-center gap-4 border-b">
|
<div className="flex items-center gap-4 border-b">
|
||||||
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
{(['monthly', 'social', 'housing', 'enrollment', 'deduction'] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
@@ -381,553 +185,76 @@ export default function SocialInsurance() {
|
|||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
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'
|
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 === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'enrollment' ? '员工参保' : '专项附加扣除'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* ========== 社保 / 公积金 Tab ========== */}
|
{/* ========== 社保 / 公积金 Tab:账户卡片列表 ========== */}
|
||||||
{(tab === 'social' || tab === 'housing') && (
|
{(tab === 'social' || tab === 'housing') && (
|
||||||
(isHousing ? housingLoading : configLoading) ? (
|
<div className="space-y-3">
|
||||||
<Card><div className="text-center py-8 text-gray-500">加载中...</div></Card>
|
{/* 新建对应类型账户按钮 */}
|
||||||
) : activeConfig ? (
|
<div className="flex justify-end">
|
||||||
<Card>
|
<Button size="sm" onClick={() => { setEditAccount(null); setShowAccountForm(true) }}>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<Plus className="w-4 h-4 mr-1" />新建{tab === 'housing' ? '公积金' : '社保'}账户
|
||||||
<div className="flex items-center gap-2">
|
</Button>
|
||||||
<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>
|
</div>
|
||||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-center gap-2 mb-3">
|
{accounts.length === 0 ? (
|
||||||
<Info className="w-4 h-4 shrink-0" />
|
<Card>
|
||||||
<span>批量调整用于每年7月统一调基。如需单独调整某员工基数,请前往「花名册」→ 点击员工 → 编辑 → 修改「社保缴费基数」/「公积金缴费基数」。</span>
|
<div className="text-center py-8 text-gray-500">
|
||||||
</div>
|
暂无{tab === 'housing' ? '公积金' : '社保'}账户,点击上方「新建{tab === 'housing' ? '公积金' : '社保'}账户」创建
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid md:grid-cols-4 gap-3 text-sm">
|
accounts.map((a: any) => (
|
||||||
<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>
|
<AccountCard
|
||||||
<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>
|
key={a.id}
|
||||||
{activeConfig.medicalBaseMin > 0 && (
|
account={a}
|
||||||
<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>
|
isHousing={tab === 'housing'}
|
||||||
)}
|
onEdit={(acc) => { setEditAccount(acc); setShowAccountForm(true) }}
|
||||||
{activeConfig.medicalBaseMax > 0 && (
|
onDelete={(acc) => deleteAccountMutation.mutate(acc.id)}
|
||||||
<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>
|
{showAccountForm && (
|
||||||
<div className="flex justify-between border-b pb-1.5"><span className="text-gray-500">生育(企业)</span><span className="font-medium">{activeConfig.maternityOrg}%</span></div>
|
<AccountFormModal
|
||||||
{Array.isArray(activeConfig.extraInsurances) && activeConfig.extraInsurances.map((ins: any, idx: number) => (
|
type={accountType}
|
||||||
<div key={idx} className="flex justify-between border-b pb-1.5">
|
account={editAccount}
|
||||||
<span className="text-gray-500">{ins.name}(企业/个人)</span>
|
onClose={() => { setShowAccountForm(false); setEditAccount(null) }}
|
||||||
<span className="font-medium">
|
onSubmit={async (data, departmentIds) => {
|
||||||
{ins.baseType === 'fixed'
|
if (editAccount) {
|
||||||
? `¥${ins.fixedAmount} / ¥${ins.empFixedAmount || 0}`
|
updateAccountMutation.mutate({ id: editAccount.id, data })
|
||||||
: `${ins.orgRate}% / ${ins.empRate}%`
|
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">
|
saving={createAccountMutation.isPending || updateAccountMutation.isPending}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 基数说明(仅社保/公积金Tab显示) */}
|
|
||||||
{(tab === 'social' || tab === 'housing') && (
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
|
||||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ========== 月度办理 Tab ========== */}
|
{/* ========== 月度办理 Tab ========== */}
|
||||||
{tab === 'monthly' && (
|
{tab === 'monthly' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
月度办理用于按月获取社保增减员名单并完成办理。流程:①选择月份点击「获取」→ ②系统自动汇总新增、减员、正常缴费人员 → ③确认无误后点击「完成办理」保存记录。办理完成后自动更新员工社保状态。关联:社保基数影响「薪税管理」的工资计算;离职员工的社保截止请在「离职管理」中确认。
|
|
||||||
</PageGuide>
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="text-sm font-medium">月度办理</h2>
|
<h2 className="text-sm font-medium">月度办理</h2>
|
||||||
|
|||||||
@@ -450,7 +450,12 @@ export default function SpecialStatus() {
|
|||||||
{/* 员工选择 */}
|
{/* 员工选择 */}
|
||||||
<div>
|
<div>
|
||||||
<Label>员工</Label>
|
<Label>员工</Label>
|
||||||
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })} disabled={!!editing}>
|
<Select value={form.employeeId} onChange={(e) => {
|
||||||
|
const emp = employees.find(em => em.id === e.target.value)
|
||||||
|
// 选择员工后,若当前类型为三期但员工为男性,自动切换为其他类型
|
||||||
|
const newType = emp?.gender === '男' && form.type === 'PREGNANCY' ? 'WORK_INJURY' : form.type
|
||||||
|
setForm({ ...form, employeeId: e.target.value, type: newType })
|
||||||
|
}} disabled={!!editing}>
|
||||||
<option value="">请选择员工</option>
|
<option value="">请选择员工</option>
|
||||||
{employees.map((emp) => (
|
{employees.map((emp) => (
|
||||||
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||||
@@ -463,11 +468,23 @@ export default function SpecialStatus() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>类型</Label>
|
<Label>类型</Label>
|
||||||
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
<Select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
|
||||||
<option value="PREGNANCY">三期</option>
|
{/* 三期仅适用于女性员工,男职工不显示该选项 */}
|
||||||
|
{(() => {
|
||||||
|
const selectedEmp = employees.find(em => em.id === form.employeeId)
|
||||||
|
const isMale = selectedEmp?.gender === '男'
|
||||||
|
return isMale ? null : <option value="PREGNANCY">三期</option>
|
||||||
|
})()}
|
||||||
<option value="WORK_INJURY">工伤</option>
|
<option value="WORK_INJURY">工伤</option>
|
||||||
<option value="MEDICAL_PERIOD">医疗期</option>
|
<option value="MEDICAL_PERIOD">医疗期</option>
|
||||||
<option value="OTHER">其他</option>
|
<option value="OTHER">其他</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
{(() => {
|
||||||
|
const selectedEmp = employees.find(em => em.id === form.employeeId)
|
||||||
|
if (selectedEmp?.gender === '男') {
|
||||||
|
return <p className="text-xs text-gray-400 mt-1">三期仅适用于女性员工</p>
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>状态</Label>
|
<Label>状态</Label>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal'
|
|||||||
import EmptyState from '../components/ui/EmptyState'
|
import EmptyState from '../components/ui/EmptyState'
|
||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
CONTRACT: '合同',
|
CONTRACT: '合同',
|
||||||
@@ -25,7 +26,7 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
const VARIABLE_LABELS: Record<string, string> = {
|
const VARIABLE_LABELS: Record<string, string> = {
|
||||||
companyName: '公司名称',
|
companyName: '公司名称',
|
||||||
employeeName: '员工姓名',
|
employeeName: '员工姓名',
|
||||||
idCard: '身份证号',
|
idCard: '证件号码',
|
||||||
address: '地址',
|
address: '地址',
|
||||||
startDate: '开始日期',
|
startDate: '开始日期',
|
||||||
position: '岗位',
|
position: '岗位',
|
||||||
@@ -78,6 +79,7 @@ export default function Templates() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>模板管理,维护各类法律文书模板,包括劳动合同、离职证明、收入证明等。支持新增、编辑、预览模板,模板变量自动填充员工信息。关联:开具证明和签订合同时使用此处模板。</PageGuide>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5 text-primary" />
|
<FileText className="h-5 w-5 text-primary" />
|
||||||
<h1 className="text-base font-semibold">用工文本模板库</h1>
|
<h1 className="text-base font-semibold">用工文本模板库</h1>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
import { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { usePageSize } from '../hooks/usePageSize'
|
import { usePageSize } from '../hooks/usePageSize'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -7,7 +8,7 @@ import { Stepper } from '../components/ui/Stepper'
|
|||||||
import { InlineAlert } from '../components/ui/InlineAlert'
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
import { rosterApi, terminationApi, esignApi } from '../lib/api-services'
|
import { rosterApi, terminationApi, esignApi, settingsApi } from '../lib/api-services'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
import Button from '../components/ui/Button'
|
import Button from '../components/ui/Button'
|
||||||
@@ -162,12 +163,34 @@ interface EmployeeProfile {
|
|||||||
export default function Termination() {
|
export default function Termination() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
|
const [view, setView] = useState<'list' | 'wizard' | 'detail'>('list')
|
||||||
const [draftId, setDraftId] = useState<string | null>(null)
|
const [draftId, setDraftId] = useState<string | null>(null)
|
||||||
const [step, setStep] = useState(0)
|
const [step, setStep] = useState(0)
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [employeeId, setEmployeeId] = useState('')
|
const [employeeId, setEmployeeId] = useState('')
|
||||||
const [terminationDate, setTerminationDate] = useState('')
|
const [terminationDate, setTerminationDate] = useState('')
|
||||||
|
|
||||||
|
// 获取组织所在城市,用于地区差异化合规提示(如北京通知工会程序)
|
||||||
|
const { data: orgData } = useQuery<any>({
|
||||||
|
queryKey: ['org-settings'],
|
||||||
|
queryFn: () => settingsApi.org(),
|
||||||
|
staleTime: 300000,
|
||||||
|
})
|
||||||
|
const isBeijing = !orgData?.city || orgData?.city === '北京' || orgData?.city === '北京市' || (orgData?.city as string)?.includes('北京')
|
||||||
|
|
||||||
|
// 从 URL 参数预填员工和解聘类型(从花名册操作栏跳转)
|
||||||
|
useEffect(() => {
|
||||||
|
const urlEmployeeId = searchParams.get('employeeId')
|
||||||
|
const urlReason = searchParams.get('reason')
|
||||||
|
if (urlEmployeeId) {
|
||||||
|
setEmployeeId(urlEmployeeId)
|
||||||
|
setView('wizard')
|
||||||
|
}
|
||||||
|
if (urlReason) {
|
||||||
|
setReason(urlReason)
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
|
const [socialInsEndMonth, setSocialInsEndMonth] = useState('')
|
||||||
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
|
const [housingFundEndMonth, setHousingFundEndMonth] = useState('')
|
||||||
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
const [checklist, setChecklist] = useState<Record<string, boolean>>({})
|
||||||
@@ -177,10 +200,32 @@ export default function Termination() {
|
|||||||
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
|
const [compAdjustments, setCompAdjustments] = useState<Array<{ field: string; from: number; to: number; reason: string }>>([])
|
||||||
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
|
const [handoverItems, setHandoverItems] = useState(DEFAULT_HANDOVER_ITEMS)
|
||||||
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
|
const [checklistOverrides, setChecklistOverrides] = useState<Record<string, { checked: boolean; overrideReason: string }>>({})
|
||||||
|
/** 工会回执上传(北京地区单方解除证据链) */
|
||||||
|
const [unionReceipt, setUnionReceipt] = useState<{
|
||||||
|
receiptNo: string
|
||||||
|
unionName: string
|
||||||
|
receiptDate: string
|
||||||
|
fileUrl: string
|
||||||
|
fileName: string
|
||||||
|
unionOpinion: string
|
||||||
|
} | null>(null)
|
||||||
|
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||||
const [editingCompField, setEditingCompField] = useState<string | null>(null)
|
const [editingCompField, setEditingCompField] = useState<string | null>(null)
|
||||||
const [editCompValue, setEditCompValue] = useState<number>(0)
|
const [editCompValue, setEditCompValue] = useState<number>(0)
|
||||||
const [editCompReason, setEditCompReason] = useState('')
|
const [editCompReason, setEditCompReason] = useState('')
|
||||||
const [approvalComment, setApprovalComment] = useState('')
|
const [approvalComment, setApprovalComment] = useState('')
|
||||||
|
/** 违法解除且实际补偿金低于法定2N时需确认风险 */
|
||||||
|
const [illegalRiskAcknowledged, setIllegalRiskAcknowledged] = useState(false)
|
||||||
|
/** 补偿月数模式:N/N+1/2N/自定义 */
|
||||||
|
const [compMonthMode, setCompMonthMode] = useState<'N' | 'N+1' | '2N' | 'CUSTOM'>('N')
|
||||||
|
const [customCompMonths, setCustomCompMonths] = useState<number>(0)
|
||||||
|
const [customCompReason, setCustomCompReason] = useState('')
|
||||||
|
/** 计算基数来源:近12月平均/合同工资/自定义 */
|
||||||
|
const [wageBaseType, setWageBaseType] = useState<'AVG_12' | 'CONTRACT' | 'CUSTOM'>('AVG_12')
|
||||||
|
const [customWageBase, setCustomWageBase] = useState<number>(0)
|
||||||
|
const [customWageReason, setCustomWageReason] = useState('')
|
||||||
|
/** 剩余年假天数及折算金额 */
|
||||||
|
const [annualLeaveDays, setAnnualLeaveDays] = useState<number>(0)
|
||||||
const [savedItems, setSavedItems] = useState<Array<{
|
const [savedItems, setSavedItems] = useState<Array<{
|
||||||
id: string
|
id: string
|
||||||
employeeId: string
|
employeeId: string
|
||||||
@@ -479,6 +524,28 @@ export default function Termination() {
|
|||||||
capped = true
|
capped = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支持自定义计算基数(近12月平均/合同工资/自定义)
|
||||||
|
let effectiveWageBase = cappedWage
|
||||||
|
if (wageBaseType === 'CONTRACT') {
|
||||||
|
effectiveWageBase = selectedEmployee.latestContract?.monthlySalary || wage
|
||||||
|
} else if (wageBaseType === 'CUSTOM' && customWageBase > 0) {
|
||||||
|
effectiveWageBase = customWageBase
|
||||||
|
// 自定义基数时提示三倍社平封顶但不强制
|
||||||
|
if (socialAvgWage > 0 && customWageBase > socialAvgWage * 3) {
|
||||||
|
capped = true // 仅标记提示
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持自定义补偿月数(N/N+1/2N/其他)
|
||||||
|
let effectiveMonths = cappedMonths
|
||||||
|
if (compMonthMode === 'N+1') {
|
||||||
|
effectiveMonths = compMonths + 1
|
||||||
|
} else if (compMonthMode === '2N') {
|
||||||
|
effectiveMonths = compMonths * 2
|
||||||
|
} else if (compMonthMode === 'CUSTOM' && customCompMonths > 0) {
|
||||||
|
effectiveMonths = customCompMonths
|
||||||
|
}
|
||||||
|
|
||||||
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
|
const reasonMap: Record<string, { multiplier: number; notice: boolean }> = {
|
||||||
NEGOTIATED: { multiplier: 1, notice: false },
|
NEGOTIATED: { multiplier: 1, notice: false },
|
||||||
FAULT: { multiplier: 0, notice: false },
|
FAULT: { multiplier: 0, notice: false },
|
||||||
@@ -488,9 +555,9 @@ export default function Termination() {
|
|||||||
ILLEGAL: { multiplier: 2, notice: false },
|
ILLEGAL: { multiplier: 2, notice: false },
|
||||||
}
|
}
|
||||||
const r = reasonMap[reason] || { multiplier: 1, notice: false }
|
const r = reasonMap[reason] || { multiplier: 1, notice: false }
|
||||||
const basePay = cappedWage * cappedMonths
|
const basePay = effectiveWageBase * effectiveMonths
|
||||||
const severancePay = basePay * r.multiplier
|
const severancePay = basePay * r.multiplier
|
||||||
const noticePay = r.notice ? cappedWage : 0
|
const noticePay = r.notice ? effectiveWageBase : 0
|
||||||
const totalSeverance = severancePay + noticePay
|
const totalSeverance = severancePay + noticePay
|
||||||
|
|
||||||
// 双倍工资计算(未签合同)
|
// 双倍工资计算(未签合同)
|
||||||
@@ -525,35 +592,45 @@ export default function Termination() {
|
|||||||
isIllegal: r.multiplier === 2,
|
isIllegal: r.multiplier === 2,
|
||||||
grandTotal: totalSeverance + doublePay,
|
grandTotal: totalSeverance + doublePay,
|
||||||
}
|
}
|
||||||
}, [selectedEmployee, terminationDate, socialAvgWage, reason])
|
}, [selectedEmployee, terminationDate, socialAvgWage, reason, compMonthMode, customCompMonths, wageBaseType, customWageBase])
|
||||||
|
|
||||||
// 计算调整后的实际补偿金(系统预估 + 手动调整差额)
|
// 年假折算金额:日工资 × 剩余年假天数 × 3(未休年假按300%支付)
|
||||||
|
const annualLeavePay = useMemo(() => {
|
||||||
|
if (!costResult || annualLeaveDays <= 0) return 0
|
||||||
|
const dailyWage = (costResult.cappedWage || 0) / 21.75
|
||||||
|
return Math.round(dailyWage * annualLeaveDays * 3 * 100) / 100
|
||||||
|
}, [costResult, annualLeaveDays])
|
||||||
|
|
||||||
|
// 计算调整后的实际补偿金(系统预估 + 手动调整差额 + 年假折算)
|
||||||
const adjustedTotal = useMemo(() => {
|
const adjustedTotal = useMemo(() => {
|
||||||
if (!costResult) return 0
|
if (!costResult) return 0
|
||||||
let total = costResult.grandTotal
|
let total = costResult.grandTotal
|
||||||
compAdjustments.forEach(adj => {
|
compAdjustments.forEach(adj => {
|
||||||
total += (adj.to - adj.from)
|
total += (adj.to - adj.from)
|
||||||
})
|
})
|
||||||
|
total += annualLeavePay
|
||||||
return total
|
return total
|
||||||
}, [costResult, compAdjustments])
|
}, [costResult, compAdjustments, annualLeavePay])
|
||||||
|
|
||||||
const canProceed = () => {
|
const canProceed = () => {
|
||||||
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
|
if (step === 0) return !!employeeId && (!!draftId || !selectedEmployee?.hasTermination || selectedEmployee?.latestTerminationStatus === 'CANCELLED' || selectedEmployee?.latestTerminationStatus === 'COMPLETED')
|
||||||
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
if (step === 1) return !!reason && !!terminationDate && (!riskAssessment?.warnings.length || acknowledgeRisk)
|
||||||
// step 2: 合规检查 — required 项必须勾选
|
// step 2: 合规检查 — 改为软阻断,未勾选 required 项可下一步但会生成待办
|
||||||
if (step === 2) {
|
if (step === 2) {
|
||||||
if (!checklistItems) return true
|
// 法定禁止情形(孕期/工伤/医疗期)仍硬阻断
|
||||||
const requiredItems = checklistItems.filter(item => item.suggestionType === 'required')
|
if (riskAssessment?.warnings.length && !acknowledgeRisk) return false
|
||||||
if (requiredItems.length === 0) return true
|
return true
|
||||||
return requiredItems.every(item => checklist[item.key] === true)
|
|
||||||
}
|
}
|
||||||
// step 3: 费用结算 — 有补偿金时需确认
|
// step 3: 费用结算 — 违法解除且实际补偿金低于法定2N时需确认风险
|
||||||
if (step === 3) return true
|
if (step === 3) {
|
||||||
// step 4: 工作交接 — 关键项需完成
|
if (reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal) {
|
||||||
|
return illegalRiskAcknowledged
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// step 4: 工作交接 — 改为软阻断,未完成可下一步但会生成待办
|
||||||
if (step === 4) {
|
if (step === 4) {
|
||||||
const keyItems = handoverItems.filter(item => item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke')
|
return true
|
||||||
if (keyItems.length === 0) return true
|
|
||||||
return keyItems.every(item => item.done)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -568,6 +645,13 @@ export default function Termination() {
|
|||||||
total: adjustedTotal,
|
total: adjustedTotal,
|
||||||
systemTotal: costResult.grandTotal,
|
systemTotal: costResult.grandTotal,
|
||||||
adjustments: compAdjustments,
|
adjustments: compAdjustments,
|
||||||
|
riskAcknowledged: reason === 'ILLEGAL' && adjustedTotal < costResult.grandTotal ? {
|
||||||
|
acknowledged: illegalRiskAcknowledged,
|
||||||
|
legalAmount: costResult.grandTotal,
|
||||||
|
actualAmount: adjustedTotal,
|
||||||
|
difference: costResult.grandTotal - adjustedTotal,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
} : null,
|
||||||
} : null
|
} : null
|
||||||
|
|
||||||
saveDraftMutation.mutate({
|
saveDraftMutation.mutate({
|
||||||
@@ -583,18 +667,53 @@ export default function Termination() {
|
|||||||
compensationBreakdown: breakdown,
|
compensationBreakdown: breakdown,
|
||||||
handoverItems,
|
handoverItems,
|
||||||
checklistOverrides,
|
checklistOverrides,
|
||||||
|
compMonthMode,
|
||||||
|
customCompMonths: compMonthMode === 'CUSTOM' ? customCompMonths : undefined,
|
||||||
|
customCompReason: compMonthMode === 'CUSTOM' ? customCompReason : undefined,
|
||||||
|
wageBaseType,
|
||||||
|
customWageBase: wageBaseType === 'CUSTOM' ? customWageBase : undefined,
|
||||||
|
customWageReason: wageBaseType === 'CUSTOM' ? customWageReason : undefined,
|
||||||
|
annualLeaveDays: annualLeaveDays > 0 ? annualLeaveDays : undefined,
|
||||||
|
annualLeavePay: annualLeavePay > 0 ? annualLeavePay : undefined,
|
||||||
|
socialAvgWage: socialAvgWage > 0 ? socialAvgWage : undefined,
|
||||||
remark: '',
|
remark: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑已有草稿 */
|
/** 编辑已有草稿(拉取完整详情后恢复所有字段) */
|
||||||
const handleEditDraft = (item: any) => {
|
const handleEditDraft = async (item: any) => {
|
||||||
setDraftId(item.id)
|
setDraftId(item.id)
|
||||||
setEmployeeId(item.employeeId)
|
setEmployeeId(item.employeeId)
|
||||||
setReason(item.reason)
|
setReason(item.reason)
|
||||||
setTerminationDate(item.terminationDate)
|
setTerminationDate(item.terminationDate)
|
||||||
setStep(item.currentStep || 0)
|
setStep(item.currentStep || 0)
|
||||||
setView('wizard')
|
setView('wizard')
|
||||||
|
// 恢复所有已保存的字段
|
||||||
|
if (item.socialInsEndMonth) setSocialInsEndMonth(item.socialInsEndMonth)
|
||||||
|
if (item.housingFundEndMonth) setHousingFundEndMonth(item.housingFundEndMonth)
|
||||||
|
if (item.checklist) setChecklist(item.checklist)
|
||||||
|
if (item.compensationBreakdown) {
|
||||||
|
setCompBreakdown(item.compensationBreakdown)
|
||||||
|
if (item.compensationBreakdown.adjustments) {
|
||||||
|
setCompAdjustments(item.compensationBreakdown.adjustments)
|
||||||
|
}
|
||||||
|
// 恢复违法解除风险确认状态
|
||||||
|
if (item.compensationBreakdown.riskAcknowledged?.acknowledged) {
|
||||||
|
setIllegalRiskAcknowledged(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.handoverItems) setHandoverItems(item.handoverItems)
|
||||||
|
if (item.checklistOverrides) setChecklistOverrides(item.checklistOverrides)
|
||||||
|
// 恢复补偿月数和计算基数设置
|
||||||
|
if (item.compMonthMode) setCompMonthMode(item.compMonthMode)
|
||||||
|
if (item.customCompMonths) setCustomCompMonths(item.customCompMonths)
|
||||||
|
if (item.customCompReason) setCustomCompReason(item.customCompReason)
|
||||||
|
if (item.wageBaseType) setWageBaseType(item.wageBaseType)
|
||||||
|
if (item.customWageBase) setCustomWageBase(item.customWageBase)
|
||||||
|
if (item.customWageReason) setCustomWageReason(item.customWageReason)
|
||||||
|
if (item.annualLeaveDays) setAnnualLeaveDays(item.annualLeaveDays)
|
||||||
|
// 恢复社平工资
|
||||||
|
if (item.socialAvgWage) setSocialAvgWage(item.socialAvgWage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查看详情 */
|
/** 查看详情 */
|
||||||
@@ -675,6 +794,14 @@ export default function Termination() {
|
|||||||
setChecklistOverrides({})
|
setChecklistOverrides({})
|
||||||
setEditingCompField(null)
|
setEditingCompField(null)
|
||||||
setApprovalComment('')
|
setApprovalComment('')
|
||||||
|
setIllegalRiskAcknowledged(false)
|
||||||
|
setCompMonthMode('N')
|
||||||
|
setCustomCompMonths(0)
|
||||||
|
setCustomCompReason('')
|
||||||
|
setWageBaseType('AVG_12')
|
||||||
|
setCustomWageBase(0)
|
||||||
|
setCustomWageReason('')
|
||||||
|
setAnnualLeaveDays(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = resetWizard
|
const handleReset = resetWizard
|
||||||
@@ -686,6 +813,9 @@ export default function Termination() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<PageGuide>
|
||||||
|
解聘补偿页面用于规范处理离职、解聘审批及补偿核算。流程:①新建解聘草稿,选择员工和解聘原因 → ②系统自动计算经济补偿金 → ③合规检查(合同状态、医疗期、孕期等) → ④确认补偿方案 → ⑤生成解聘协议等法律文书。支持协商解除、过错解除、非过错解除、经济性裁员等场景。关联:完成后可下载离职证明;社保截止请前往「社保公积金」处理;档案状态更新请前往「花名册」确认。
|
||||||
|
</PageGuide>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Shield className="h-5 w-5 text-primary" />
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
@@ -694,11 +824,6 @@ export default function Termination() {
|
|||||||
<p className="mt-1 text-sm text-gray-500">规范处理离职、解聘审批及补偿核算流程</p>
|
<p className="mt-1 text-sm text-gray-500">规范处理离职、解聘审批及补偿核算流程</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{view === 'list' && (
|
|
||||||
<Button size="sm" onClick={handleNewTermination}>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />新建解聘
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{view !== 'list' && (
|
{view !== 'list' && (
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
|
<Button variant="secondary" size="sm" onClick={() => { setView('list'); resetWizard() }}>
|
||||||
<ChevronLeft className="w-4 h-4 mr-1" />返回列表
|
<ChevronLeft className="w-4 h-4 mr-1" />返回列表
|
||||||
@@ -709,9 +834,6 @@ export default function Termination() {
|
|||||||
{/* 草稿列表视图 */}
|
{/* 草稿列表视图 */}
|
||||||
{view === 'list' && (
|
{view === 'list' && (
|
||||||
<>
|
<>
|
||||||
<PageGuide>
|
|
||||||
解聘补偿页面用于规范处理离职、解聘审批及补偿核算。流程:①新建解聘草稿,选择员工和解聘原因 → ②系统自动计算经济补偿金 → ③合规检查(合同状态、医疗期、孕期等) → ④确认补偿方案 → ⑤生成解聘协议等法律文书。支持协商解除、过错解除、非过错解除、经济性裁员等场景。关联:完成后可下载离职证明;社保截止请前往「社保公积金」处理;档案状态更新请前往「花名册」确认。
|
|
||||||
</PageGuide>
|
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索员工姓名或部门"
|
placeholder="搜索员工姓名或部门"
|
||||||
@@ -787,7 +909,7 @@ export default function Termination() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<List className="w-8 h-8 text-gray-400" />}
|
icon={<List className="w-8 h-8 text-gray-400" />}
|
||||||
title="暂无解聘记录"
|
title="暂无解聘记录"
|
||||||
description="点击「新建解聘」开始创建"
|
description="请在花名册中选择员工发起解聘"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -1079,7 +1201,7 @@ body { font-family: SimSun, serif; font-size: 14pt; line-height: 2; text-align:
|
|||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<body>
|
||||||
<div class="title">解除/终止劳动合同证明书</div>
|
<div class="title">解除/终止劳动合同证明书</div>
|
||||||
<div class="body">兹证明 ${draftDetail.employeeName}(身份证号:${draftDetail.idCardNumber || '—'}),原系我公司 ${draftDetail.department || '—'} 部门员工,于 ${draftDetail.terminationDate} 因 ${reasonLabel} 原因,正式解除/终止劳动合同。</div>
|
<div class="body">兹证明 ${draftDetail.employeeName}(证件号码:${draftDetail.idCardNumber || '—'}),原系我公司 ${draftDetail.department || '—'} 部门员工,于 ${draftDetail.terminationDate} 因 ${reasonLabel} 原因,正式解除/终止劳动合同。</div>
|
||||||
<div class="body">经济补偿金已结清:¥${fmt(draftDetail.compensation)}。社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}。</div>
|
<div class="body">经济补偿金已结清:¥${fmt(draftDetail.compensation)}。社保截止月份:${draftDetail.socialInsEndMonth || '—'},公积金截止月份:${draftDetail.housingFundEndMonth || '—'}。</div>
|
||||||
<div class="body">特此证明。</div>
|
<div class="body">特此证明。</div>
|
||||||
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>
|
<div class="sign">公司(盖章)<br/>${new Date().toISOString().slice(0, 10)}</div>
|
||||||
@@ -1268,6 +1390,17 @@ ${items}
|
|||||||
{r.steps.map((s, i) => (
|
{r.steps.map((s, i) => (
|
||||||
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
|
<li key={i} className={`text-xs leading-relaxed ${s.includes('⚠') ? 'text-amber-600 font-medium' : 'text-gray-600'}`}>{s}</li>
|
||||||
))}
|
))}
|
||||||
|
{/* 北京地区单方解除须通知工会 */}
|
||||||
|
{isBeijing && ['FAULT', 'NONFAULT', 'LAYOFF'].includes(r.value) && (
|
||||||
|
<>
|
||||||
|
<li className="text-xs leading-relaxed text-amber-600 font-medium">
|
||||||
|
⚠ 北京地区:单方解除须提前5个工作日书面通知工会,未建立工会的通知上一级工会(经营地乡镇/街道/园区/开发区总工会)。可在「文本模板库」使用《拟解除劳动合同通知工会函》模板
|
||||||
|
</li>
|
||||||
|
<li className="text-xs leading-relaxed text-amber-600 font-medium">
|
||||||
|
⚠ 收到工会书面回执后,请上传回执扫描件作为证据链留存
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1275,7 +1408,26 @@ ${items}
|
|||||||
})()}
|
})()}
|
||||||
<div>
|
<div>
|
||||||
<Label>解聘日期</Label>
|
<Label>解聘日期</Label>
|
||||||
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
|
<Input type="date" value={terminationDate} onChange={(e) => {
|
||||||
|
const date = e.target.value
|
||||||
|
setTerminationDate(date)
|
||||||
|
// 自动推导社保/公积金截止月:15号前(含)截止上月,15号后截止当月
|
||||||
|
if (date) {
|
||||||
|
const d = new Date(date)
|
||||||
|
const day = d.getDate()
|
||||||
|
const monthStr = date.slice(0, 7)
|
||||||
|
if (day <= 15) {
|
||||||
|
// 上月
|
||||||
|
const prev = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
||||||
|
const prevMonth = `${prev.getFullYear()}-${String(prev.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
setSocialInsEndMonth(prevMonth)
|
||||||
|
setHousingFundEndMonth(prevMonth)
|
||||||
|
} else {
|
||||||
|
setSocialInsEndMonth(monthStr)
|
||||||
|
setHousingFundEndMonth(monthStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
|
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
|
||||||
{step === 1 && costResult && (
|
{step === 1 && costResult && (
|
||||||
@@ -1406,6 +1558,101 @@ ${items}
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 工会回执上传区域(北京地区单方解除,checklist 包含 union_receipt 项时显示) */}
|
||||||
|
{checklistItems?.some(item => item.key === 'union_receipt') && checklist['notify_union'] && (
|
||||||
|
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
|
||||||
|
<div className="text-xs font-medium text-gray-700 flex items-center gap-1.5">
|
||||||
|
<Shield className="w-3.5 h-3.5 text-primary" />
|
||||||
|
工会回执上传(证据链留存)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
收到工会书面回执后,请上传回执扫描件。回执信息将自动保存到解聘草稿并追加到证据链,作为劳动仲裁举证材料。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unionReceipt && unionReceipt.fileUrl ? (
|
||||||
|
/* 已上传回执展示 */
|
||||||
|
<div className="space-y-2 bg-white rounded-md p-2.5 border">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<CheckCircle className="w-4 h-4 text-safe" />
|
||||||
|
<span className="font-medium">回执已上传</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600">
|
||||||
|
<div>回执编号:{unionReceipt.receiptNo || '—'}</div>
|
||||||
|
<div>工会名称:{unionReceipt.unionName || '—'}</div>
|
||||||
|
<div>回执日期:{unionReceipt.receiptDate || '—'}</div>
|
||||||
|
<div>文件:<a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">{unionReceipt.fileName}</a></div>
|
||||||
|
</div>
|
||||||
|
{unionReceipt.unionOpinion && (
|
||||||
|
<div className="text-xs text-gray-600">工会意见:{unionReceipt.unionOpinion}</div>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setUnionReceipt(null)}>重新上传</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 上传表单 */
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>回执编号</Label>
|
||||||
|
<Input value={unionReceipt?.receiptNo || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptNo: e.target.value }))} placeholder="如:2026第001号" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>工会名称</Label>
|
||||||
|
<Input value={unionReceipt?.unionName || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionName: e.target.value }))} placeholder="如:XX街道总工会" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>回执日期</Label>
|
||||||
|
<Input type="date" value={unionReceipt?.receiptDate || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), receiptDate: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>工会意见(选填)</Label>
|
||||||
|
<Input value={unionReceipt?.unionOpinion || ''} onChange={(e) => setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), unionOpinion: e.target.value }))} placeholder="如有意见建议请填写" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>回执扫描件</Label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.bmp"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file || !draftId) return
|
||||||
|
setUploadingReceipt(true)
|
||||||
|
try {
|
||||||
|
const res = await terminationApi.uploadUnionReceipt(draftId, file)
|
||||||
|
setUnionReceipt(prev => ({ ...(prev || { receiptNo: '', unionName: '', receiptDate: '', fileUrl: '', fileName: '', unionOpinion: '' }), fileUrl: res.fileUrl, fileName: res.fileName }))
|
||||||
|
toast.success('文件上传成功')
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploadingReceipt(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="block w-full text-xs text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:bg-primary file:text-white hover:file:bg-primary/90"
|
||||||
|
/>
|
||||||
|
{uploadingReceipt && <div className="text-xs text-gray-400 mt-1">上传中...</div>}
|
||||||
|
</div>
|
||||||
|
{draftId && unionReceipt?.fileUrl && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await terminationApi.saveUnionReceipt(draftId, unionReceipt)
|
||||||
|
toast.success('工会回执已保存到证据链')
|
||||||
|
setChecklist({ ...checklist, union_receipt: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '保存失败')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>保存回执到证据链</Button>
|
||||||
|
)}
|
||||||
|
{!draftId && (
|
||||||
|
<div className="text-xs text-amber-600">请先保存草稿后再上传工会回执</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1428,6 +1675,57 @@ ${items}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 补偿月数和计算基数选择 */}
|
||||||
|
{!costResult.noComp && (
|
||||||
|
<div className="border rounded-md p-3 space-y-3 bg-blue-50/30">
|
||||||
|
<div className="text-xs font-medium text-gray-700">补偿金计算参数(支持多种来源)</div>
|
||||||
|
{/* 补偿月数模式 */}
|
||||||
|
<div>
|
||||||
|
<Label>补偿月数</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{(['N', 'N+1', '2N', 'CUSTOM'] as const).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCompMonthMode(mode)}
|
||||||
|
className={`px-3 py-1 rounded text-xs border ${compMonthMode === mode ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
|
||||||
|
>
|
||||||
|
{mode === 'N' ? 'N(法定)' : mode === 'N+1' ? 'N+1(含代通知)' : mode === '2N' ? '2N(违法解除)' : '自定义'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{compMonthMode === 'CUSTOM' && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<Input type="number" value={customCompMonths || ''} onChange={(e) => setCustomCompMonths(Number(e.target.value) || 0)} placeholder="自定义补偿月数" />
|
||||||
|
<Input value={customCompReason} onChange={(e) => setCustomCompReason(e.target.value)} placeholder="自定义原因(必填)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 计算基数来源 */}
|
||||||
|
<div>
|
||||||
|
<Label>计算基数来源</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{(['AVG_12', 'CONTRACT', 'CUSTOM'] as const).map(bt => (
|
||||||
|
<button
|
||||||
|
key={bt}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWageBaseType(bt)}
|
||||||
|
className={`px-3 py-1 rounded text-xs border ${wageBaseType === bt ? 'bg-primary text-white border-primary' : 'bg-white text-gray-600 border-gray-300 hover:border-primary'}`}
|
||||||
|
>
|
||||||
|
{bt === 'AVG_12' ? '近12月平均工资' : bt === 'CONTRACT' ? '合同约定工资' : '自定义基数'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{wageBaseType === 'CUSTOM' && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<Input type="number" value={customWageBase || ''} onChange={(e) => setCustomWageBase(Number(e.target.value) || 0)} placeholder="自定义月工资基数" />
|
||||||
|
<Input value={customWageReason} onChange={(e) => setCustomWageReason(e.target.value)} placeholder="自定义原因(必填)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 经济补偿金 / 赔偿金 */}
|
{/* 经济补偿金 / 赔偿金 */}
|
||||||
{costResult.noComp ? (
|
{costResult.noComp ? (
|
||||||
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
<div className="px-3 py-2 rounded-md bg-gray-50 text-gray-700 text-xs">
|
||||||
@@ -1496,8 +1794,55 @@ ${items}
|
|||||||
{/* 合计 */}
|
{/* 合计 */}
|
||||||
<div className="border-t pt-3">
|
<div className="border-t pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium">合计应付</span>
|
<span className="font-medium">系统预估合计</span>
|
||||||
<span className="text-lg font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
|
<span className="text-lg font-bold text-gray-700">¥{fmt(costResult.grandTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{compAdjustments.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="font-medium text-primary">调整后实际合计</span>
|
||||||
|
<span className="text-lg font-bold text-danger">¥{fmt(adjustedTotal)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 违法解除风险提醒:实际补偿金低于法定2N时需确认 */}
|
||||||
|
{reason === 'ILLEGAL' && costResult && adjustedTotal < costResult.grandTotal && (
|
||||||
|
<div className="border border-danger rounded-md p-3 space-y-2 bg-red-50">
|
||||||
|
<div className="text-xs font-medium text-danger flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4" />违法解除合规风险提醒
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-danger">
|
||||||
|
违法解除劳动合同应按经济补偿金的2倍支付赔偿金(《劳动合同法》第87条)。当前实际补偿金 ¥{fmt(adjustedTotal)} 低于法定标准 ¥{fmt(costResult.grandTotal)},差额 ¥{fmt(costResult.grandTotal - adjustedTotal)}。
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-danger">降低补偿金存在被劳动者申请劳动仲裁要求补足差额的风险,请确认已知晓风险后再继续。</p>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-danger cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={illegalRiskAcknowledged}
|
||||||
|
onChange={(e) => setIllegalRiskAcknowledged(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>我已知晓降低补偿金的风险,确认继续</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 剩余年假折算 */}
|
||||||
|
<div className="border rounded-md p-3 space-y-2 bg-green-50/30">
|
||||||
|
<div className="text-xs font-medium flex items-center gap-1.5">
|
||||||
|
<Calculator className="w-3.5 h-3.5" />剩余年假折算
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">未休年假按日工资的300%支付(《职工带薪年休假条例》第5条)</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label>剩余年假天数</Label>
|
||||||
|
<Input type="number" min="0" step="0.5" value={annualLeaveDays || ''} onChange={(e) => setAnnualLeaveDays(Number(e.target.value) || 0)} placeholder="0" />
|
||||||
|
</div>
|
||||||
|
{annualLeaveDays > 0 && costResult && (
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
<div>日工资:¥{fmt((costResult.cappedWage || 0) / 21.75)}/天</div>
|
||||||
|
<div className="font-medium text-danger">年假折算金额:¥{fmt(annualLeavePay)}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1666,6 +2011,13 @@ ${items}
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div>工作交接:{handoverItems.filter(i => i.done).length}/{handoverItems.length} 项完成</div>
|
<div>工作交接:{handoverItems.filter(i => i.done).length}/{handoverItems.length} 项完成</div>
|
||||||
|
{checklistItems?.some(item => item.key === 'notify_union') && (
|
||||||
|
<div className={checklist['notify_union'] ? 'text-safe' : 'text-danger'}>
|
||||||
|
工会通知:{checklist['notify_union'] ? '已通知' : '⚠ 未通知工会(北京地区单方解除必经程序)'}
|
||||||
|
{checklist['notify_union'] && checklist['union_receipt'] ? ' · 已收到回执' : checklist['notify_union'] ? ' · 未收到回执' : ''}
|
||||||
|
{unionReceipt?.fileUrl && <a href={unionReceipt.fileUrl} target="_blank" rel="noopener noreferrer" className="ml-1 text-primary hover:underline">查看回执</a>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 风险提示 */}
|
{/* 风险提示 */}
|
||||||
@@ -1884,11 +2236,11 @@ ${items}
|
|||||||
</Button>
|
</Button>
|
||||||
{step < 4 ? (
|
{step < 4 ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!canProceed() && step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
|
{step === 2 && checklistItems?.some(item => item.suggestionType === 'required' && !checklist[item.key]) && (
|
||||||
<span className="text-xs text-danger">请先勾选所有必检项</span>
|
<span className="text-xs text-amber-600">未勾选的必检项将生成待办项</span>
|
||||||
)}
|
)}
|
||||||
{!canProceed() && step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
|
{step === 4 && handoverItems.some(item => (item.key === 'work_handover' || item.key === 'equipment_return' || item.key === 'access_revoke') && !item.done) && (
|
||||||
<span className="text-xs text-danger">请先完成工作交接、设备归还、权限收回</span>
|
<span className="text-xs text-amber-600">未完成的交接项将生成待办项</span>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
<Button onClick={() => setStep(step + 1)} disabled={!canProceed()}>
|
||||||
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
下一步<ChevronRight className="w-4 h-4 ml-1" />
|
||||||
|
|||||||
@@ -1,896 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { usePageSize } from '../hooks/usePageSize'
|
|
||||||
import { useUnsavedChanges } from '../hooks/useUnsavedChanges'
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { toastError } from '../lib/errorToast'
|
|
||||||
import {
|
|
||||||
UserPlus, LogIn, FileSignature, Edit, CheckCircle, RefreshCw,
|
|
||||||
Repeat, Pause, FileText, Briefcase,
|
|
||||||
Loader2, ChevronRight, Trash2, Send, X, Eye, Search, Download, Users,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { workProcessApi, templatesApi, employeeApi } from '../lib/api-services'
|
|
||||||
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 Pagination from '../components/ui/Pagination'
|
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
|
||||||
import QueryError from '../components/ui/QueryError'
|
|
||||||
|
|
||||||
const PROCESS_ICONS: Record<string, any> = {
|
|
||||||
HIRE: UserPlus, ONBOARD: LogIn, CUSTOM_CONTRACT: FileSignature,
|
|
||||||
INFO_SUBMIT: Edit, CONFIRM: CheckCircle, CHANGE: RefreshCw,
|
|
||||||
RENEW: Repeat, SUSPEND: Pause, INCOME_CERT: FileText,
|
|
||||||
FLEXIBLE: Briefcase,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PROCESS_TYPES: Record<string, { label: string; description: string }> = {
|
|
||||||
HIRE: { label: '员工录用', description: '录用新员工并起草劳动合同' },
|
|
||||||
ONBOARD: { label: '员工入职', description: '办理员工入职手续' },
|
|
||||||
CUSTOM_CONTRACT: { label: '自定义合同签署', description: '自定义合同内容并签署' },
|
|
||||||
INFO_SUBMIT: { label: '员工信息提交', description: '提交员工信息变更' },
|
|
||||||
CONFIRM: { label: '员工转正', description: '试用期员工转正' },
|
|
||||||
CHANGE: { label: '合同变更', description: '变更合同内容' },
|
|
||||||
RENEW: { label: '合同续签', description: '到期合同续签' },
|
|
||||||
SUSPEND: { label: '合同中止', description: '中止履行合同' },
|
|
||||||
INCOME_CERT: { label: '开具收入证明', description: '为员工开具收入证明' },
|
|
||||||
FLEXIBLE: { label: '灵活用工', description: '灵活用工协议签署' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|
||||||
DRAFT: { label: '草稿', color: 'bg-gray-100 text-gray-600' },
|
|
||||||
PENDING_APPROVAL: { label: '待审批', color: 'bg-amber-50 text-amber-700' },
|
|
||||||
APPROVED: { label: '已审批', color: 'bg-blue-50 text-blue-700' },
|
|
||||||
REJECTED: { label: '已驳回', color: 'bg-red-50 text-red-700' },
|
|
||||||
EXECUTING: { label: '执行中', color: 'bg-purple-50 text-purple-700' },
|
|
||||||
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe' },
|
|
||||||
CANCELLED: { label: '已撤销', color: 'bg-gray-100 text-gray-400' },
|
|
||||||
}
|
|
||||||
|
|
||||||
// 各流程类型的表单字段配置(required 标记必填项)
|
|
||||||
const FORM_FIELDS: Record<string, { key: string; label: string; type: 'text' | 'date' | 'number' | 'select' | 'textarea' | 'enterprise-template' | 'employee-select' | 'contract-select'; options?: string[]; required?: boolean }[]> = {
|
|
||||||
HIRE: [
|
|
||||||
{ key: 'name', label: '员工姓名', type: 'text', required: true },
|
|
||||||
{ key: 'department', label: '部门', type: 'text', required: true },
|
|
||||||
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
|
|
||||||
{ key: 'monthlySalary', label: '月薪', type: 'number' },
|
|
||||||
{ key: 'phone', label: '手机号', type: 'text', required: true },
|
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
|
|
||||||
{ key: 'gender', label: '性别', type: 'select', options: ['男', '女'] },
|
|
||||||
{ key: 'contractStartDate', label: '合同开始日期', type: 'date' },
|
|
||||||
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
|
|
||||||
],
|
|
||||||
ONBOARD: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'hireDate', label: '入职日期', type: 'date', required: true },
|
|
||||||
],
|
|
||||||
CUSTOM_CONTRACT: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'contractStartDate', label: '合同开始日期', type: 'date', required: true },
|
|
||||||
{ key: 'contractEndDate', label: '合同结束日期', type: 'date' },
|
|
||||||
{ key: 'contractType', label: '合同类型', type: 'select', options: ['FIXED', 'UNFIXED', 'INTERNSHIP'] },
|
|
||||||
],
|
|
||||||
INFO_SUBMIT: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'department', label: '部门', type: 'text' },
|
|
||||||
{ key: 'phone', label: '手机号', type: 'text' },
|
|
||||||
{ key: 'address', label: '地址', type: 'text' },
|
|
||||||
{ key: 'emergencyContact', label: '紧急联系人', type: 'text' },
|
|
||||||
{ key: 'emergencyPhone', label: '紧急联系电话', type: 'text' },
|
|
||||||
],
|
|
||||||
CONFIRM: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'confirmDate', label: '转正日期', type: 'date', required: true },
|
|
||||||
{ key: 'regularSalary', label: '转正薪资', type: 'number' },
|
|
||||||
],
|
|
||||||
CHANGE: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
|
|
||||||
{ key: 'newEndDate', label: '新到期日期', type: 'date', required: true },
|
|
||||||
],
|
|
||||||
RENEW: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'oldContractId', label: '原合同ID', type: 'text' },
|
|
||||||
{ key: 'newStartDate', label: '新合同开始日期', type: 'date', required: true },
|
|
||||||
{ key: 'newEndDate', label: '新合同结束日期', type: 'date' },
|
|
||||||
{ key: 'newSalary', label: '新薪资', type: 'number' },
|
|
||||||
],
|
|
||||||
SUSPEND: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'contractId', label: '选择合同', type: 'contract-select', required: true },
|
|
||||||
{ key: 'suspendDate', label: '中止日期', type: 'date', required: true },
|
|
||||||
],
|
|
||||||
INCOME_CERT: [
|
|
||||||
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
|
||||||
{ key: 'position', label: '职务', type: 'text' },
|
|
||||||
{ key: 'monthlyIncome', label: '月收入', type: 'text' },
|
|
||||||
{ key: 'purpose', label: '用途', type: 'text' },
|
|
||||||
],
|
|
||||||
TERMINATE: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
|
|
||||||
{ key: 'terminateDate', label: '终止日期', type: 'date', required: true },
|
|
||||||
{ key: 'reason', label: '终止原因', type: 'select', options: ['EXPIRED', 'NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
|
|
||||||
{ key: 'compensation', label: '经济补偿金', type: 'number' },
|
|
||||||
],
|
|
||||||
RESCIND: [
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'contractId', label: '选择合同', type: 'contract-select' },
|
|
||||||
{ key: 'rescindDate', label: '解除日期', type: 'date', required: true },
|
|
||||||
{ key: 'reason', label: '解除原因', type: 'select', options: ['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF'], required: true },
|
|
||||||
{ key: 'compensation', label: '经济补偿金', type: 'number' },
|
|
||||||
],
|
|
||||||
LEAVING_CERT: [
|
|
||||||
{ key: 'enterpriseTemplateId', label: '关联企业模板(选填)', type: 'enterprise-template' },
|
|
||||||
{ key: 'employeeId', label: '选择员工', type: 'employee-select', required: true },
|
|
||||||
{ key: 'employeeName', label: '员工姓名', type: 'text' },
|
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text' },
|
|
||||||
{ key: 'position', label: '职务', type: 'text' },
|
|
||||||
{ key: 'hireDate', label: '入职日期', type: 'date' },
|
|
||||||
{ key: 'leaveDate', label: '离职日期', type: 'date', required: true },
|
|
||||||
],
|
|
||||||
FLEXIBLE: [
|
|
||||||
{ key: 'name', label: '姓名', type: 'text', required: true },
|
|
||||||
{ key: 'phone', label: '手机号', type: 'text' },
|
|
||||||
{ key: 'idCardNumber', label: '身份证号', type: 'text', required: true },
|
|
||||||
{ key: 'department', label: '部门', type: 'text' },
|
|
||||||
{ key: 'agreementStartDate', label: '协议开始日期', type: 'date', required: true },
|
|
||||||
{ key: 'agreementEndDate', label: '协议结束日期', type: 'date' },
|
|
||||||
{ key: 'payMethod', label: '计酬方式', type: 'text' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字段 key → 中文 label 映射(用于展示已保存的表单数据)
|
|
||||||
const FIELD_LABEL_MAP: Record<string, string> = Object.values(FORM_FIELDS).flat().reduce((acc, f) => {
|
|
||||||
acc[f.key] = f.label
|
|
||||||
return acc
|
|
||||||
}, {} as Record<string, string>)
|
|
||||||
|
|
||||||
export default function WorkProcess() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
|
||||||
const [selectedType, setSelectedType] = useState<string>(() => {
|
|
||||||
try { return localStorage.getItem('workprocess-draft-type') || '' } catch { return '' }
|
|
||||||
})
|
|
||||||
const [formData, setFormData] = useState<Record<string, any>>(() => {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('workprocess-draft-data')
|
|
||||||
return saved ? JSON.parse(saved) : {}
|
|
||||||
} catch { return {} }
|
|
||||||
})
|
|
||||||
const [filterType, setFilterType] = useState('')
|
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
|
||||||
const [detailId, setDetailId] = useState<string | null>(null)
|
|
||||||
const [previewContent, setPreviewContent] = useState<string | null>(null)
|
|
||||||
const [showBatch, setShowBatch] = useState(false)
|
|
||||||
const [batchType, setBatchType] = useState<string>('INCOME_CERT')
|
|
||||||
const [batchEmployees, setBatchEmployees] = useState<string[]>([])
|
|
||||||
const [batchSearch, setBatchSearch] = useState('')
|
|
||||||
const pageSize = usePageSize()
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
|
|
||||||
// 持久化草稿到 localStorage,防止录入数据丢失
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
if (selectedType && Object.keys(formData).length > 0) {
|
|
||||||
localStorage.setItem('workprocess-draft-type', selectedType)
|
|
||||||
localStorage.setItem('workprocess-draft-data', JSON.stringify(formData))
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('workprocess-draft-type')
|
|
||||||
localStorage.removeItem('workprocess-draft-data')
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, [selectedType, formData])
|
|
||||||
|
|
||||||
const isDirty = selectedType && Object.keys(formData).length > 0
|
|
||||||
useUnsavedChanges(!!isDirty)
|
|
||||||
|
|
||||||
const { data: listData, isLoading, isError, error, refetch } = useQuery({
|
|
||||||
queryKey: ['work-processes', filterType, filterStatus, page, pageSize],
|
|
||||||
queryFn: async () => {
|
|
||||||
const params: any = { page, pageSize }
|
|
||||||
if (filterType) params.type = filterType
|
|
||||||
if (filterStatus) params.status = filterStatus
|
|
||||||
return await workProcessApi.list({ page, pageSize, type: filterType || undefined, status: filterStatus || undefined } as any)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: async (data: any) => {
|
|
||||||
return await workProcessApi.create(data)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('已创建草稿,可在列表中查看详情并提交', {
|
|
||||||
action: { label: '去提交', onClick: () => {} },
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
setShowCreate(false)
|
|
||||||
setFormData({})
|
|
||||||
setSelectedType('')
|
|
||||||
},
|
|
||||||
onError: (err: any) => toastError(err, '创建失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await workProcessApi.submit(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('已提交并执行', {
|
|
||||||
action: { label: '查看文书', onClick: () => navigate('/evidence') },
|
|
||||||
})
|
|
||||||
toast.info('文书已生成,可在「证据管理」中查看和下载', { duration: 5000 })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
setDetailId(null)
|
|
||||||
},
|
|
||||||
onError: (err: any) => toastError(err, '提交失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await workProcessApi.cancel(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('已撤销')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
setDetailId(null)
|
|
||||||
},
|
|
||||||
onError: (err: any) => toastError(err, '撤销失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
await workProcessApi.remove(id)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('已删除')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
},
|
|
||||||
onError: (err: any) => toastError(err, '删除失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const previewMutation = useMutation({
|
|
||||||
mutationFn: async (id: string) => {
|
|
||||||
return await workProcessApi.preview(id)
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setPreviewContent(data.content)
|
|
||||||
},
|
|
||||||
onError: (err: any) => toastError(err, '预览失败'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
if (!selectedType) {
|
|
||||||
toast.error('请选择流程类型')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 校验必填项
|
|
||||||
const requiredFields = (FORM_FIELDS[selectedType] || []).filter(f => f.required)
|
|
||||||
const missingFields = requiredFields.filter(f => !formData[f.key] || String(formData[f.key]).trim() === '')
|
|
||||||
if (missingFields.length > 0) {
|
|
||||||
toast.error(`请填写必填项:${missingFields.map(f => f.label).join('、')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createMutation.mutate({
|
|
||||||
type: selectedType,
|
|
||||||
title: PROCESS_TYPES[selectedType].label,
|
|
||||||
employeeId: formData.employeeId || undefined,
|
|
||||||
formData,
|
|
||||||
status: 'DRAFT',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateAndSubmit = () => {
|
|
||||||
if (!selectedType) {
|
|
||||||
toast.error('请选择流程类型')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
createMutation.mutate(
|
|
||||||
{ type: selectedType, title: PROCESS_TYPES[selectedType].label, employeeId: formData.employeeId || undefined, formData, status: 'DRAFT' },
|
|
||||||
{
|
|
||||||
onSuccess: (data: any) => {
|
|
||||||
const newId = data?.id
|
|
||||||
if (newId) {
|
|
||||||
submitMutation.mutate(newId)
|
|
||||||
} else {
|
|
||||||
toast.success('草稿已创建,请手动提交')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
}
|
|
||||||
setShowCreate(false)
|
|
||||||
setFormData({})
|
|
||||||
setSelectedType('')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFieldChange = (key: string, value: any) => {
|
|
||||||
setFormData(prev => ({ ...prev, [key]: value }))
|
|
||||||
// 选择员工后自动填充相关字段
|
|
||||||
if (key === 'employeeId' && value) {
|
|
||||||
employeeApi.detail(value).then((emp: any) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
employeeName: emp.name || prev.employeeName,
|
|
||||||
idCardNumber: emp.idCardNumber || prev.idCardNumber,
|
|
||||||
position: emp.position || prev.position,
|
|
||||||
monthlyIncome: emp.monthlySalary ? String(emp.monthlySalary) : prev.monthlyIncome,
|
|
||||||
hireDate: emp.hireDate ? emp.hireDate.slice(0, 10) : prev.hireDate,
|
|
||||||
department: emp.department || prev.department,
|
|
||||||
phone: emp.phone || prev.phone,
|
|
||||||
}))
|
|
||||||
}).catch(() => {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBatchSubmit = () => {
|
|
||||||
if (batchEmployees.length === 0) {
|
|
||||||
toast.error('请至少选择一名员工')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let success = 0
|
|
||||||
let failed = 0
|
|
||||||
Promise.all(
|
|
||||||
batchEmployees.map(async (empId) => {
|
|
||||||
try {
|
|
||||||
const emp = allEmployees.find((e: any) => e.id === empId)
|
|
||||||
if (!emp) return
|
|
||||||
const data: any = {
|
|
||||||
type: batchType,
|
|
||||||
title: PROCESS_TYPES[batchType].label,
|
|
||||||
employeeId: empId,
|
|
||||||
formData: {
|
|
||||||
employeeName: emp.name,
|
|
||||||
idCardNumber: emp.idCardNumber || '',
|
|
||||||
position: emp.position || '',
|
|
||||||
},
|
|
||||||
status: 'DRAFT',
|
|
||||||
}
|
|
||||||
const res: any = await workProcessApi.create(data)
|
|
||||||
if (res?.id) {
|
|
||||||
await workProcessApi.submit(res.id)
|
|
||||||
success++
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
failed++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
).then(() => {
|
|
||||||
toast.success(`批量开具完成:成功 ${success} 个${failed > 0 ? ',失败 ' + failed + ' 个' : ''}`)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
|
|
||||||
setShowBatch(false)
|
|
||||||
setBatchEmployees([])
|
|
||||||
setBatchSearch('')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: allEmployees = [] } = useQuery<any[]>({
|
|
||||||
queryKey: ['employee-list-batch'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await employeeApi.allLite()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredEmployees = allEmployees.filter((e: any) => {
|
|
||||||
if (!batchSearch) return true
|
|
||||||
return e.name.includes(batchSearch) || (e.department || '').includes(batchSearch)
|
|
||||||
})
|
|
||||||
|
|
||||||
const items = listData?.items || []
|
|
||||||
const total = listData?.total || 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<PageGuide>
|
|
||||||
用工办理用于管理员工入职、转正、调岗、离职等全生命周期手续。选择办理类型后填写相关信息并提交,系统自动生成对应文书并更新员工档案。支持批量办理和流程跟踪。关联:入职登记后请前往「花名册」查看档案;离职办理后请前往「离职管理」计算补偿。
|
|
||||||
</PageGuide>
|
|
||||||
{/* 发起办理 */}
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-sm font-medium">用工办理</h2>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{isDirty && (
|
|
||||||
<span className="text-xs text-amber-600 flex items-center gap-1">
|
|
||||||
<FileText className="w-3.5 h-3.5" />
|
|
||||||
有未完成的草稿:{PROCESS_TYPES[selectedType]?.label}
|
|
||||||
<button className="text-primary hover:underline" onClick={() => { setSelectedType(''); setFormData({}) }}>清除</button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
|
||||||
<UserPlus className="w-4 h-4 mr-1" />发起办理
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => setShowBatch(true)}>
|
|
||||||
<Users className="w-4 h-4 mr-1" />批量开具证明
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 13类流程卡片 */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
|
|
||||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
|
|
||||||
const Icon = PROCESS_ICONS[key] || FileText
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedType(key)
|
|
||||||
setShowCreate(true)
|
|
||||||
setFormData({})
|
|
||||||
}}
|
|
||||||
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-xs font-medium text-gray-900">{config.label}</div>
|
|
||||||
<div className="text-[10px] text-gray-500 truncate">{config.description}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 办理记录 */}
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<h3 className="text-sm font-medium">办理记录</h3>
|
|
||||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-32">
|
|
||||||
<option value="">全部类型</option>
|
|
||||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => (
|
|
||||||
<option key={key} value={key}>{config.label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-32">
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
|
|
||||||
<option key={key} value={key}>{config.label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
|
||||||
) : isError ? (
|
|
||||||
<QueryError error={error} onRetry={refetch} />
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-sm text-gray-400">暂无办理记录</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{items.map((item: any) => {
|
|
||||||
const Icon = PROCESS_ICONS[item.type] || FileText
|
|
||||||
const statusCfg = STATUS_CONFIG[item.status] || STATUS_CONFIG.DRAFT
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center gap-3 p-3 rounded-md border border-gray-200 hover:bg-gray-50 cursor-pointer"
|
|
||||||
onClick={() => setDetailId(item.id)}
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4 text-gray-400 shrink-0" />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-gray-900">{item.title}</span>
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{item.employee ? `${item.employee.name} · ${item.employee.department}` : (item.formData?.employeeName || item.formData?.name || '未关联员工')}
|
|
||||||
{' · '}{new Date(item.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(item.status === 'COMPLETED' || item.status === 'EXECUTING') && item.documents && item.documents.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="text-xs text-primary hover:underline shrink-0 flex items-center gap-0.5"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setDetailId(item.id) }}
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5" />查看文书
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="w-4 h-4 text-gray-300" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Pagination
|
|
||||||
page={page}
|
|
||||||
pageSize={pageSize}
|
|
||||||
total={total}
|
|
||||||
onPageChange={setPage}
|
|
||||||
onPageSizeChange={() => setPage(1)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 创建/编辑弹窗 */}
|
|
||||||
<Modal open={showCreate} onClose={() => setShowCreate(false)} title={selectedType ? `发起:${PROCESS_TYPES[selectedType]?.label}` : '发起办理'} size="lg">
|
|
||||||
{!selectedType ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{Object.entries(PROCESS_TYPES).map(([key, config]) => {
|
|
||||||
const Icon = PROCESS_ICONS[key] || FileText
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setSelectedType(key)}
|
|
||||||
className="flex items-start gap-2 p-3 rounded-md border border-gray-200 hover:border-primary hover:bg-primary/5 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium">{config.label}</div>
|
|
||||||
<div className="text-[10px] text-gray-500">{config.description}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs text-gray-500 mb-2">{PROCESS_TYPES[selectedType]?.description}</div>
|
|
||||||
{(FORM_FIELDS[selectedType] || []).map(field => (
|
|
||||||
<div key={field.key}>
|
|
||||||
<Label>{field.label}{field.required && <span className="text-danger ml-0.5">*</span>}</Label>
|
|
||||||
{field.type === 'select' ? (
|
|
||||||
<Select value={formData[field.key] || ''} onChange={(e) => handleFieldChange(field.key, e.target.value)}>
|
|
||||||
<option value="">请选择</option>
|
|
||||||
{field.options?.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
|
||||||
</Select>
|
|
||||||
) : field.type === 'textarea' ? (
|
|
||||||
<textarea
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm min-h-[80px]"
|
|
||||||
value={formData[field.key] || ''}
|
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
|
||||||
/>
|
|
||||||
) : field.type === 'enterprise-template' ? (
|
|
||||||
<EnterpriseTemplateSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} />
|
|
||||||
) : field.type === 'employee-select' ? (
|
|
||||||
<EmployeeSelect value={formData[field.key] || ''} onChange={(emp) => {
|
|
||||||
handleFieldChange(field.key, emp.id)
|
|
||||||
if (emp.name) handleFieldChange('employeeName', emp.name)
|
|
||||||
if (emp.idCardNumber) handleFieldChange('idCardNumber', emp.idCardNumber)
|
|
||||||
if (emp.position) handleFieldChange('position', emp.position)
|
|
||||||
if (emp.monthlySalary) handleFieldChange('monthlyIncome', String(emp.monthlySalary))
|
|
||||||
if (emp.hireDate) handleFieldChange('hireDate', emp.hireDate?.slice(0, 10))
|
|
||||||
if (emp.department) handleFieldChange('department', emp.department)
|
|
||||||
if (emp.phone) handleFieldChange('phone', emp.phone)
|
|
||||||
}} />
|
|
||||||
) : field.type === 'contract-select' ? (
|
|
||||||
<ContractSelect value={formData[field.key] || ''} onChange={(v) => handleFieldChange(field.key, v)} employeeId={formData['employeeId'] || ''} />
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type={field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'}
|
|
||||||
value={formData[field.key] || ''}
|
|
||||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
|
||||||
<Button onClick={handleCreate} disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : null}
|
|
||||||
存草稿
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleCreateAndSubmit} disabled={createMutation.isPending || submitMutation.isPending}>
|
|
||||||
{(createMutation.isPending || submitMutation.isPending) ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
|
|
||||||
直接提交
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onClick={() => setSelectedType('')}>
|
|
||||||
返回选择
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 详情弹窗 */}
|
|
||||||
<Modal open={!!detailId} onClose={() => { setDetailId(null); setPreviewContent(null) }} title="办理详情" size="lg">
|
|
||||||
<DetailContent
|
|
||||||
id={detailId}
|
|
||||||
previewContent={previewContent}
|
|
||||||
onPreview={(id) => previewMutation.mutate(id)}
|
|
||||||
onSubmit={(id) => submitMutation.mutate(id)}
|
|
||||||
onCancel={(id) => cancelMutation.mutate(id)}
|
|
||||||
onDelete={(id) => deleteMutation.mutate(id)}
|
|
||||||
loading={submitMutation.isPending || cancelMutation.isPending}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* 批量开具证明弹窗 */}
|
|
||||||
<Modal open={showBatch} onClose={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }} title="批量开具证明" size="lg">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label>证明类型</Label>
|
|
||||||
<Select value={batchType} onChange={(e) => setBatchType(e.target.value)}>
|
|
||||||
<option value="INCOME_CERT">收入证明</option>
|
|
||||||
<option value="LEAVING_CERT">离职证明</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>选择员工(已选 {batchEmployees.length} 人)</Label>
|
|
||||||
<div className="relative mb-2">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
||||||
<Input
|
|
||||||
value={batchSearch}
|
|
||||||
onChange={(e) => setBatchSearch(e.target.value)}
|
|
||||||
placeholder="搜索姓名或部门..."
|
|
||||||
className="pl-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-[300px] overflow-y-auto border rounded-md">
|
|
||||||
{filteredEmployees.map((emp: any) => (
|
|
||||||
<label
|
|
||||||
key={emp.id}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-50 cursor-pointer border-b last:border-0"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={batchEmployees.includes(emp.id)}
|
|
||||||
onChange={() => {
|
|
||||||
setBatchEmployees(prev =>
|
|
||||||
prev.includes(emp.id) ? prev.filter(id => id !== emp.id) : [...prev, emp.id]
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1">{emp.name}</span>
|
|
||||||
<span className="text-xs text-gray-400">{emp.department || '—'}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
{filteredEmployees.length === 0 && (
|
|
||||||
<div className="text-center py-4 text-xs text-gray-400">未找到匹配员工</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => { setShowBatch(false); setBatchEmployees([]); setBatchSearch('') }}>取消</Button>
|
|
||||||
<Button size="sm" onClick={handleBatchSubmit} disabled={batchEmployees.length === 0}>
|
|
||||||
<Send className="w-4 h-4 mr-1" />
|
|
||||||
批量提交({batchEmployees.length}人)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailContent({ id, previewContent, onPreview, onSubmit, onCancel, onDelete, loading }: {
|
|
||||||
id: string | null
|
|
||||||
previewContent: string | null
|
|
||||||
onPreview: (id: string) => void
|
|
||||||
onSubmit: (id: string) => void
|
|
||||||
onCancel: (id: string) => void
|
|
||||||
onDelete: (id: string) => void
|
|
||||||
loading: boolean
|
|
||||||
}) {
|
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['work-process', id],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await workProcessApi.detail(id!)
|
|
||||||
},
|
|
||||||
enabled: !!id,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading || !data) return <div className="flex items-center justify-center py-8"><Loader2 className="w-5 h-5 animate-spin text-gray-400" /></div>
|
|
||||||
|
|
||||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.DRAFT
|
|
||||||
const Icon = PROCESS_ICONS[data.type] || FileText
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Icon className="w-5 h-5 text-primary" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">{data.title}</span>
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${statusCfg.color}`}>{statusCfg.label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{PROCESS_TYPES[data.type]?.label} · {data.employee ? `${data.employee.name}(${data.employee.department})` : (data.formData?.employeeName || data.formData?.name || '未关联员工')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表单数据 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-medium text-gray-700 mb-2">表单信息</h4>
|
|
||||||
<div className="bg-gray-50 rounded-md p-3 space-y-1">
|
|
||||||
{Object.entries(data.formData || {}).map(([key, value]: [string, any]) => (
|
|
||||||
<div key={key} className="flex text-xs">
|
|
||||||
<span className="text-gray-500 w-28 shrink-0">{FIELD_LABEL_MAP[key] || key}</span>
|
|
||||||
<span className="text-gray-900">{key === 'enterpriseTemplateId' && value ? `已关联企业模板` : (value ? String(value) : '-')}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(data.formData || {}).length === 0 && <span className="text-xs text-gray-400">无表单数据</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 文书预览 */}
|
|
||||||
{previewContent && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-medium text-gray-700 mb-2">文书预览</h4>
|
|
||||||
<pre className="bg-gray-50 rounded-md p-3 text-xs whitespace-pre-wrap max-h-[300px] overflow-y-auto">{previewContent}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 生成的文书 */}
|
|
||||||
{data.documents && data.documents.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="text-xs font-medium text-gray-700 mb-2">已生成文书</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{data.documents.map((doc: any, i: number) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded-md p-2">
|
|
||||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
|
||||||
<span className="flex-1 truncate">{doc.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary hover:underline shrink-0"
|
|
||||||
onClick={() => onPreview(data.id)}
|
|
||||||
>
|
|
||||||
<Eye className="w-3.5 h-3.5 inline mr-0.5" />查看
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-primary hover:underline shrink-0"
|
|
||||||
onClick={() => {
|
|
||||||
const content = previewContent || ''
|
|
||||||
if (!content) {
|
|
||||||
onPreview(data.id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const blob = new Blob(['\ufeff' + content], { type: 'application/msword;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = doc.name.endsWith('.doc') ? doc.name : `${doc.name}.doc`
|
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5 inline mr-0.5" />下载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex items-center gap-2 pt-2 border-t">
|
|
||||||
{data.status === 'DRAFT' && (
|
|
||||||
<>
|
|
||||||
<Button size="sm" onClick={() => onPreview(data.id)} variant="secondary">
|
|
||||||
<Eye className="w-4 h-4 mr-1" />预览文书
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => onSubmit(data.id)} disabled={loading}>
|
|
||||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Send className="w-4 h-4 mr-1" />}
|
|
||||||
提交办理
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="danger" onClick={() => onDelete(data.id)}>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />删除
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!['COMPLETED', 'CANCELLED'].includes(data.status) && data.status !== 'DRAFT' && (
|
|
||||||
<Button size="sm" variant="secondary" onClick={() => onCancel(data.id)} disabled={loading}>
|
|
||||||
<X className="w-4 h-4 mr-1" />撤销
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EnterpriseTemplateSelect({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
||||||
const { data, isLoading } = useQuery<any>({
|
|
||||||
queryKey: ['enterprise-templates-for-cert'],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await templatesApi.enterpriseList({ pageSize: 100 } as any)
|
|
||||||
return res
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const items = data?.items || []
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={isLoading}>
|
|
||||||
<option value="">{isLoading ? '加载中...' : '使用系统默认模板'}</option>
|
|
||||||
{items.map((t: any) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.name}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
{items.length === 0 && !isLoading && (
|
|
||||||
<p className="text-xs text-gray-400 mt-1">暂无企业模板,可前往「模板库」→「企业文本库」创建</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmployeeSelect({ value, onChange }: { value: string; onChange: (employee: any) => void }) {
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const { data: employees = [], isLoading } = useQuery<any[]>({
|
|
||||||
queryKey: ['employee-list-for-select'],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await employeeApi.allLite()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const filtered = employees.filter((e: any) => {
|
|
||||||
if (!search) return true
|
|
||||||
return e.name.includes(search) || (e.department || '').includes(search) || (e.phone || '').includes(search)
|
|
||||||
})
|
|
||||||
const selected = employees.find((e: any) => e.id === value)
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
|
||||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary text-sm cursor-pointer flex items-center justify-between"
|
|
||||||
onClick={() => setOpen(!open)}
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<span>{selected.name} · {selected.department || '未分配部门'}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">{isLoading ? '加载中...' : '点击选择员工'}</span>
|
|
||||||
)}
|
|
||||||
<Search className="w-3.5 h-3.5 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute z-50 mt-1 w-full bg-white rounded-md border border-gray-200 shadow-lg max-h-[240px] overflow-hidden">
|
|
||||||
<div className="p-2 border-b border-gray-100">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
placeholder="搜索姓名/部门/手机号"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="w-full px-2 py-1 text-sm border border-gray-200 rounded focus:outline-none focus:ring-1 focus:ring-primary"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-y-auto max-h-[180px]">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<div className="px-3 py-4 text-center text-xs text-gray-400">未找到匹配员工</div>
|
|
||||||
) : (
|
|
||||||
filtered.map((e: any) => (
|
|
||||||
<div
|
|
||||||
key={e.id}
|
|
||||||
className="px-3 py-2 text-sm hover:bg-primary/5 cursor-pointer flex items-center justify-between"
|
|
||||||
onClick={() => {
|
|
||||||
onChange(e)
|
|
||||||
setOpen(false)
|
|
||||||
setSearch('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{e.name}</span>
|
|
||||||
<span className="text-xs text-gray-400">{e.department || ''}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContractSelect({ value, onChange, employeeId }: { value: string; onChange: (v: string) => void; employeeId: string }) {
|
|
||||||
const { data: contracts = [], isLoading } = useQuery<any[]>({
|
|
||||||
queryKey: ['employee-contracts', employeeId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!employeeId) return []
|
|
||||||
const res = await employeeApi.detail(employeeId)
|
|
||||||
return res?.contracts || []
|
|
||||||
},
|
|
||||||
enabled: !!employeeId,
|
|
||||||
})
|
|
||||||
const activeContracts = contracts.filter((c: any) => c.status === 'ACTIVE' || c.status === 'SUSPENDED')
|
|
||||||
return (
|
|
||||||
<Select value={value} onChange={(e) => onChange(e.target.value)} disabled={!employeeId}>
|
|
||||||
<option value="">{!employeeId ? '请先选择员工' : isLoading ? '加载中...' : activeContracts.length === 0 ? '无可用合同' : '请选择合同'}</option>
|
|
||||||
{activeContracts.map((c: any) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.contractType === 'UNFIXED' ? '无固定期限' : `${c.startDate?.slice(0, 10)} ~ ${c.endDate?.slice(0, 10)}`}{c.status === 'SUSPENDED' ? '(已中止)' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -238,7 +238,7 @@ export function ChatTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col" style={{ height: 'calc(100vh - 220px)', minHeight: '400px' }}>
|
<div className="flex flex-col" style={{ height: 'calc(100vh - 250px)', minHeight: '400px' }}>
|
||||||
{/* 顶部操作栏 */}
|
{/* 顶部操作栏 */}
|
||||||
<div className="flex items-center gap-2 pb-2 border-b">
|
<div className="flex items-center gap-2 pb-2 border-b">
|
||||||
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" />新对话</Button>
|
<Button size="sm" variant="secondary" onClick={newConversation}><Plus className="w-4 h-4 mr-1" />新对话</Button>
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export default function ForgotPassword() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="flex items-center justify-center gap-2 mb-8">
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
<Logo className="w-8 h-8 text-primary" />
|
<Logo className="w-8 h-8 object-contain" />
|
||||||
<span className="text-xl font-bold">企业用工专家</span>
|
<span className="text-xl font-bold">安职通</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -89,14 +89,10 @@ export default function Login() {
|
|||||||
|
|
||||||
<div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full">
|
<div className="relative z-10 flex flex-col justify-between p-12 xl:p-16 text-white w-full">
|
||||||
{/* Logo + 品牌名 */}
|
{/* Logo + 品牌名 */}
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<div className="w-11 h-11 rounded-xl bg-white/15 backdrop-blur flex items-center justify-center">
|
<Logo className="w-32 h-32 object-contain mb-4" />
|
||||||
<Logo className="w-7 h-7 text-white" />
|
<div className="text-xl font-bold tracking-tight">安职通</div>
|
||||||
</div>
|
<div className="text-xs text-white/60 mt-0.5">嘉驰英才(上海)信息科技有限公司</div>
|
||||||
<div>
|
|
||||||
<div className="text-xl font-bold tracking-tight">企业用工专家</div>
|
|
||||||
<div className="text-xs text-white/60 mt-0.5">TurboHR · 智能人力资源平台</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标语 */}
|
{/* 标语 */}
|
||||||
@@ -126,7 +122,7 @@ export default function Login() {
|
|||||||
|
|
||||||
{/* 底部版权 */}
|
{/* 底部版权 */}
|
||||||
<div className="text-xs text-white/40">
|
<div className="text-xs text-white/40">
|
||||||
© 2026 TurboHR · 企业用工专家
|
© 2026 嘉驰英才(上海)信息科技有限公司
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,9 +131,9 @@ export default function Login() {
|
|||||||
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
<div className="flex-1 flex items-center justify-center px-4 py-12">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
{/* 移动端 Logo */}
|
{/* 移动端 Logo */}
|
||||||
<div className="flex lg:hidden items-center justify-center gap-2 mb-8">
|
<div className="flex lg:hidden flex-col items-center mb-8">
|
||||||
<Logo className="w-8 h-8 text-primary" />
|
<Logo className="w-24 h-24 object-contain mb-3" />
|
||||||
<span className="text-xl font-bold">企业用工专家</span>
|
<span className="text-xl font-bold">安职通</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 欢迎语 */}
|
{/* 欢迎语 */}
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ export default function Register() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
<div className="min-h-screen flex items-center justify-center bg-surface px-4">
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<div className="flex items-center justify-center gap-2 mb-8">
|
<div className="flex items-center justify-center gap-2 mb-8">
|
||||||
<Logo className="w-8 h-8 text-primary" />
|
<Logo className="w-8 h-8 object-contain" />
|
||||||
<span className="text-xl font-bold">企业用工专家</span>
|
<span className="text-xl font-bold">安职通</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { usePageSize } from '../../hooks/usePageSize'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../../hooks/useConfirm'
|
import { useConfirm } from '../../hooks/useConfirm'
|
||||||
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck } from 'lucide-react'
|
import { Calculator, AlertCircle, Info, Upload, Layers, Settings as SettingsIcon, Archive, Plus, Trash2, AlertTriangle, Download, FileText, X, ChevronLeft, Clock, Users, TrendingDown, TrendingUp, BadgeCheck, DollarSign, CalendarCheck } from 'lucide-react'
|
||||||
import { Stepper, type Step } from '../../components/ui/Stepper'
|
import { Stepper, type Step } from '../../components/ui/Stepper'
|
||||||
import { InlineAlert } from '../../components/ui/InlineAlert'
|
import { InlineAlert } from '../../components/ui/InlineAlert'
|
||||||
import PageGuide from '../../components/ui/PageGuide'
|
import PageGuide from '../../components/ui/PageGuide'
|
||||||
@@ -107,6 +107,8 @@ export function BatchManager() {
|
|||||||
const [filterType, setFilterType] = useState<string>('')
|
const [filterType, setFilterType] = useState<string>('')
|
||||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [createMonth, setCreateMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||||
|
const [createPayMonth, setCreatePayMonth] = useState('')
|
||||||
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
|
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
|
||||||
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
|
const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch' | 'custom'>('copy_last')
|
||||||
const [sourceBatchId, setSourceBatchId] = useState<string>('')
|
const [sourceBatchId, setSourceBatchId] = useState<string>('')
|
||||||
@@ -146,7 +148,11 @@ export function BatchManager() {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => payrollApi.createBatch(data),
|
mutationFn: (data: any) => payrollApi.createBatch(data),
|
||||||
onSuccess: () => {
|
onSuccess: (_data: any, vars: any) => {
|
||||||
|
// 自动切换列表筛选到批次所属月,确保新批次可见
|
||||||
|
if (vars.month && vars.month !== month) {
|
||||||
|
setMonth(vars.month)
|
||||||
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
queryClient.invalidateQueries({ queryKey: ['batch-check'] })
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
@@ -199,9 +205,6 @@ export function BatchManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<PageGuide>
|
|
||||||
发薪批次用于按月生成工资数据。流程:①选择月份和发薪类型(常规/解聘/奖金/补偿)→ ②选择员工范围或复制上月 → ③系统自动计算社保、公积金、个税 → ④确认并发布工资条。支持批量编辑、导出Excel。
|
|
||||||
</PageGuide>
|
|
||||||
{/* 重复发薪提醒 */}
|
{/* 重复发薪提醒 */}
|
||||||
{checkResult?.hasArchivedBatch && (
|
{checkResult?.hasArchivedBatch && (
|
||||||
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs">
|
<div className="flex items-center gap-2 p-2 rounded-md bg-amber-50 text-warning text-xs">
|
||||||
@@ -260,6 +263,18 @@ export function BatchManager() {
|
|||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-xs font-medium mb-3">创建发薪批次</h2>
|
<h2 className="text-xs font-medium mb-3">创建发薪批次</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>所属月(计薪月)*</Label>
|
||||||
|
<Input type="month" value={createMonth} onChange={(e) => setCreateMonth(e.target.value)} />
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">决定调用哪个月的社保标准、个税累计</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>发薪年月</Label>
|
||||||
|
<Input type="month" value={createPayMonth} onChange={(e) => setCreatePayMonth(e.target.value)} />
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">实际发放月份,留空=与所属月相同。如十一提前发薪:所属月=10月,发薪月=9月</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>批次类型</Label>
|
<Label>批次类型</Label>
|
||||||
@@ -307,7 +322,7 @@ export function BatchManager() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => createMutation.mutate({ month, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
|
onClick={() => createMutation.mutate({ month: createMonth, payMonth: createPayMonth || undefined, type: createType, mode: createMode, sourceBatchId: sourceBatchId || undefined, employeeIds: createMode === 'custom' ? selectedEmployeeIds : undefined })}
|
||||||
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
|
disabled={createMutation.isPending || (createMode === 'copy_batch' && !sourceBatchId) || (createMode === 'custom' && selectedEmployeeIds.length === 0)}
|
||||||
>
|
>
|
||||||
{createMutation.isPending ? '创建中...' : '确认创建'}
|
{createMutation.isPending ? '创建中...' : '确认创建'}
|
||||||
@@ -330,6 +345,7 @@ export function BatchManager() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-xs text-gray-500">
|
<tr className="border-b text-left text-xs text-gray-500">
|
||||||
<th className="py-2 px-3">批次名称</th>
|
<th className="py-2 px-3">批次名称</th>
|
||||||
|
<th className="py-2 px-3">所属月/发薪月</th>
|
||||||
<th className="py-2 px-3">类型</th>
|
<th className="py-2 px-3">类型</th>
|
||||||
<th className="py-2 px-3 text-right">人数</th>
|
<th className="py-2 px-3 text-right">人数</th>
|
||||||
<th className="py-2 px-3 text-right">应发合计</th>
|
<th className="py-2 px-3 text-right">应发合计</th>
|
||||||
@@ -378,6 +394,12 @@ export function BatchManager() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-xs text-gray-600">
|
||||||
|
<div>{batch.month}</div>
|
||||||
|
{batch.payMonth && batch.payMonth !== batch.month && (
|
||||||
|
<div className="text-amber-600">发薪:{batch.payMonth}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-2.5 px-3 text-xs text-gray-600">
|
<td className="py-2.5 px-3 text-xs text-gray-600">
|
||||||
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type}
|
{batch.type === 'REGULAR' ? '常规发薪' : batch.type === 'BONUS' ? '年终奖/奖金' : batch.type === 'TERMINATION' ? '离职结算' : batch.type === 'SEVERANCE' ? '补偿金' : batch.type}
|
||||||
</td>
|
</td>
|
||||||
@@ -556,6 +578,70 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const fetchBonusMutation = useMutation({
|
||||||
|
mutationFn: () => payrollApi.fetchBonusToBatch(batchId),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||||
|
if (res.data?.filled > 0) {
|
||||||
|
toast.success(res.data.message)
|
||||||
|
} else {
|
||||||
|
toast.info(res.data?.message || '无提成奖金数据')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('获取提成奖金失败')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchPerformanceMutation = useMutation({
|
||||||
|
mutationFn: () => payrollApi.fetchPerformanceToBatch(batchId),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||||
|
if (res.data?.filled > 0) {
|
||||||
|
toast.success(res.data.message)
|
||||||
|
} else {
|
||||||
|
toast.info(res.data?.message || '无绩效工资数据')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('获取绩效工资失败')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchDisciplinaryMutation = useMutation({
|
||||||
|
mutationFn: () => payrollApi.fetchDisciplinaryToBatch(batchId),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||||
|
if (res.data?.filled > 0) {
|
||||||
|
toast.success(res.data.message)
|
||||||
|
} else {
|
||||||
|
toast.info(res.data?.message || '无违纪扣款数据')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('获取违纪扣款失败')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchAttendanceDeductionMutation = useMutation({
|
||||||
|
mutationFn: () => payrollApi.fetchAttendanceDeductionToBatch(batchId),
|
||||||
|
onSuccess: (res: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batch-detail'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||||
|
if (res.data?.filled > 0) {
|
||||||
|
toast.success(res.data.message)
|
||||||
|
} else {
|
||||||
|
toast.info(res.data?.message || '无考勤扣款数据')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('获取考勤扣款失败')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const importPayrollMutation = useMutation({
|
const importPayrollMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const token = useAuthStore.getState().accessToken
|
const token = useAuthStore.getState().accessToken
|
||||||
@@ -774,6 +860,46 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
<Calculator className="w-4 h-4 mr-1" />
|
<Calculator className="w-4 h-4 mr-1" />
|
||||||
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
|
{importOvertimeMutation.isPending ? '导入中...' : '导入加班费'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchBonusMutation.mutate()}
|
||||||
|
disabled={fetchBonusMutation.isPending || isArchived}
|
||||||
|
title="从提成奖金模块按月拉取填充奖金字段"
|
||||||
|
>
|
||||||
|
<DollarSign className="w-4 h-4 mr-1" />
|
||||||
|
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchPerformanceMutation.mutate()}
|
||||||
|
disabled={fetchPerformanceMutation.isPending || isArchived}
|
||||||
|
title="按考核等级系数计算绩效工资(A=1.2/B=1.0/C=0.8/D=0.6),无考核记录按1.0"
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-4 h-4 mr-1" />
|
||||||
|
{fetchPerformanceMutation.isPending ? '获取中...' : '获取绩效工资'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchDisciplinaryMutation.mutate()}
|
||||||
|
disabled={fetchDisciplinaryMutation.isPending || isArchived}
|
||||||
|
title="按批次月份从违纪记录中汇总扣款金额(action=DEDUCTION)"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4 mr-1" />
|
||||||
|
{fetchDisciplinaryMutation.isPending ? '获取中...' : '获取违纪扣款'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchAttendanceDeductionMutation.mutate()}
|
||||||
|
disabled={fetchAttendanceDeductionMutation.isPending || isArchived}
|
||||||
|
title="按批次月份从考勤确认中获取扣款金额(需先在考勤确认中填写)"
|
||||||
|
>
|
||||||
|
<CalendarCheck className="w-4 h-4 mr-1" />
|
||||||
|
{fetchAttendanceDeductionMutation.isPending ? '获取中...' : '获取考勤扣款'}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -922,8 +1048,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<Stepper
|
<Stepper
|
||||||
steps={[
|
steps={[
|
||||||
{ key: 'edit', title: '编辑薪资', description: '填写/导入工资数据', status: isArchived ? 'complete' : 'current' },
|
{ key: 'edit', title: '编辑薪资', description: '填写/导入/核对工资数据', status: isArchived ? 'complete' : 'current' },
|
||||||
{ key: 'review', title: '核对汇总', description: '检查应发/个税/实发', status: isArchived ? 'complete' : 'pending' },
|
|
||||||
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
|
{ key: 'archive', title: '归档锁定', description: '归档后不可编辑', status: isArchived ? 'complete' : 'pending' },
|
||||||
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
|
{ key: 'publish', title: '发布工资条', description: '员工端可见', status: batch.payslipPublished ? 'complete' : 'pending' },
|
||||||
] as Step[]}
|
] as Step[]}
|
||||||
@@ -938,6 +1063,21 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。
|
请检查社保/公积金基数是否过大,导致实发工资 ≤ 0 的记录需修正后再归档。
|
||||||
</InlineAlert>
|
</InlineAlert>
|
||||||
)}
|
)}
|
||||||
|
{batch.entries.some((e: any) => e.minWage > 0 && e.netPay < e.minWage && (e.minWageApplied || 0) === 0) && (
|
||||||
|
<InlineAlert type="error" title="存在实发低于最低工资的员工">
|
||||||
|
部分员工实发工资低于当地最低工资标准 ¥{Math.min(...batch.entries.filter((e: any) => e.minWage > 0 && e.netPay < e.minWage).map((e: any) => e.minWage))},请检查社保基数或在社保配置中设置最低工资标准。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
{batch.entries.some((e: any) => (e.minWageApplied || 0) > 0) && (
|
||||||
|
<InlineAlert type="warning" title="最低工资保护已触发">
|
||||||
|
{batch.entries.filter((e: any) => (e.minWageApplied || 0) > 0).length} 名员工实发已补齐到最低工资标准。免扣的社保/公积金及额外补齐差额已递延,将在次月工资中补扣,请在「递延扣款」列查看明细。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
{batch.entries.some((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0) && (
|
||||||
|
<InlineAlert type="warning" title="本月补扣上月递延">
|
||||||
|
{batch.entries.filter((e: any) => (e.prevDeferredSocialEmp || 0) + (e.prevDeferredHousingEmp || 0) + (e.prevDeferredMinWage || 0) > 0).length} 名员工本月补扣了上月递延的社保/公积金/最低工资补齐差额。
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
|
{batch.entries.some((e: any) => e.baseSalary === 0 && e.bonus === 0 && e.totalPay === 0) && (
|
||||||
<InlineAlert type="warning" title="存在全零记录">
|
<InlineAlert type="warning" title="存在全零记录">
|
||||||
部分员工所有金额为 0,请确认是否需要填写或移除这些人员。
|
部分员工所有金额为 0,请确认是否需要填写或移除这些人员。
|
||||||
@@ -1045,6 +1185,7 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
<th className="py-2 px-2 text-right">公积金(个人)</th>
|
<th className="py-2 px-2 text-right">公积金(个人)</th>
|
||||||
<th className="py-2 px-2 text-right text-gray-500">个税</th>
|
<th className="py-2 px-2 text-right text-gray-500">个税</th>
|
||||||
<th className="py-2 px-2 text-right text-gray-500">实发</th>
|
<th className="py-2 px-2 text-right text-gray-500">实发</th>
|
||||||
|
<th className="py-2 px-2 text-right text-gray-500">递延扣款</th>
|
||||||
<th className="py-2 px-2">风险</th>
|
<th className="py-2 px-2">风险</th>
|
||||||
{!isArchived && <th className="py-2 px-2">操作</th>}
|
{!isArchived && <th className="py-2 px-2">操作</th>}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1069,12 +1210,34 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
|
|||||||
{renderCell(entry, 'socialEmp')}
|
{renderCell(entry, 'socialEmp')}
|
||||||
{renderCell(entry, 'housingEmp')}
|
{renderCell(entry, 'housingEmp')}
|
||||||
<td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
|
<td className="py-2 px-2 text-right text-gray-500 cursor-pointer hover:text-primary hover:underline" onClick={() => handleTaxDetailClick(entry.employeeId, entry.employee.name)} title="点击查看个税计算明细">{fmt(entry.tax)}</td>
|
||||||
<td className="py-2 px-2 text-right font-bold text-safe">{fmt(entry.netPay)}</td>
|
{/* 实发:最低工资保护触发时高亮显示 */}
|
||||||
|
<td className={`py-2 px-2 text-right font-bold ${(entry.minWageApplied || 0) > 0 ? 'text-amber-600' : 'text-safe'}`} title={(entry.minWageApplied || 0) > 0 ? `最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发补齐到 ¥${fmt(entry.minWage)}\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)} + 免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)} + 额外补齐 ¥${fmt(entry.deferredMinWage || 0)} = 递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))}(次月补扣)` : ''}>
|
||||||
|
{fmt(entry.netPay)}
|
||||||
|
{(entry.minWageApplied || 0) > 0 && <span className="text-xs text-amber-500 ml-1">★</span>}
|
||||||
|
</td>
|
||||||
|
{/* 递延扣款明细 */}
|
||||||
|
<td className="py-2 px-2 text-right text-xs">
|
||||||
|
{(() => {
|
||||||
|
const deferred = (entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0)
|
||||||
|
const prevDeferred = (entry.prevDeferredSocialEmp || 0) + (entry.prevDeferredHousingEmp || 0) + (entry.prevDeferredMinWage || 0)
|
||||||
|
if (deferred > 0) {
|
||||||
|
return <span className="text-amber-600 cursor-help" title={`递延到次月补扣:\n免扣社保 ¥${fmt(entry.deferredSocialEmp || 0)}\n免扣公积金 ¥${fmt(entry.deferredHousingEmp || 0)}\n额外补齐 ¥${fmt(entry.deferredMinWage || 0)}\n合计 ¥${fmt(deferred)}`}>递延 ¥{fmt(deferred)}</span>
|
||||||
|
}
|
||||||
|
if (prevDeferred > 0) {
|
||||||
|
return <span className="text-blue-600 cursor-help" title={`本月补扣上月递延:\n社保 ¥${fmt(entry.prevDeferredSocialEmp || 0)}\n公积金 ¥${fmt(entry.prevDeferredHousingEmp || 0)}\n最低工资补齐 ¥${fmt(entry.prevDeferredMinWage || 0)}\n合计 ¥${fmt(prevDeferred)}`}>补扣 ¥{fmt(prevDeferred)}</span>
|
||||||
|
}
|
||||||
|
return <span className="text-gray-300">—</span>
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
<td className="py-2 px-2">
|
<td className="py-2 px-2">
|
||||||
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
|
{entry.riskWarnings && entry.riskWarnings.length > 0 ? (
|
||||||
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
|
<span className="text-danger cursor-help" title={entry.riskWarnings.join('\n')}>
|
||||||
<AlertTriangle className="w-4 h-4" />
|
<AlertTriangle className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
|
) : (entry.minWageApplied || 0) > 0 ? (
|
||||||
|
<span className="text-amber-500 cursor-help" title={`最低工资保护已触发\n应发 ¥${fmt(entry.totalPay)} → 实发 ¥${fmt(entry.minWage)}\n递延 ¥${fmt((entry.deferredSocialEmp || 0) + (entry.deferredHousingEmp || 0) + (entry.deferredMinWage || 0))} 次月补扣`}>
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-300">—</span>
|
<span className="text-gray-300">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user