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:
Generated
+789
-13
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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-MM(null=至今有效)
|
||||
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-MM(null=至今有效)
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user