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:
@@ -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,手动填写 |
|
||||||
@@ -16,7 +16,10 @@ const createDeptSchema = z.object({
|
|||||||
description: z.string().max(200).optional(),
|
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) => {
|
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.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
|
||||||
...(data.description !== undefined ? { description: data.description } : {}),
|
...(data.description !== undefined ? { description: data.description } : {}),
|
||||||
...(level !== undefined ? { level } : {}),
|
...(level !== undefined ? { level } : {}),
|
||||||
|
...(data.socialAccountId !== undefined ? { socialAccountId: data.socialAccountId } : {}),
|
||||||
|
...(data.housingAccountId !== undefined ? { housingAccountId: data.housingAccountId } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
res.json({ success: true, data: dept })
|
res.json({ success: true, data: dept })
|
||||||
|
|||||||
@@ -231,6 +231,103 @@ router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Resp
|
|||||||
} catch (err) { next(err) }
|
} 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 继承,不向下到普通部门)
|
// 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门)
|
||||||
router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
changeType: 'ONBOARDING',
|
changeType: 'ONBOARDING',
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
city: data.city || '北京',
|
city: data.city || '北京',
|
||||||
|
accountId: data.socialAccountId || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -409,6 +410,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
|||||||
changeType: 'ONBOARDING',
|
changeType: 'ONBOARDING',
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
city: data.city || '北京',
|
city: data.city || '北京',
|
||||||
|
accountId: data.housingAccountId || null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -547,6 +547,15 @@ export const socialAccountApi = {
|
|||||||
/** 按员工获取适用账户 */
|
/** 按员工获取适用账户 */
|
||||||
employeeAccount: (employeeId: string) =>
|
employeeAccount: (employeeId: string) =>
|
||||||
get(`/social/employee-account/${employeeId}`).then(unwrap<any>()),
|
get(`/social/employee-account/${employeeId}`).then(unwrap<any>()),
|
||||||
|
/** 获取账户已关联的根部门 */
|
||||||
|
accountDepartments: (id: string) =>
|
||||||
|
get(`/social/accounts/${id}/departments`).then(unwrap<any[]>()),
|
||||||
|
/** 账户关联根部门(批量) */
|
||||||
|
linkDepartments: (id: string, departmentIds: string[]) =>
|
||||||
|
put(`/social/accounts/${id}/departments`, { departmentIds }).then(unwrap<any>()),
|
||||||
|
/** 按部门获取适用账户(选部门时自动带出) */
|
||||||
|
departmentAccount: (departmentId: string) =>
|
||||||
|
get(`/social/department-account/${departmentId}`).then(unwrap<any>()),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socialInsuranceApi = {
|
export const socialInsuranceApi = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { 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 { settingsApi, notificationsApi, socialAccountApi } from '../lib/api-services'
|
||||||
|
import api from '../lib/api'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
import { getPageSize, setPageSize as setGlobalPageSize } from '../lib/pageSize'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -1710,11 +1711,25 @@ function SocialAccountSettings() {
|
|||||||
type={accountType}
|
type={accountType}
|
||||||
account={editAccount}
|
account={editAccount}
|
||||||
onClose={() => { setShowForm(false); setEditAccount(null) }}
|
onClose={() => { setShowForm(false); setEditAccount(null) }}
|
||||||
onSubmit={(data) => {
|
onSubmit={async (data, departmentIds) => {
|
||||||
if (editAccount) {
|
if (editAccount) {
|
||||||
updateMutation.mutate({ id: editAccount.id, data })
|
updateMutation.mutate({ id: editAccount.id, data })
|
||||||
|
if (departmentIds) {
|
||||||
|
await socialAccountApi.linkDepartments(editAccount.id, departmentIds)
|
||||||
|
toast.success('账户已更新,部门关联已同步')
|
||||||
|
}
|
||||||
} else {
|
} 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}
|
saving={createMutation.isPending || updateMutation.isPending}
|
||||||
@@ -1728,7 +1743,7 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
|
|||||||
type: string
|
type: string
|
||||||
account: any
|
account: any
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: any) => void
|
onSubmit: (data: any, departmentIds?: string[]) => void
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(account?.name || '')
|
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 [accountTypeVal, setAccountTypeVal] = useState(account?.accountType || 'BASIC')
|
||||||
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
|
const [isDefault, setIsDefault] = useState(account?.isDefault || false)
|
||||||
const [remark, setRemark] = useState(account?.remark || '')
|
const [remark, setRemark] = useState(account?.remark || '')
|
||||||
|
const [selectedDeptIds, setSelectedDeptIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
// 加载 level=0 根部门列表
|
||||||
|
const { data: rootDepartments = [] } = useQuery<any[]>({
|
||||||
|
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 (
|
return (
|
||||||
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
|
<Modal open onClose={onClose} title={account ? '编辑账户' : '新建账户'} size="md">
|
||||||
@@ -1788,6 +1824,30 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
|
|||||||
<Label>备注</Label>
|
<Label>备注</Label>
|
||||||
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
|
<Input value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 关联根部门 */}
|
||||||
|
<div>
|
||||||
|
<Label>关联根部门(公司/分公司/子公司)</Label>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">勾选后,这些根部门下的员工将自动继承此账户。未勾选则使用公司默认账户。</p>
|
||||||
|
{rootDepts.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-400">暂无根部门</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||||
|
{rootDepts.map((d: any) => (
|
||||||
|
<label key={d.id} className="flex items-center gap-2 text-sm py-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedDeptIds.includes(d.id)}
|
||||||
|
onChange={() => toggleDept(d.id)}
|
||||||
|
/>
|
||||||
|
<span>{d.name}</span>
|
||||||
|
{d._count?.employees > 0 && <span className="text-xs text-gray-400">({d._count.employees}人)</span>}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
|
||||||
设为默认账户(同类型下仅一个默认)
|
设为默认账户(同类型下仅一个默认)
|
||||||
@@ -1796,7 +1856,7 @@ function AccountFormModal({ type, account, onClose, onSubmit, saving }: {
|
|||||||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!name || !city || saving}
|
disabled={!name || !city || saving}
|
||||||
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark })}
|
onClick={() => onSubmit({ name, city, accountNo, orgName, orgCode, bankName, bankAccount, accountType: accountTypeVal, isDefault, remark }, selectedDeptIds)}
|
||||||
>
|
>
|
||||||
{saving ? '保存中...' : '保存'}
|
{saving ? '保存中...' : '保存'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { rosterApi, socialInsuranceApi, employeeApi } from '../../lib/api-services'
|
import { rosterApi, socialInsuranceApi, employeeApi, socialAccountApi } from '../../lib/api-services'
|
||||||
import api from '../../lib/api'
|
import api from '../../lib/api'
|
||||||
import Button from "../../components/ui/Button"
|
import Button from "../../components/ui/Button"
|
||||||
import { Input, Label, Select } from "../../components/ui/Input"
|
import { Input, Label, Select } from "../../components/ui/Input"
|
||||||
@@ -710,7 +710,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
if (saved) return JSON.parse(saved)
|
if (saved) return JSON.parse(saved)
|
||||||
} catch {}
|
} catch {}
|
||||||
return {
|
return {
|
||||||
name: '', department: '', position: '', hireDate: todayStr, monthlySalary: '',
|
name: '', department: '', departmentId: '', position: '', hireDate: todayStr, monthlySalary: '',
|
||||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||||
city: '北京', education: '',
|
city: '北京', education: '',
|
||||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||||
@@ -735,6 +735,36 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
|
|
||||||
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
const hireMonth = form.hireDate ? form.hireDate.slice(0, 7) : ''
|
||||||
|
|
||||||
|
// 选定部门后自动带出社保公积金账户
|
||||||
|
const [autoAccounts, setAutoAccounts] = useState<{ socialAccount: any; housingAccount: any; socialStandard: any; housingStandard: any } | null>(null)
|
||||||
|
const [manualSocialAccountId, setManualSocialAccountId] = useState<string>('')
|
||||||
|
const [manualHousingAccountId, setManualHousingAccountId] = useState<string>('')
|
||||||
|
|
||||||
|
// 部门变化时查询适用账户
|
||||||
|
useEffect(() => {
|
||||||
|
if (form.departmentId) {
|
||||||
|
socialAccountApi.departmentAccount(form.departmentId).then((res: any) => {
|
||||||
|
setAutoAccounts(res)
|
||||||
|
setManualSocialAccountId(res.socialAccount?.id || '')
|
||||||
|
setManualHousingAccountId(res.housingAccount?.id || '')
|
||||||
|
}).catch(() => {})
|
||||||
|
} else {
|
||||||
|
setAutoAccounts(null)
|
||||||
|
}
|
||||||
|
}, [form.departmentId])
|
||||||
|
|
||||||
|
// 加载所有账户列表(供手动调整)
|
||||||
|
const { data: allSocialAccounts = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['social-accounts', 'SOCIAL'],
|
||||||
|
queryFn: () => socialAccountApi.list('SOCIAL'),
|
||||||
|
enabled: !!form.departmentId,
|
||||||
|
})
|
||||||
|
const { data: allHousingAccounts = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['social-accounts', 'HOUSING'],
|
||||||
|
queryFn: () => socialAccountApi.list('HOUSING'),
|
||||||
|
enabled: !!form.departmentId,
|
||||||
|
})
|
||||||
|
|
||||||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||||||
const handleHireDateChange = (hireDate: string) => {
|
const handleHireDateChange = (hireDate: string) => {
|
||||||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||||||
@@ -913,7 +943,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data: any = {
|
const data: any = {
|
||||||
name: form.name, department: form.department,
|
name: form.name, department: form.department, departmentId: form.departmentId || undefined,
|
||||||
position: form.position || undefined,
|
position: form.position || undefined,
|
||||||
hireDate: new Date(form.hireDate).toISOString(),
|
hireDate: new Date(form.hireDate).toISOString(),
|
||||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||||
@@ -925,6 +955,8 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
socialInsStartMonth: form.socialInsStartMonth || undefined,
|
||||||
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
|
||||||
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
housingFundStartMonth: form.housingFundStartMonth || undefined,
|
||||||
|
socialAccountId: manualSocialAccountId || undefined,
|
||||||
|
housingAccountId: manualHousingAccountId || undefined,
|
||||||
}
|
}
|
||||||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||||||
data.contract = {
|
data.contract = {
|
||||||
@@ -964,7 +996,10 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
{/* 基本信息 */}
|
{/* 基本信息 */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div><Label>姓名 *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
|
<div><Label>姓名 *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
|
||||||
<div><Label>部门 *</Label><Select value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })}><option value="">请选择部门</option>{deptOptions.map(d => <option key={d.id} value={d.label}>{' '.repeat(d.level)}{d.label}</option>)}</Select></div>
|
<div><Label>部门 *</Label><Select value={form.departmentId} onChange={(e) => {
|
||||||
|
const opt = deptOptions.find(d => d.id === e.target.value)
|
||||||
|
setForm({ ...form, departmentId: e.target.value, department: opt?.label || '' })
|
||||||
|
}}><option value="">请选择部门</option>{deptOptions.map(d => <option key={d.id} value={d.id}>{' '.repeat(d.level)}{d.label}</option>)}</Select></div>
|
||||||
<div><Label>职务/岗位</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
|
<div><Label>职务/岗位</Label><Input value={form.position} onChange={(e) => setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" /></div>
|
||||||
<div><Label>证件号码 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
<div><Label>证件号码 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||||
{idCardDuplicate?.exists && (
|
{idCardDuplicate?.exists && (
|
||||||
@@ -1037,9 +1072,42 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
|||||||
{isNoSocialContract ? (
|
{isNoSocialContract ? (
|
||||||
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
<span className="text-xs text-amber-600">劳务协议/实习协议人员不缴纳社保公积金</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-500">默认与月工资一致,可手动修改</span>
|
<span className="text-xs text-gray-500">选定部门后自动带出账户,可手动调整。缴费基数默认与月工资一致。</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 账户选择(选部门后自动带出,可手动调整) */}
|
||||||
|
{!isNoSocialContract && (
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-3">
|
||||||
|
<div>
|
||||||
|
<Label>社保账户</Label>
|
||||||
|
<Select value={manualSocialAccountId} onChange={(e) => setManualSocialAccountId(e.target.value)} disabled={!form.departmentId}>
|
||||||
|
<option value="">{form.departmentId ? '未选择' : '请先选择部门'}</option>
|
||||||
|
{allSocialAccounts.map((a: any) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}({a.city})</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{autoAccounts?.socialStandard && manualSocialAccountId === autoAccounts.socialAccount?.id && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
当前标准:基数 {autoAccounts.socialStandard.baseMin}~{autoAccounts.socialStandard.baseMax},养老 {autoAccounts.socialStandard.pensionOrg}%/{autoAccounts.socialStandard.pensionEmp}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>公积金账户</Label>
|
||||||
|
<Select value={manualHousingAccountId} onChange={(e) => setManualHousingAccountId(e.target.value)} disabled={!form.departmentId}>
|
||||||
|
<option value="">{form.departmentId ? '未选择' : '请先选择部门'}</option>
|
||||||
|
{allHousingAccounts.map((a: any) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.name}({a.city})</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{autoAccounts?.housingStandard && manualHousingAccountId === autoAccounts.housingAccount?.id && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
当前标准:基数 {autoAccounts.housingStandard.baseMin}~{autoAccounts.housingStandard.baseMax},公积金 {autoAccounts.housingStandard.housingOrg}%/{autoAccounts.housingStandard.housingEmp}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={`grid grid-cols-4 gap-4 ${isNoSocialContract ? 'opacity-50' : ''}`}>
|
<div className={`grid grid-cols-4 gap-4 ${isNoSocialContract ? 'opacity-50' : ''}`}>
|
||||||
<div>
|
<div>
|
||||||
<Label>社保缴费基数</Label>
|
<Label>社保缴费基数</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user