diff --git a/20260816-薪税管理逻辑.md b/20260816-薪税管理逻辑.md new file mode 100644 index 0000000..0ea574d --- /dev/null +++ b/20260816-薪税管理逻辑.md @@ -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 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,手动填写 | diff --git a/backend/src/routes/department.routes.ts b/backend/src/routes/department.routes.ts index 501af7c..4e2236e 100644 --- a/backend/src/routes/department.routes.ts +++ b/backend/src/routes/department.routes.ts @@ -16,7 +16,10 @@ const createDeptSchema = z.object({ description: z.string().max(200).optional(), }) -const updateDeptSchema = createDeptSchema.partial() +const updateDeptSchema = createDeptSchema.partial().extend({ + socialAccountId: z.string().nullable().optional(), + housingAccountId: z.string().nullable().optional(), +}) /** 获取部门树 */ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => { @@ -109,6 +112,8 @@ router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => { ...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}), ...(data.description !== undefined ? { description: data.description } : {}), ...(level !== undefined ? { level } : {}), + ...(data.socialAccountId !== undefined ? { socialAccountId: data.socialAccountId } : {}), + ...(data.housingAccountId !== undefined ? { housingAccountId: data.housingAccountId } : {}), }, }) res.json({ success: true, data: dept }) diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index 1eb96e3..0c077dc 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -231,6 +231,103 @@ router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Resp } catch (err) { next(err) } }) +// 账户关联根部门(level=0)批量设置 +router.put('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { id } = req.params + const { departmentIds } = req.body as { departmentIds: string[] } + const account = await prisma.socialAccount.findFirst({ where: { id, orgId } }) + if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } }) + + // 先清除该账户的所有部门关联 + const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId' + await prisma.department.updateMany({ + where: { orgId, [field]: id }, + data: { [field]: null }, + }) + // 批量设置新关联(仅 level=0 根部门) + if (departmentIds && departmentIds.length > 0) { + await prisma.department.updateMany({ + where: { id: { in: departmentIds }, orgId, level: 0 }, + data: { [field]: id }, + }) + } + res.json({ success: true, data: { message: `已关联 ${departmentIds?.length || 0} 个根部门` } }) + } catch (err) { next(err) } +}) + +// 获取账户已关联的根部门列表 +router.get('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { id } = req.params + const account = await prisma.socialAccount.findFirst({ where: { id, orgId } }) + if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } }) + + const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId' + const departments = await prisma.department.findMany({ + where: { orgId, level: 0, [field]: id }, + select: { id: true, name: true }, + }) + res.json({ success: true, data: departments }) + } catch (err) { next(err) } +}) + +// 按部门获取适用账户(选部门时自动带出) +router.get('/department-account/:departmentId', async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const orgId = req.user!.orgId + const { departmentId } = req.params + // 向上找到 level=0 的根部门 + let currentDept: any = await prisma.department.findFirst({ where: { id: departmentId, orgId } }) + if (!currentDept) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '部门不存在' } }) + + while (currentDept && currentDept.level > 0 && currentDept.parentId) { + currentDept = await prisma.department.findUnique({ where: { id: currentDept.parentId } }) + } + const rootDeptId = currentDept?.id || null + + let socialAccount: any = null + let housingAccount: any = null + if (rootDeptId) { + const rootDept = await prisma.department.findUnique({ + where: { id: rootDeptId }, + include: { socialAccount: true, housingAccount: true }, + }) + socialAccount = rootDept?.socialAccount || null + housingAccount = rootDept?.housingAccount || null + } + + // 回退到公司默认账户 + if (!socialAccount) { + socialAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'SOCIAL', isDefault: true } }) + } + if (!housingAccount) { + housingAccount = await prisma.socialAccount.findFirst({ where: { orgId, type: 'HOUSING', isDefault: true } }) + } + + // 获取当前生效标准 + const currentMonth = new Date().toISOString().slice(0, 7) + let socialStandard: any = null + let housingStandard: any = null + if (socialAccount) { + socialStandard = await prisma.socialYearStandard.findFirst({ + where: { accountId: socialAccount.id, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, + }) + } + if (housingAccount) { + housingStandard = await prisma.socialYearStandard.findFirst({ + where: { accountId: housingAccount.id, isCurrent: true }, + orderBy: { effectiveFrom: 'desc' }, + }) + } + + res.json({ success: true, data: { socialAccount, housingAccount, socialStandard, housingStandard } }) + } catch (err) { next(err) } +}) + // 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门) router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => { try { diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 6134c4c..085d48d 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -396,6 +396,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) { changeType: 'ONBOARDING', createdBy: userId, city: data.city || '北京', + accountId: data.socialAccountId || null, }, }) @@ -409,6 +410,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) { changeType: 'ONBOARDING', createdBy: userId, city: data.city || '北京', + accountId: data.housingAccountId || null, }, }) diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 709f9e8..abfa642 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -547,6 +547,15 @@ export const socialAccountApi = { /** 按员工获取适用账户 */ employeeAccount: (employeeId: string) => get(`/social/employee-account/${employeeId}`).then(unwrap()), + /** 获取账户已关联的根部门 */ + accountDepartments: (id: string) => + get(`/social/accounts/${id}/departments`).then(unwrap()), + /** 账户关联根部门(批量) */ + linkDepartments: (id: string, departmentIds: string[]) => + put(`/social/accounts/${id}/departments`, { departmentIds }).then(unwrap()), + /** 按部门获取适用账户(选部门时自动带出) */ + departmentAccount: (departmentId: string) => + get(`/social/department-account/${departmentId}`).then(unwrap()), } export const socialInsuranceApi = { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 0218679..0aacd5a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, ClipboardList, LayoutGrid, PenTool, HeartPulse, Trash2, Shield, Edit2 } from 'lucide-react' import { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services' +import api from '../lib/api' import { useAuthStore } from '../store/authStore' import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize' import Card from '../components/ui/Card' @@ -1710,11 +1711,25 @@ function SocialAccountSettings() { type={accountType} account={editAccount} onClose={() => { setShowForm(false); setEditAccount(null) }} - onSubmit={(data) => { + onSubmit={async (data, departmentIds) => { if (editAccount) { updateMutation.mutate({ id: editAccount.id, data }) + if (departmentIds) { + await socialAccountApi.linkDepartments(editAccount.id, departmentIds) + toast.success('账户已更新,部门关联已同步') + } } else { - createMutation.mutate({ ...data, type: accountType }) + createMutation.mutate( + { ...data, type: accountType }, + { + onSuccess: async (created: any) => { + if (departmentIds && departmentIds.length > 0) { + await socialAccountApi.linkDepartments(created.id, departmentIds) + } + queryClient.invalidateQueries({ queryKey: ['social-accounts'] }) + }, + } + ) } }} saving={createMutation.isPending || updateMutation.isPending} @@ -1728,7 +1743,7 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: { type: string account: any onClose: () => void - onSubmit: (data: any) => void + onSubmit: (data: any, departmentIds?: string[]) => void saving: boolean }) { const [name, setName] = useState(account?.name || '') @@ -1741,6 +1756,27 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: { const [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC') const [isDefault, setIsDefault] = useState(account?.isDefault || false) const [remark, setRemark] = useState(account?.remark || '') + const [selectedDeptIds, setSelectedDeptIds] = useState([]) + + // 加载 level=0 根部门列表 + const { data: rootDepartments = [] } = useQuery({ + queryKey: ['departments'], + queryFn: () => api.get('/departments').then(r => r.data), + }) + const rootDepts = rootDepartments.filter((d: any) => d.level === 0) + + // 编辑时加载已关联部门 + useEffect(() => { + if (account?.id) { + socialAccountApi.accountDepartments(account.id).then((depts: any[]) => { + setSelectedDeptIds(depts.map((d: any) => d.id)) + }).catch(() => {}) + } + }, [account?.id]) + + const toggleDept = (id: string) => { + setSelectedDeptIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) + } return ( @@ -1788,6 +1824,30 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: { setRemark(e.target.value)} /> + + {/* 关联根部门 */} +
+ +

勾选后,这些根部门下的员工将自动继承此账户。未勾选则使用公司默认账户。

+ {rootDepts.length === 0 ? ( +
暂无根部门
+ ) : ( +
+ {rootDepts.map((d: any) => ( + + ))} +
+ )} +
+