Files
TurboHR/backend/src/routes/social.routes.ts
T
selfrelease 1d9890e607 fix: 创建年度标准时同effectiveFrom已存在则更新而非报错
问题:创建年度标准时如果该账户已有同 effectiveFrom 的标准,
会触发 P2002 唯一约束冲突,返回400"数据已存在"。

修复:先查是否已存在同 effectiveFrom 的标准,存在则更新,
不存在才创建新标准。

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-16 15:48:21 +08:00

2005 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { decrypt } from '../lib/crypto'
import { z } from 'zod'
import OpenAI from 'openai'
const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
city: z.string().min(1),
pensionOrg: z.number().optional(),
pensionEmp: z.number().optional(),
medicalOrg: z.number().optional(),
medicalEmp: z.number().optional(),
medicalOrgExtra: z.number().min(0).optional(),
medicalEmpExtra: z.number().min(0).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(),
minWage: z.number().min(0).optional(),
}
const housingConfigFields = {
city: z.string().min(1),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
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/inherit-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const account = await prisma.socialAccount.findFirst({ where: { id: accountId, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 1. 优先用当前年度标准
const currentStandard = await prisma.socialYearStandard.findFirst({
where: { orgId, accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (currentStandard) {
return res.json({ success: true, data: { ...currentStandard, source: 'current_standard' } })
}
// 2. 回退到旧社保/公积金配置表(按城市)
if (account.type === 'HOUSING') {
const oldConfig = await prisma.housingFundConfig.findFirst({
where: { orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
if (oldConfig) {
return res.json({ success: true, data: { ...oldConfig, source: 'old_housing_config' } })
}
} else {
const oldConfig = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: account.city },
orderBy: { effectiveFrom: 'desc' },
})
if (oldConfig) {
return res.json({ success: true, data: { ...oldConfig, source: 'old_social_config' } })
}
}
// 3. 都没有,返回 null
res.json({ success: true, data: null })
} 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(),
medicalOrgExtra: z.number().min(0).optional(),
medicalEmpExtra: z.number().min(0).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(),
housingBaseMin: z.number().min(0).optional(),
housingBaseMax: z.number().min(0).optional(),
minWage: z.number().min(0).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: '账户不存在' } })
// 检查是否已存在同 effectiveFrom 的标准
const existing = await prisma.socialYearStandard.findFirst({ where: { accountId, effectiveFrom: data.effectiveFrom } })
if (existing) {
// 已存在:更新而非报错
const standard = await prisma.socialYearStandard.update({
where: { id: existing.id },
data: { ...data, isCurrent: true, effectiveTo: null },
})
// 将其他当前版本标记为失效
await prisma.socialYearStandard.updateMany({
where: { accountId, isCurrent: true, id: { not: existing.id } },
data: { isCurrent: false, effectiveTo: data.effectiveFrom },
})
return res.json({ success: true, data: standard })
}
// 将旧当前版本标记为失效
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) }
})
/**
* 快速更新当前年度标准的最低工资(无需新建版本)
*/
router.put('/accounts/:accountId/min-wage', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { accountId } = req.params
const { minWage } = req.body as { minWage: number }
if (minWage === undefined || minWage < 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: 'minWage 必须为非负数' } })
}
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 standard = await prisma.socialYearStandard.findFirst({ where: { accountId, isCurrent: true } })
if (!standard) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '当前年度标准不存在' } })
const updated = await prisma.socialYearStandard.update({ where: { id: standard.id }, data: { minWage } })
res.json({ success: true, data: updated })
} catch (err) { next(err) }
})
// 账户关联根部门(level=0)批量设置
router.put('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { id } = req.params
const { departmentIds } = req.body as { departmentIds: string[] }
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
// 先清除该账户的所有部门关联
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
await prisma.department.updateMany({
where: { orgId, [field]: id },
data: { [field]: null },
})
// 批量设置新关联(仅 level=0 根部门)
if (departmentIds && departmentIds.length > 0) {
await prisma.department.updateMany({
where: { id: { in: departmentIds }, orgId, level: 0 },
data: { [field]: id },
})
}
res.json({ success: true, data: { message: `已关联 ${departmentIds?.length || 0} 个根部门` } })
} catch (err) { next(err) }
})
// 获取账户已关联的根部门列表
router.get('/accounts/:id/departments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { id } = req.params
const account = await prisma.socialAccount.findFirst({ where: { id, orgId } })
if (!account) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '账户不存在' } })
const field = account.type === 'SOCIAL' ? 'socialAccountId' : 'housingAccountId'
const departments = await prisma.department.findMany({
where: { orgId, level: 0, [field]: id },
select: { id: true, name: true },
})
res.json({ success: true, data: departments })
} catch (err) { next(err) }
})
// 按部门获取适用账户(选部门时自动带出)
router.get('/department-account/:departmentId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { departmentId } = req.params
// 向上找到 level=0 的根部门
let currentDept: any = await prisma.department.findFirst({ where: { id: departmentId, orgId } })
if (!currentDept) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '部门不存在' } })
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 } })
}
// 获取当前生效标准
const currentMonth = new Date().toISOString().slice(0, 7)
let socialStandard: any = null
let housingStandard: any = null
if (socialAccount) {
socialStandard = await prisma.socialYearStandard.findFirst({
where: { accountId: socialAccount.id, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (housingAccount) {
housingStandard = await prisma.socialYearStandard.findFirst({
where: { accountId: housingAccount.id, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
res.json({ success: true, data: { socialAccount, housingAccount, socialStandard, housingStandard } })
} 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 {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.socialInsuranceConfig.findFirst({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 获取所有城市列表(从配置中提取)
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const configs = await prisma.socialInsuranceConfig.findMany({
where: { orgId: req.user!.orgId },
select: { city: true },
distinct: ['city'],
})
const cities = configs.map(c => c.city).filter(Boolean)
if (!cities.includes('北京')) cities.unshift('北京')
res.json({ success: true, data: cities })
} catch (err) {
next(err)
}
})
// 获取所有版本列表(支持按城市筛选)
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
const versions = await prisma.socialInsuranceConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 按月份获取适用版本
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.params
const config = await prisma.socialInsuranceConfig.findFirst({
where: {
orgId: req.user!.orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
// 回退到当前版本
const current = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
})
return res.json({ success: true, data: current })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 新建版本(年度调基/比例变更)
const createVersionSchema = z.object({
...socialConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createVersionSchema.parse(req.body)
const orgId = req.user!.orgId
// 检查同一城市同一生效月份是否已有版本
const existing = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效(按城市过滤)
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: data.city, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.socialInsuranceConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
// 创建新版本
const version = await prisma.socialInsuranceConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: {
orgId,
month: { gte: lastYearStart, lte: lastYearEnd },
},
select: { employeeId: true, totalPay: true },
})
// 按员工汇总上年月均工资
const avgSalaryMap = new Map<string, number>()
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
for (const [empId, pays] of empPayslipMap) {
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
avgSalaryMap.set(empId, avg)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldSocialBase = emp.socialInsBase ?? monthlyWage
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase: oldSocialBase,
avgSalary,
monthlyWage,
suggestedBase: suggestedSocialBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行员工基数调整(接收用户编辑后的数据)
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const { items } = adjustApplySchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧社保记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新社保记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base: socialBase,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
})
adjusted++
}
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置社保基数调整(撤销本次调整,重新来过)
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更(按城市筛选)
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
city: config.city,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsBase: prevRecord?.base ?? null,
socialInsStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
city: z.string().optional(),
})
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.socialInsuranceConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的社保配置,请先在社保管理中创建` } })
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
const medicalBase = Math.min(Math.max(base, medMin), medMax)
const pensionOrg = actualBase * config.pensionOrg / 100
const pensionEmp = actualBase * config.pensionEmp / 100
const medicalOrg = medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0)
const medicalEmp = medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0)
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
const injuryOrg = actualBase * config.injuryOrg / 100
const maternityOrg = medicalBase * config.maternityOrg / 100
let totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
let totalEmp = pensionEmp + medicalEmp + unemploymentEmp
// 附加险种
const extraItems: any[] = []
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
for (const ins of config.extraInsurances as any[]) {
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
if (ins.baseType === 'fixed' && ins.fixedAmount) {
const orgAmt = ins.fixedAmount
const empAmt = ins.empFixedAmount || 0
totalOrg += orgAmt
totalEmp += empAmt
extraItems.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: orgAmt, empAmount: empAmt })
} else {
const orgAmt = insBase * (ins.orgRate || 0) / 100
const empAmt = insBase * (ins.empRate || 0) / 100
totalOrg += orgAmt
totalEmp += empAmt
extraItems.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: orgAmt, empAmount: empAmt })
}
}
}
const total = totalOrg + totalEmp
res.json({
success: true,
data: {
actualBase,
medicalBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
items: [
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
...extraItems,
],
totalOrg,
totalEmp,
total,
},
})
} catch (err) {
next(err)
}
})
// ========== 公积金配置 ==========
// 获取当前公积金配置(支持按城市、账户类型筛选)
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const accountType = req.query.accountType as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
if (accountType) where.accountType = accountType
const configs = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 兼容旧接口:无 accountType 参数时返回第一条
const config = accountType ? configs.find(c => c.accountType === accountType) || configs[0] : configs[0]
if (!config) {
return res.json({ success: true, data: null })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 公积金配置版本列表(支持按城市、账户类型筛选)
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const accountType = req.query.accountType as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
if (accountType) where.accountType = accountType
const versions = await prisma.housingFundConfig.findMany({
where,
orderBy: [{ accountType: 'asc' }, { effectiveFrom: 'desc' }],
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 新建公积金配置版本
const createHousingVersionSchema = z.object({
...housingConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const acctType = data.accountType || 'BASIC'
const existing = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, accountType: acctType, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有${acctType === 'SUPPLEMENTARY' ? '补充' : '基本'}公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, accountType: acctType, isCurrent: true },
})
if (prevCurrent) {
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.housingFundConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const version = await prisma.housingFundConfig.create({
data: {
orgId,
accountType: acctType,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 公积金计算
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.housingFundConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.housingFundConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `未找到${city || ''}的公积金配置,请先在公积金管理中创建` } })
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingOrg = actualBase * config.housingOrg / 100
const housingEmp = actualBase * config.housingEmp / 100
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
housingOrg,
housingEmp,
total: housingOrg + housingEmp,
},
})
} catch (err) {
next(err)
}
})
// 公积金调基预览
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
select: { employeeId: true, totalPay: true },
})
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldBase = emp.housingFundBase ?? monthlyWage
const payslips = empPayslipMap.get(emp.id)
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase,
avgSalary,
monthlyWage,
suggestedBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行公积金调基
const adjustHousingSchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const { items } = adjustHousingSchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
})
adjusted++
}
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置公积金基数调整(撤销本次调整,重新来过)
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有公积金记录变更
await prisma.employeeHousingFundRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工公积金基数为调整前
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
housingFundBase: prevRecord?.base ?? null,
housingFundStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
/** 根据基数和社保配置计算各项企业/个人缴费明细 */
function calcSocialDetail(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const medMin = config.medicalBaseMin && config.medicalBaseMin > 0 ? config.medicalBaseMin : config.baseMin
const medMax = config.medicalBaseMax && config.medicalBaseMax > 0 ? config.medicalBaseMax : config.baseMax
const medicalBase = Math.min(Math.max(base, medMin), medMax)
const items = [
{ name: '养老', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: actualBase * config.pensionOrg / 100, empAmount: actualBase * config.pensionEmp / 100 },
{ name: '医疗', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalBase * config.medicalOrg / 100 + (config.medicalOrgExtra || 0), empAmount: medicalBase * config.medicalEmp / 100 + (config.medicalEmpExtra || 0) },
{ name: '失业', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: actualBase * config.unemploymentOrg / 100, empAmount: actualBase * config.unemploymentEmp / 100 },
{ name: '工伤', orgRate: config.injuryOrg, empRate: 0, orgAmount: actualBase * config.injuryOrg / 100, empAmount: 0 },
{ name: '生育', orgRate: config.maternityOrg, empRate: 0, orgAmount: medicalBase * config.maternityOrg / 100, empAmount: 0 },
]
// 附加险种
if (config.extraInsurances && Array.isArray(config.extraInsurances)) {
for (const ins of config.extraInsurances as any[]) {
const insBase = ins.baseType === 'medical' ? medicalBase : ins.baseType === 'fixed' ? 1 : actualBase
if (ins.baseType === 'fixed' && ins.fixedAmount) {
items.push({ name: ins.name, orgRate: 0, empRate: 0, orgAmount: ins.fixedAmount, empAmount: ins.empFixedAmount || 0 })
} else {
items.push({ name: ins.name, orgRate: ins.orgRate || 0, empRate: ins.empRate || 0, orgAmount: insBase * (ins.orgRate || 0) / 100, empAmount: insBase * (ins.empRate || 0) / 100 })
}
}
}
const totalOrg = items.reduce((s, i) => s + i.orgAmount, 0)
const totalEmp = items.reduce((s, i) => s + i.empAmount, 0)
return { actualBase, medicalBase, items, totalOrg, totalEmp }
}
/** 根据基数和公积金配置计算企业/个人缴费明细 */
function calcHousingDetail(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const orgAmount = actualBase * config.housingOrg / 100
const empAmount = actualBase * config.housingEmp / 100
return { actualBase, orgAmount, empAmount, total: orgAmount + empAmount }
}
/** 按月份匹配社保配置版本 */
async function getSocialConfigByMonth(orgId: string, month: string, city?: string) {
const where: any = { orgId }
if (city) where.city = city
let config = await prisma.socialInsuranceConfig.findFirst({
where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
config = await prisma.socialInsuranceConfig.findFirst({ where: { ...where, isCurrent: true } })
}
return config
}
/** 按月份匹配公积金配置版本 */
async function getHousingConfigByMonth(orgId: string, month: string, city?: string) {
const where: any = { orgId }
if (city) where.city = city
let config = await prisma.housingFundConfig.findFirst({
where: { ...where, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
config = await prisma.housingFundConfig.findFirst({ where: { ...where, isCurrent: true } })
}
return config
}
// 社保月度增减员
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
// 增员:startMonth == month
const additions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
// 减员:endMonth == month 且 changeType 为 TERMINATION 或 CITY_CHANGE
const reductions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
// 按城市缓存配置
const configCache = new Map<string, any>()
const getConfigForCity = async (city: string) => {
if (!configCache.has(city)) {
configCache.set(city, await getSocialConfigByMonth(orgId, month, city))
}
return configCache.get(city)
}
const mapRecord = async (r: any) => {
const config = await getConfigForCity(r.city)
const detail = config ? calcSocialDetail(r.base, config) : null
return {
recordId: r.id,
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
city: r.city,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
detail: detail ? {
items: detail.items,
totalOrg: detail.totalOrg,
totalEmp: detail.totalEmp,
total: detail.totalOrg + detail.totalEmp,
} : null,
}
}
// 按城市分组
const allRecords = [...additions, ...reductions]
const cities = [...new Set(allRecords.map((r) => r.city))]
const configs: Record<string, any> = {}
for (const c of cities) {
const cfg = await getConfigForCity(c)
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax }
}
res.json({
success: true,
data: {
month,
configs,
additions: await Promise.all(additions.map(mapRecord)),
reductions: await Promise.all(reductions.map(mapRecord)),
},
})
} catch (err) {
next(err)
}
})
// 公积金月度增减员
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const additions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
const reductions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, endMonth: month, changeType: { in: ['TERMINATION', 'CITY_CHANGE'] } },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
const configCache = new Map<string, any>()
const getConfigForCity = async (city: string) => {
if (!configCache.has(city)) {
configCache.set(city, await getHousingConfigByMonth(orgId, month, city))
}
return configCache.get(city)
}
const mapRecord = async (r: any) => {
const config = await getConfigForCity(r.city)
const detail = config ? calcHousingDetail(r.base, config) : null
return {
recordId: r.id,
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
city: r.city,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null,
}
}
const allRecords = [...additions, ...reductions]
const cities = [...new Set(allRecords.map((r) => r.city))]
const configs: Record<string, any> = {}
for (const c of cities) {
const cfg = await getConfigForCity(c)
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp }
}
res.json({
success: true,
data: {
month,
configs,
additions: await Promise.all(additions.map(mapRecord)),
reductions: await Promise.all(reductions.map(mapRecord)),
},
})
} catch (err) {
next(err)
}
})
// ========== 在职申报 ==========
// 社保在保人员
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeSocialInsRecord.findMany({
where: {
orgId,
startMonth: { lt: month },
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
const configCache = new Map<string, any>()
const getConfigForCity = async (city: string) => {
if (!configCache.has(city)) {
configCache.set(city, await getSocialConfigByMonth(orgId, month, city))
}
return configCache.get(city)
}
const items = await Promise.all(records.map(async (r) => {
const config = await getConfigForCity(r.city)
const detail = config ? calcSocialDetail(r.base, config) : null
return {
recordId: r.id,
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
city: r.city,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
detail: detail ? {
items: detail.items,
totalOrg: detail.totalOrg,
totalEmp: detail.totalEmp,
total: detail.totalOrg + detail.totalEmp,
} : null,
}
}))
const cities = [...new Set(records.map((r) => r.city))]
const configs: Record<string, any> = {}
for (const c of cities) {
const cfg = await getConfigForCity(c)
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax }
}
res.json({
success: true,
data: { month, configs, items },
})
} catch (err) {
next(err)
}
})
// 公积金在保人员
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeHousingFundRecord.findMany({
where: {
orgId,
startMonth: { lt: month },
OR: [{ endMonth: null }, { endMonth: { gt: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
const configCache = new Map<string, any>()
const getConfigForCity = async (city: string) => {
if (!configCache.has(city)) {
configCache.set(city, await getHousingConfigByMonth(orgId, month, city))
}
return configCache.get(city)
}
const items = await Promise.all(records.map(async (r) => {
const config = await getConfigForCity(r.city)
const detail = config ? calcHousingDetail(r.base, config) : null
return {
recordId: r.id,
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
city: r.city,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
detail: detail ? { orgAmount: detail.orgAmount, empAmount: detail.empAmount, total: detail.total } : null,
}
}))
const cities = [...new Set(records.map((r) => r.city))]
const configs: Record<string, any> = {}
for (const c of cities) {
const cfg = await getConfigForCity(c)
if (cfg) configs[c] = { city: cfg.city, effectiveFrom: cfg.effectiveFrom, baseMin: cfg.baseMin, baseMax: cfg.baseMax, housingOrg: cfg.housingOrg, housingEmp: cfg.housingEmp }
}
res.json({
success: true,
data: { month, configs, items },
})
} catch (err) {
next(err)
}
})
// ========== 月度办理完成(保存快照) ==========
// 列出所有已办理月份(用于办理总览)
router.get('/monthly-process/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const records = await prisma.socialMonthlyProcess.findMany({
where: { orgId },
orderBy: { month: 'desc' },
select: { id: true, month: true, type: true, status: true, processedAt: true, processedBy: true },
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// 查询某月办理状态
router.get('/monthly-process/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.socialMonthlyProcess.findMany({
where: { orgId, month },
})
res.json({
success: true,
data: {
month,
social: records.find((r) => r.type === 'SOCIAL') || null,
housing: records.find((r) => r.type === 'HOUSING') || null,
},
})
} catch (err) {
next(err)
}
})
// 办理完成(保存快照)
router.post('/monthly-process/complete', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, type, snapshot } = req.body as { month: string; type: 'SOCIAL' | 'HOUSING'; snapshot: any }
const orgId = req.user!.orgId
if (!month || !type || !snapshot) {
return res.status(400).json({ success: false, message: '缺少必要参数' })
}
const existing = await prisma.socialMonthlyProcess.findUnique({
where: { orgId_month_type: { orgId, month, type } },
})
if (existing) {
// 已存在则更新快照
const updated = await prisma.socialMonthlyProcess.update({
where: { id: existing.id },
data: { snapshot, processedBy: req.user!.id, processedAt: new Date() },
})
return res.json({ success: true, data: updated })
}
const record = await prisma.socialMonthlyProcess.create({
data: {
orgId,
month,
type,
snapshot,
processedBy: req.user!.id,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// ========== 记录修正(直接更新 + 审计日志) ==========
// 修正社保记录
router.put('/records/social/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string }
const record = await prisma.employeeSocialInsRecord.findFirst({ where: { id: req.params.id, orgId } })
if (!record) return res.status(404).json({ success: false, message: '记录不存在' })
const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark }
const updateData: any = {}
if (city !== undefined) updateData.city = city
if (base !== undefined) updateData.base = base
if (startMonth !== undefined) updateData.startMonth = startMonth
if (endMonth !== undefined) updateData.endMonth = endMonth || null
if (changeType !== undefined) updateData.changeType = changeType
if (remark !== undefined) updateData.remark = remark
const updated = await prisma.employeeSocialInsRecord.update({ where: { id: req.params.id }, data: updateData })
// 同步员工便捷字段(如果修正的是当前在保记录)
if (!updated.endMonth) {
await prisma.employee.update({
where: { id: record.employeeId },
data: {
...(city !== undefined ? { city } : {}),
...(base !== undefined ? { socialInsBase: base } : {}),
...(startMonth !== undefined ? { socialInsStartMonth: startMonth } : {}),
},
})
}
// 写审计日志
await prisma.auditLog.create({
data: {
orgId,
userId: req.user!.id,
action: 'CORRECT',
entity: 'EmployeeSocialInsRecord',
entityId: req.params.id,
detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' },
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 修正公积金记录
router.put('/records/housing/:id/correct', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const { city, base, startMonth, endMonth, changeType, remark } = req.body as { city?: string; base?: number; startMonth?: string; endMonth?: string; changeType?: string; remark?: string }
const record = await prisma.employeeHousingFundRecord.findFirst({ where: { id: req.params.id, orgId } })
if (!record) return res.status(404).json({ success: false, message: '记录不存在' })
const oldData = { city: record.city, base: record.base, startMonth: record.startMonth, endMonth: record.endMonth, changeType: record.changeType, remark: record.remark }
const updateData: any = {}
if (city !== undefined) updateData.city = city
if (base !== undefined) updateData.base = base
if (startMonth !== undefined) updateData.startMonth = startMonth
if (endMonth !== undefined) updateData.endMonth = endMonth || null
if (changeType !== undefined) updateData.changeType = changeType
if (remark !== undefined) updateData.remark = remark
const updated = await prisma.employeeHousingFundRecord.update({ where: { id: req.params.id }, data: updateData })
// 同步员工便捷字段(如果修正的是当前在保记录)
if (!updated.endMonth) {
await prisma.employee.update({
where: { id: record.employeeId },
data: {
...(city !== undefined ? { city } : {}),
...(base !== undefined ? { housingFundBase: base } : {}),
...(startMonth !== undefined ? { housingFundStartMonth: startMonth } : {}),
},
})
}
// 写审计日志
await prisma.auditLog.create({
data: {
orgId,
userId: req.user!.id,
action: 'CORRECT',
entity: 'EmployeeHousingFundRecord',
entityId: req.params.id,
detail: { old: oldData, new: updateData, reason: req.body.reason || '数据修正' },
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// ========== 专项附加扣除按月录入 ==========
// 查询员工某月专项附加扣除
router.get('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month } = req.query
if (!employeeId || !month) return res.status(400).json({ success: false, message: '缺少 employeeId 或 month' })
const record = await prisma.specialDeductionRecord.findUnique({
where: { employeeId_month: { employeeId: employeeId as string, month: month as string } },
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 批量查询员工某月专项附加扣除
router.get('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
if (!month) return res.status(400).json({ success: false, message: '缺少 month' })
const records = await prisma.specialDeductionRecord.findMany({
where: { orgId: req.user!.orgId, month: month as string },
include: { employee: { select: { name: true, department: true } } },
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// 创建/更新专项附加扣除
const upsertDeductionSchema = z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
amount: z.number().min(0).optional(),
children: z.number().min(0).optional(),
elderly: z.number().min(0).optional(),
housing: z.number().min(0).optional(),
education: z.number().min(0).optional(),
infant: z.number().min(0).optional(),
remark: z.string().optional(),
})
router.post('/special-deduction', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = upsertDeductionSchema.parse(req.body)
const orgId = req.user!.orgId
const amount = data.amount ?? (data.children || 0) + (data.elderly || 0) + (data.housing || 0) + (data.education || 0) + (data.infant || 0)
const record = await prisma.specialDeductionRecord.upsert({
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
create: {
orgId,
employeeId: data.employeeId,
month: data.month,
amount,
children: data.children || 0,
elderly: data.elderly || 0,
housing: data.housing || 0,
education: data.education || 0,
infant: data.infant || 0,
remark: data.remark,
createdBy: req.user!.id,
},
update: {
amount,
children: data.children || 0,
elderly: data.elderly || 0,
housing: data.housing || 0,
education: data.education || 0,
infant: data.infant || 0,
remark: data.remark,
},
})
// 同步员工便捷字段
await prisma.employee.update({ where: { id: data.employeeId }, data: { specialDeduction: amount } })
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 批量录入专项附加扣除
router.post('/special-deduction/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, items } = req.body as { month: string; items: any[] }
if (!month || !items || !Array.isArray(items)) return res.status(400).json({ success: false, message: '缺少 month 或 items' })
const orgId = req.user!.orgId
const result = { total: items.length, updated: 0, errors: [] as string[] }
for (let i = 0; i < items.length; i++) {
const item = items[i]
try {
const amount = item.amount ?? (item.children || 0) + (item.elderly || 0) + (item.housing || 0) + (item.education || 0) + (item.infant || 0)
await prisma.specialDeductionRecord.upsert({
where: { employeeId_month: { employeeId: item.employeeId, month } },
create: {
orgId,
employeeId: item.employeeId,
month,
amount,
children: item.children || 0,
elderly: item.elderly || 0,
housing: item.housing || 0,
education: item.education || 0,
infant: item.infant || 0,
remark: item.remark,
createdBy: req.user!.id,
},
update: {
amount,
children: item.children || 0,
elderly: item.elderly || 0,
housing: item.housing || 0,
education: item.education || 0,
infant: item.infant || 0,
remark: item.remark,
},
})
await prisma.employee.update({ where: { id: item.employeeId }, data: { specialDeduction: amount } })
result.updated++
} catch (e: any) {
result.errors.push(`${i + 1}行:${e?.message || '录入失败'}`)
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// ========== AI 社保政策建议 ==========
const aiSuggestSchema = z.object({
city: z.string(),
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
type: z.enum(['social', 'housing']).default('social'),
})
router.post('/ai-suggest', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { city, effectiveFrom, type } = aiSuggestSchema.parse(req.body)
const apiKey = process.env.DASHSCOPE_API_KEY || ''
if (!apiKey) {
return res.status(400).json({ success: false, error: { code: 'NO_API_KEY', message: 'AI服务未配置' } })
}
const client = new OpenAI({ apiKey, baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', timeout: 30 * 1000, maxRetries: 1 })
const year = effectiveFrom.split('-')[0]
const prompt = type === 'social'
? `请提供${city}${year}年度的社保缴费政策。请严格按照以下JSON格式返回,不要包含任何其他文字:
{
"baseMin": 数字,
"baseMax": 数字,
"medicalBaseMin": 数字,
"medicalBaseMax": 数字,
"pensionOrg": 数字,
"pensionEmp": 数字,
"medicalOrg": 数字,
"medicalEmp": 数字,
"unemploymentOrg": 数字,
"unemploymentEmp": 数字,
"injuryOrg": 数字,
"maternityOrg": 数字,
"extraInsurances": [
{ "name": "险种名称", "baseType": "fixed或pension或medical", "fixedAmount": 数字, "empFixedAmount": 数字, "orgRate": 数字, "empRate": 数字 }
]
}
说明:
- baseMin/baseMax: 养老/失业/工伤保险缴费基数上下限
- medicalBaseMin/medicalBaseMax: 医疗/生育保险缴费基数上下限(与养老相同则填相同值)
- 所有比例单位为百分比,如养老企业16%则填16
- extraInsurances: 大病医疗、长期护理险等附加险种,fixed类型用fixedAmount/empFixedAmount(元/月),比例类型用orgRate/empRate(百分比)
- 如果没有附加险种,返回空数组
- 请基于${year}年度${city}的最新社保政策填写`
: `请提供${city}${year}年度的住房公积金缴存政策。请严格按照以下JSON格式返回,不要包含任何其他文字:
{
"baseMin": 数字,
"baseMax": 数字,
"housingOrg": 数字,
"housingEmp": 数字
}
说明:
- baseMin/baseMax: 公积金缴存基数上下限
- housingOrg/housingEmp: 企业和个人缴存比例(百分比),如12%则填12
- 请基于${year}年度${city}的最新公积金政策填写`
const response = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: '你是社保政策专家,精通中国各城市的社会保险和住房公积金缴费政策。请只返回JSON格式数据,不要包含markdown代码块标记。' },
{ role: 'user', content: prompt },
],
temperature: 0.1,
})
const content = response.choices[0]?.message?.content || ''
// 提取JSON(兼容markdown代码块)
let jsonStr = content.trim()
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/)
if (jsonMatch) jsonStr = jsonMatch[1].trim()
// 去除可能的非JSON前后文字
const firstBrace = jsonStr.indexOf('{')
const lastBrace = jsonStr.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace >= 0) jsonStr = jsonStr.substring(firstBrace, lastBrace + 1)
const suggested = JSON.parse(jsonStr)
res.json({ success: true, data: suggested })
} catch (err: any) {
next(err)
}
})
// ========== 员工参保信息列表 ==========
router.get('/employee-enrollment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const keyword = (req.query.keyword as string) || ''
// 查询所有在职员工
const employees = await prisma.employee.findMany({
where: {
orgId,
status: 'ACTIVE',
...(keyword ? { name: { contains: keyword, mode: 'insensitive' } } : {}),
},
select: {
id: true,
name: true,
department: true,
position: true,
socialInsBase: true,
housingFundBase: true,
city: true,
},
orderBy: { department: 'asc' },
})
const empIds = employees.map(e => e.id)
// 查询当前有效的社保记录(endMonth 为 null
const socialRecords = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
// 查询当前有效的公积金记录
const housingRecords = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, employeeId: { in: empIds }, endMonth: null },
select: { employeeId: true, city: true, base: true, startMonth: true, changeType: true },
})
const socialMap = new Map(socialRecords.map(r => [r.employeeId, r]))
const housingMap = new Map(housingRecords.map(r => [r.employeeId, r]))
const list = employees.map(emp => {
const social = socialMap.get(emp.id)
const housing = housingMap.get(emp.id)
return {
id: emp.id,
name: emp.name,
department: emp.department,
position: emp.position,
socialInsBase: social?.base ?? emp.socialInsBase ?? 0,
socialInsCity: social?.city ?? emp.city ?? '',
socialInsStart: social?.startMonth ?? '',
socialInsStatus: social ? 'INSURED' : 'UNINSURED',
housingFundBase: housing?.base ?? emp.housingFundBase ?? 0,
housingFundCity: housing?.city ?? emp.city ?? '',
housingFundStart: housing?.startMonth ?? '',
housingFundStatus: housing ? 'INSURED' : 'UNINSURED',
}
})
res.json({ success: true, data: list })
} catch (err) {
next(err)
}
})
export default router