feat: 社保AI建议、合同附件多文件上传预览、删除合同、扩展上传格式

This commit is contained in:
freedakgmail
2026-07-27 23:45:22 +08:00
parent 71d7ab2de5
commit 5e22a82163
21 changed files with 1246 additions and 178 deletions
+249
View File
@@ -0,0 +1,249 @@
# 多批次工资发放算法
## 一、整体流程
```
创建批次 → 拉入员工/复制数据 → 计算每个员工薪资 → 编辑调整 → 归档批次 → 生成工资条
```
一个月份可以有多个批次(如第1批常规发薪、第2批离职结算、第3批奖金),每个批次独立计算,归档后汇总生成一张工资条。
**核心约束**:当月存在未归档(DRAFT)的批次时,不允许创建新批次,确保个税按批次累计计算。
## 二、批次类型
| 类型 | code | 社保公积金 | 个税方式 |
|------|------|-----------|---------|
| 常规发薪 | `REGULAR` | 正常扣缴 | 累计预扣法 |
| 离职结算 | `TERMINATION` | 正常扣缴 | 累计预扣法 |
| 年终奖/奖金 | `BONUS` | 不扣 | 单独计税 |
| 补偿金 | `SEVERANCE` | 不扣 | 累计预扣法 |
## 三、创建批次(POST /batches
### 3.1 前置校验
- 当月存在 `DRAFT` 状态的批次时,拒绝创建,返回错误"当月存在未归档的批次,请先归档后再创建新批次"
- 确保新批次创建时,之前所有批次均已归档(已生成工资条),保证个税累计计算正确
### 3.2 确定批次号
取当月最大 `batchNo + 1`,避免删除批次后 `count` 不准导致唯一键冲突(`@@unique([orgId, month, batchNo])`)。
### 3.3 四种初始化模式
| 模式 | 说明 |
|------|------|
| `copy_last` | 拉入在职员工,从上月工资条复制基本工资/津贴/扣款,自动取当月加班费 |
| `blank_employees` | 拉入在职员工,所有金额为0,仅从员工档案取基本工资 |
| `blank_all` | 空批次,不拉入员工,后续手动添加 |
| `copy_batch` | 从指定已归档批次复制员工和薪资数据 |
### 3.4 员工范围
- **常规发薪(REGULAR**`status = ACTIVE` + 当月离职员工(`status = RESIGNED``updatedAt` 在当月)
- **离职结算/补偿金(TERMINATION/SEVERANCE**:当月有离职记录(`terminationDate` 在当月)的员工
### 3.5 社保公积金扣缴逻辑(按员工个人、按实际金额)
```
对每个员工独立判断:
1. 计算当月应缴社保公积金全额(基于社保基数和配置)
2. 查询该员工当月已归档的常规/离职批次中已扣的社保公积金累计金额
3. 本批次应扣 = max(0, 应缴全额 - 已扣金额)
- 已扣金额 = 0 → 本批次扣全额
- 已扣金额 < 应缴全额 → 本批次补扣差额
- 已扣金额 ≥ 应缴全额 → 本批次扣0(足额)
```
**规则**
- 按员工个人维度、按实际金额计算,而非全局判断是否扣过
- 第1批:员工A扣全额社保 → 归档后 → 第2批中员工A已扣金额=全额,应扣=0;员工B未在第1批中,已扣=0,应扣=全额
- BONUS 和 SEVERANCE 批次本身不扣社保
- 用户可手动编辑覆盖社保值
## 四、单条目计算(calcBatchEntry
### 4.1 应发合计
```
totalPay = baseSalary + overtimePay + allowance + bonus - deduction
```
### 4.2 社保公积金计算
**基数**:优先用员工核定基数(`employee.socialInsBase` / `employee.housingFundBase`),否则用基本工资
**社保(calcSocialInsurance**
- 养老/失业/工伤:基数 × 各自比例
- 医疗/生育:独立基数(`medicalBaseMin/Max`fallback 到统一基数)
- 附加险种:支持固定金额或按比例
**公积金(calcHousingFund**
- 基数 × `housingEmp%`(个人)/ `housingOrg%`(单位)
**跳过条件**
- `batchType = BONUS 或 SEVERANCE` → 不扣
- `skipSocial = true`(同月已有归档常规批次)→ 不扣
- `overrideSocial` 手动覆盖 → 用用户输入值
### 4.3 个税计算
**年终奖批次(BONUS)— 单独计税**
```
monthlyBonus = bonus / 12
→ 查税率表确定 rate 和 quickDeduction
→ tax = bonus × rate - quickDeduction
```
| 月均奖金 | 税率 | 速算扣除数 |
|---------|------|-----------|
| ≤3000 | 3% | 0 |
| ≤12000 | 10% | 210 |
| ≤25000 | 20% | 1410 |
| ≤35000 | 25% | 2660 |
| ≤55000 | 30% | 4410 |
| ≤80000 | 35% | 7160 |
| >80000 | 45% | 15160 |
**其他批次(REGULAR/TERMINATION/SEVERANCE)— 累计预扣法**
```
1. 查询该员工当年所有已归档批次的 BatchEntry(不依赖工资条是否已生成)
2. 累计收入 = Σ 已归档批次.totalPay + 本批次.totalPay
3. 累计社保 = Σ 已归档批次.socialEmp + 本批次.socialEmp
4. 累计公积金 = Σ 已归档批次.housingEmp + 本批次.housingEmp
5. 累计专项扣除 = employee.specialDeduction × 月份序号
6. 累计应纳税所得额 = max(0, 累计收入 - 5000×月份 - 累计社保 - 累计公积金 - 累计专项扣除)
7. 累计税额 = calcTax(累计应纳税所得额) // 查7级超额累进税率表
8. 当月应预扣 = max(0, 累计税额 - Σ 已归档批次.tax)
```
**7级超额累进税率表**
| 累计应纳税所得额 | 税率 | 速算扣除数 |
|----------------|------|-----------|
| ≤36000 | 3% | 0 |
| ≤144000 | 10% | 2520 |
| ≤300000 | 20% | 16920 |
| ≤420000 | 25% | 31920 |
| ≤660000 | 30% | 52920 |
| ≤960000 | 35% | 85920 |
| >960000 | 45% | 181920 |
### 4.4 实发工资
```
netPay = totalPay - socialEmp - housingEmp - tax
```
## 五、添加/删除员工后更新汇总
添加或删除员工后,重新查询所有条目并汇总更新批次:
```
employeeCount = 条目数
totalPay = Σ entry.totalPay
totalNetPay = Σ entry.netPay
totalTax = Σ entry.tax
totalSocialEmp = Σ entry.socialEmp
totalSocialOrg = Σ entry.socialOrg
totalHousingEmp = Σ entry.housingEmp
totalHousingOrg = Σ entry.housingOrg
```
## 六、编辑条目后重算
- 合并用户修改的输入项(baseSalary/overtimePay/allowance/deduction/bonus
- 重新调用 `calcBatchEntry`(含 `skipSocial` 逻辑)
- 如果用户手动输入了社保值,用 `overrideSocial` 优先
- 更新条目后重新汇总批次
## 七、归档批次(POST /batches/:batchId/archive
- 将批次状态改为 `ARCHIVED`,记录 `archivedAt`
- 归档后批次锁定不可编辑、不可删除
- 当月存在未归档批次时,不允许创建新批次
## 八、取消归档(POST /batches/:batchId/unarchive
- 将批次状态从 `ARCHIVED` 恢复为 `DRAFT`,清除 `archivedAt`
- **只能依次取消**:只能取消最后一个归档批次(按 `batchNo` 倒序),确保个税累计链不断裂
- 取消归档后处理工资条:
- 当月已无归档批次 → 删除该月所有相关员工的工资条(完全撤销个税累计)
- 当月仍有归档批次 → 基于剩余归档批次重新生成工资条(更新累计数据)
## 九、生成工资条(POST /payslips/generate
### 8.1 汇总归档批次
```
1. 获取当月所有 ARCHIVED 批次
2. 按员工ID分组,累加所有批次的各项金额:
baseSalary, overtimePay, allowance, deduction, bonus,
socialEmp, socialOrg, housingEmp, housingOrg, tax, totalPay, netPay
```
### 8.2 生成/更新工资条
```
upsert(employeeId + month)
- 各项金额 = 多批次汇总值
- ytdIncome = Σ 历史工资条.totalPay + 当月汇总.totalPay
- ytdTaxDeducted = Σ 历史工资条.tax + 当月汇总.tax
- ytdSocialEmp, ytdHousingEmp 同理
- status = PUBLISHED
```
### 8.3 唯一约束
`Payslip``@@unique([employeeId, month])`,同一员工同月只有一条工资条,多次生成会覆盖更新。
## 九、多批次场景示例
### 场景1:常规工资 + 奖金
| 步骤 | 批次1REGULAR | 批次2BONUS |
|------|-----------------|---------------|
| 创建 | 拉入员工,复制上月数据 | 空白批次,手动添加人员 |
| 社保 | 正常扣缴 | 不扣 |
| 个税 | 累计预扣(含历史工资条) | 单独计税 |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条 |
### 场景2:常规工资 + 补发常规工资
| 步骤 | 批次1REGULAR | 批次2REGULAR |
|------|-----------------|-----------------|
| 创建 | 拉入员工,正常计算 | 拉入员工,skipSocial=true |
| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) |
| 个税 | 累计预扣(含历史) | 累计预扣(含历史+批次1的工资条) |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条,社保只算一次,个税累计 |
### 场景3:常规工资 + 离职结算
| 步骤 | 批次1REGULAR | 批次2TERMINATION |
|------|-----------------|---------------------|
| 创建 | 拉入在职员工 | 拉入当月离职员工 |
| 社保 | 正常扣缴 | 跳过(同月已有归档常规批次) |
| 个税 | 累计预扣 | 累计预扣(含批次1的工资条) |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条 |
## 十、关键代码位置
| 模块 | 文件 | 行号 |
|------|------|------|
| 创建批次 | `backend/src/routes/payroll2.routes.ts` | 220 |
| 单条目计算 | `backend/src/services/payroll.service.ts` | 126 |
| 社保计算 | `backend/src/services/payroll.service.ts` | 37 |
| 公积金计算 | `backend/src/services/payroll.service.ts` | 69 |
| 累计预扣个税 | `backend/src/services/payroll.service.ts` | 97 |
| 年终奖计税 | `backend/src/services/payroll.service.ts` | 108 |
| 编辑条目 | `backend/src/routes/payroll2.routes.ts` | 435 |
| 添加员工 | `backend/src/routes/payroll2.routes.ts` | 504 |
| 删除员工 | `backend/src/routes/payroll2.routes.ts` | 585 |
| 归档批次 | `backend/src/routes/payroll2.routes.ts` | 662 |
| 生成工资条 | `backend/src/services/payroll.service.ts` | 275 |