Files
TurboHR/docs/payroll-batch-algorithm.md

250 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多批次工资发放算法
## 一、整体流程
```
创建批次 → 拉入员工/复制数据 → 计算每个员工薪资 → 编辑调整 → 归档批次 → 生成工资条
```
一个月份可以有多个批次(如第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 |