Files
TurboHR/20260816-薪税管理逻辑.md
T
selfrelease 904c9c6369 feat: 薪资表格增加最低工资保护和递延扣款的可视化提示
1. 实发列:最低工资保护触发时显示橙色 + ★标记 + tooltip
2. 新增「递延扣款」列:显示递延金额(橙色)或补扣金额(蓝色)
3. 风险列:最低工资保护触发时显示橙色警告图标 + tooltip
4. 工作流步骤从4步改为3步(编辑薪资→归档锁定→发布工资条)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 14:30:08 +08:00

551 lines
21 KiB
Markdown
Raw 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.
# 薪税管理完整处理逻辑和取数逻辑
> 生成时间:2026-08-16
> 涉及模块:薪资发放批次、社保公积金、个税累计预扣、工资条生成与发布
> 核心文件:`backend/src/services/payroll.service.ts`、`backend/src/routes/payroll2.routes.ts`、`backend/src/routes/social.routes.ts`
---
## 一、整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 薪税管理三大模块 │
├──────────────┬──────────────────┬───────────────────────────┤
│ 社保公积金 │ 个税计算 │ 工资条生成/发布 │
│ (缴纳/扣除) │ (累计预扣法) │ (汇总/发布/定时) │
└──────────────┴──────────────────┴───────────────────────────┘
↓ 取数 ↓ ↓ 取数 ↓
┌──────────────┬──────────────────┬───────────────────────────┐
│ SocialAccount│ Employee 表 │ PayrollBatch (已归档) │
│ + YearStandard│ SpecialDeduction │ + BatchEntry │
│ + 旧Config表 │ + Record表 │ → Payslip │
└──────────────┴──────────────────┴───────────────────────────┘
```
---
## 二、核心计算函数 `calcBatchEntry`
这是所有薪税计算的入口,在三个时机被调用:
1. **创建批次时**:为每个员工初始化计算
2. **编辑条目时**:修改任意金额后实时重算
3. **归档时**:最终锁定前再重算一遍
### 输入参数
```typescript
calcBatchEntry(
orgId, // 组织ID
employeeId, // 员工ID
month, // 批次月份 YYYY-MM
inputs: { // HR 可编辑的输入项
baseSalary, // 基本工资
overtimePay, // 加班费
allowance, // 其他津贴
deduction, // 扣款
bonus, // 奖金
positionSalary?, // 岗位工资
performanceSalary?, // 绩效工资
senioritySalary?, // 工龄工资
transportAllowance?, // 交通补贴
mealAllowance?, // 餐补
housingAllowance?, // 住房补贴
communicationAllowance?, // 通讯补贴
otherDeduction?, // 其他扣款
},
batchType, // REGULAR | TERMINATION | BONUS | SEVERANCE
options?: { // 可选覆盖
skipSocial?, // 跳过社保计算
overrideSocial?, // 手动覆盖社保值
}
)
```
---
## 三、社保公积金取数逻辑
### 取数优先级(三层回退)
```
① 员工 → 根部门(level=0) → socialAccount / housingAccount
↓ 找不到
② 公司默认账户 (isDefault=true)
↓ 找不到
③ 旧配置表 SocialInsuranceConfig / HousingFundConfig(按城市匹配)
```
### 新逻辑(账户体系)
```
getEmployeeAccounts(orgId, employeeId)
├─ 查员工所属部门
├─ 向上遍历到 level=0 的根部门
├─ 读取根部门的 socialAccountId / housingAccountId
├─ 若根部门未关联 → 查公司默认账户 (isDefault=true)
└─ 返回 { socialAccount, housingAccount }
getStandardByAccountAndMonth(accountId, month)
├─ 查 SocialYearStandard: accountId + effectiveFrom ≤ month ≤ effectiveTo
├─ 找不到 → 回退到 isCurrent=true 的当前标准
└─ 返回年度标准(比例 + 基数上下限)
```
### 旧逻辑(兼容回退)
```
SocialInsuranceConfig.findFirst({
orgId, city: employee.city,
effectiveFrom ≤ month, effectiveTo ≥ month 或 null
})
```
### 社保基数确定
```typescript
socialBase = employee.socialInsBase || inputs.baseSalary
housingBase = employee.housingFundBase || inputs.baseSalary
```
优先级:员工核定基数 > 基本工资
### 社保金额计算
```typescript
// 当月应缴全额
fullSocialEmp = calcSocialInsurance(socialBase, socialConfig).socialEmp
fullSocialOrg = calcSocialInsurance(socialBase, socialConfig).socialOrg
fullHousingEmp = calcHousingFund(housingBase, housingConfig).housingEmp
fullHousingOrg = calcHousingFund(housingBase, housingConfig).housingOrg
// 已归档批次已扣金额(避免多批次重复扣社保)
deductedSocialEmp = SUM(archivedEntries.socialEmp)
deductedSocialOrg = SUM(archivedEntries.socialOrg)
deductedHousingEmp = SUM(archivedEntries.housingEmp)
deductedHousingOrg = SUM(archivedEntries.housingOrg)
// 本批次应扣 = 应缴全额 - 已扣金额(差额补扣,足额为0)
socialEmp = Math.max(0, fullSocialEmp - deductedSocialEmp)
socialOrg = Math.max(0, fullSocialOrg - deductedSocialOrg)
housingEmp = Math.max(0, fullHousingEmp - deductedHousingEmp)
housingOrg = Math.max(0, fullHousingOrg - deductedHousingOrg)
```
### 关键设计:差额补扣机制
- 同一员工同一月份可能有多个批次(如常规发薪 + 离职结算)
- 第一个批次扣全额,后续批次扣差额(应缴全额 - 已扣)
- 避免重复扣除
### 批次类型对社保的影响
| 批次类型 | 社保公积金 | 说明 |
|---------|-----------|------|
| REGULAR | ✅ 计算 | 常规发薪,差额补扣 |
| TERMINATION | ✅ 计算 | 离职结算,差额补扣 |
| BONUS | ❌ 不扣 | 年终奖单独计税,无社保 |
| SEVERANCE | ❌ 不扣 | 补偿金不走社保个税 |
### 手动覆盖
```typescript
// HR 可在编辑界面手动修改社保值
if (options?.overrideSocial) {
socialEmp = overrideSocial.socialEmp // 覆盖个人社保
socialOrg = overrideSocial.socialOrg // 覆盖单位社保
housingEmp = overrideSocial.housingEmp // 覆盖个人公积金
housingOrg = overrideSocial.housingOrg // 覆盖单位公积金
}
// 同时保存 systemSocialEmp 等系统计算值,用于对比展示
```
---
## 四、应发合计计算
```typescript
totalPay =
baseSalary // 基本工资
+ positionSalary // 岗位工资
+ performanceSalary // 绩效工资
+ senioritySalary // 工龄工资
+ overtimePay // 加班费
+ transportAllowance // 交通补贴
+ mealAllowance // 餐补
+ housingAllowance // 住房补贴
+ communicationAllowance // 通讯补贴
+ allowance // 其他津贴
+ bonus // 奖金
- deduction // 扣款
- otherDeduction // 其他扣款
```
---
## 五、个税计算逻辑
### 两种计税方式
#### 1. 年终奖单独计税(BONUS 批次)
```typescript
tax = calcBonusTax(inputs.bonus)
// 年终奖 ÷ 12 → 找税率区间 → 年终奖 × 税率 - 速算扣除数
```
#### 2. 累计预扣法(REGULAR / TERMINATION / SEVERANCE
```typescript
// ── 取数:当年已归档批次的历史数据 ──
archivedEntries = BatchEntry.findMany({
orgId, employeeId,
batch: { month: startsWith(year), status: 'ARCHIVED' }
})
// 注意:不依赖 Payslip 是否已生成,直接从已归档批次取数
// ── 累计计算 ──
ytdIncome = SUM(archivedEntries.totalPay) + totalPay
ytdSocialEmp = SUM(archivedEntries.socialEmp) + socialEmp
ytdHousingEmp = SUM(archivedEntries.housingEmp) + housingEmp
ytdTaxDeducted = SUM(archivedEntries.tax)
// ── 专项附加扣除取数(三层回退)──
deductionRecords = SpecialDeductionRecord.findMany({
orgId, employeeId,
month: startsWith(year) AND lte(month) // 当年至当月
})
if (deductionRecords.length > 0) {
// ✅ 优先:按月实际填报金额累加
ytdSpecialDeduction = SUM(deductionRecords.amount)
} else {
// ⚠️ 回退:员工便捷字段 × 月数(兼容旧数据)
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
}
// ── 累计应纳税所得额 ──
deductionAmount = 5000 × // 基本减除费用
ytdTaxableIncome = max(0,
ytdIncome
- deductionAmount // 累计减除费用(5000/月)
- ytdSocialEmp // 累计个人社保
- ytdHousingEmp // 累计个人公积金
- ytdSpecialDeduction // 累计专项附加扣除
)
// ── 当月应扣个税 ──
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
// = 累计应纳税额 - 已预扣个税
```
### 专项附加扣除数据来源
| 来源 | 表 | 说明 |
|------|---|------|
| **按月记录**(优先) | `SpecialDeductionRecord` | HR/员工按月填报,含子女教育、赡养老人、住房贷款、继续教育、婴幼儿照护 |
| **便捷字段**(回退) | `Employee.specialDeduction` | 员工表上的当前值,× 月数估算累计 |
**按月记录的优势**:员工某月取消专项附加扣除,该月金额为 0,累计值准确反映实际。
### taxBreakdown 返回的明细
```json
{
"method": "累计预扣法",
"month": 8,
"ytdIncome": 80000, // 累计收入
"deductionAmount": 40000, // 累计减除费用 (5000×8)
"ytdSocialEmp": 6720, // 累计个人社保
"ytdHousingEmp": 3840, // 累计个人公积金
"ytdSpecialDeduction": 12000, // 累计专项附加扣除
"specialDeductionSource": "按月记录", // 数据来源标识
"specialDeductionRecords": 8, // 按月记录条数
"ytdTaxableIncome": 17440, // 累计应纳税所得额
"ytdTaxDeducted": 480, // 已预扣个税
"currentMonthTax": 360, // 当月应扣个税
"archivedCount": 7 // 已归档批次数
}
```
---
## 六、实发工资
```typescript
netPay = totalPay - socialEmp - housingEmp - tax
// 应发 - 个人社保 - 个人公积金 - 个税
```
---
## 七、创建批次时的初始化取数
### 员工来源
| 批次类型 | 员工来源 |
|---------|---------|
| REGULAR / BONUS | `status=ACTIVE` + 本月离职(`status=RESIGNED, updatedAt 在本月` |
| TERMINATION | 本月 `TerminationRecord` 关联的员工 |
| SEVERANCE | 本月离职记录中已审批(APPROVED/EXECUTING/COMPLETED)且有补偿金的 |
### 数据初始化(5 种模式)
| 模式 | baseSalary 取数 | overtimePay | allowance/deduction |
|------|----------------|-------------|---------------------|
| **copy_last** | 试用期→`probationSalary`;转正→上月工资条`baseSalary`;无→`monthlySalary` | 当月 `OvertimeRecord.totalPay` | 上月工资条复制 |
| **blank_employees** | `employee.monthlySalary`(解密) | 0 | 0 |
| **blank_all** | 0(无员工) | 0 | 0 |
| **copy_batch** | 源批次条目复制 | 源批次复制 | 源批次复制 |
| **custom** | 0(手动填写) | 0 | 0 |
### 试用期判定
```typescript
isInProbation(contract, batchMonthEnd)
// 试用期结束日 = contract.startDate + contract.probationMonths
// 若 batchMonthEnd < 试用期结束日 → 在试用期内
```
统一规则(所有模式适用,SEVERANCE/TERMINATION 除外):
- 在试用期内且 `probationSalary > 0``baseSalary = probationSalary`
---
## 八、编辑条目时的重算
```
HR 修改任意金额字段
PUT /batches/:batchId/entries/:employeeId
合并新旧 inputs(未修改的保留原值)
重新调用 calcBatchEntry() → 重算社保/个税/实发
更新 BatchEntry + 重算批次汇总
```
**社保手动覆盖**:编辑社保字段时,通过 `overrideSocial` 传入,覆盖系统计算值,同时保存 `systemSocial*` 用于对比。
---
## 九、归档时的最终重算
```
POST /batches/:batchId/archive
① 遍历所有条目,重新调用 calcBatchEntry()
- 此时已归档批次的累计数据是最新的
- 社保差额补扣准确
- 个税累计预扣准确
② 更新批次汇总(totalPay/totalNetPay/各项合计)
③ 标记 status=ARCHIVED, archivedAt=now
④ 批次锁定,不可再编辑
```
**归档时不自动生成工资条**,需单独调用 `POST /payslips/generate`
---
## 十、工资条生成与发布
### 生成工资条
```
POST /payslips/generate { month }
generatePayslipFromBatches(orgId, month)
① 查当月所有已归档批次(status=ARCHIVED
② 按员工汇总所有批次的 BatchEntry(多批次合并)
③ 查当年历史工资条计算累计数据
④ upsert PayslipemployeeId+month 唯一键)
```
### 发布工资条
```
POST /batches/:batchId/publish
更新 Payslip.publishStatus = 'PUBLISHED', publishedAt = now
员工端可见
```
### 定时发送
```
POST /batches/:batchId/schedule { scheduledAt }
更新 Payslip.publishStatus = 'SCHEDULED', scheduledAt = 指定时间
(定时任务到点后发布)
```
---
## 十一、取消归档
```
POST /batches/:batchId/unarchive
① 只能取消最后一个归档批次
② 批次恢复为 DRAFT
③ 如果还有其他归档批次 → 重新生成工资条(基于剩余批次)
④ 如果没有归档批次了 → 删除该月工资条
```
---
## 十二、完整数据流图
```
┌─────────────────┐
│ Employee 表 │
│ socialInsBase │
│ housingFundBase │
│ monthlySalary │
│ specialDeduction│
└────────┬────────┘
┌────────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────────┐ ┌─────────────────┐
│SocialAccount│ │SpecialDeduction │ │ OvertimeRecord │
│+YearStandard│ │Record (按月) │ │ (加班费) │
│(社保比例) │ └──────────────────┘ └─────────────────┘
└──────┬─────┘ │ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────┐
│ calcBatchEntry() │
│ │
│ 社保 = 应缴全额 - 已归档批次已扣(差额补扣) │
│ 应发 = 基本工资 + 各项津贴 + 奖金 - 扣款 │
│ 个税 = 累计预扣法(从已归档批次取累计数据) │
│ 实发 = 应发 - 个人社保 - 个人公积金 - 个税 │
└──────────────────────┬───────────────────────────────┘
┌────────────────┐
│ BatchEntry │
│ (批次条目) │
└───────┬────────┘
│ 归档
┌────────────────┐ 汇总生成
│ PayrollBatch │ ──────────────→ ┌──────────┐
│ status=ARCHIVED│ │ Payslip │
└────────────────┘ │ (工资条) │
└────┬─────┘
│ 发布
员工端可见
```
---
## 十三、关键取数表汇总
| 数据项 | 取数表 | 取数条件 | 用途 |
|--------|--------|---------|------|
| 社保比例 | `SocialYearStandard` | accountId + 月份在生效区间 | 计算社保公积金 |
| 社保比例(回退) | `SocialInsuranceConfig` | orgId + city + 月份在生效区间 | 兼容旧数据 |
| 社保基数 | `Employee.socialInsBase` | — | 优先于基本工资 |
| 公积金基数 | `Employee.housingFundBase` | — | 优先于基本工资 |
| 已扣社保 | `BatchEntry` | 当月已归档批次 | 差额补扣 |
| 累计收入 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 累计社保 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 累计个税 | `BatchEntry` | 当年已归档批次 | 个税累计预扣 |
| 专项附加扣除 | `SpecialDeductionRecord` | 当年至当月 | 个税累计预扣 |
| 专项附加扣除(回退) | `Employee.specialDeduction` | × 月数 | 兼容旧数据 |
| 基本减除费用 | 固定 5000 | × 月数 | 个税累计预扣 |
| 加班费 | `OvertimeRecord` | employeeId + month | 创建批次时自动拉取 |
| 基本工资 | `Payslip`(上月) | employeeId + 上月 | copy_last 模式 |
| 基本工资(回退) | `Employee.monthlySalary` | 解密 | 无上月工资条时 |
| 试用期工资 | `LaborContract.probationSalary` | 最新合同 | 试用期内的 baseSalary |
---
## 十四、3 步工作流
```
① 编辑薪资(含核对) → ② 归档锁定 → ③ 发布工资条
```
### 步骤 1:编辑薪资(DRAFT 状态)
可操作:
- **点击单元格编辑**:基本工资、加班费、津贴、奖金、扣款、社保个人/单位、公积金个人/单位
- **导入工资表 Excel**:批量填充薪资数据
- **下载导入模板**
- **导入加班费**:从加班记录按月自动填充 `overtimePay`
- **获取提成奖金**:从提成奖金模块按月填充 `bonus`
- **添加/删除人员**:动态调整批次人员
- **查看个税明细**:点击个税金额查看累计预扣计算过程
核对(编辑页面内直接展示,非独立步骤):
- 批次汇总卡片:应发合计、社保合计、公积金合计、个税合计、实发合计
- 质量门禁自动检查:实发为负、全零记录、最低工资、递延扣款等
- 可导出 **薪资汇总表** / **薪资明细表**CSV
质量门禁(草稿状态自动检查):
- 实发为负的员工 → 红色警告
- 实发低于最低工资 → 红色警告
- 最低工资保护已触发(递延扣款)→ 黄色警告
- 本月补扣上月递延 → 黄色警告
- 全零记录 → 黄色警告
### 步骤 2:归档锁定(ARCHIVED
**归档时后端自动执行**
1. **重算所有条目**:调用 `calcBatchEntry` 重新计算社保公积金和个税
2. **更新批次汇总**:重算 totalPay/totalNetPay/各项合计
3. **标记为 ARCHIVED**:设置 `archivedAt`,不可再编辑
### 步骤 3:发布工资条
- 调用 `POST /batches/:id/publish` → 更新 `Payslip.publishStatus = PUBLISHED`
- 或调用 `POST /batches/:id/schedule` → 设置 `publishStatus = SCHEDULED` + `scheduledAt`
- 员工在员工端查看已发布的工资条
---
## 十五、状态流转
```
DRAFT(草稿,可编辑)
↓ 归档
ARCHIVED(已归档,锁定不可编辑)
↓ 取消归档
DRAFT(恢复草稿)
↓ 发布工资条
Payslip.publishStatus: PUBLISHED(员工端可见)
```
---
## 十六、批次类型详解
| 类型 | 说明 | 社保公积金 | 个税计算 | 员工来源 |
|------|------|-----------|---------|---------|
| **REGULAR** 常规发薪 | 月度工资 | ✅ 差额补扣 | 累计预扣法 | 在职 + 本月离职 |
| **TERMINATION** 离职结算 | 离职员工当月工资 | ✅ 差额补扣 | 累计预扣法 | 本月离职记录 |
| **BONUS** 年终奖/奖金 | 单独计税 | ❌ 不扣 | 单独计税 | 在职 + 本月离职 |
| **SEVERANCE** 补偿金 | 离职补偿金 | ❌ 不扣 | 累计预扣法(无社保扣除) | 已审批且有补偿金的离职记录 |
---
## 十七、创建批次的 5 种数据初始化模式
| 模式 | 说明 | 员工来源 | 数据来源 |
|------|------|---------|---------|
| **copy_last** 复制上月 | 默认模式 | 在职 + 本月离职 | 上月工资条复制基本工资/津贴/扣款 + 当月加班费 |
| **blank_employees** 本月空白 | 拉入员工 | 在职 + 本月离职 | 所有金额为 0,手动填写 |
| **blank_all** 全空白 | 不拉入员工 | 无 | 后续手动添加人员 |
| **copy_batch** 复制指定批次 | 从源批次 | 源批次的员工 | 复制源批次薪资数据 |
| **custom** 自定义选择 | 按部门/姓名筛选勾选 | 指定员工 | 金额为 0,手动填写 |