# 20260816 社保公积金账户化重构

> 目标：将社保公积金从"按城市直接关联版本"改为"账户 + 年度标准"两层实体。
> 账户代表用户开设的社保/公积金账户（对应不同子公司/分公司/地区），年度标准是账户下每个年度的缴费比例和基数标准。

> **状态：已完成实施**（2026-08-16）
> 账户+年度标准模型已上线，前端已重构为账户卡片列表+展开年度标准管理。

---

## 一、现状分析

### 当前数据模型（已重构）

| 表 | 说明 | 唯一约束 |
|----|------|---------|
| `SocialAccount` | 社保/公积金账户实体（type=SOCIAL/HOUSING） | `@@unique([orgId, type, name])` |
| `SocialYearStandard` | 年度标准（比例+基数+最低工资+生效月份），关联到账户 | `@@unique([accountId, effectiveFrom])` |
| `SocialInsuranceConfig` | 旧社保版本表（保留兼容，回退用） | `@@unique([orgId, city, effectiveFrom])` |
| `HousingFundConfig` | 旧公积金版本表（保留兼容） | — |
| `EmployeeSocialInsRecord` | 员工社保参保记录，已加 accountId | — |
| `EmployeeHousingFundRecord` | 员工公积金参保记录，已加 accountId | — |
| `SocialMonthlyProcess` | 月度办理记录 | `@@unique([orgId, month, type])` |

### 已解决的问题

1. ✅ **"城市"只是字符串** → 已改为 SocialAccount 实体化管理
2. ✅ **多子公司/分公司场景缺失** → 同一城市可有多个账户，按根部门关联
3. ✅ **版本直接挂在 orgId+city 上** → 改为 SocialYearStandard 关联到 accountId
4. ✅ **员工参保记录只有 city** → 已加 accountId 字段
5. ✅ **薪资计算中社保配置查询** → 改为按 accountId 查 SocialYearStandard，旧表回退
6. ✅ **最低工资标准** → SocialYearStandard 新增 minWage 字段，支持最低工资保护和递延扣款

---

## 二、目标数据模型

### 新增：SocialAccount（社保/公积金账户）

```
model SocialAccount {
  id            String   @id @default(cuid())
  orgId         String
  org           Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
  type          String   // SOCIAL=社保账户, HOUSING=公积金账户
  name          String   // 账户名称，如"北京总公司社保账户"、"上海分公司社保账户"
  city          String   // 参保城市
  accountNo     String?  // 社保登记号 / 公积金单位账号
  bankName      String?  // 公积金开户行（公积金专用）
  bankAccount   String?  // 公积金银行账号（公积金专用）
  orgName       String?  // 缴费主体名称（子公司/分公司名称）
  orgCode       String?  // 缴费主体统一社会信用代码
  accountType   String?  // 公积金账户类型 BASIC=基本, SUPPLEMENTARY=补充（公积金专用）
  isDefault     Boolean  @default(false) // 是否默认账户（同 type 下仅一个默认）
  status        String   @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
  remark        String?
  createdBy     String
  createdAt     DateTime @default(now())
  updatedAt     DateTime @updatedAt

  yearStandards     SocialYearStandard[]
  socialRecords     EmployeeSocialInsRecord[]
  housingRecords    EmployeeHousingFundRecord[]

  // 部门关联（根部门 level=0 代表分公司/子公司）
  deptSocialAccounts   Department[] @relation("SocialAccountDepartments")
  deptHousingAccounts  Department[] @relation("HousingAccountDepartments")

  @@unique([orgId, type, name])
  @@index([orgId, type, city])
  @@index([orgId, type, isDefault])
}
```

### 新增：SocialYearStandard（年度标准，替代原 Config 表）

```
model SocialYearStandard {
  id              String   @id @default(cuid())
  orgId           String
  org             Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
  accountId       String   // 关联到账户
  account         SocialAccount @relation(fields: [accountId], references: [id], onDelete: Cascade)

  // 社保比例（type=SOCIAL 时使用）
  pensionOrg      Float    @default(16)
  pensionEmp      Float    @default(8)
  medicalOrg      Float    @default(9.8)
  medicalEmp      Float    @default(2)
  unemploymentOrg Float    @default(0.5)
  unemploymentEmp Float    @default(0.5)
  injuryOrg       Float    @default(0.2)
  maternityOrg    Float    @default(0.8)
  baseMin         Float    @default(6326)
  baseMax         Float    @default(33891)
  medicalBaseMin  Float    @default(0)
  medicalBaseMax  Float    @default(0)
  extraInsurances Json?

  // 最低工资标准（仅社保，20260816新增）
  minWage         Float    @default(0) // 当地月最低工资标准，0=不检查

  // 公积金比例（type=HOUSING 时使用）
  housingOrg      Float    @default(12)
  housingEmp      Float    @default(12)

  effectiveFrom   String   // 生效月份 YYYY-MM
  effectiveTo     String?  // 失效月份 YYYY-MM（null=当前有效）
  isCurrent       Boolean  @default(true)
  adjustmentDone  Boolean  @default(false)
  createdBy       String
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@unique([accountId, effectiveFrom])
  @@index([accountId, isCurrent])
}
```

