feat: 社保公积金账户化重构

新增 SocialAccount(账户)+ SocialYearStandard(年度标准)两层实体,
替代原 SocialInsuranceConfig/HousingFundConfig 按城市管理的方式。

- DB: 新增 SocialAccount、SocialYearStandard 表,Department 加账户关联
- 迁移: 旧 Config 表数据迁移到 Account + YearStandard
- 后端: 新增账户 CRUD + 年度标准 API,薪资计算适配 accountId
- 前端: 设置页新增账户管理 Tab,组织架构提示 level=0 可关联账户
- 前端: SocialInsurance.tsx 城市选择器改为账户选择器
- 兼容: 旧 Config 表保留,薪资计算回退旧表

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 13:50:15 +08:00
parent 454b3d4b05
commit 63b6c9dcc7
8 changed files with 674 additions and 81 deletions
+8
View File
@@ -486,6 +486,9 @@ model SocialAccount {
socialRecords EmployeeSocialInsRecord[]
housingRecords EmployeeHousingFundRecord[]
monthlyProcesses SocialMonthlyProcess[]
// 部门关联(员工通过部门继承账户)
deptSocialAccounts Department[] @relation("DeptSocialAccount")
deptHousingAccounts Department[] @relation("DeptHousingAccount")
@@unique([orgId, type, name])
@@index([orgId, type, city])
@@ -1675,6 +1678,11 @@ model Department {
level Int @default(0) // 层级(0=根)
sortOrder Int @default(0) // 同级排序
description String?
// 社保公积金账户关联(员工通过部门继承账户)
socialAccountId String? // 社保账户
socialAccount SocialAccount? @relation("DeptSocialAccount", fields: [socialAccountId], references: [id], onDelete: SetNull)
housingAccountId String? // 公积金账户
housingAccount SocialAccount? @relation("DeptHousingAccount", fields: [housingAccountId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+241
View File
@@ -34,6 +34,247 @@ const housingConfigFields = {
baseMax: z.number().optional(),
}
// ==========================================
// 账户管理 API(新)
// ==========================================
const accountSchema = z.object({
type: z.enum(['SOCIAL', 'HOUSING']),
name: z.string().min(1),
city: z.string().min(1),
accountNo: z.string().optional(),
bankName: z.string().optional(),
bankAccount: z.string().optional(),
orgName: z.string().optional(),
orgCode: z.string().optional(),
accountType: z.string().optional(),
isDefault: z.boolean().optional(),
remark: z.string().optional(),
})
// 账户列表
router.get('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const type = req.query.type as string | undefined
const where: any = { orgId }
if (type) where.type = type
const accounts = await prisma.socialAccount.findMany({
where,
orderBy: [{ type: 'asc' }, { isDefault: 'desc' }, { city: 'asc' }],
include: {
_count: { select: { socialRecords: true, housingRecords: true, deptSocialAccounts: true, deptHousingAccounts: true } },
},
})
res.json({ success: true, data: accounts })
} catch (err) { next(err) }
})
// 新建账户
router.post('/accounts', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const data = accountSchema.parse(req.body)
// 如果设为默认,先取消同 type 其他默认
if (data.isDefault) {
await prisma.socialAccount.updateMany({ where: { orgId, type: data.type, isDefault: true }, data: { isDefault: false } })
}
const account = await prisma.socialAccount.create({
data: { ...data, orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: account })
} catch (err) { next(err) }
})
// 编辑账户
router.put('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const data = accountSchema.partial().parse(req.body)
if (data.isDefault) {
const account = await prisma.socialAccount.findUnique({ where: { id: req.params.id } })
await prisma.socialAccount.updateMany({ where: { orgId, type: account?.type, isDefault: true, id: { not: req.params.id } }, data: { isDefault: false } })
}
const account = await prisma.socialAccount.update({
where: { id: req.params.id },
data,
})
res.json({ success: true, data: account })
} catch (err) { next(err) }
})
// 删除账户(无关联记录时可删)
router.delete('/accounts/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 检查是否有关联记录
const [socialCount, housingCount, deptCount] = await Promise.all([
prisma.employeeSocialInsRecord.count({ where: { accountId: account.id } }),
prisma.employeeHousingFundRecord.count({ where: { accountId: account.id } }),
prisma.department.count({ where: { OR: [{ socialAccountId: account.id }, { housingAccountId: account.id }] } }),
])
if (socialCount + housingCount + deptCount > 0) {
return res.status(400).json({ success: false, error: { code: 'IN_USE', message: `账户仍关联 ${socialCount + housingCount} 条参保记录、${deptCount} 个部门,无法删除` } })
}
await prisma.socialAccount.delete({ where: { id: account.id } })
res.json({ success: true, data: { message: '已删除' } })
} catch (err) { next(err) }
})
// 设为默认账户
router.put('/accounts/:id/default', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const account = await prisma.socialAccount.findFirst({ where: { id: req.params.id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
await prisma.socialAccount.updateMany({ where: { orgId, type: account.type, isDefault: true }, data: { isDefault: false } })
await prisma.socialAccount.update({ where: { id: account.id }, data: { isDefault: true } })
res.json({ success: true, data: { message: '已设为默认' } })
} catch (err) { next(err) }
})
// ==========================================
// 年度标准 API(新,按 accountId
// ==========================================
// 按账户获取年度标准列表
router.get('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const standards = await prisma.socialYearStandard.findMany({
where: { orgId, accountId },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: standards })
} catch (err) { next(err) }
})
// 按账户获取当前生效标准
router.get('/accounts/:accountId/current-standard', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const standard = await prisma.socialYearStandard.findFirst({
where: { orgId, accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
// 按账户+月份获取适用标准
router.get('/accounts/:accountId/standard-by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId, month } = req.params
const standard = await prisma.socialYearStandard.findFirst({
where: {
orgId, accountId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!standard) {
const current = await prisma.socialYearStandard.findFirst({ where: { orgId, accountId, isCurrent: true } })
return res.json({ success: true, data: current })
}
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
// 新建年度标准
const yearStandardSchema = z.object({
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
medicalEmp: z.number().optional(),
unemploymentOrg: z.number().optional(),
unemploymentEmp: z.number().optional(),
injuryOrg: z.number().optional(),
maternityOrg: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
medicalBaseMin: z.number().optional(),
medicalBaseMax: z.number().optional(),
extraInsurances: z.any().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
})
router.post('/accounts/:accountId/standards', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const data = yearStandardSchema.parse(req.body)
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 将旧当前版本标记为失效
const current = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
if (current) {
const prevMonth = data.effectiveFrom
await prisma.socialYearStandard.update({
where: { id: current.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const standard = await prisma.socialYearStandard.create({
data: { ...data, orgId, accountId, isCurrent: true, createdBy: req.user!.id },
})
res.json({ success: true, data: standard })
} catch (err) { next(err) }
})
// 按员工获取适用账户(通过根部门 level=0 继承,不向下到普通部门)
router.get('/employee-account/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { employeeId } = req.params
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true },
})
if (!emp) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
// 向上找到 level=0 的根部门(代表分公司/子公司)
let currentDept: any = emp.dept
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 } })
}
res.json({ success: true, data: { socialAccount, housingAccount } })
} catch (err) { next(err) }
})
// 获取当前生效版本(支持按城市筛选)
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
+87 -16
View File
@@ -1,5 +1,69 @@
import prisma from '../lib/prisma'
// ========== 社保公积金账户辅助函数 ==========
/**
* 通过员工获取适用的社保/公积金账户
* 优先从员工所属根部门(level=0)关联的账户继承,回退到公司默认账户
*/
async function getEmployeeAccounts(orgId: string, employeeId: string) {
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { dept: true },
})
if (!emp) return { socialAccount: null, housingAccount: null }
// 向上找到 level=0 的根部门
let currentDept: any = emp.dept
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 } })
}
return { socialAccount, housingAccount }
}
/**
* 通过账户获取指定月份的年度标准
*/
async function getStandardByAccountAndMonth(accountId: string, month: string) {
const standard = await prisma.socialYearStandard.findFirst({
where: {
accountId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!standard) {
// 回退到当前生效标准
return prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
return standard
}
// ========== 薪酬模版 ==========
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
@@ -142,25 +206,32 @@ export async function calcBatchEntry(
const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId } })
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
// 通过员工账户查年度标准(新逻辑),回退到旧配置(兼容)
const { socialAccount, housingAccount } = await getEmployeeAccounts(orgId, employeeId)
let socialConfig: any = null
let housingConfig: any = null
if (socialAccount) {
socialConfig = await getStandardByAccountAndMonth(socialAccount.id, month)
}
if (housingAccount) {
housingConfig = await getStandardByAccountAndMonth(housingAccount.id, month)
}
// 回退到旧配置表(兼容未迁移数据)
const cityWhere = employee.city ? { orgId, city: employee.city } : { orgId }
const [socialConfig, housingConfig] = await Promise.all([
prisma.socialInsuranceConfig.findFirst({
where: {
...cityWhere,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
if (!socialConfig) {
socialConfig = await prisma.socialInsuranceConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: {
...cityWhere,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
})
}
if (!housingConfig) {
housingConfig = await prisma.housingFundConfig.findFirst({
where: { ...cityWhere, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
})
}
// 社保基数:优先用员工核定基数,否则用基本工资
const socialBase = employee.socialInsBase || inputs.baseSalary