diff --git a/20260816-社保优化.md b/20260816-社保优化.md new file mode 100644 index 0000000..45f953b --- /dev/null +++ b/20260816-社保优化.md @@ -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 仍可用。 diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 95b7310..42938f2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -200,6 +200,8 @@ model Organization { eSignRecords ESignRecord[] commissionBonuses CommissionBonus[] medicalPeriodPolicies MedicalPeriodPolicy[] + socialAccounts SocialAccount[] + socialYearStandards SocialYearStandard[] // 组织架构 + 审批流 departments Department[] 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 { id String @id @default(cuid()) orgId String @@ -877,7 +949,9 @@ model EmployeeSocialInsRecord { org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) employeeId String 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 endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) base Float // 缴费基数 @@ -890,6 +964,7 @@ model EmployeeSocialInsRecord { @@index([orgId, employeeId]) @@index([employeeId, startMonth, endMonth]) @@index([orgId, city]) + @@index([accountId]) } model EmployeeHousingFundRecord { @@ -898,7 +973,9 @@ model EmployeeHousingFundRecord { org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) employeeId String 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 endMonth String? // 截止缴费年月 YYYY-MM(null=至今有效) base Float // 缴费基数 @@ -910,6 +987,7 @@ model EmployeeHousingFundRecord { @@index([orgId, employeeId]) @@index([employeeId, startMonth, endMonth]) + @@index([accountId]) } /// 月度社保/公积金办理记录(标记某月已办理完成,保存快照) @@ -917,6 +995,8 @@ model SocialMonthlyProcess { 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]) month String // 办理月份 YYYY-MM type String // SOCIAL=社保, HOUSING=公积金 status String @default("COMPLETED") // COMPLETED=已办理 @@ -928,6 +1008,7 @@ model SocialMonthlyProcess { @@unique([orgId, month, type]) @@index([orgId, month]) + @@index([accountId, month]) } model EmployeeDepartmentRecord { diff --git a/backend/scripts/migrate-social-accounts.ts b/backend/scripts/migrate-social-accounts.ts new file mode 100644 index 0000000..45fa090 --- /dev/null +++ b/backend/scripts/migrate-social-accounts.ts @@ -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() // 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() // 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() + }) diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index ffdbdb8..168790d 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -258,7 +258,22 @@ export async function calcBatchEntry( const ytdIncome = archivedEntries.reduce((s, e) => s + e.totalPay, 0) + totalPay const ytdSocialEmp = archivedEntries.reduce((s, e) => s + e.socialEmp, 0) + socialEmp 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 deductionAmount = 5000 * Number(month.slice(5, 7)) const ytdTaxableIncome = Math.max(0, ytdIncome - deductionAmount - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) @@ -271,6 +286,8 @@ export async function calcBatchEntry( ytdSocialEmp, ytdHousingEmp, ytdSpecialDeduction, + specialDeductionSource: deductionRecords.length > 0 ? '按月记录' : '便捷字段', + specialDeductionRecords: deductionRecords.length, ytdTaxableIncome, ytdTaxDeducted, currentMonthTax: tax,