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

9.4 KiB
Raw Blame History

多批次工资发放算法

一、整体流程

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

一个月份可以有多个批次(如第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 员工范围

  • 常规发薪(REGULARstatus = ACTIVE + 当月离职员工(status = RESIGNEDupdatedAt 在当月)
  • 离职结算/补偿金(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/Maxfallback 到统一基数)
  • 附加险种:支持固定金额或按比例

公积金(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