### 修改：EmployeeSocialInsRecord

```
model EmployeeSocialInsRecord {
  // 新增字段
  accountId   String?  // 关联到 SocialAccount（迁移后非空）
  account     SocialAccount? @relation(fields: [accountId], references: [id])

  // 保留 city 字段用于兼容（迁移后从 account.city 派生，后续可废弃）
  city        String   @default("北京")

  // 其余字段不变
}
```

### 修改：EmployeeHousingFundRecord

```
model EmployeeHousingFundRecord {
  // 新增字段
  accountId   String?
  account     SocialAccount? @relation(fields: [accountId], references: [id])

  // 保留 city 字段用于兼容
  city        String   @default("北京")

  // 其余字段不变
}
```

### 修改：SocialMonthlyProcess

```
model SocialMonthlyProcess {
  // 新增字段
  accountId   String?  // 关联到账户（迁移后非空）

  // 保留原字段
  month        String
  type         String
  // ...
}
```

### 修改：Organization

```
model Organization {
  // 新增关联
  socialAccounts   SocialAccount[]
}
```

---

## 三、数据迁移方案

### 迁移步骤

1. **创建新表**：`SocialAccount` + `SocialYearStandard`
2. **给员工记录表加 accountId 字段**（可空，迁移期间兼容）
3. **迁移 SocialInsuranceConfig → SocialAccount + SocialYearStandard**：
   - 按 `(orgId, city)` 分组，每组创建一个 `SocialAccount`（type=SOCIAL）
   - 每条 Config 创建一条 `SocialYearStandard`（accountId 关联到对应账户）
4. **迁移 HousingFundConfig → SocialAccount + SocialYearStandard**：
   - 按 `(orgId, city, accountType)` 分组，每组创建一个 `SocialAccount`（type=HOUSING）
   - 每条 Config 创建一条 `SocialYearStandard`
5. **迁移 EmployeeSocialInsRecord.accountId**：按 `(orgId, city)` 匹配到 SocialAccount
6. **迁移 EmployeeHousingFundRecord.accountId**：按 `(orgId, city)` 匹配到 SocialAccount
7. **迁移 SocialMonthlyProcess.accountId**：按 `(orgId, type, snapshot.city)` 匹配
8. **验证数据完整性**：确认所有记录都有 accountId
9. **将 accountId 设为非空**（可选，或保持可空兼容）
10. **保留旧表**（SocialInsuranceConfig / HousingFundConfig）作为备份，代码切换后再删除

### 迁移脚本

```sql
-- 1. 创建社保账户（从 SocialInsuranceConfig 提取唯一 orgId+city）
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "isDefault", status, "createdBy", "createdAt", "updatedAt")
SELECT
  gen_random_uuid(),
  "orgId",
  'SOCIAL',
  city || '社保账户',
  city,
  true,
  'ACTIVE',
  'migration',
  NOW(),
  NOW()
FROM (SELECT DISTINCT "orgId", city FROM "SocialInsuranceConfig") t;

-- 2. 创建公积金账户（从 HousingFundConfig 提取唯一 orgId+city+accountType）
INSERT INTO "SocialAccount" (id, orgId, type, name, city, "accountType", "isDefault", status, "createdBy", "createdAt", "updatedAt")
SELECT
  gen_random_uuid(),
  "orgId",
  'HOUSING',
  city || COALESCE("accountType", 'BASIC') || '公积金账户',
  city,
  COALESCE("accountType", 'BASIC'),
  true,
  'ACTIVE',
  'migration',
  NOW(),
  NOW()
FROM (SELECT DISTINCT "orgId", city, "accountType" FROM "HousingFundConfig") t;

-- 3. 迁移社保年度标准
INSERT INTO "SocialYearStandard" (id, orgId, "accountId", "pensionOrg", "pensionEmp", ...)
SELECT
  gen_random_uuid(),
  c."orgId",
  a.id,
  c."pensionOrg", c."pensionEmp", ...
FROM "SocialInsuranceConfig" c
JOIN "SocialAccount" a ON a."orgId" = c."orgId" AND a.city = c.city AND a.type = 'SOCIAL';

-- 4. 迁移公积金年度标准
-- 5. 迁移员工参保记录 accountId
-- 6. 迁移月度办理记录 accountId
```

