# 多批次工资发放算法

## 一、整体流程

```
创建批次 → 拉入员工/复制数据 → 计算每个员工薪资 → 编辑调整 → 归档批次 → 生成工资条
```

一个月份可以有多个批次（如第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：常规工资 + 奖金

| 步骤 | 批次1（REGULAR） | 批次2（BONUS） |
|------|-----------------|---------------|
| 创建 | 拉入员工，复制上月数据 | 空白批次，手动添加人员 |
| 社保 | 正常扣缴 | 不扣 |
| 个税 | 累计预扣（含历史工资条） | 单独计税 |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条 |

### 场景2：常规工资 + 补发常规工资

| 步骤 | 批次1（REGULAR） | 批次2（REGULAR） |
|------|-----------------|-----------------|
| 创建 | 拉入员工，正常计算 | 拉入员工，skipSocial=true |
| 社保 | 正常扣缴 | 跳过（同月已有归档常规批次） |
| 个税 | 累计预扣（含历史） | 累计预扣（含历史+批次1的工资条） |
| 归档 | 归档 | 归档 |
| 生成工资条 | 汇总批次1+2 → 一张工资条，社保只算一次，个税累计 |

### 场景3：常规工资 + 离职结算

| 步骤 | 批次1（REGULAR） | 批次2（TERMINATION） |
|------|-----------------|---------------------|
| 创建 | 拉入在职员工 | 拉入当月离职员工 |
| 社保 | 正常扣缴 | 跳过（同月已有归档常规批次） |
| 个税 | 累计预扣 | 累计预扣（含批次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 |
