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