---

## 四、后端 API 变更

### 新增：账户管理 API（已实施）

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/social/accounts` | 账户列表（支持 type 筛选） |
| POST | `/social/accounts` | 新建账户 |
| PUT | `/social/accounts/:id` | 编辑账户 |
| DELETE | `/social/accounts/:id` | 删除账户（无关联记录时可删） |
| PUT | `/social/accounts/:id/default` | 设为默认账户 |
| PUT | `/social/accounts/:id/departments` | 账户关联根部门（批量） |
| GET | `/social/accounts/:id/departments` | 获取账户已关联的根部门 |

### 新增：年度标准 API（已实施）

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/social/accounts/:accountId/standards` | 账户的年度标准列表 |
| GET | `/social/accounts/:accountId/current-standard` | 账户的当前生效标准 |
| GET | `/social/accounts/:accountId/standard-by-month/:month` | 按账户+月份获取适用标准 |
| POST | `/social/accounts/:accountId/standards` | 新建年度标准（旧版本自动归档） |
| PUT | `/social/accounts/:accountId/min-wage` | 快速更新当前标准的最低工资 |

### 改造：薪资计算（已实施）

- `payroll.service.ts` 中社保配置查询：从 `orgId + city` 改为通过员工→根部门→账户→年度标准
- 员工参保记录查询：从 `employeeId + city` 改为 `employeeId + accountId`
- 新增最低工资保护和递延扣款逻辑（详见 20260816-薪税管理逻辑.md 第十五章）

### 废弃：城市相关 API

| 方法 | 路径 | 说明 | 处理 |
|------|------|------|------|
| GET | `/social/config/cities` | 城市列表 | 废弃，改为 `/social/accounts` 返回账户列表（含 city） |

### 改造：月度办理 API

- 月度办理按账户分别办理，不同账户不同城市的增减员
- `SocialMonthlyProcess` 新增 accountId

### 改造：薪资计算

- `payroll.service.ts` 中社保配置查询：从 `orgId + city` 改为 `accountId`
- 员工参保记录查询：从 `employeeId + city` 改为 `employeeId + accountId`

---

## 五、前端 UI 变更（已实施）

### 1. 社保公积金菜单（SocialInsurance.tsx）— 已重构

**社保/公积金 Tab**（20260816 重构）：
- ~~原城市选择器 → 改为账户选择器~~ → 已改为**账户卡片列表 + 展开年度标准管理**
- 每个账户卡片：名称、城市、账号、关联部门数、参保记录数
- 卡片可展开/折叠（ChevronDown/ChevronRight 图标）
- 展开后显示：
  - 当前年度标准（比例/基数/最低工资）
  - 最低工资快速编辑（仅社保，Input + 保存按钮）
  - 操作按钮：新建年度标准 | 批量调基 | 查看历史
  - 新建年度标准弹窗（原"新建版本"改名，保留 AI 建议、附加险种配置）
  - 版本历史展示
  - 调基预览（保留原有展示和编辑逻辑）
  - 试算工具（使用该账户的城市配置）
- 顶部"新建账户"按钮（账户新建/编辑/删除/设默认已从设置页面迁移到此）
- 新增组件：`AccountCard.tsx`（账户卡片+展开内容）、`AccountFormModal.tsx`（账户新建/编辑弹窗）

**月度办理 Tab**：保留不变

**员工参保 Tab**：保留不变

**专项附加扣除 Tab**：保留不变

### 2. 设置页面（Settings.tsx）— 已简化

- ~~新增"社保公积金账户管理"Tab~~ → 已去掉该 Tab
- 账户管理全部移到社保公积金菜单中完成
- SocialAccountSettings 和 AccountFormModal 组件定义保留在文件中（不再显示）

### 3. 员工表单（roster/modals.tsx）

- 社保公积金区：原城市选择 → 改为**账户选择**（下拉，按 type 筛选）
- 选中账户后自动带出城市（只读展示）

---

## 六、影响范围清单

### 后端

