From 4f125d309bc18e654bda3cea95054766fb3642ca Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Thu, 23 Jul 2026 20:02:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A4=BE=E4=BF=9D=E5=85=AC=E7=A7=AF?= =?UTF-8?q?=E9=87=91=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE+=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8C=96=E7=BC=B4=E8=B4=B9=E8=AE=B0=E5=BD=95+?= =?UTF-8?q?=E6=9C=88=E5=BA=A6=E5=A2=9E=E5=87=8F=E5=91=98+=E8=A1=A5?= =?UTF-8?q?=E5=81=BF=E9=87=91=E6=89=B9=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schema: 拆分社保/公积金配置,新增EmployeeSocialInsRecord/EmployeeHousingFundRecord/DepartmentRecord模型,扩展SalaryChangeRecord,增加SEVERANCE批次类型 - 后端: createEmployee/rehireEmployee接收社保公积金字段并创建缴费记录版本;createTermination/createResignation接收截止年月并关闭缴费记录;调薪/调部门API+版本记录;月度增减员API;公积金独立CRUD/计算/调基;SEVERANCE批次calcBatchEntry - 前端: AddEmployeeModal/RehireModal增加社保公积金输入;ResignModal/Termination增加截止年月+日期不一致提醒;花名册增加调薪/调部门弹窗;SocialInsurance.tsx Tab拆分(社保/公积金/月度增减员)+CSV导出;Money.tsx增加补偿金批次类型 - 修复: seed.ts移除housingOrg/housingEmp;risk.service.ts从HousingFundConfig获取公积金费率 --- 20260723-优化-1.md | 377 +++++++++++ backend/prisma/schema.prisma | 108 ++- backend/prisma/seed.ts | 2 - backend/scripts/migrate-records.ts | 150 +++++ backend/src/routes/payroll2.routes.ts | 8 +- backend/src/routes/roster.routes.ts | 133 +++- backend/src/routes/social.routes.ts | 486 +++++++++++++- backend/src/services/contract.service.ts | 186 +++++- backend/src/services/payroll.service.ts | 20 +- backend/src/services/risk.service.ts | 7 +- backend/src/services/termination.service.ts | 62 +- frontend/src/pages/Money.tsx | 4 +- frontend/src/pages/Roster.tsx | 302 ++++++++- frontend/src/pages/SocialInsurance.tsx | 691 ++++++++++++-------- frontend/src/pages/Termination.tsx | 26 + 15 files changed, 2227 insertions(+), 335 deletions(-) create mode 100644 20260723-优化-1.md create mode 100644 backend/scripts/migrate-records.ts diff --git a/20260723-优化-1.md b/20260723-优化-1.md new file mode 100644 index 0000000..60af68f --- /dev/null +++ b/20260723-优化-1.md @@ -0,0 +1,377 @@ +# 社保公积金优化方案 + +## 核心原则 + +- 社保和公积金**完全分离**:独立配置、独立调基、独立增减员、独立申报 +- 社保公积金开始/截止年月**必填**,增减变以此为准 +- 基数缺省等于工资,可修改 +- 调薪后社保公积金基数**不自动调整**(社保基数通常每年7月统一调基,调薪仅影响发薪基数) +- 发薪列表和社保/公积金申报列表中,入离职日期与社保公积金年月不一致时**提醒** +- 所有变更(入职/重新入职/调基/调薪/调部门/离职/解聘)都按**版本记录**保存,算薪和月度处理时按月份获取当前有效版本 + +--- + +## 一、Schema 改动 + +### 1.1 拆分配置模型 + +现有 `SocialInsuranceConfig`(含社保+公积金比例)拆为: + +- **`SocialInsuranceConfig`**(保留,移除公积金字段):养老/医疗/失业/工伤/生育比例 + 社保基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记 +- **`HousingFundConfig`**(新增):公积金企业/个人比例 + 公积金基数上下限 + 生效月份 + 版本管理 + `adjustmentDone` 标记(字段结构同社保配置) + +> Organization 和 Employee 需增加反向关联字段: +> - Organization: `housingFundConfigs HousingFundConfig[]`、`socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]`(`salaryChangeRecords` 已存在) +> - Employee: `socialInsRecords EmployeeSocialInsRecord[]`、`housingFundRecords EmployeeHousingFundRecord[]`、`departmentRecords EmployeeDepartmentRecord[]`(`salaryChanges` 已存在) + +### 1.2 新增模型:社保/公积金缴费记录(按版本保存) + +社保和公积金的基数、起止年月不是 Employee 上的简单字段,而是按**版本记录**保存。每次入职/重新入职/调基/离职/解聘都生成新版本,形成完整变更历史。 + +#### EmployeeSocialInsRecord(社保缴费记录) + +```prisma +model EmployeeSocialInsRecord { + 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) + startMonth String // 开始缴费年月 YYYY-MM + endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) + base Float // 缴费基数 + // 变更来源 + changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘 + changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时) + remark String? + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本 +} +``` + +#### EmployeeHousingFundRecord(公积金缴费记录) + +```prisma +model EmployeeHousingFundRecord { + 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) + startMonth String // 开始缴费年月 YYYY-MM + endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) + base Float // 缴费基数 + // 变更来源 + changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘 + changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时) + remark String? + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, startMonth, endMonth]) // 复合索引:按员工+月份查询有效版本 +} +``` + +#### Employee 保留便捷字段(当前生效值,由后端同步维护) + +``` +socialInsStartMonth String? // 当前社保开始年月(=最新记录的startMonth) +socialInsBase Float? // 当前社保基数(=最新记录的base) +socialInsEndMonth String? // 当前社保截止年月(=最新记录的endMonth,null=在保) +housingFundStartMonth String? // 当前公积金开始年月 +housingFundBase Float? // 当前公积金基数 +housingFundEndMonth String? // 当前公积金截止年月 +``` + +> 这些字段是冗余的便捷查询字段,由后端在创建/更新缴费记录时自动同步。增减员和在职申报查询主要使用 Record 表,发薪计算使用 Employee 便捷字段。 + +### 1.3 TerminationRecord 增加字段 + +``` +socialInsEndMonth String // 社保截止缴费年月 YYYY-MM(必填) +housingFundEndMonth String // 公积金截止缴费年月 YYYY-MM(必填) +``` + +> TerminationRecord 保存截止年月的同时,后端自动创建一条 EmployeeSocialInsRecord / EmployeeHousingFundRecord,将上一条有效记录的 endMonth 设为此值,并同步 Employee 便捷字段。 + +### 1.4 扩展模型:调薪/调部门按版本保存 + +调薪和调部门也按**版本记录**保存,与社保公积金缴费记录同理。每次变更生成新版本,算薪和社保公积金月度处理时获取当前最新版。 + +#### 扩展现有 SalaryChangeRecord(增加版本字段) + +现有 `SalaryChangeRecord` 已有 `oldSalary`/`newSalary`/`effectiveDate`/`reason`,与其新建模型,直接扩展: + +```prisma +// 在现有 SalaryChangeRecord 增加字段: + effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换) + endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置) + changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪 + + @@index([employeeId, effectiveMonth, endMonth]) // 复合索引 +``` + +> 不新建 `EmployeeSalaryRecord`,直接复用 `SalaryChangeRecord`,避免数据分散。入职时也创建一条(oldSalary=0, newSalary=月薪, changeType=ONBOARDING)。 + +#### EmployeeDepartmentRecord(部门变更记录,新增模型) + +```prisma +model EmployeeDepartmentRecord { + 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) + oldDepartment String // 调整前部门 + newDepartment String // 调整后部门 + effectiveMonth String // 生效年月 YYYY-MM + endMonth String? // 失效年月 YYYY-MM(null=至今有效) + reason String? // 调部门原因 + changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门 + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, effectiveMonth, endMonth]) // 复合索引 +} +``` + +> Employee 上的 `monthlySalary` 和 `department` 作为便捷字段由后端同步维护。 + +### 1.5 PayrollBatchType 增加枚举 + +``` +SEVERANCE // 补偿金按月发放(无社保,个税按政策处理) +``` + +### 1.6 数据迁移策略 + +Schema 改动后,需要为现有员工创建初始 Record: + +- **EmployeeSocialInsRecord**:为每个现有员工创建一条,`startMonth` = 入职日期年月,`endMonth` = 已离职员工的离职日期年月(如有),`base` = 现有 `socialInsBase` 或月薪,`changeType` = 'ONBOARDING' +- **EmployeeHousingFundRecord**:同上,`base` = 现有 `housingFundBase` 或月薪 +- **SalaryChangeRecord**:为每个现有员工创建一条初始记录,`oldSalary` = 0, `newSalary` = 当前月薪, `effectiveMonth` = 入职日期年月, `endMonth` = null +- **EmployeeDepartmentRecord**:为每个现有员工创建一条,`oldDepartment` = '', `newDepartment` = 当前部门, `effectiveMonth` = 入职日期年月, `endMonth` = null +- **迁移脚本**:`npx prisma db push` 后执行一次性迁移脚本 `scripts/migrate-records.ts` + +--- + +## 二、需求1:新增/重新入职填写社保公积金开始年月+基数 + +### 前端 AddEmployeeModal + +- 新增4个必填字段(2列布局): + - 社保开始年月(type=month,缺省=入职日期年月,可修改) + - 社保基数(type=number,缺省=月薪,可修改) + - 公积金开始年月(type=month,缺省=入职日期年月,可修改) + - 公积金基数(type=number,缺省=月薪,可修改) +- 当入职日期变更时(`handleHireDateChange`),自动同步4个缺省值 +- `canSubmit` 增加这4个字段的必填校验 + +### 前端 RehireModal + +- 同 AddEmployeeModal,缺省=新入职日期年月 + +### 后端 + +- `createEmployeeSchema` 增加 `socialInsStartMonth`、`socialInsBase`、`housingFundStartMonth`、`housingFundBase`(必填) +- `createEmployee` 存储这些字段到 Employee 便捷字段,**同时创建一条 `EmployeeSocialInsRecord`(changeType=ONBOARDING)和一条 `EmployeeHousingFundRecord`(changeType=ONBOARDING)** +- `rehireEmployee` 接收并更新这些字段,**同时创建新版本缴费记录(changeType=REHIRE)**,并将之前有效记录的 endMonth 设为重新入职前一个月 + +--- + +## 二.5 需求补充:花名册增加调薪/调部门操作 + +### 前端花名册列表 + +- 每行操作区增加「调薪」「调部门」按钮(与「离职」并列) + +### 前端调薪弹窗(SalaryChangeModal) + +- 显示:员工姓名、当前月薪、当前部门 +- 输入: + - 新月薪(必填,缺省=当前月薪) + - 生效年月(type=month,必填,缺省=当月) + - 调薪原因(选填) +- 提交后: + - 后端创建 `SalaryChangeRecord`(oldSalary=当前月薪,newSalary=新月薪,effectiveMonth=生效年月, changeType=SALARY_CHANGE) + - 将之前有效记录的 `endMonth` 设为生效月前一个月 + - 同步 `Employee.monthlySalary` = 新月薪 + +### 前端调部门弹窗(DepartmentChangeModal) + +- 显示:员工姓名、当前部门 +- 输入: + - 新部门(必填,缺省=当前部门) + - 生效年月(type=month,必填,缺省=当月) + - 调部门原因(选填) +- 提交后: + - 后端创建 `EmployeeDepartmentRecord`(oldDepartment=当前部门,newDepartment=新部门,effectiveMonth=生效年月) + - 将之前有效记录的 `endMonth` 设为生效月前一个月 + - 同步 `Employee.department` = 新部门 + +### 后端 + +- `POST /roster/:id/salary-change` — 调薪,创建版本记录 + 同步 Employee +- `POST /roster/:id/department-change` — 调部门,创建版本记录 + 同步 Employee +- `GET /roster/:id/salary-records` — 调薪历史 +- `GET /roster/:id/department-records` — 调部门历史 + +### 算薪和社保公积金月度处理 + +- 算薪时:根据发薪月份获取该月有效的 `SalaryChangeRecord`(`effectiveMonth <= month` 且 `endMonth == null 或 >= month`),使用该记录的 `newSalary` 作为发薪基数 +- 社保公积金月度处理时:根据月份获取该月有效的 `EmployeeSocialInsRecord` / `EmployeeHousingFundRecord`,使用该记录的 `base` 作为缴费基数 +- 部门信息:根据月份获取该月有效的 `EmployeeDepartmentRecord`,用于月度报表中的部门归属 +- **调薪与社保基数关系**:调薪仅影响发薪基数,**不自动调整**社保公积金基数。社保公积金基数仅在每年7月统一调基时调整 + +--- + +## 三、需求2:离职/解聘填写社保公积金截止年月 + +### 前端 ResignModal(Roster.tsx) + +- 新增2个必填字段: + - 社保截止年月(type=month,缺省=离职日期年月,可修改) + - 公积金截止年月(type=month,缺省=离职日期年月,可修改) +- 当离职日期变更时,自动同步缺省值 +- `canSubmit` 增加必填校验 + +### 前端 Termination.tsx(解聘向导 Step 1) + +- 在解聘日期下方增加社保截止年月、公积金截止年月输入 +- 缺省=解聘日期年月,可修改 + +### 后端 + +- `terminationChecklistSchema` 增加 `socialInsEndMonth`、`housingFundEndMonth`(必填) +- `createTermination` 和 `createResignation` 存储这些字段到 TerminationRecord +- **同时更新 Employee 便捷字段**(`socialInsEndMonth`、`housingFundEndMonth`) +- **同时创建/更新缴费记录**:将当前有效记录的 `endMonth` 设为截止年月,同步 Employee 便捷字段 + +--- + +## 四、需求3:社保公积金Tab增加月度增减员+在职申报+导出 + +### 4.1 前端 SocialInsurance.tsx 改造 + +增加顶层 Tab 切换: + +- **「社保」Tab**:社保配置管理 + 社保调基 + 社保月度增减员 + 社保在职申报 +- **「公积金」Tab**:公积金配置管理 + 公积金调基 + 公积金月度增减员 + 公积金在职申报 + +每个 Tab 内再分子 Tab: + +- 配置管理(现有功能,社保/公积金各自独立) +- 月度增减员 +- 在职申报 + +### 4.2 月度增减员 + +**后端 API**: + +- `GET /social/monthly-changes?month=YYYY-MM` — 社保增减员 +- `GET /housing/monthly-changes?month=YYYY-MM` — 公积金增减员 + +**逻辑**(统一使用 Record 表查询,确保历史月份也能查到已离职员工): + +- **增员**:查 `EmployeeSocialInsRecord.startMonth == month`(姓名、部门、基数、开始年月、changeType) +- **减员**:查 `EmployeeSocialInsRecord.endMonth == month` 且 `changeType == 'TERMINATION'`(姓名、部门、基数、截止年月、离职类型) +- 支持导出 CSV(前端生成,无需后端依赖) + +**前端**:选择月份 → 显示增员表和减员表(两个表格或折叠分区)→ 导出按钮 + +### 4.3 在职申报 + +**后端 API**: + +- `GET /social/active-declaration?month=YYYY-MM` — 社保在保人员 +- `GET /housing/active-declaration?month=YYYY-MM` — 公积金在保人员 + +**逻辑**(使用 Record 表查询): + +- 筛选条件:`EmployeeSocialInsRecord.startMonth <= month` 且 `endMonth == null 或 >= month` +- 返回:姓名、身份证号、部门、社保基数、开始年月、截止年月 +- 支持导出 CSV(前端生成,无需后端依赖) + +**前端**:选择月份 → 显示在保人员表格 → 导出按钮 + +### 4.4 调基拆分 + +现有调基操作同时调整社保和公积金基数。改为: + +- 社保调基:只调整社保基数,使用 `SocialInsuranceConfig` 的上下限 + - 将当前有效记录的 `endMonth` 设为调基月前一个月 + - 创建新 `EmployeeSocialInsRecord`(changeType=ADJUST),startMonth=调基月,base=新基数 + - 同步 Employee.socialInsBase / socialInsStartMonth +- 公积金调基:只调整公积金基数,使用 `HousingFundConfig` 的上下限 + - 将当前有效记录的 `endMonth` 设为调基月前一个月 + - 创建新 `EmployeeHousingFundRecord`(changeType=ADJUST),startMonth=调基月,base=新基数 + - 同步 Employee.housingFundBase / housingFundStartMonth +- 两个调基操作独立执行,各自有 `adjustmentDone` 标记 + +--- + +## 五、需求4:已离职员工按月发放补偿金 + +### 后端 + +- `PayrollBatchType` 增加 `SEVERANCE` +- `calcBatchEntry`:当 `batchType === 'SEVERANCE'` 时: + - `socialEmp=0`、`housingEmp=0`、`socialOrg=0`、`housingOrg=0`(无社保公积金) + - `tax`:经济补偿金在当地社平工资3倍以内免征个税,超过部分按单独税率计税。简化处理:`tax=0`,备注注明「补偿金免征个税(社平3倍以内)」,如超过3倍需手动计算 +- 允许 `status === 'RESIGNED'` 的员工加入 `SEVERANCE` 批次 +- 补偿金发放可设置**发放月数**(如约定发放6个月),到期后自动标记为已完成 +- 也可手动停止发放 + +### 前端 Money.tsx + +- 批次类型下拉增加「补偿金发放」选项 +- `SEVERANCE` 批次:员工选择列表包含已离职员工 +- 输入项简化:只有补偿金金额(baseSalary),无加班/津贴/扣款 +- 可设置发放月数 +- 工资条显示:社保=0、公积金=0、个税=0(备注:补偿金免征) + +--- + +## 六、需求5:日期不一致提醒 + +### 发薪列表提醒 + +在发薪批次详情中,对每个员工检查发薪月份与入离职日期的一致性: + +- 发薪月份 < 入职日期年月 → ⚠️ "该员工2025-07入职,当前发薪月份2025-06尚未入职" +- 发薪月份 > 离职日期年月 → ⚠️ "该员工已于2025-06离职,当前发薪月份2025-07已离职" +- 同时也检查社保公积金年月范围,如有不一致也提醒 + +### 社保/公积金申报列表提醒 + +- **增员**:`socialInsStartMonth` 与 `hireDate` 年月不一致 → ⚠️ "社保开始年月与入职日期不一致" +- **减员**:`socialInsEndMonth` 与 `terminationDate` 年月不一致 → ⚠️ "社保截止年月与离职日期不一致" +- **在职申报**:`hireDate` 年月与 `socialInsStartMonth` 不一致、`terminationDate` 年月与 `socialInsEndMonth` 不一致 → ⚠️ 提醒 + +--- + +## 七、实施顺序 + +| 步骤 | 内容 | 涉及 | +|------|------|------| +| 1 | Schema 改动(拆分配置、新增缴费/部门记录模型、扩展SalaryChangeRecord、增加字段、增加枚举)+ `prisma db push` | 后端 | +| 1.5 | 数据迁移脚本:为现有员工创建初始 Record | 后端 | +| 2 | 后端:`createEmployee`/`rehireEmployee` 接收社保公积金字段 + 创建缴费记录版本 | 后端 | +| 3 | 后端:`createTermination`/`createResignation` 接收截止年月 + 更新缴费记录版本 | 后端 | +| 3.5 | 后端:调薪/调部门 API + 创建/扩展版本记录 + 同步 Employee | 后端 | +| 4 | 前端:AddEmployeeModal 增加社保公积金输入 | 前端 | +| 5 | 前端:RehireModal 同步 | 前端 | +| 6 | 前端:ResignModal 增加截止年月 | 前端 | +| 6.5 | 前端:花名册增加调薪/调部门弹窗 | 前端 | +| 7 | 前端:Termination.tsx 解聘向导增加截止年月 | 前端 | +| 8 | 后端:月度增减员 + 在职申报 API | 后端 | +| 9 | 后端:`SEVERANCE` 批次类型 + `calcBatchEntry` 修改 | 后端 | +| 10 | 前端:SocialInsurance.tsx 改造(Tab拆分+增减员+申报+导出) | 前端 | +| 11 | 前端:Money.tsx 增加补偿金批次 | 前端 | +| 12 | 前端:日期不一致提醒 | 前端 | +| 13 | 编译验证 + git 推送 | 全部 | diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d78c5f8..860f9e5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,6 +55,7 @@ enum PayrollBatchType { REGULAR // 常规发薪 TERMINATION // 离职结算 BONUS // 年终奖/奖金 + SEVERANCE // 补偿金按月发放(无社保,个税按政策处理) } enum PayrollBatchStatus { @@ -131,6 +132,10 @@ model Organization { onboardingLinks OnboardingLink[] confirmLinks ContractConfirmLink[] socialInsuranceConfig SocialInsuranceConfig[] + housingFundConfigs HousingFundConfig[] + socialInsRecords EmployeeSocialInsRecord[] + housingFundRecords EmployeeHousingFundRecord[] + departmentRecords EmployeeDepartmentRecord[] notificationSetting NotificationSetting? overtimeConfig OvertimeConfig? notificationLogs NotificationLog[] @@ -178,8 +183,12 @@ model Employee { isInMedicalPeriod Boolean @default(false) isWorkInjured Boolean @default(false) // 薪税扩展 - socialInsBase Float? // 社保缴费基数(按人核定) - housingFundBase Float? // 公积金缴费基数(按人核定) + socialInsBase Float? // 社保缴费基数(便捷字段,由Record同步) + housingFundBase Float? // 公积金缴费基数(便捷字段,由Record同步) + socialInsStartMonth String? // 当前社保开始年月(便捷字段) + socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保) + housingFundStartMonth String? // 当前公积金开始年月(便捷字段) + housingFundEndMonth String? // 当前公积金截止年月(便捷字段) specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报) createdBy String createdAt DateTime @default(now()) @@ -197,6 +206,9 @@ model Employee { attendanceRecords AttendanceRecord[] trainingRecords TrainingRecord[] performanceRecords PerformanceRecord[] + socialInsRecords EmployeeSocialInsRecord[] + housingFundRecords EmployeeHousingFundRecord[] + departmentRecords EmployeeDepartmentRecord[] } model LaborContract { @@ -256,6 +268,8 @@ model TerminationRecord { terminationDate DateTime resignationReason String? // 主动离职原因(type=RESIGNATION时使用) compensation Float @default(0) + socialInsEndMonth String? // 社保截止缴费年月 YYYY-MM + housingFundEndMonth String? // 公积金截止缴费年月 YYYY-MM riskLevel RiskAssessment @default(SAFE) checklist Json remark String? @@ -315,14 +329,33 @@ model SocialInsuranceConfig { unemploymentEmp Float @default(0.5) // 失业保险 个人比例 % injuryOrg Float @default(0.2) // 工伤保险 企业比例 % maternityOrg Float @default(0.8) // 生育保险 企业比例 % - housingOrg Float @default(12) // 公积金 企业比例 % - housingEmp Float @default(12) // 公积金 个人比例 % - baseMin Float @default(6326) // 缴费基数下限 - baseMax Float @default(33891) // 缴费基数上限 + baseMin Float @default(6326) // 社保缴费基数下限 + baseMax Float @default(33891) // 社保缴费基数上限 effectiveFrom String // 生效月份 YYYY-MM effectiveTo String? // 失效月份 YYYY-MM(null=当前有效) isCurrent Boolean @default(true) // 是否当前生效版本 - adjustmentDone Boolean @default(false) // 是否已执行过员工基数调整 + adjustmentDone Boolean @default(false) // 是否已执行过社保基数调整 + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([orgId, effectiveFrom]) + @@index([orgId, isCurrent]) +} + +model HousingFundConfig { + id String @id @default(cuid()) + orgId String + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + city String @default("北京") + housingOrg Float @default(12) // 公积金 企业比例 % + housingEmp Float @default(12) // 公积金 个人比例 % + baseMin Float @default(6326) // 公积金缴费基数下限 + baseMax Float @default(33891) // 公积金缴费基数上限 + 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 @@ -613,11 +646,72 @@ model SalaryChangeRecord { oldSalary Float newSalary Float effectiveDate DateTime // 生效日期 + effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换) + endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置) + changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪 reason String? createdBy String createdAt DateTime @default(now()) @@index([orgId, employeeId]) + @@index([employeeId, effectiveMonth, endMonth]) +} + +model EmployeeSocialInsRecord { + 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) + startMonth String // 开始缴费年月 YYYY-MM + endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) + base Float // 缴费基数 + changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘 + changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时) + remark String? + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, startMonth, endMonth]) +} + +model EmployeeHousingFundRecord { + 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) + startMonth String // 开始缴费年月 YYYY-MM + endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) + base Float // 缴费基数 + changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘 + changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时) + remark String? + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, startMonth, endMonth]) +} + +model EmployeeDepartmentRecord { + 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) + oldDepartment String // 调整前部门 + newDepartment String // 调整后部门 + effectiveMonth String // 生效年月 YYYY-MM + endMonth String? // 失效年月 YYYY-MM(null=至今有效) + reason String? // 调部门原因 + changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门 + createdBy String + createdAt DateTime @default(now()) + + @@index([orgId, employeeId]) + @@index([employeeId, effectiveMonth, endMonth]) } model OnboardingLink { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 0b9019e..d8c53bf 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -109,8 +109,6 @@ async function main() { unemploymentEmp: 0.5, injuryOrg: 0.2, maternityOrg: 0.8, - housingOrg: 7, - housingEmp: 7, baseMin: 7384, baseMax: 36921, effectiveFrom: '2025-07', diff --git a/backend/scripts/migrate-records.ts b/backend/scripts/migrate-records.ts new file mode 100644 index 0000000..2ce3959 --- /dev/null +++ b/backend/scripts/migrate-records.ts @@ -0,0 +1,150 @@ +/** + * 一次性迁移脚本:为现有员工创建初始版本记录 + * 运行方式:npx tsx scripts/migrate-records.ts + */ +import prisma from '../src/lib/prisma.js' +import { decrypt } from '../src/lib/crypto.js' + +function dateToMonth(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return `${y}-${m}` +} + +function prevMonth(month: string): string { + const [y, m] = month.split('-').map(Number) + const d = new Date(y, m - 2, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + +async function main() { + const employees = await prisma.employee.findMany({ + include: { + terminations: { orderBy: { terminationDate: 'desc' }, take: 1 }, + salaryChanges: { orderBy: { createdAt: 'desc' }, take: 1 }, + socialInsRecords: { take: 1 }, + housingFundRecords: { take: 1 }, + departmentRecords: { take: 1 }, + }, + }) + + console.log(`Found ${employees.length} employees to migrate`) + + for (const emp of employees) { + const hireMonth = dateToMonth(emp.hireDate) + const termination = emp.terminations[0] + const endMonth = termination ? dateToMonth(termination.terminationDate) : null + + // 解密月薪获取数值 + let salaryNum = 0 + try { + salaryNum = parseFloat(decrypt(emp.monthlySalary)) || 0 + } catch { + salaryNum = parseFloat(emp.monthlySalary) || 0 + } + + const socialInsBase = emp.socialInsBase ?? salaryNum + const housingFundBase = emp.housingFundBase ?? salaryNum + + // 1. 社保缴费记录(仅当尚无记录时创建) + if (emp.socialInsRecords.length === 0) { + await prisma.employeeSocialInsRecord.create({ + data: { + orgId: emp.orgId, + employeeId: emp.id, + startMonth: emp.socialInsStartMonth || hireMonth, + endMonth: endMonth || emp.socialInsEndMonth || null, + base: socialInsBase, + changeType: 'ONBOARDING', + createdBy: emp.createdBy, + }, + }) + } + + // 2. 公积金缴费记录 + if (emp.housingFundRecords.length === 0) { + await prisma.employeeHousingFundRecord.create({ + data: { + orgId: emp.orgId, + employeeId: emp.id, + startMonth: emp.housingFundStartMonth || hireMonth, + endMonth: endMonth || emp.housingFundEndMonth || null, + base: housingFundBase, + changeType: 'ONBOARDING', + createdBy: emp.createdBy, + }, + }) + } + + // 3. 薪资变更记录(仅当尚无记录时创建) + if (emp.salaryChanges.length === 0) { + await prisma.salaryChangeRecord.create({ + data: { + orgId: emp.orgId, + employeeId: emp.id, + oldSalary: 0, + newSalary: salaryNum, + effectiveDate: emp.hireDate, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', + createdBy: emp.createdBy, + }, + }) + } else { + // 已有记录但缺少 effectiveMonth/endMonth/changeType,补充 + const latest = emp.salaryChanges[0] + if (!latest.effectiveMonth || !latest.changeType) { + await prisma.salaryChangeRecord.update({ + where: { id: latest.id }, + data: { + effectiveMonth: dateToMonth(latest.effectiveDate), + changeType: latest.changeType || 'SALARY_CHANGE', + }, + }) + } + } + + // 4. 部门变更记录 + if (emp.departmentRecords.length === 0) { + await prisma.employeeDepartmentRecord.create({ + data: { + orgId: emp.orgId, + employeeId: emp.id, + oldDepartment: '', + newDepartment: emp.department, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', + createdBy: emp.createdBy, + }, + }) + } + + // 5. 同步 Employee 便捷字段 + await prisma.employee.update({ + where: { id: emp.id }, + data: { + socialInsStartMonth: emp.socialInsStartMonth || hireMonth, + socialInsEndMonth: endMonth || emp.socialInsEndMonth || null, + socialInsBase, + housingFundStartMonth: emp.housingFundStartMonth || hireMonth, + housingFundEndMonth: endMonth || emp.housingFundEndMonth || null, + housingFundBase, + }, + }) + + console.log(` ✓ ${emp.name} (${emp.department}) — records created/synced`) + } + + console.log('\nMigration complete!') +} + +main() + .catch((e) => { + console.error('Migration failed:', e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 741f793..6f2562b 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -182,7 +182,7 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun // 创建批次 const createBatchSchema = z.object({ month: z.string().regex(/^\d{4}-\d{2}$/), - type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'), + type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'), mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'), sourceBatchId: z.string().optional(), name: z.string().optional(), @@ -211,7 +211,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1) const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}` - const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}` + const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}` // 根据模式确定员工列表和数据来源 let employees: any[] = [] @@ -236,7 +236,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti }) } else { // copy_last 或 blank_employees:拉入员工 - if (type === 'TERMINATION') { + if (type === 'TERMINATION' || type === 'SEVERANCE') { const terminations = await prisma.terminationRecord.findMany({ where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } }, include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } }, @@ -317,7 +317,7 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti // 计算社保、个税等 // 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖 - const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0 + const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0 const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial }) // 风险提示 diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 4291fa7..7c73aa1 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -2,7 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express' import { authMiddleware, AuthRequest } from '../middleware/auth' import { auditLog } from '../middleware/auditLog' import prisma from '../lib/prisma' -import { decrypt } from '../lib/crypto' +import { decrypt, encrypt } from '../lib/crypto' import { getContractStatus } from '../services/contract.service' const router = Router() @@ -570,4 +570,135 @@ router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: } catch (err) { next(err) } }) +// ========== 调薪/调部门 API ========== + +function dateToMonth(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return `${y}-${m}` +} + +function prevMonth(month: string): string { + const [y, m] = month.split('-').map(Number) + const d = new Date(y, m - 2, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + +// 调薪 +router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { newSalary, effectiveMonth, reason } = req.body + const employee = await prisma.employee.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + } + + const oldSalary = safeDecrypt(employee.monthlySalary) + const effMonth = effectiveMonth || dateToMonth(new Date()) + const prevEffMonth = prevMonth(effMonth) + + // 关闭之前有效记录 + await prisma.salaryChangeRecord.updateMany({ + where: { employeeId: req.params.id, endMonth: null }, + data: { endMonth: prevEffMonth }, + }) + + // 创建新薪资记录 + const record = await prisma.salaryChangeRecord.create({ + data: { + orgId: req.user!.orgId, + employeeId: req.params.id, + oldSalary, + newSalary: Number(newSalary), + effectiveDate: new Date(`${effMonth}-01`), + effectiveMonth: effMonth, + endMonth: null, + changeType: 'SALARY_CHANGE', + reason: reason || null, + createdBy: req.user!.id, + }, + }) + + // 同步 Employee 便捷字段 + await prisma.employee.update({ + where: { id: req.params.id }, + data: { monthlySalary: encrypt(String(newSalary)) }, + }) + + await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary }) + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + +// 调薪历史 +router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const records = await prisma.salaryChangeRecord.findMany({ + where: { employeeId: req.params.id, orgId: req.user!.orgId }, + orderBy: { effectiveDate: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + +// 调部门 +router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const { newDepartment, effectiveMonth, reason } = req.body + const employee = await prisma.employee.findFirst({ + where: { id: req.params.id, orgId: req.user!.orgId }, + }) + if (!employee) { + return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } }) + } + + const oldDepartment = employee.department + const effMonth = effectiveMonth || dateToMonth(new Date()) + const prevEffMonth = prevMonth(effMonth) + + // 关闭之前有效记录 + await prisma.employeeDepartmentRecord.updateMany({ + where: { employeeId: req.params.id, endMonth: null }, + data: { endMonth: prevEffMonth }, + }) + + // 创建新部门记录 + const record = await prisma.employeeDepartmentRecord.create({ + data: { + orgId: req.user!.orgId, + employeeId: req.params.id, + oldDepartment, + newDepartment, + effectiveMonth: effMonth, + endMonth: null, + changeType: 'TRANSFER', + reason: reason || null, + createdBy: req.user!.id, + }, + }) + + // 同步 Employee 便捷字段 + await prisma.employee.update({ + where: { id: req.params.id }, + data: { department: newDepartment }, + }) + + await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment }) + res.json({ success: true, data: record }) + } catch (err) { next(err) } +}) + +// 调部门历史 +router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => { + try { + const records = await prisma.employeeDepartmentRecord.findMany({ + where: { employeeId: req.params.id, orgId: req.user!.orgId }, + orderBy: { effectiveMonth: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + export default router diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index 8fd1924..f4d9913 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' const router = Router() router.use(authMiddleware) -const configFields = { +const socialConfigFields = { city: z.string().optional(), pensionOrg: z.number().optional(), pensionEmp: z.number().optional(), @@ -17,6 +17,12 @@ const configFields = { unemploymentEmp: z.number().optional(), injuryOrg: z.number().optional(), maternityOrg: z.number().optional(), + baseMin: z.number().optional(), + baseMax: z.number().optional(), +} + +const housingConfigFields = { + city: z.string().optional(), housingOrg: z.number().optional(), housingEmp: z.number().optional(), baseMin: z.number().optional(), @@ -85,7 +91,7 @@ router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, ne // 新建版本(年度调基/比例变更) const createVersionSchema = z.object({ - ...configFields, + ...socialConfigFields, effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/), }) @@ -147,7 +153,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, - select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: true, monthlySalary: true }, + select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true }, orderBy: { name: 'asc' }, }) @@ -180,21 +186,16 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, let monthlyWage = 0 try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 } const oldSocialBase = emp.socialInsBase ?? monthlyWage - const oldHousingBase = emp.housingFundBase ?? monthlyWage const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage - // 建议基数 = 上年月均工资按上下限裁剪 const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax) - const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax) return { employeeId: emp.id, name: emp.name, department: emp.department, oldSocialBase, - oldHousingBase, avgSalary, monthlyWage, suggestedSocialBase, - suggestedHousingBase, } }) @@ -209,7 +210,6 @@ const adjustApplySchema = z.object({ items: z.array(z.object({ employeeId: z.string(), newSocialBase: z.number(), - newHousingBase: z.number(), })), }) @@ -225,19 +225,40 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' }) const { items } = adjustApplySchema.parse(req.body) + const adjustMonth = config.effectiveFrom + const prevAdjustMonth = (() => { + const [y, m] = adjustMonth.split('-').map(Number) + const d = new Date(y, m - 2, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + })() let adjusted = 0 for (const item of items) { - // 裁剪到上下限范围内 const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax) - const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax) + // 关闭旧社保记录 + await prisma.employeeSocialInsRecord.updateMany({ + where: { employeeId: item.employeeId, endMonth: null }, + data: { endMonth: prevAdjustMonth }, + }) + + // 创建新社保记录 + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: item.employeeId, + startMonth: adjustMonth, + endMonth: null, + base: socialBase, + changeType: 'ADJUST', + createdBy: req.user!.id, + }, + }) + + // 同步 Employee 便捷字段 await prisma.employee.update({ where: { id: item.employeeId }, - data: { - socialInsBase: socialBase, - housingFundBase: housingBase, - }, + data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth }, }) adjusted++ } @@ -296,11 +317,8 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc const unemploymentEmp = actualBase * config.unemploymentEmp / 100 const injuryOrg = actualBase * config.injuryOrg / 100 const maternityOrg = actualBase * config.maternityOrg / 100 - const housingOrg = actualBase * config.housingOrg / 100 - const housingEmp = actualBase * config.housingEmp / 100 - - const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + housingOrg - const totalEmp = pensionEmp + medicalEmp + unemploymentEmp + housingEmp + const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + const totalEmp = pensionEmp + medicalEmp + unemploymentEmp const total = totalOrg + totalEmp res.json({ @@ -317,7 +335,6 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc { name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp }, { name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 }, { name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 }, - { name: '住房公积金', orgRate: config.housingOrg, empRate: config.housingEmp, orgAmount: housingOrg, empAmount: housingEmp }, ], totalOrg, totalEmp, @@ -329,4 +346,431 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc } }) +// ========== 公积金配置 ========== + +// 获取当前公积金配置 +router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + let config = await prisma.housingFundConfig.findFirst({ + where: { orgId: req.user!.orgId, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, + }) + if (!config) { + config = await prisma.housingFundConfig.create({ + data: { + orgId: req.user!.orgId, + effectiveFrom: new Date().toISOString().slice(0, 7), + createdBy: req.user!.id, + }, + }) + } + res.json({ success: true, data: config }) + } catch (err) { + next(err) + } +}) + +// 公积金配置版本列表 +router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const versions = await prisma.housingFundConfig.findMany({ + where: { orgId: req.user!.orgId }, + orderBy: { effectiveFrom: 'desc' }, + }) + res.json({ success: true, data: versions }) + } catch (err) { + next(err) + } +}) + +// 新建公积金配置版本 +const createHousingVersionSchema = z.object({ + ...housingConfigFields, + effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/), +}) + +router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const data = createHousingVersionSchema.parse(req.body) + const orgId = req.user!.orgId + + const existing = await prisma.housingFundConfig.findUnique({ + where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } }, + }) + if (existing) { + return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` }) + } + + const prevCurrent = await prisma.housingFundConfig.findFirst({ + where: { orgId, isCurrent: true }, + }) + if (prevCurrent) { + const [year, mon] = data.effectiveFrom.split('-').map(Number) + const prevMonth = mon === 1 + ? `${year - 1}-12` + : `${year}-${String(mon - 1).padStart(2, '0')}` + await prisma.housingFundConfig.update({ + where: { id: prevCurrent.id }, + data: { isCurrent: false, effectiveTo: prevMonth }, + }) + } + + const version = await prisma.housingFundConfig.create({ + data: { + orgId, + ...data, + isCurrent: true, + createdBy: req.user!.id, + }, + }) + res.json({ success: true, data: version }) + } catch (err) { + next(err) + } +}) + +// 公积金计算 +router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { base, month } = calcSchema.parse(req.body) + const orgId = req.user!.orgId + + let config + if (month) { + config = await prisma.housingFundConfig.findFirst({ + where: { + orgId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }) + } + if (!config) { + config = await prisma.housingFundConfig.findFirst({ + where: { orgId, isCurrent: true }, + }) + } + if (!config) { + config = await prisma.housingFundConfig.create({ + data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id }, + }) + } + + const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) + const housingOrg = actualBase * config.housingOrg / 100 + const housingEmp = actualBase * config.housingEmp / 100 + + res.json({ + success: true, + data: { + actualBase, + originalBase: base, + capped: base > config.baseMax, + floored: base < config.baseMin, + configVersion: config.effectiveFrom, + housingOrg, + housingEmp, + total: housingOrg + housingEmp, + }, + }) + } catch (err) { + next(err) + } +}) + +// 公积金调基预览 +router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const config = await prisma.housingFundConfig.findFirst({ + where: { id, orgId }, + }) + if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' }) + if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' }) + + const employees = await prisma.employee.findMany({ + where: { orgId, status: 'ACTIVE' }, + select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true }, + orderBy: { name: 'asc' }, + }) + + const now = new Date() + const lastYearStart = `${now.getFullYear() - 1}-01` + const lastYearEnd = `${now.getFullYear() - 1}-12` + + const lastYearPayslips = await prisma.payslip.findMany({ + where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } }, + select: { employeeId: true, totalPay: true }, + }) + + const empPayslipMap = new Map() + for (const p of lastYearPayslips) { + if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, []) + empPayslipMap.get(p.employeeId)!.push(p.totalPay) + } + + const items = employees.map((emp) => { + let monthlyWage = 0 + try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 } + const oldBase = emp.housingFundBase ?? monthlyWage + const payslips = empPayslipMap.get(emp.id) + const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage + const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax) + return { + employeeId: emp.id, + name: emp.name, + department: emp.department, + oldBase, + avgSalary, + monthlyWage, + suggestedBase, + } + }) + + res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } }) + } catch (err) { + next(err) + } +}) + +// 执行公积金调基 +const adjustHousingSchema = z.object({ + items: z.array(z.object({ + employeeId: z.string(), + newBase: z.number(), + })), +}) + +router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const { id } = req.params + const orgId = req.user!.orgId + + const config = await prisma.housingFundConfig.findFirst({ + where: { id, orgId }, + }) + if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' }) + if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' }) + + const { items } = adjustHousingSchema.parse(req.body) + const adjustMonth = config.effectiveFrom + const prevAdjustMonth = (() => { + const [y, m] = adjustMonth.split('-').map(Number) + const d = new Date(y, m - 2, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + })() + + let adjusted = 0 + for (const item of items) { + const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax) + + // 关闭旧记录 + await prisma.employeeHousingFundRecord.updateMany({ + where: { employeeId: item.employeeId, endMonth: null }, + data: { endMonth: prevAdjustMonth }, + }) + + // 创建新记录 + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: item.employeeId, + startMonth: adjustMonth, + endMonth: null, + base, + changeType: 'ADJUST', + createdBy: req.user!.id, + }, + }) + + // 同步 Employee 便捷字段 + await prisma.employee.update({ + where: { id: item.employeeId }, + data: { housingFundBase: base, housingFundStartMonth: adjustMonth }, + }) + adjusted++ + } + + await prisma.housingFundConfig.update({ + where: { id }, + data: { adjustmentDone: true }, + }) + + res.json({ success: true, data: { adjusted, total: items.length } }) + } catch (err) { + next(err) + } +}) + +// ========== 月度增减员 ========== + +// 社保月度增减员 +router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + + // 增员:startMonth == month + const additions = await prisma.employeeSocialInsRecord.findMany({ + where: { orgId, startMonth: month }, + include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + // 减员:endMonth == month 且 changeType == TERMINATION + const reductions = await prisma.employeeSocialInsRecord.findMany({ + where: { orgId, endMonth: month, changeType: 'TERMINATION' }, + include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + res.json({ + success: true, + data: { + month, + additions: additions.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + startMonth: r.startMonth, + changeType: r.changeType, + })), + reductions: reductions.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + endMonth: r.endMonth, + changeType: r.changeType, + })), + }, + }) + } catch (err) { + next(err) + } +}) + +// 公积金月度增减员 +router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + + const additions = await prisma.employeeHousingFundRecord.findMany({ + where: { orgId, startMonth: month }, + include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + const reductions = await prisma.employeeHousingFundRecord.findMany({ + where: { orgId, endMonth: month, changeType: 'TERMINATION' }, + include: { employee: { select: { name: true, department: true, idCardNumber: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + res.json({ + success: true, + data: { + month, + additions: additions.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + startMonth: r.startMonth, + changeType: r.changeType, + })), + reductions: reductions.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + endMonth: r.endMonth, + changeType: r.changeType, + })), + }, + }) + } catch (err) { + next(err) + } +}) + +// ========== 在职申报 ========== + +// 社保在保人员 +router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + + const records = await prisma.employeeSocialInsRecord.findMany({ + where: { + orgId, + startMonth: { lte: month }, + OR: [{ endMonth: null }, { endMonth: { gte: month } }], + }, + include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + res.json({ + success: true, + data: { + month, + items: records.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + })), + }, + }) + } catch (err) { + next(err) + } +}) + +// 公积金在保人员 +router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + const orgId = req.user!.orgId + + const records = await prisma.employeeHousingFundRecord.findMany({ + where: { + orgId, + startMonth: { lte: month }, + OR: [{ endMonth: null }, { endMonth: { gte: month } }], + }, + include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } }, + orderBy: { createdAt: 'asc' }, + }) + + res.json({ + success: true, + data: { + month, + items: records.map((r) => ({ + employeeId: r.employeeId, + name: r.employee.name, + department: r.employee.department, + base: r.base, + startMonth: r.startMonth, + endMonth: r.endMonth, + changeType: r.changeType, + })), + }, + }) + } catch (err) { + next(err) + } +}) + export default router diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index d0e7702..0db4c1d 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -6,6 +6,18 @@ function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) } +function dateToMonth(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return `${y}-${m}` +} + +function prevMonth(month: string): string { + const [y, m] = month.split('-').map(Number) + const d = new Date(y, m - 2, 1) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` +} + export function getContractStatus(contract: { signDate: Date | null startDate: Date @@ -155,12 +167,20 @@ export async function getEmployeeDetail(orgId: string, id: string) { } export async function createEmployee(orgId: string, userId: string, data: any) { + const hireDate = new Date(data.hireDate) + const hireMonth = dateToMonth(hireDate) + const salaryNum = Number(data.monthlySalary) || 0 + const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum + const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const socialInsStartMonth = data.socialInsStartMonth || hireMonth + const housingFundStartMonth = data.housingFundStartMonth || hireMonth + const employee = await prisma.employee.create({ data: { orgId, name: data.name, department: data.department, - hireDate: new Date(data.hireDate), + hireDate, monthlySalary: encrypt(data.monthlySalary), gender: data.gender, phone: data.phone, @@ -168,6 +188,65 @@ export async function createEmployee(orgId: string, userId: string, data: any) { isPregnant: data.isPregnant || false, isInMedicalPeriod: data.isInMedicalPeriod || false, isWorkInjured: data.isWorkInjured || false, + socialInsBase, + housingFundBase, + socialInsStartMonth, + housingFundStartMonth, + createdBy: userId, + }, + }) + + // 创建社保缴费记录 + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: employee.id, + startMonth: socialInsStartMonth, + endMonth: null, + base: socialInsBase, + changeType: 'ONBOARDING', + createdBy: userId, + }, + }) + + // 创建公积金缴费记录 + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: employee.id, + startMonth: housingFundStartMonth, + endMonth: null, + base: housingFundBase, + changeType: 'ONBOARDING', + createdBy: userId, + }, + }) + + // 创建初始薪资变更记录 + await prisma.salaryChangeRecord.create({ + data: { + orgId, + employeeId: employee.id, + oldSalary: 0, + newSalary: salaryNum, + effectiveDate: hireDate, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', + createdBy: userId, + }, + }) + + // 创建初始部门记录 + await prisma.employeeDepartmentRecord.create({ + data: { + orgId, + employeeId: employee.id, + oldDepartment: '', + newDepartment: data.department, + effectiveMonth: hireMonth, + endMonth: null, + changeType: 'ONBOARDING', createdBy: userId, }, }) @@ -227,6 +306,38 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' } } + const newHireMonth = dateToMonth(newHireDate) + const salaryNum = Number(decrypt(employee.monthlySalary)) || 0 + const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum + const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum + const socialInsStartMonth = data.socialInsStartMonth || newHireMonth + const housingFundStartMonth = data.housingFundStartMonth || newHireMonth + const prevHireMonth = prevMonth(newHireMonth) + + // 关闭旧社保缴费记录 + await prisma.employeeSocialInsRecord.updateMany({ + where: { employeeId: id, endMonth: null }, + data: { endMonth: prevHireMonth }, + }) + + // 关闭旧公积金缴费记录 + await prisma.employeeHousingFundRecord.updateMany({ + where: { employeeId: id, endMonth: null }, + data: { endMonth: prevHireMonth }, + }) + + // 关闭旧薪资记录 + await prisma.salaryChangeRecord.updateMany({ + where: { employeeId: id, endMonth: null }, + data: { endMonth: prevHireMonth }, + }) + + // 关闭旧部门记录 + await prisma.employeeDepartmentRecord.updateMany({ + where: { employeeId: id, endMonth: null }, + data: { endMonth: prevHireMonth }, + }) + await prisma.employee.update({ where: { id }, data: { @@ -236,6 +347,67 @@ export async function rehireEmployee(orgId: string, userId: string, id: string, isPregnant: false, isInMedicalPeriod: false, isWorkInjured: false, + socialInsBase, + housingFundBase, + socialInsStartMonth, + socialInsEndMonth: null, + housingFundStartMonth, + housingFundEndMonth: null, + }, + }) + + // 创建新社保缴费记录 + await prisma.employeeSocialInsRecord.create({ + data: { + orgId, + employeeId: id, + startMonth: socialInsStartMonth, + endMonth: null, + base: socialInsBase, + changeType: 'REHIRE', + createdBy: userId, + }, + }) + + // 创建新公积金缴费记录 + await prisma.employeeHousingFundRecord.create({ + data: { + orgId, + employeeId: id, + startMonth: housingFundStartMonth, + endMonth: null, + base: housingFundBase, + changeType: 'REHIRE', + createdBy: userId, + }, + }) + + // 创建新薪资记录 + await prisma.salaryChangeRecord.create({ + data: { + orgId, + employeeId: id, + oldSalary: salaryNum, + newSalary: salaryNum, + effectiveDate: newHireDate, + effectiveMonth: newHireMonth, + endMonth: null, + changeType: 'REHIRE', + createdBy: userId, + }, + }) + + // 创建新部门记录 + await prisma.employeeDepartmentRecord.create({ + data: { + orgId, + employeeId: id, + oldDepartment: employee.department, + newDepartment: data.department || employee.department, + effectiveMonth: newHireMonth, + endMonth: null, + changeType: 'REHIRE', + createdBy: userId, }, }) @@ -287,13 +459,23 @@ export async function updateEmployee(orgId: string, id: string, data: any) { updateData.monthlySalary = encrypt(data.monthlySalary) // 记录薪资变更 if (oldSalary !== newSalary) { + const now = new Date() + const nowMonth = dateToMonth(now) + // 关闭之前有效记录 + await prisma.salaryChangeRecord.updateMany({ + where: { employeeId: id, endMonth: null }, + data: { endMonth: prevMonth(nowMonth) }, + }) await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: id, oldSalary, newSalary, - effectiveDate: new Date(), + effectiveDate: now, + effectiveMonth: nowMonth, + endMonth: null, + changeType: 'SALARY_CHANGE', reason: data.salaryChangeReason || '手动调整', createdBy: '', }, diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index fe4b767..b0aa4c6 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -117,7 +117,7 @@ export async function calcBatchEntry( batchType: string = 'REGULAR', options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } }, ) { - const [employee, socialConfig] = await Promise.all([ + const [employee, socialConfig, housingConfig] = await Promise.all([ prisma.employee.findFirst({ where: { id: employeeId, orgId } }), prisma.socialInsuranceConfig.findFirst({ where: { @@ -127,6 +127,14 @@ export async function calcBatchEntry( }, orderBy: { effectiveFrom: 'desc' }, }), + prisma.housingFundConfig.findFirst({ + where: { + orgId, + effectiveFrom: { lte: month }, + OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], + }, + orderBy: { effectiveFrom: 'desc' }, + }), ]) if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } @@ -136,13 +144,15 @@ export async function calcBatchEntry( let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 - // 年终奖/奖金批次:不扣社保公积金 - if (batchType !== 'BONUS' && !options?.skipSocial) { + // 年终奖/奖金批次、补偿金批次:不扣社保公积金 + if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) { if (socialConfig) { const social = calcSocialInsurance(socialBase, socialConfig) - const housing = calcHousingFund(housingBase, socialConfig) socialEmp = social.socialEmp socialOrg = social.socialOrg + } + if (housingConfig) { + const housing = calcHousingFund(housingBase, housingConfig) housingEmp = housing.housingEmp housingOrg = housing.housingOrg } @@ -164,7 +174,7 @@ export async function calcBatchEntry( // 年终奖单独计税 tax = calcBonusTax(inputs.bonus) } else { - // 累计预扣法 + // 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除) const year = month.slice(0, 4) const prevPayslips = await prisma.payslip.findMany({ where: { diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index b6628cb..5fcb644 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -282,7 +282,7 @@ export async function getDashboardData(orgId: string) { const [ employeeCount, highRisks, pendingRisks, riskItems, resolvedItems, - overtimeRecords, payslips, batchEntries, socialConfig, + overtimeRecords, payslips, batchEntries, socialConfig, housingConfig, monthContracts, monthTerminations, monthDisciplinary, monthAttendance, monthSeverancePay, ] = await Promise.all([ @@ -315,6 +315,7 @@ export async function getDashboardData(orgId: string) { select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true }, }), prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), + prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.laborContract.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, }), @@ -408,8 +409,8 @@ export async function getDashboardData(orgId: string) { const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount - housingOrgTotal = avgBase * socialConfig.housingOrg / 100 * employeeCount - housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount + housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount + housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount } // 个税:优先用归档批次的实际计算值,否则估算 diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index 70a00b8..518cc6d 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -6,6 +6,12 @@ function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) } +function dateToMonth(date: Date): string { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return `${y}-${m}` +} + export interface ChecklistItem { key: string label: string @@ -152,14 +158,21 @@ export async function createTermination(orgId: string, userId: string, data: any const { level } = assessRisk(employee, data.reason) + const termDate = new Date(data.terminationDate) + const termMonth = dateToMonth(termDate) + const socialInsEndMonth = data.socialInsEndMonth || termMonth + const housingFundEndMonth = data.housingFundEndMonth || termMonth + const record = await prisma.terminationRecord.create({ data: { orgId, employeeId: data.employeeId, type: 'TERMINATION', reason: data.reason, - terminationDate: new Date(data.terminationDate), + terminationDate: termDate, compensation: data.compensation || 0, + socialInsEndMonth, + housingFundEndMonth, riskLevel: level, checklist: data.checklist || {}, remark: data.remark, @@ -167,15 +180,30 @@ export async function createTermination(orgId: string, userId: string, data: any }, }) + // 关闭社保缴费记录(设置 endMonth) + await prisma.employeeSocialInsRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: socialInsEndMonth, changeRefId: record.id }, + }) + + // 关闭公积金缴费记录 + await prisma.employeeHousingFundRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: housingFundEndMonth, changeRefId: record.id }, + }) + // 根据解聘日期判断在职/离职状态 - const termDate = new Date(data.terminationDate) const today = new Date() today.setHours(0, 0, 0, 0) const isResigned = termDate <= today await prisma.employee.update({ where: { id: data.employeeId }, - data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' }, + data: { + status: isResigned ? 'RESIGNED' : 'ACTIVE', + socialInsEndMonth, + housingFundEndMonth, + }, }) await prisma.riskItem.updateMany({ @@ -202,15 +230,22 @@ export async function createResignation(orgId: string, userId: string, data: any throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' } } + const termDate = new Date(data.terminationDate) + const termMonth = dateToMonth(termDate) + const socialInsEndMonth = data.socialInsEndMonth || termMonth + const housingFundEndMonth = data.housingFundEndMonth || termMonth + const record = await prisma.terminationRecord.create({ data: { orgId, employeeId: data.employeeId, type: 'RESIGNATION', reason: 'RESIGNATION', - terminationDate: new Date(data.terminationDate), + terminationDate: termDate, resignationReason: data.resignationReason || null, compensation: 0, + socialInsEndMonth, + housingFundEndMonth, riskLevel: 'SAFE', checklist: {}, remark: data.remark || null, @@ -218,15 +253,30 @@ export async function createResignation(orgId: string, userId: string, data: any }, }) + // 关闭社保缴费记录 + await prisma.employeeSocialInsRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: socialInsEndMonth, changeRefId: record.id }, + }) + + // 关闭公积金缴费记录 + await prisma.employeeHousingFundRecord.updateMany({ + where: { employeeId: data.employeeId, endMonth: null }, + data: { endMonth: housingFundEndMonth, changeRefId: record.id }, + }) + // 根据离职日期判断在职/离职状态 - const termDate = new Date(data.terminationDate) const today = new Date() today.setHours(0, 0, 0, 0) const isResigned = termDate <= today await prisma.employee.update({ where: { id: data.employeeId }, - data: { status: isResigned ? 'RESIGNED' : 'ACTIVE' }, + data: { + status: isResigned ? 'RESIGNED' : 'ACTIVE', + socialInsEndMonth, + housingFundEndMonth, + }, }) await prisma.riskItem.updateMany({ diff --git a/frontend/src/pages/Money.tsx b/frontend/src/pages/Money.tsx index 9cdc06d..92e5e04 100644 --- a/frontend/src/pages/Money.tsx +++ b/frontend/src/pages/Money.tsx @@ -56,7 +56,7 @@ function BatchManager() { const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) const [selectedBatchId, setSelectedBatchId] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) - const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS'>('REGULAR') + const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR') const [createMode, setCreateMode] = useState<'copy_last' | 'blank_employees' | 'blank_all' | 'copy_batch'>('copy_last') const [sourceBatchId, setSourceBatchId] = useState('') const [page, setPage] = useState(1) @@ -143,6 +143,7 @@ function BatchManager() { +
@@ -206,6 +207,7 @@ function BatchManager() { {batch.employeeCount} 人 · 应发 ¥{fmt(batch.totalPay)} · 实发 ¥{fmt(batch.totalNetPay)} {batch.type === 'BONUS' && ' · 单独计税'} {batch.type === 'TERMINATION' && ' · 离职结算'} + {batch.type === 'SEVERANCE' && ' · 补偿金'}
diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index aba42ea..7424763 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -23,6 +23,10 @@ export default function Roster() { const [resignEmployee, setResignEmployee] = useState(null) const [showRehireModal, setShowRehireModal] = useState(false) const [rehireEmployee, setRehireEmployee] = useState(null) + const [showSalaryModal, setShowSalaryModal] = useState(false) + const [salaryEmployee, setSalaryEmployee] = useState(null) + const [showDeptModal, setShowDeptModal] = useState(false) + const [deptEmployee, setDeptEmployee] = useState(null) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(10) @@ -71,6 +75,26 @@ export default function Roster() { }, }) + const salaryChangeMutation = useMutation({ + mutationFn: (data: any) => api.post(`/roster/${salaryEmployee?.id}/salary-change`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['roster'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + setShowSalaryModal(false) + setSalaryEmployee(null) + }, + }) + + const deptChangeMutation = useMutation({ + mutationFn: (data: any) => api.post(`/roster/${deptEmployee?.id}/department-change`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['roster'] }) + queryClient.invalidateQueries({ queryKey: ['dashboard'] }) + setShowDeptModal(false) + setDeptEmployee(null) + }, + }) + const filtered = employees?.filter((e: any) => !search || e.name.includes(search) || e.department.includes(search) ) || [] @@ -179,16 +203,38 @@ export default function Roster() { {e.counts?.payslips || 0} {e.status === 'ACTIVE' && !e.hasTermination && ( - +
+ + + +
)} {e.hasTermination && e.status === 'ACTIVE' && (
@@ -257,6 +303,26 @@ export default function Roster() { error={rehireMutation.error as any} /> )} + + {showSalaryModal && salaryEmployee && ( + { setShowSalaryModal(false); setSalaryEmployee(null) }} + onSubmit={(data) => salaryChangeMutation.mutate(data)} + loading={salaryChangeMutation.isPending} + error={salaryChangeMutation.error as any} + /> + )} + + {showDeptModal && deptEmployee && ( + { setShowDeptModal(false); setDeptEmployee(null) }} + onSubmit={(data) => deptChangeMutation.mutate(data)} + loading={deptChangeMutation.isPending} + error={deptChangeMutation.error as any} + /> + )}
) } @@ -729,6 +795,136 @@ function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; ) } +function SalaryChangeModal({ employee, onClose, onSubmit, loading, error }: { + employee: any + onClose: () => void + onSubmit: (data: any) => void + loading: boolean + error: any +}) { + const todayStr = new Date().toISOString().slice(0, 10) + const [form, setForm] = useState({ + newSalary: '', + effectiveDate: todayStr, + reason: '', + }) + + const handleSubmit = () => { + onSubmit({ + newSalary: parseFloat(form.newSalary), + effectiveDate: new Date(form.effectiveDate).toISOString(), + reason: form.reason || undefined, + }) + } + + const canSubmit = form.newSalary && parseFloat(form.newSalary) > 0 && form.effectiveDate + + return ( + +
+
+
+ +
{employee.name} - {employee.department}
+
+
+ +
¥{fmt(employee.monthlySalary)}
+
+
+
+
+ + setForm({ ...form, newSalary: e.target.value })} placeholder="元" /> +
+
+ + setForm({ ...form, effectiveDate: e.target.value })} /> +
+
+
+ + setForm({ ...form, reason: e.target.value })} placeholder="如:年度调薪、晋升加薪" /> +
+ {error && ( +
+ {(error as any)?.response?.data?.error?.message || '操作失败,请重试'} +
+ )} +
+ + +
+
+
+ ) +} + +function DeptChangeModal({ employee, onClose, onSubmit, loading, error }: { + employee: any + onClose: () => void + onSubmit: (data: any) => void + loading: boolean + error: any +}) { + const todayStr = new Date().toISOString().slice(0, 10) + const [form, setForm] = useState({ + newDepartment: employee.department || '', + effectiveDate: todayStr, + reason: '', + }) + + const handleSubmit = () => { + onSubmit({ + newDepartment: form.newDepartment, + effectiveDate: new Date(form.effectiveDate).toISOString(), + reason: form.reason || undefined, + }) + } + + const canSubmit = form.newDepartment && form.effectiveDate && form.newDepartment !== employee.department + + return ( + +
+
+
+ +
{employee.name}
+
+
+ +
{employee.department}
+
+
+
+
+ + setForm({ ...form, newDepartment: e.target.value })} placeholder="如:市场部" /> +
+
+ + setForm({ ...form, effectiveDate: e.target.value })} /> +
+
+
+ + setForm({ ...form, reason: e.target.value })} placeholder="如:组织架构调整" /> +
+ {error && ( +
+ {(error as any)?.response?.data?.error?.message || '操作失败,请重试'} +
+ )} +
+ + +
+
+
+ ) +} + function ResignModal({ employee, onClose, onSubmit, loading, error }: { employee: any onClose: () => void @@ -740,8 +936,12 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: { terminationDate: new Date().toISOString().slice(0, 10), resignationReason: '个人原因', remark: '', + socialInsEndMonth: '', + housingFundEndMonth: '', }) + const terminationMonth = form.terminationDate ? form.terminationDate.slice(0, 7) : '' + const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他'] const handleSubmit = () => { @@ -750,6 +950,8 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: { terminationDate: new Date(form.terminationDate).toISOString(), resignationReason: form.resignationReason, remark: form.remark || undefined, + socialInsEndMonth: form.socialInsEndMonth || terminationMonth, + housingFundEndMonth: form.housingFundEndMonth || terminationMonth, }) } @@ -777,6 +979,26 @@ function ResignModal({ employee, onClose, onSubmit, loading, error }: { {reasons.map((r) => )} +
+ +
默认与离职日期同月,可手动修改
+
+
+ + setForm({ ...form, socialInsEndMonth: e.target.value })} /> +
+
+ + setForm({ ...form, housingFundEndMonth: e.target.value })} /> +
+
+ {((form.socialInsEndMonth && form.socialInsEndMonth !== terminationMonth) || (form.housingFundEndMonth && form.housingFundEndMonth !== terminationMonth)) && ( +
+ + 截止缴费年月与离职日期不在同月,请确认是否为多缴/少缴月份。 +
+ )} +
setForm({ ...form, remark: e.target.value })} placeholder="补充说明" /> @@ -821,7 +1043,10 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: { contractYears: 3, probationMonths: 0, probationSalary: 0, + socialInsBase: '', socialInsStartMonth: '', + housingFundBase: '', housingFundStartMonth: '', }) + const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : '' // 计算合同月数 const contractMonths = (() => { @@ -914,6 +1139,10 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: { const data: any = { hireDate: new Date(form.hireDate).toISOString(), department: form.department, + socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined, + socialInsStartMonth: form.socialInsStartMonth || undefined, + housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined, + housingFundStartMonth: form.housingFundStartMonth || undefined, } if (form.contractType !== 'UNSIGNED' && form.startDate) { data.contract = { @@ -953,6 +1182,28 @@ function RehireModal({ employee, onClose, onSubmit, loading, error }: { handleHireDateChange(e.target.value)} />
+
+ +
默认与月工资一致,可手动修改
+
+
+ + setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" /> +
+
+ + setForm({ ...form, socialInsStartMonth: e.target.value })} /> +
+
+ + setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" /> +
+
+ + setForm({ ...form, housingFundStartMonth: e.target.value })} /> +
+
+
setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
+
+ +
默认与月工资一致,可手动修改
+
+
+ + setForm({ ...form, socialInsBase: e.target.value })} placeholder="默认为月工资" /> +
+
+ + setForm({ ...form, socialInsStartMonth: e.target.value })} /> +
+
+ + setForm({ ...form, housingFundBase: e.target.value })} placeholder="默认为月工资" /> +
+
+ + setForm({ ...form, housingFundStartMonth: e.target.value })} /> +
+
+
setEditItems({ - ...editItems, - [item.employeeId]: { - socialBase: Number(e.target.value) || 0, - housingBase: edit?.housingBase ?? item.suggestedHousingBase, - }, - })} - /> - {socialChanged && } - - ¥{fmt(item.oldHousingBase)} - ¥{fmt(item.suggestedHousingBase)} - - setEditItems({ - ...editItems, - [item.employeeId]: { - socialBase: edit?.socialBase ?? item.suggestedSocialBase, - housingBase: Number(e.target.value) || 0, - }, - })} - /> - {housingChanged && } + setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} /> + {changed && } ) @@ -262,34 +333,22 @@ export default function SocialInsurance() {
- - +
)} {/* 版本历史 */} - {showVersions && ( + {tab !== 'monthly' && showVersions && ( -

版本历史

- {!versions || versions.length === 0 ? ( +

{isHousing ? '公积金' : '社保'}版本历史

+ {!activeVersions || activeVersions.length === 0 ? (
暂无版本记录
) : (
@@ -301,28 +360,35 @@ export default function SocialInsurance() { 城市 基数下限 基数上限 - 养老% - 医疗% - 公积金% + {isHousing ? ( + 公积金% + ) : ( + <> + 养老% + 医疗% + + )} 状态 - {versions.map((v: any) => ( + {activeVersions.map((v: any) => ( {v.effectiveFrom} {v.effectiveTo || '—'} {v.city} ¥{fmt(v.baseMin)} ¥{fmt(v.baseMax)} - {v.pensionOrg}/{v.pensionEmp} - {v.medicalOrg}/{v.medicalEmp} - {v.housingOrg}/{v.housingEmp} + {isHousing ? ( + {v.housingOrg}/{v.housingEmp} + ) : ( + <> + {v.pensionOrg}/{v.pensionEmp} + {v.medicalOrg}/{v.medicalEmp} + + )} - {v.isCurrent - ? 当前 - : 历史 - } + {v.isCurrent ? 当前 : 历史} ))} @@ -334,80 +400,40 @@ export default function SocialInsurance() { )} {/* 新建版本 */} - {showNewVersion && ( + {tab !== 'monthly' && showNewVersion && ( -

新建配置版本

+

新建{isHousing ? '公积金' : '社保'}配置版本

-
- 新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。 - 通常每年7月社保调基时新建版本。 -
+
新版本生效后,当前版本将自动归档。发薪批次计算时按批次月份匹配对应版本的配置。通常每年7月调基时新建版本。
-
- - setNewVersion({ ...newVersion, effectiveFrom: e.target.value })} /> -
-
- - setNewVersion({ ...newVersion, city: e.target.value })} /> -
-
- - setNewVersion({ ...newVersion, baseMin: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, baseMax: Number(e.target.value) })} /> -
+
activeSetNewVersion({ ...activeNewVersion, effectiveFrom: e.target.value })} />
+
activeSetNewVersion({ ...activeNewVersion, city: e.target.value })} />
+
activeSetNewVersion({ ...activeNewVersion, baseMin: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, baseMax: Number(e.target.value) })} />
-
-
- - setNewVersion({ ...newVersion, pensionOrg: Number(e.target.value) })} /> + {isHousing ? ( +
+
activeSetNewVersion({ ...activeNewVersion, housingOrg: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, housingEmp: Number(e.target.value) })} />
-
- - setNewVersion({ ...newVersion, pensionEmp: Number(e.target.value) })} /> + ) : ( +
+
activeSetNewVersion({ ...activeNewVersion, pensionOrg: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, pensionEmp: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, medicalOrg: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, medicalEmp: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, unemploymentOrg: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, unemploymentEmp: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, injuryOrg: Number(e.target.value) })} />
+
activeSetNewVersion({ ...activeNewVersion, maternityOrg: Number(e.target.value) })} />
-
- - setNewVersion({ ...newVersion, medicalOrg: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, medicalEmp: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, unemploymentOrg: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, unemploymentEmp: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, injuryOrg: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, maternityOrg: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, housingOrg: Number(e.target.value) })} /> -
-
- - setNewVersion({ ...newVersion, housingEmp: Number(e.target.value) })} /> -
-
+ )}
-
@@ -416,82 +442,201 @@ export default function SocialInsurance() { )} {/* 试算工具 */} -
- -

试算工具

-
-
- - setBase(Number(e.target.value) || 0)} /> -
- - {config && ( -
- 当前配置:{config.city} | 基数范围 {fmt(config.baseMin)}~{fmt(config.baseMax)} -
- )} -
-
- - -

