feat: 账户关联根部门 + 员工新增自动带出账户

1. 账户管理:新建/编辑时可勾选关联 level=0 根部门(公司/分公司/子公司)
2. 后端新增 API:
   - PUT /social/accounts/:id/departments 批量关联根部门
   - GET /social/accounts/:id/departments 查询已关联部门
   - GET /social/department-account/:departmentId 按部门带出适用账户+标准
3. 部门更新 API 支持 socialAccountId/housingAccountId 字段
4. 员工新增表单:选定部门后自动带出社保公积金账户,可手动调整
5. 参保记录创建时写入 accountId

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 13:59:06 +08:00
parent 63b6c9dcc7
commit 014c94e482
7 changed files with 799 additions and 10 deletions
+548
View File
@@ -0,0 +1,548 @@
# 薪税管理完整处理逻辑和取数逻辑
> 生成时间: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 |
---
## 十四、4 步工作流
```
① 编辑薪资 → ② 核对汇总 → ③ 归档锁定 → ④ 发布工资条
```
### 步骤 1:编辑薪资(DRAFT 状态)
可操作:
- **点击单元格编辑**:基本工资、加班费、津贴、奖金、扣款、社保个人/单位、公积金个人/单位
- **导入工资表 Excel**:批量填充薪资数据
- **下载导入模板**
- **导入加班费**:从加班记录按月自动填充 `overtimePay`
- **获取提成奖金**:从提成奖金模块按月填充 `bonus`
- **添加/删除人员**:动态调整批次人员
- **查看个税明细**:点击个税金额查看累计预扣计算过程
质量门禁(草稿状态自动检查):
- 实发为负的员工 → 红色警告
- 全零记录 → 黄色警告
### 步骤 2:核对汇总
批次列表展示汇总数据:
- 应发合计、社保合计、公积金合计、个税合计、实发合计
- 可导出 **薪资汇总表** / **薪资明细表**CSV
### 步骤 3:归档锁定(ARCHIVED
**归档时后端自动执行**
1. **重算所有条目**:调用 `calcBatchEntry` 重新计算社保公积金和个税
2. **更新批次汇总**:重算 totalPay/totalNetPay/各项合计
3. **标记为 ARCHIVED**:设置 `archivedAt`,不可再编辑
### 步骤 4:发布工资条
- 调用 `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,手动填写 |