| 文件 | 改动范围 |
|------|---------|
| `prisma/schema.prisma` | 新增 SocialAccount、SocialYearStandard，修改员工记录表加 accountId |
| `src/routes/social.routes.ts` | 全面重构：账户 CRUD + 年度标准改为按 accountId |
| `src/services/payroll.service.ts` | 社保配置查询改为 account → yearStandard 两级 |
| `src/routes/payroll2.routes.ts` | 批次计算中社保配置查询适配 |
| `src/routes/payroll.routes.ts` | 旧版薪资计算适配 |
| `src/routes/roster.routes.ts` | socialInsuranceStatus 派生逻辑适配 |
| `src/services/contract.service.ts` | createEmployee 社保记录创建时关联 accountId |
| `src/routes/import.routes.ts` | Excel 导入时社保账户关联 |

### 前端

| 文件 | 改动范围 |
|------|---------|
| `src/pages/SocialInsurance.tsx` | 全面重构：账户选择器 + 年度标准 + 月度办理 |
| `src/pages/Settings.tsx` | 新增账户管理入口 |
| `src/pages/roster/modals.tsx` | 社保公积金区改为账户选择 |
| `src/lib/api-services.ts` | 新增 socialAccountApi，改造 socialInsuranceApi |

### 数据库迁移

| 操作 | 说明 |
|------|------|
| 新增表 | SocialAccount、SocialYearStandard |
| 修改表 | EmployeeSocialInsRecord 加 accountId，EmployeeHousingFundRecord 加 accountId，SocialMonthlyProcess 加 accountId |
| 数据迁移 | SocialInsuranceConfig → SocialAccount + SocialYearStandard |
| 数据迁移 | HousingFundConfig → SocialAccount + SocialYearStandard |
| 数据迁移 | 员工参保记录按 city 匹配 accountId |
| 保留旧表 | SocialInsuranceConfig / HousingFundConfig 暂保留，代码切换后删除 |

---

## 七、实施计划与完成状态

### 第一步：DB schema + 迁移脚本 ✅ 已完成
- ✅ 新增 SocialAccount、SocialYearStandard 表
- ✅ 员工记录表加 accountId 字段（可空）
- ✅ SocialYearStandard 新增 minWage 字段（最低工资标准）
- ✅ Department 表新增 socialAccountId / housingAccountId（根部门关联账户）
- ✅ 编写并执行迁移脚本
- ✅ 验证数据完整性

### 第二步：后端 API 重构 ✅ 已完成
- ✅ 新增账户 CRUD API（`/social/accounts`）
- ✅ 年度标准 API 改为按 accountId（`/social/accounts/:accountId/standards`）
- ✅ 新增按账户获取当前标准 API（`/social/accounts/:accountId/current-standard`）
- ✅ 新增快速更新最低工资 API（`/social/accounts/:accountId/min-wage`）
- ✅ 薪资计算适配（payroll.service.ts 按 accountId 查标准，旧表回退）
- ✅ 员工参保记录适配
- ✅ 保留旧 API 兼容（过渡期）

### 第三步：前端账户管理 ✅ 已完成
- ✅ ~~设置页新增账户管理 UI~~ → 已移到社保公积金菜单
- ✅ SocialInsurance.tsx 改为账户卡片列表 + 展开年度标准管理
- ✅ 新建版本改名为"新建年度标准"
- ✅ 最低工资快速编辑（当前配置区域可直接编辑）
- ✅ 新增 AccountCard.tsx 组件（账户卡片+展开内容）
- ✅ 新增 AccountFormModal.tsx 组件（账户新建/编辑弹窗）

### 第四步：前端参保流程适配 ✅ 已完成
- ✅ 员工表单社保公积金区改为账户选择
- ✅ 月度办理保留不变
- ✅ 员工参保记录展示账户名称

### 第五步：清理 ⏳ 待执行
- ⏳ 删除旧 SocialInsuranceConfig / HousingFundConfig 表
- ⏳ 删除旧 API
- ⏳ 删除前端城市选择器残留代码

### 额外完成的功能（20260816）
- ✅ 最低工资保护与递延扣款机制（详见 20260816-薪税管理逻辑.md 第十五章）
- ✅ 最低工资保护支持当月累计实发判断（多批次场景）
- ✅ 预入职状态（PRE_ONBOARD），预入职员工不进入薪资批次
- ✅ 薪资批次新增 payMonth（发薪年月）字段，支持提前发薪场景

---

## 八、风险与回滚

| 风险 | 应对 |
|------|------|
| 迁移脚本数据丢失 | 迁移前备份数据库，旧表保留不删 |
| 薪资计算引用旧表 | 过渡期双写，确认新表数据正确后再切换 |
| 前端缓存旧城市选择器 | 强制刷新 + 版本号 |
| 员工参保记录无 accountId | 迁移脚本按 city 匹配，未匹配的标记待处理 |

**回滚方案**：保留旧表和旧 API，前端可切回旧版本，后端旧 API 仍可用。
