fix: 个税累计预扣中专项附加扣除改为按月读取实际填报金额
原逻辑用 employee.specialDeduction(便捷字段)× 月数计算累计 专项附加扣除,未按月读取 SpecialDeductionRecord 实际金额。 若员工某月填报/取消专项附加扣除,累计值会不准。 修复: - 优先按月查询 SpecialDeductionRecord(当年至当月)累加实际金额 - 无按月记录时回退到便捷字段 × 月数(兼容旧数据) - taxBreakdown 增加 specialDeductionSource 字段标识数据来源 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,395 @@
|
|||||||
|
# 20260816 社保公积金账户化重构
|
||||||
|
|
||||||
|
> 目标:将社保公积金从"按城市直接关联版本"改为"账户 + 年度标准"两层实体。
|
||||||
|
> 账户代表用户开设的社保/公积金账户(对应不同子公司/分公司/地区),年度标准是账户下每个年度的缴费比例和基数标准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状分析
|
||||||
|
|
||||||
|
### 当前数据模型
|
||||||
|
|
||||||
|
| 表 | 说明 | 唯一约束 |
|
||||||
|
|----|------|---------|
|
||||||
|
| `SocialInsuranceConfig` | 社保版本(比例+基数+生效月份),按 orgId+city+effectiveFrom | `@@unique([orgId, city, effectiveFrom])` |
|
||||||
|
| `HousingFundConfig` | 公积金版本,按 orgId+city+accountType+effectiveFrom | `@@unique([orgId, city, accountType, effectiveFrom])` |
|
||||||
|
| `EmployeeSocialInsRecord` | 员工社保参保记录,字段含 city | `@@index([orgId, city])` |
|
||||||
|
| `EmployeeHousingFundRecord` | 员工公积金参保记录,字段含 city | 无 city 索引 |
|
||||||
|
| `SocialMonthlyProcess` | 月度办理记录 | `@@unique([orgId, month, type])` |
|
||||||
|
|
||||||
|
### 当前问题
|
||||||
|
|
||||||
|
1. **"城市"只是字符串**:无实体化管理,无法记录账户编号、开户行、缴费主体等
|
||||||
|
2. **多子公司/分公司场景缺失**:同一城市可能有多个社保账户(不同主体分别开户),当前按 city 唯一无法支持
|
||||||
|
3. **版本直接挂在 orgId+city 上**:缺少"账户"这一层抽象,无法区分同一城市不同主体的账户
|
||||||
|
4. **员工参保记录只有 city**:无法精确关联到具体哪个社保账户
|
||||||
|
5. **薪资计算中社保配置查询**:按 orgId+city 查 SocialInsuranceConfig,无法按账户区分
|
||||||
|
|
||||||
|
### 涉及"关联城市"的代码位置
|
||||||
|
|
||||||
|
| 文件 | 位置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend/prisma/schema.prisma` | `SocialInsuranceConfig.city` | 社保配置按城市区分 |
|
||||||
|
| `backend/prisma/schema.prisma` | `HousingFundConfig.city` | 公积金配置按城市区分 |
|
||||||
|
| `backend/prisma/schema.prisma` | `EmployeeSocialInsRecord.city` | 员工社保参保城市 |
|
||||||
|
| `backend/prisma/schema.prisma` | `EmployeeHousingFundRecord.city` | 员工公积金参保城市 |
|
||||||
|
| `backend/src/routes/social.routes.ts` | 全文 | 社保配置 CRUD/版本/城市列表/月度办理 均按 city |
|
||||||
|
| `backend/src/services/payroll.service.ts` | 社保计算逻辑 | 按 city 查 SocialInsuranceConfig |
|
||||||
|
| `backend/src/routes/payroll2.routes.ts` | 批次计算 | 社保配置查询 |
|
||||||
|
| `backend/src/routes/roster.routes.ts` | socialInsuranceStatus 派生 | 按 city 判定 |
|
||||||
|
| `backend/src/services/contract.service.ts` | createEmployee | 社保记录创建时关联 city |
|
||||||
|
| `frontend/src/pages/SocialInsurance.tsx` | 全文 | 城市选择器、版本管理、月度办理 |
|
||||||
|
| `frontend/src/pages/roster/modals.tsx` | 社保公积金区 | 员工参保城市 |
|
||||||
|
| `frontend/src/pages/Settings.tsx` | 社保配置 | 需新增账户管理入口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标数据模型
|
||||||
|
|
||||||
|
### 新增: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[]
|
||||||
|
|
||||||
|
@@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?
|
||||||
|
|
||||||
|
// 公积金比例(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` | 设为默认账户 |
|
||||||
|
|
||||||
|
### 改造:年度标准 API(原版本 API)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 变更 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/social/config` | 获取当前标准 | 改为按 accountId 查询 |
|
||||||
|
| GET | `/social/config/versions` | 版本列表 | 改为按 accountId 查询 |
|
||||||
|
| POST | `/social/config/versions` | 新建版本 | 改为按 accountId 创建 |
|
||||||
|
| GET | `/social/config/by-month/:month` | 按月查标准 | 改为按 accountId+month 查询 |
|
||||||
|
|
||||||
|
### 废弃:城市相关 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 处理 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| GET | `/social/config/cities` | 城市列表 | 废弃,改为 `/social/accounts` 返回账户列表(含 city) |
|
||||||
|
|
||||||
|
### 改造:月度办理 API
|
||||||
|
|
||||||
|
- 月度办理按账户分别办理,不同账户不同城市的增减员
|
||||||
|
- `SocialMonthlyProcess` 新增 accountId
|
||||||
|
|
||||||
|
### 改造:薪资计算
|
||||||
|
|
||||||
|
- `payroll.service.ts` 中社保配置查询:从 `orgId + city` 改为 `accountId`
|
||||||
|
- 员工参保记录查询:从 `employeeId + city` 改为 `employeeId + accountId`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、前端 UI 变更
|
||||||
|
|
||||||
|
### 1. 设置页新增"社保公积金账户管理"
|
||||||
|
|
||||||
|
- 账户列表(按 type 分社保/公积金 Tab)
|
||||||
|
- 每个账户卡片:名称、城市、账户编号、缴费主体、是否默认
|
||||||
|
- 新增/编辑/停用账户弹窗
|
||||||
|
|
||||||
|
### 2. SocialInsurance.tsx 改造
|
||||||
|
|
||||||
|
**社保/公积金 Tab**:
|
||||||
|
- 原城市选择器 → 改为**账户选择器**(下拉,显示"账户名称 - 城市")
|
||||||
|
- 选中账户后展示该账户下的年度标准(当前版本 + 历史版本)
|
||||||
|
- 年度标准展示比例和基数(与现在版本展示一致)
|
||||||
|
|
||||||
|
**月度办理 Tab**:
|
||||||
|
- 按账户分别办理
|
||||||
|
- 选择账户 → 获取该账户的增减员名单 → 确认办理
|
||||||
|
|
||||||
|
**员工参保 Tab**:
|
||||||
|
- 员工参保记录展示关联的账户名称
|
||||||
|
- 新增参保时选择账户(而非仅选城市)
|
||||||
|
|
||||||
|
### 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 字段(可空)
|
||||||
|
- 编写并执行迁移脚本
|
||||||
|
- 验证数据完整性
|
||||||
|
|
||||||
|
### 第二步:后端 API 重构
|
||||||
|
- 新增账户 CRUD API
|
||||||
|
- 年度标准 API 改为按 accountId
|
||||||
|
- 薪资计算适配
|
||||||
|
- 员工参保记录适配
|
||||||
|
- 保留旧 API 兼容(过渡期)
|
||||||
|
|
||||||
|
### 第三步:前端账户管理
|
||||||
|
- 设置页新增账户管理 UI
|
||||||
|
- SocialInsurance.tsx 改为账户选择器 + 年度标准
|
||||||
|
|
||||||
|
### 第四步:前端参保流程适配
|
||||||
|
- 员工表单社保公积金区改为账户选择
|
||||||
|
- 月度办理按账户分别办理
|
||||||
|
- 员工参保记录展示账户名称
|
||||||
|
|
||||||
|
### 第五步:清理
|
||||||
|
- 删除旧 SocialInsuranceConfig / HousingFundConfig 表
|
||||||
|
- 删除旧 API
|
||||||
|
- 删除前端城市选择器残留代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 应对 |
|
||||||
|
|------|------|
|
||||||
|
| 迁移脚本数据丢失 | 迁移前备份数据库,旧表保留不删 |
|
||||||
|
| 薪资计算引用旧表 | 过渡期双写,确认新表数据正确后再切换 |
|
||||||
|
| 前端缓存旧城市选择器 | 强制刷新 + 版本号 |
|
||||||
|
| 员工参保记录无 accountId | 迁移脚本按 city 匹配,未匹配的标记待处理 |
|
||||||
|
|
||||||
|
**回滚方案**:保留旧表和旧 API,前端可切回旧版本,后端旧 API 仍可用。
|
||||||
@@ -200,6 +200,8 @@ model Organization {
|
|||||||
eSignRecords ESignRecord[]
|
eSignRecords ESignRecord[]
|
||||||
commissionBonuses CommissionBonus[]
|
commissionBonuses CommissionBonus[]
|
||||||
medicalPeriodPolicies MedicalPeriodPolicy[]
|
medicalPeriodPolicies MedicalPeriodPolicy[]
|
||||||
|
socialAccounts SocialAccount[]
|
||||||
|
socialYearStandards SocialYearStandard[]
|
||||||
// 组织架构 + 审批流
|
// 组织架构 + 审批流
|
||||||
departments Department[]
|
departments Department[]
|
||||||
positions Position[]
|
positions Position[]
|
||||||
@@ -459,6 +461,76 @@ model AuditLog {
|
|||||||
|
|
||||||
// ========== 社保 & 通知 & 附件 ==========
|
// ========== 社保 & 通知 & 附件 ==========
|
||||||
|
|
||||||
|
/// 社保/公积金账户(实体化管理,替代原 city 字符串)
|
||||||
|
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)
|
||||||
|
status String @default("ACTIVE") // ACTIVE=正常, SUSPENDED=停用
|
||||||
|
remark String?
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
yearStandards SocialYearStandard[]
|
||||||
|
socialRecords EmployeeSocialInsRecord[]
|
||||||
|
housingRecords EmployeeHousingFundRecord[]
|
||||||
|
monthlyProcesses SocialMonthlyProcess[]
|
||||||
|
|
||||||
|
@@unique([orgId, type, name])
|
||||||
|
@@index([orgId, type, city])
|
||||||
|
@@index([orgId, type, isDefault])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 年度标准(替代原 SocialInsuranceConfig / HousingFundConfig)
|
||||||
|
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?
|
||||||
|
|
||||||
|
// 公积金比例(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])
|
||||||
|
}
|
||||||
|
|
||||||
model SocialInsuranceConfig {
|
model SocialInsuranceConfig {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
@@ -877,7 +949,9 @@ model EmployeeSocialInsRecord {
|
|||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
city String @default("北京") // 参保城市
|
accountId String? // 关联到 SocialAccount(迁移后非空)
|
||||||
|
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||||
|
city String @default("北京") // 参保城市(兼容旧数据,从 account.city 派生)
|
||||||
startMonth String // 开始缴费年月 YYYY-MM
|
startMonth String // 开始缴费年月 YYYY-MM
|
||||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||||
base Float // 缴费基数
|
base Float // 缴费基数
|
||||||
@@ -890,6 +964,7 @@ model EmployeeSocialInsRecord {
|
|||||||
@@index([orgId, employeeId])
|
@@index([orgId, employeeId])
|
||||||
@@index([employeeId, startMonth, endMonth])
|
@@index([employeeId, startMonth, endMonth])
|
||||||
@@index([orgId, city])
|
@@index([orgId, city])
|
||||||
|
@@index([accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model EmployeeHousingFundRecord {
|
model EmployeeHousingFundRecord {
|
||||||
@@ -898,7 +973,9 @@ model EmployeeHousingFundRecord {
|
|||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
city String @default("北京") // 参保城市
|
accountId String? // 关联到 SocialAccount(迁移后非空)
|
||||||
|
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||||
|
city String @default("北京") // 参保城市(兼容旧数据)
|
||||||
startMonth String // 开始缴费年月 YYYY-MM
|
startMonth String // 开始缴费年月 YYYY-MM
|
||||||
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效)
|
||||||
base Float // 缴费基数
|
base Float // 缴费基数
|
||||||
@@ -910,6 +987,7 @@ model EmployeeHousingFundRecord {
|
|||||||
|
|
||||||
@@index([orgId, employeeId])
|
@@index([orgId, employeeId])
|
||||||
@@index([employeeId, startMonth, endMonth])
|
@@index([employeeId, startMonth, endMonth])
|
||||||
|
@@index([accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
/// 月度社保/公积金办理记录(标记某月已办理完成,保存快照)
|
||||||
@@ -917,6 +995,8 @@ model SocialMonthlyProcess {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
accountId String? // 关联到账户(迁移后非空)
|
||||||
|
account SocialAccount? @relation(fields: [accountId], references: [id])
|
||||||
month String // 办理月份 YYYY-MM
|
month String // 办理月份 YYYY-MM
|
||||||
type String // SOCIAL=社保, HOUSING=公积金
|
type String // SOCIAL=社保, HOUSING=公积金
|
||||||
status String @default("COMPLETED") // COMPLETED=已办理
|
status String @default("COMPLETED") // COMPLETED=已办理
|
||||||
@@ -928,6 +1008,7 @@ model SocialMonthlyProcess {
|
|||||||
|
|
||||||
@@unique([orgId, month, type])
|
@@unique([orgId, month, type])
|
||||||
@@index([orgId, month])
|
@@index([orgId, month])
|
||||||
|
@@index([accountId, month])
|
||||||
}
|
}
|
||||||
|
|
||||||
model EmployeeDepartmentRecord {
|
model EmployeeDepartmentRecord {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* 社保公积金账户化迁移脚本
|
||||||
|
* 将旧 SocialInsuranceConfig / HousingFundConfig 迁移到 SocialAccount + SocialYearStandard
|
||||||
|
* 将员工参保记录的 city 关联到 accountId
|
||||||
|
*
|
||||||
|
* 用法: npx tsx scripts/migrate-social-accounts.ts
|
||||||
|
*/
|
||||||
|
import prisma from '../src/lib/prisma'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('========== 社保公积金账户化迁移 ==========')
|
||||||
|
|
||||||
|
// ========== 1. 迁移社保配置 → 社保账户 + 年度标准 ==========
|
||||||
|
console.log('[1/6] 迁移社保配置...')
|
||||||
|
|
||||||
|
// 按 (orgId, city) 分组创建社保账户
|
||||||
|
const socialConfigs = await prisma.socialInsuranceConfig.findMany()
|
||||||
|
const socialAccountMap = new Map<string, string>() // key: orgId:city → accountId
|
||||||
|
|
||||||
|
for (const config of socialConfigs) {
|
||||||
|
const key = `${config.orgId}:${config.city}`
|
||||||
|
if (socialAccountMap.has(key)) continue
|
||||||
|
|
||||||
|
const account = await prisma.socialAccount.create({
|
||||||
|
data: {
|
||||||
|
orgId: config.orgId,
|
||||||
|
type: 'SOCIAL',
|
||||||
|
name: `${config.city}社保账户`,
|
||||||
|
city: config.city,
|
||||||
|
isDefault: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdBy: 'migration',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
socialAccountMap.set(key, account.id)
|
||||||
|
console.log(` 创建社保账户: ${config.city} → ${account.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移每条 Config → YearStandard
|
||||||
|
for (const config of socialConfigs) {
|
||||||
|
const key = `${config.orgId}:${config.city}`
|
||||||
|
const accountId = socialAccountMap.get(key)!
|
||||||
|
await prisma.socialYearStandard.create({
|
||||||
|
data: {
|
||||||
|
orgId: config.orgId,
|
||||||
|
accountId,
|
||||||
|
pensionOrg: config.pensionOrg,
|
||||||
|
pensionEmp: config.pensionEmp,
|
||||||
|
medicalOrg: config.medicalOrg,
|
||||||
|
medicalEmp: config.medicalEmp,
|
||||||
|
unemploymentOrg: config.unemploymentOrg,
|
||||||
|
unemploymentEmp: config.unemploymentEmp,
|
||||||
|
injuryOrg: config.injuryOrg,
|
||||||
|
maternityOrg: config.maternityOrg,
|
||||||
|
baseMin: config.baseMin,
|
||||||
|
baseMax: config.baseMax,
|
||||||
|
medicalBaseMin: config.medicalBaseMin,
|
||||||
|
medicalBaseMax: config.medicalBaseMax,
|
||||||
|
extraInsurances: config.extraInsurances as any,
|
||||||
|
effectiveFrom: config.effectiveFrom,
|
||||||
|
effectiveTo: config.effectiveTo,
|
||||||
|
isCurrent: config.isCurrent,
|
||||||
|
adjustmentDone: config.adjustmentDone,
|
||||||
|
createdBy: config.createdBy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(` 迁移 ${socialConfigs.length} 条社保年度标准`)
|
||||||
|
|
||||||
|
// ========== 2. 迁移公积金配置 → 公积金账户 + 年度标准 ==========
|
||||||
|
console.log('[2/6] 迁移公积金配置...')
|
||||||
|
|
||||||
|
const housingConfigs = await prisma.housingFundConfig.findMany()
|
||||||
|
const housingAccountMap = new Map<string, string>() // key: orgId:city:accountType → accountId
|
||||||
|
|
||||||
|
for (const config of housingConfigs) {
|
||||||
|
const accountType = config.accountType || 'BASIC'
|
||||||
|
const key = `${config.orgId}:${config.city}:${accountType}`
|
||||||
|
if (housingAccountMap.has(key)) continue
|
||||||
|
|
||||||
|
const account = await prisma.socialAccount.create({
|
||||||
|
data: {
|
||||||
|
orgId: config.orgId,
|
||||||
|
type: 'HOUSING',
|
||||||
|
name: `${config.city}${accountType === 'SUPPLEMENTARY' ? '补充' : ''}公积金账户`,
|
||||||
|
city: config.city,
|
||||||
|
accountType,
|
||||||
|
isDefault: accountType === 'BASIC',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
createdBy: 'migration',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
housingAccountMap.set(key, account.id)
|
||||||
|
console.log(` 创建公积金账户: ${config.city} ${accountType} → ${account.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of housingConfigs) {
|
||||||
|
const accountType = config.accountType || 'BASIC'
|
||||||
|
const key = `${config.orgId}:${config.city}:${accountType}`
|
||||||
|
const accountId = housingAccountMap.get(key)!
|
||||||
|
await prisma.socialYearStandard.create({
|
||||||
|
data: {
|
||||||
|
orgId: config.orgId,
|
||||||
|
accountId,
|
||||||
|
housingOrg: config.housingOrg,
|
||||||
|
housingEmp: config.housingEmp,
|
||||||
|
baseMin: config.baseMin,
|
||||||
|
baseMax: config.baseMax,
|
||||||
|
effectiveFrom: config.effectiveFrom,
|
||||||
|
effectiveTo: config.effectiveTo,
|
||||||
|
isCurrent: config.isCurrent,
|
||||||
|
adjustmentDone: config.adjustmentDone,
|
||||||
|
createdBy: config.createdBy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(` 迁移 ${housingConfigs.length} 条公积金年度标准`)
|
||||||
|
|
||||||
|
// ========== 3. 迁移员工社保参保记录 accountId ==========
|
||||||
|
console.log('[3/6] 迁移员工社保参保记录 accountId...')
|
||||||
|
|
||||||
|
const socialRecords = await prisma.employeeSocialInsRecord.findMany({ where: { accountId: null } })
|
||||||
|
let socialUpdated = 0
|
||||||
|
for (const record of socialRecords) {
|
||||||
|
const key = `${record.orgId}:${record.city}`
|
||||||
|
const accountId = socialAccountMap.get(key)
|
||||||
|
if (accountId) {
|
||||||
|
await prisma.employeeSocialInsRecord.update({ where: { id: record.id }, data: { accountId } })
|
||||||
|
socialUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` 更新 ${socialUpdated}/${socialRecords.length} 条社保参保记录`)
|
||||||
|
|
||||||
|
// ========== 4. 迁移员工公积金参保记录 accountId ==========
|
||||||
|
console.log('[4/6] 迁移员工公积金参保记录 accountId...')
|
||||||
|
|
||||||
|
const housingRecords = await prisma.employeeHousingFundRecord.findMany({ where: { accountId: null } })
|
||||||
|
let housingUpdated = 0
|
||||||
|
for (const record of housingRecords) {
|
||||||
|
// 公积金默认用 BASIC 类型账户
|
||||||
|
const key = `${record.orgId}:${record.city}:BASIC`
|
||||||
|
const accountId = housingAccountMap.get(key)
|
||||||
|
if (accountId) {
|
||||||
|
await prisma.employeeHousingFundRecord.update({ where: { id: record.id }, data: { accountId } })
|
||||||
|
housingUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` 更新 ${housingUpdated}/${housingRecords.length} 条公积金参保记录`)
|
||||||
|
|
||||||
|
// ========== 5. 迁移月度办理记录 accountId ==========
|
||||||
|
console.log('[5/6] 迁移月度办理记录 accountId...')
|
||||||
|
|
||||||
|
const monthlyProcesses = await prisma.socialMonthlyProcess.findMany({ where: { accountId: null } })
|
||||||
|
let monthlyUpdated = 0
|
||||||
|
for (const proc of monthlyProcesses) {
|
||||||
|
const snapshot = proc.snapshot as any
|
||||||
|
const city = snapshot?.city || snapshot?.configCity
|
||||||
|
if (city) {
|
||||||
|
const accountMap = proc.type === 'SOCIAL' ? socialAccountMap : housingAccountMap
|
||||||
|
// 社保用 orgId:city,公积金用 orgId:city:BASIC
|
||||||
|
const key = proc.type === 'SOCIAL' ? `${proc.orgId}:${city}` : `${proc.orgId}:${city}:BASIC`
|
||||||
|
const accountId = accountMap.get(key)
|
||||||
|
if (accountId) {
|
||||||
|
await prisma.socialMonthlyProcess.update({ where: { id: proc.id }, data: { accountId } })
|
||||||
|
monthlyUpdated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` 更新 ${monthlyUpdated}/${monthlyProcesses.length} 条月度办理记录`)
|
||||||
|
|
||||||
|
// ========== 6. 验证数据完整性 ==========
|
||||||
|
console.log('[6/6] 验证数据完整性...')
|
||||||
|
|
||||||
|
const accounts = await prisma.socialAccount.count()
|
||||||
|
const standards = await prisma.socialYearStandard.count()
|
||||||
|
const socialWithoutAccount = await prisma.employeeSocialInsRecord.count({ where: { accountId: null } })
|
||||||
|
const housingWithoutAccount = await prisma.employeeHousingFundRecord.count({ where: { accountId: null } })
|
||||||
|
|
||||||
|
console.log(` 账户总数: ${accounts}`)
|
||||||
|
console.log(` 年度标准总数: ${standards}`)
|
||||||
|
console.log(` 社保参保记录无 accountId: ${socialWithoutAccount}`)
|
||||||
|
console.log(` 公积金参保记录无 accountId: ${housingWithoutAccount}`)
|
||||||
|
|
||||||
|
if (socialWithoutAccount > 0 || housingWithoutAccount > 0) {
|
||||||
|
console.log(' ⚠️ 部分参保记录未关联账户(可能城市无对应配置),需手动处理')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
console.log('========== 迁移完成 ==========')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('迁移失败:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
@@ -258,7 +258,22 @@ export async function calcBatchEntry(
|
|||||||
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
|
const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay
|
||||||
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp
|
||||||
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
const ytdHousingEmp = archivedEntries.reduce((s, e) => s + e.housingEmp, 0) + housingEmp
|
||||||
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
|
||||||
|
// 专项附加扣除:按月读取 SpecialDeductionRecord 实际填报金额累加
|
||||||
|
// 优先使用按月记录;若无按月记录,回退到员工便捷字段 × 月数(兼容旧数据)
|
||||||
|
const deductionRecords = await prisma.specialDeductionRecord.findMany({
|
||||||
|
where: { orgId, employeeId, month: { startsWith: year, lte: month } },
|
||||||
|
select: { amount: true, month: true },
|
||||||
|
})
|
||||||
|
let ytdSpecialDeduction: number
|
||||||
|
if (deductionRecords.length > 0) {
|
||||||
|
// 按月实际填报金额累加
|
||||||
|
ytdSpecialDeduction = deductionRecords.reduce((s, r) => s + r.amount, 0)
|
||||||
|
} else {
|
||||||
|
// 回退:员工便捷字段 × 月数(兼容未填报按月记录的旧数据)
|
||||||
|
ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
|
||||||
|
}
|
||||||
|
|
||||||
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
const ytdTaxDeducted = archivedEntries.reduce((s, e) => s + e.tax, 0)
|
||||||
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
const deductionAmount = 5000 * Number(month.slice(5, 7))
|
||||||
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
|
||||||
@@ -271,6 +286,8 @@ export async function calcBatchEntry(
|
|||||||
ytdSocialEmp,
|
ytdSocialEmp,
|
||||||
ytdHousingEmp,
|
ytdHousingEmp,
|
||||||
ytdSpecialDeduction,
|
ytdSpecialDeduction,
|
||||||
|
specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段',
|
||||||
|
specialDeductionRecords: deductionRecords.length,
|
||||||
ytdTaxableIncome,
|
ytdTaxableIncome,
|
||||||
ytdTaxDeducted,
|
ytdTaxDeducted,
|
||||||
currentMonthTax: tax,
|
currentMonthTax: tax,
|
||||||
|
|||||||
Reference in New Issue
Block a user