# 薪税管理完整处理逻辑和取数逻辑 > 生成时间: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 Payslip(employeeId+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,手动填写 |