计算结果

- {result ? ( + {tab !== 'monthly' && ( +
+ +

{isHousing ? '公积金' : '社保'}试算

-
- 缴费基数:¥{fmt(result.actualBase)} - {result.capped && (已封顶)} - {result.floored && (已保底)} - {result.configVersion && | 配置版本:{result.configVersion}} +
+ + setBase(Number(e.target.value) || 0)} />
-
- - - - - - - - - - - - {result.items.map((item: any) => ( - - - - - - - - ))} - - - - - - - - -
险种企业%个人%企业缴纳个人缴纳
{item.name}{item.orgRate}%{item.empRate}%¥{fmt(item.orgAmount)}¥{fmt(item.empAmount)}
合计¥{fmt(result.totalOrg)}¥{fmt(result.totalEmp)}
-
-
-
- 总费用 - ¥{fmt(result.total)} + + {activeConfig && ( +
+ 当前配置:{activeConfig.city} | 基数范围 {fmt(activeConfig.baseMin)}~{fmt(activeConfig.baseMax)}
-
- 企业承担 ¥{fmt(result.totalOrg)} + 个人承担 ¥{fmt(result.totalEmp)} -
-
+ )}
- ) : ( -
点击「开始计算」查看结果
- )} + + + +

计算结果

+ {(() => { + const r = isHousing ? housingResult : result + if (!r) return
点击「开始计算」查看结果
+ return ( +
+
+ 缴费基数:¥{fmt(r.actualBase)} + {r.capped && (已封顶)} + {r.floored && (已保底)} + {r.configVersion && | 配置版本:{r.configVersion}} +
+
+ + + + + + + + + + + + {r.items.map((item: any) => ( + + + + + + + + ))} + + + + + + + + +
险种企业%个人%企业缴纳个人缴纳
{item.name}{item.orgRate}%{item.empRate}%¥{fmt(item.orgAmount)}¥{fmt(item.empAmount)}
合计¥{fmt(r.totalOrg)}¥{fmt(r.totalEmp)}
+
+
+
+ 总费用 + ¥{fmt(r.total)} +
+
+ 企业承担 ¥{fmt(r.totalOrg)} + 个人承担 ¥{fmt(r.totalEmp)} +
+
+
+ ) + })()} +
+
+ )} + + {/* ========== 月度增减员 Tab ========== */} + {tab === 'monthly' && ( + +
+

月度增减员

+
+ setMonthlyMonth(e.target.value)} className="!w-32" /> + + +
+
+
+ 展示当月新增参保(入职/重新入职)和减少参保(离职/解聘)的员工列表,用于社保和公积金经办机构申报。 +
+ {(() => { + if (!monthlyChanges) return
加载中...
+ const sAdd = monthlyChanges.social?.additions || [] + const sSub = monthlyChanges.social?.subtractions || [] + const hAdd = monthlyChanges.housing?.additions || [] + const hSub = monthlyChanges.housing?.subtractions || [] + if (sAdd.length === 0 && sSub.length === 0 && hAdd.length === 0 && hSub.length === 0) { + return
{monthlyMonth} 无增减员记录
+ } + return ( +
+ {/* 社保增减员 */} +
+

社保

+
+ + + + + + + + + + + + + {sAdd.map((i: any) => ( + + + + + + + + + ))} + {sSub.map((i: any) => ( + + + + + + + + + ))} + +
姓名部门类型基数开始年月截止年月
{i.name}{i.department}新增¥{fmt(i.base)}{i.startMonth}
{i.name}{i.department}减少¥{fmt(i.base)}{i.endMonth}
+
+
+ {/* 公积金增减员 */} +
+

公积金

+
+ + + + + + + + + + + + + {hAdd.map((i: any) => ( + + + + + + + + + ))} + {hSub.map((i: any) => ( + + + + + + + + + ))} + +
姓名部门类型基数开始年月截止年月
{i.name}{i.department}新增¥{fmt(i.base)}{i.startMonth}
{i.name}{i.department}减少¥{fmt(i.base)}{i.endMonth}
+
+
+
+ ) + })()}
-
+ )}

