# 多批次工资发放算法 ## 一、整体流程 ``` 创建批次 → 拉入员工/复制数据 → 计算每个员工薪资 → 编辑调整 → 归档批次 → 生成工资条 ``` 一个月份可以有多个批次(如第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 |