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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user