社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。 diff --git a/frontend/src/pages/Termination.tsx b/frontend/src/pages/Termination.tsx index 9ee1b19..2753dda 100644 --- a/frontend/src/pages/Termination.tsx +++ b/frontend/src/pages/Termination.tsx @@ -56,6 +56,8 @@ export default function Termination() { const [reason, setReason] = useState('') const [employeeId, setEmployeeId] = useState('') const [terminationDate, setTerminationDate] = useState('') + const [socialInsEndMonth, setSocialInsEndMonth] = useState('') + const [housingFundEndMonth, setHousingFundEndMonth] = useState('') const [checklist, setChecklist] = useState>({}) const [acknowledgeRisk, setAcknowledgeRisk] = useState(false) const [socialAvgWage, setSocialAvgWage] = useState(0) @@ -289,6 +291,8 @@ export default function Termination() { employeeId, reason, terminationDate: new Date(terminationDate).toISOString(), + socialInsEndMonth: socialInsEndMonth || terminationDate.slice(0, 7), + housingFundEndMonth: housingFundEndMonth || terminationDate.slice(0, 7), compensation: costResult?.totalSeverance || 0, checklist, remark: '', @@ -394,6 +398,8 @@ export default function Termination() { setReason('') setEmployeeId('') setTerminationDate('') + setSocialInsEndMonth('') + setHousingFundEndMonth('') setChecklist({}) setAcknowledgeRisk(false) } @@ -523,6 +529,26 @@ export default function Termination() { setTerminationDate(e.target.value)} />

+
+ +
默认与解聘日期同月,可手动修改
+
+
+ + setSocialInsEndMonth(e.target.value)} /> +
+
+ + setHousingFundEndMonth(e.target.value)} /> +
+
+ {terminationDate && ((socialInsEndMonth && socialInsEndMonth !== terminationDate.slice(0, 7)) || (housingFundEndMonth && housingFundEndMonth !== terminationDate.slice(0, 7))) && ( +
+ + 截止缴费年月与解聘日期不在同月,请确认是否为多缴/少缴月份。 +
+ )} +
{/* 禁止解聘检查 */} {riskAssessment && riskAssessment.warnings.length > 0 && (