feat: 完成优化-4全部任务 + 多城市社保 + pgvector修复

- AIAssistant: 会话历史保存/加载/删除,风险预测支持范围筛选,审查结果保存到员工档案
- Dashboard: 待办批量操作,风险分布可下钻,刷新按钮Tab级联,薪税tab导出Excel
- SocialInsurance: 多城市社保/公积金配置支持,城市选择器
- Roster: 新增参保城市字段
- risk.service: 修复风险项去重逻辑(用employeeId:type:actionUrl替代含动态天数的title)
- payroll.routes: 修复OvertimeRecord/Payslip字段名错误
- pgvector: 从源码编译安装x86_64版本兼容postgresql@15
- 优化-4文档: 全部8项标记为已完成
This commit is contained in:
freedakgmail
2026-07-24 07:04:08 +08:00
parent 559a567b9b
commit f1c72f3eb0
20 changed files with 2031 additions and 260 deletions
+789 -13
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"exceljs": "^4.4.0",
"express": "^4.19.0",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
+39 -2
View File
@@ -131,6 +131,8 @@ model Organization {
salaryChangeRecords SalaryChangeRecord[]
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
aiConversations AIConversation[]
aiReviewRecords AIReviewRecord[]
socialInsuranceConfig SocialInsuranceConfig[]
housingFundConfigs HousingFundConfig[]
socialInsRecords EmployeeSocialInsRecord[]
@@ -191,6 +193,7 @@ model Employee {
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -210,6 +213,7 @@ model Employee {
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
aiReviewRecords AIReviewRecord[]
@@unique([orgId, idCardHash])
}
@@ -342,7 +346,7 @@ model SocialInsuranceConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, effectiveFrom])
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
@@ -363,7 +367,7 @@ model HousingFundConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, effectiveFrom])
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
@@ -666,6 +670,7 @@ model EmployeeSocialInsRecord {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
@@ -677,6 +682,7 @@ model EmployeeSocialInsRecord {
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
@@index([orgId, city])
}
model EmployeeHousingFundRecord {
@@ -685,6 +691,7 @@ model EmployeeHousingFundRecord {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
@@ -752,3 +759,33 @@ model ContractConfirmLink {
@@index([orgId, status])
}
// ========== AI 会话 & 审查记录 ==========
model AIConversation {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String
title String @default("新对话")
messages Json // [{ role, content }]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, userId])
}
model AIReviewRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
type String // REVIEW=合同审查, CASE=案例匹配
input String // 用户输入的合同文本或争议情形
result String // AI 返回的审查/分析结果
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
+144 -1
View File
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
@@ -143,7 +144,35 @@ router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) =
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgContext = await buildOrgContext(req.user!.orgId)
const scope = (req.query.scope as string) || 'all'
const department = req.query.department as string
const employeeId = req.query.employeeId as string
const riskType = req.query.riskType as string
let orgContext = await buildOrgContext(req.user!.orgId)
if (employeeId) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
if (emp) {
const contract = emp.contracts[0]
orgContext = `员工详情:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
}
} else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
}
if (riskType && riskType !== 'all') {
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
}
const result = await predictRisks(orgContext)
res.json({ success: true, data: { result } })
} catch (err) {
@@ -151,6 +180,120 @@ router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
}
})
// ========== AI 会话历史 ==========
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conversations = await prisma.aIConversation.findMany({
where: { orgId: req.user!.orgId, userId: req.user!.id },
orderBy: { updatedAt: 'desc' },
take: 50,
select: { id: true, title: true, createdAt: true, updatedAt: true },
})
res.json({ success: true, data: conversations })
} catch (err) {
next(err)
}
})
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages: any[] }
const conv = await prisma.aIConversation.create({
data: {
orgId: req.user!.orgId,
userId: req.user!.id,
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
messages: messages || [],
},
})
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages?: any[] }
const conv = await prisma.aIConversation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
data: {
...(title ? { title } : {}),
...(messages ? { messages } : {}),
},
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== AI 审查记录保存到员工档案 ==========
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string(),
type: z.enum(['REVIEW', 'CASE']),
input: z.string(),
result: z.string(),
})
const data = schema.parse(req.body)
const record = await prisma.aIReviewRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
type: data.type,
input: data.input,
result: data.result,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.aIReviewRecord.findMany({
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
orderBy: { createdAt: 'desc' },
take: 20,
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (req: AuthRequest, res, next) => {
try {
+31
View File
@@ -2,6 +2,7 @@ import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
@@ -46,4 +47,34 @@ router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res:
}
})
// 批量标记待办为已完成
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
// 批量忽略待办
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
export default router
+84
View File
@@ -2,6 +2,7 @@ import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import ExcelJS from 'exceljs'
const router = Router()
@@ -49,4 +50,87 @@ router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next)
}
})
// 导出本月薪税汇总 Excel
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const entries = await prisma.batchEntry.findMany({
where: { orgId, batch: { month, status: 'ARCHIVED' } },
include: { employee: true, batch: true },
orderBy: { employee: { name: 'asc' } },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('薪税汇总')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '基本工资', key: 'baseSalary', width: 12 },
{ header: '加班费', key: 'overtimePay', width: 12 },
{ header: '津贴补贴', key: 'allowance', width: 12 },
{ header: '奖金', key: 'bonus', width: 12 },
{ header: '扣款', key: 'deduction', width: 12 },
{ header: '应发合计', key: 'totalPay', width: 12 },
{ header: '个人社保', key: 'socialEmp', width: 12 },
{ header: '个人公积金', key: 'housingEmp', width: 12 },
{ header: '个人所得税', key: 'tax', width: 12 },
{ header: '实发工资', key: 'netPay', width: 12 },
{ header: '企业社保', key: 'socialOrg', width: 12 },
{ header: '企业公积金', key: 'housingOrg', width: 12 },
{ header: '企业总成本', key: 'orgCost', width: 12 },
]
ws.getRow(1).font = { bold: true }
for (const e of entries) {
ws.addRow({
name: e.employee.name,
department: e.employee.department,
baseSalary: e.baseSalary,
overtimePay: e.overtimePay,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
totalPay: e.totalPay,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
netPay: e.netPay,
socialOrg: e.socialOrg,
housingOrg: e.housingOrg,
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
})
}
// 汇总行
const totalRow = ws.addRow({
name: '合计',
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
tax: { formula: `SUM(K2:K${entries.length + 1})` },
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
})
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
export default router
+134
View File
@@ -79,6 +79,54 @@ router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunct
}
})
// 更新加班记录(按ID
const overtimeUpdateSchema = z.object({
weekdayHours: z.number().min(0).optional(),
weekendHours: z.number().min(0).optional(),
holidayHours: z.number().min(0).optional(),
monthlyWage: z.number().positive().optional(),
})
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const data = overtimeUpdateSchema.parse(req.body)
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
if (!existing) {
res.status(404).json({ success: false, message: '记录不存在' })
return
}
const monthlyWage = data.monthlyWage ?? 0
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
const weekendHours = data.weekendHours ?? existing.weekendHours
const holidayHours = data.holidayHours ?? existing.holidayHours
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.update({
where: { id },
data: {
weekdayHours,
weekendHours,
holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
@@ -441,4 +489,90 @@ router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res:
}
})
// ========== 税率试算 ==========
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
const orgId = req.user!.orgId
// 获取员工和配置
const [employee, socialConfig, housingConfig] = await Promise.all([
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
const socialBase = emp.socialInsBase || baseSalary
const housingBase = emp.housingFundBase || baseSalary
// 计算社保公积金
let socialEmp = 0, housingEmp = 0
if (socialConfig) {
const { calcSocialInsurance } = await import('../services/payroll.service')
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
}
if (housingConfig) {
const { calcHousingFund } = await import('../services/payroll.service')
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
}
// 获取 YTD 数据计算累计个税
const year = month.slice(0, 4)
const ytdPayslips = employeeId
? await prisma.payslip.findMany({
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
orderBy: { month: 'asc' },
})
: []
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
const { calcCumulativeTax } = await import('../services/payroll.service')
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
const netPay = totalPay - socialEmp - housingEmp - tax
res.json({
success: true,
data: {
baseSalary: baseSalary || 0,
overtimePay: overtimePay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
bonus: bonus || 0,
totalPay,
socialEmp,
housingEmp,
specialDeduction: specialDeduction || 0,
taxableIncome,
estimatedTax: tax,
netPay,
ytdPayslipCount: ytdPayslips.length,
breakdown: [
{ label: '应发合计', value: totalPay },
{ label: '个人社保', value: -socialEmp },
{ label: '个人公积金', value: -housingEmp },
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
{ label: '应纳税所得额', value: taxableIncome },
{ label: '当月个税', value: -tax },
{ label: '实发工资', value: netPay },
],
},
})
} catch (err) {
next(err)
}
})
export default router
+1
View File
@@ -89,6 +89,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
id: e.id,
name: e.name,
department: e.department,
city: e.city,
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
+80 -30
View File
@@ -29,33 +29,75 @@ const housingConfigFields = {
baseMax: z.number().optional(),
}
// 获取当前生效版本
// 获取当前生效版本(支持按城市筛选)
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: { orgId: req.user!.orgId, isCurrent: true },
where,
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
createdBy: req.user!.id,
},
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
try {
config = await prisma.socialInsuranceConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
city: city || '北京',
isCurrent: true,
createdBy: req.user!.id,
},
})
} catch {
// 唯一约束冲突,查询同城市任意配置
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, city: city || '北京' },
orderBy: { effectiveFrom: 'desc' },
})
}
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
}
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: { orgId: req.user!.orgId },
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
@@ -100,9 +142,9 @@ router.post('/config/versions', async (req: AuthRequest, res: Response, next: Ne
const data = createVersionSchema.parse(req.body)
const orgId = req.user!.orgId
// 检查同一生效月份是否已有版本
const existing = await prisma.socialInsuranceConfig.findUnique({
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
// 检查同一城市同一生效月份是否已有版本
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} 已有配置版本` })
@@ -152,7 +194,7 @@ router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response,
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
@@ -247,6 +289,7 @@ router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response,
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base: socialBase,
@@ -292,24 +335,25 @@ router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Respon
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更
// 删除该版本创建的所有社保记录变更(按城市筛选)
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
city: config.city,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录)
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
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, startMonth: { lt: config.effectiveFrom } },
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
@@ -331,18 +375,21 @@ router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Respon
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 } = calcSchema.parse(req.body)
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: {
orgId,
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
@@ -351,12 +398,12 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
}
if (!config) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
@@ -447,8 +494,8 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const existing = await prisma.housingFundConfig.findUnique({
where: { orgId_effectiveFrom: { orgId, effectiveFrom: data.effectiveFrom } },
const existing = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
@@ -485,14 +532,16 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
// 公积金计算
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month } = calcSchema.parse(req.body)
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: {
orgId,
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
@@ -501,12 +550,12 @@ router.post('/housing-calculate', async (req: AuthRequest, res: Response, next:
}
if (!config) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.housingFundConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
@@ -545,7 +594,7 @@ router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: R
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
@@ -631,6 +680,7 @@ router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Re
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base,
+2
View File
@@ -10,6 +10,7 @@ export const createEmployeeSchema = z.object({
isPregnant: z.boolean().default(false),
isInMedicalPeriod: z.boolean().default(false),
isWorkInjured: z.boolean().default(false),
city: z.string().max(20).optional(),
contract: z.object({
signDate: z.string().datetime().nullable(),
startDate: z.string().datetime(),
@@ -40,6 +41,7 @@ export const updateEmployeeSchema = z.object({
socialInsBase: z.number().min(0).nullable().optional(),
housingFundBase: z.number().min(0).nullable().optional(),
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).optional(),
})
export const batchRenewSchema = z.object({
+7
View File
@@ -202,6 +202,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
},
})
@@ -215,6 +216,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
@@ -228,6 +230,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
@@ -362,6 +365,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
socialInsEndMonth: null,
housingFundStartMonth,
housingFundEndMonth: null,
city: data.city || employee.city || '北京',
},
})
@@ -375,6 +379,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
base: socialInsBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
@@ -388,6 +393,7 @@ export async function rehireEmployee(orgId: string, userId: string, id: string,
base: housingFundBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
@@ -504,6 +510,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
await prisma.employee.update({ where: { id }, data: updateData })
await runRiskDetection(orgId)
+16 -2
View File
@@ -233,7 +233,7 @@ export async function runRiskDetection(orgId: string) {
const existingRisks = await prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
})
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`))
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}:${r.actionUrl}`))
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
@@ -251,7 +251,7 @@ export async function runRiskDetection(orgId: string) {
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
]
@@ -468,6 +468,19 @@ export async function getDashboardData(orgId: string) {
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
}
const topRisks = riskItems
.filter((r: typeof riskItems[number]) => r.level === 'HIGH')
.slice(0, 5)
.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as string,
level: r.level.toLowerCase() as string,
title: r.title,
description: r.description,
employeeName: r.employee?.name || null,
actionUrl: r.actionUrl || '/',
}))
const todos = riskItems.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
@@ -505,6 +518,7 @@ export async function getDashboardData(orgId: string) {
todos,
resolvedTodos,
riskDistribution,
topRisks,
aiPrediction: null,
payrollSummary,
monthlyActivities,