1d9890e607
问题:创建年度标准时如果该账户已有同 effectiveFrom 的标准, 会触发 P2002 唯一约束冲突,返回400"数据已存在"。 修复:先查是否已存在同 effectiveFrom 的标准,存在则更新, 不存在才创建新标准。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2005 lines
74 KiB
TypeScript
2005 lines
74 KiB
TypeScript
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
|