feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强

- 新增工作日历页面(月历视图、事件管理、自定义事件)
- 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录)
- AI顾问新增人力报告Tab,支持流式生成+Word导出
- 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分
- 花名册/合同/解聘补偿新增部门和状态筛选
- 薪税管理新增工资表导入模板下载、银行代发CSV导出
- 社保公积金支持多公积金账户类型显示
- 数据导出新增花名册/解聘记录导出,中文文件名编码修复
- 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出
- 移除工作台日历卡片(已迁移至独立工作日历页面)
- 新增20260728/20260729更新测试指导文档
This commit is contained in:
freedakgmail
2026-07-29 08:35:29 +08:00
parent d020d04a8a
commit fb36b10402
45 changed files with 3756 additions and 169 deletions
+212 -1
View File
@@ -1,6 +1,6 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream } from '../services/ai.service'
import { chat, chatStream, reviewContract, matchCase, predictRisks, predictRisksStream, predictStructuredStream, generateHRReportStream } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable, searchHelp, seedHelpKnowledge } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
@@ -803,4 +803,215 @@ router.post('/contract-decision', authMiddleware, async (req: AuthRequest, res,
}
})
// ========== AI 人力分析报告 ==========
router.post('/hr-report-stream', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const month = new Date().toISOString().slice(0, 7)
// 聚合企业数据
const [employees, risks, batches] = await Promise.all([
prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: {
name: true, department: true, gender: true, hireDate: true,
birthDate: true, education: true, city: true,
isPregnant: true, isInMedicalPeriod: true, isWorkInjured: true,
contracts: { orderBy: { createdAt: 'desc' }, take: 1, select: { contractType: true, endDate: true, startDate: true } },
},
}),
prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
select: { title: true, level: true, type: true, description: true, employee: { select: { name: true } } },
}),
prisma.payrollBatch.findMany({
where: { orgId, month, status: 'ARCHIVED' },
select: { totalPay: true, totalSocialOrg: true, totalHousingOrg: true, totalTax: true, employeeCount: true },
}),
])
const now = new Date()
// 员工概况
const genderDist: Record<string, number> = {}
const eduDist: Record<string, number> = {}
const deptDist: Record<string, number> = {}
let totalAge = 0, ageCount = 0
let totalTenure = 0
for (const e of employees) {
const g = e.gender || '未知'
genderDist[g] = (genderDist[g] || 0) + 1
const edu = e.education || '未知'
eduDist[edu] = (eduDist[edu] || 0) + 1
deptDist[e.department] = (deptDist[e.department] || 0) + 1
if (e.birthDate) {
totalAge += now.getFullYear() - e.birthDate.getFullYear()
ageCount++
}
totalTenure += (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000)
}
const avgAge = ageCount > 0 ? (totalAge / ageCount).toFixed(1) : '未知'
const avgTenure = employees.length > 0 ? (totalTenure / employees.length).toFixed(1) : '0'
// 成本数据
const monthCost = batches.reduce((acc, b) => ({
totalPay: acc.totalPay + b.totalPay,
totalSocialOrg: acc.totalSocialOrg + b.totalSocialOrg,
totalHousingOrg: acc.totalHousingOrg + b.totalHousingOrg,
totalTax: acc.totalTax + b.totalTax,
employeeCount: acc.employeeCount + b.employeeCount,
}), { totalPay: 0, totalSocialOrg: 0, totalHousingOrg: 0, totalTax: 0, employeeCount: 0 })
const totalCost = monthCost.totalPay + monthCost.totalSocialOrg + monthCost.totalHousingOrg
const perCapita = monthCost.employeeCount > 0 ? totalCost / monthCost.employeeCount : 0
// 特殊状态员工
const specialEmployees = employees
.filter(e => e.isPregnant || e.isInMedicalPeriod || e.isWorkInjured)
.map(e => {
const tags: string[] = []
if (e.isPregnant) tags.push('孕期/哺乳期')
if (e.isInMedicalPeriod) tags.push('医疗期')
if (e.isWorkInjured) tags.push('工伤')
return `${e.name}${e.department}):${tags.join('、')}`
})
// 合同即将到期(30天内)
const expiringContracts = employees
.filter(e => {
const c = e.contracts[0]
if (!c?.endDate) return false
const days = Math.floor((c.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return days >= 0 && days <= 30
})
.map(e => `${e.name}${e.department}),到期日:${e.contracts[0].endDate?.toISOString().slice(0, 10)}`)
const orgData = `企业人力数据概览(截至 ${now.toISOString().slice(0, 10)}):
【员工概况】
- 在职员工总数:${employees.length}
- 性别分布:${Object.entries(genderDist).map(([k, v]) => `${k} ${v}`).join('、')}
- 学历分布:${Object.entries(eduDist).map(([k, v]) => `${k} ${v}`).join('、')}
- 平均年龄:${avgAge}
- 平均司龄:${avgTenure}
- 部门分布:${Object.entries(deptDist).map(([k, v]) => `${k} ${v}`).join('、')}
【本月人力成本】
- 工资总额:¥${monthCost.totalPay.toFixed(2)}
- 企业社保:¥${monthCost.totalSocialOrg.toFixed(2)}
- 企业公积金:¥${monthCost.totalHousingOrg.toFixed(2)}
- 个人所得税:¥${monthCost.totalTax.toFixed(2)}
- 企业总成本:¥${totalCost.toFixed(2)}
- 人均成本:¥${perCapita.toFixed(2)}
- 覆盖人数:${monthCost.employeeCount}
【当前风险项】(${risks.length} 项)
${risks.map(r => `- [${r.level}] ${r.title}${r.employee?.name || '通用'}):${r.description || '无描述'}`).join('\n')}
【特殊状态员工】(${specialEmployees.length} 人)
${specialEmployees.length > 0 ? specialEmployees.join('\n') : '无'}
【合同即将到期】(30天内,${expiringContracts.length} 人)
${expiringContracts.length > 0 ? expiringContracts.join('\n') : '无'}`
await checkUsageLimit(orgId, 'chat')
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
let usageRecorded = false
try {
for await (const delta of generateHRReportStream(orgData)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
if (typeof (res as any).flush === 'function') (res as any).flush()
}
res.write('data: [DONE]\n\n')
} catch (streamErr: any) {
res.write(`data: ${JSON.stringify({ error: streamErr.message || 'AI 服务异常' })}\n\n`)
res.write('data: [DONE]\n\n')
} finally {
if (!usageRecorded) {
await recordUsage(orgId, req.user!.id, 'chat')
usageRecorded = true
}
}
res.end()
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
// ========== 人工咨询服务 ==========
router.post('/consultation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
type: z.enum(['LEGAL', 'ARBITRATION', 'COURT']),
title: z.string().min(1, '标题不能为空'),
description: z.string().min(1, '描述不能为空'),
contactName: z.string().min(1, '联系人不能为空'),
contactPhone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
aiConversationId: z.string().optional(),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const consultation = await (prisma as any).consultation.create({
data: {
orgId: req.user!.orgId,
type: data.type,
title: data.title,
description: data.description,
contactName: data.contactName,
contactPhone: data.contactPhone,
aiConversationId: data.aiConversationId || null,
remark: data.remark || null,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: consultation })
} catch (err) {
next(err)
}
})
router.get('/consultations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const consultations = await (prisma as any).consultation.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
take: 50,
})
res.json({ success: true, data: consultations })
} catch (err) {
next(err)
}
})
router.patch('/consultations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
status: z.enum(['PENDING', 'CONTACTED', 'COMPLETED', 'CANCELLED']),
remark: z.string().optional(),
})
const data = schema.parse(req.body)
const result = await (prisma as any).consultation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId },
data: {
status: data.status,
...(data.remark !== undefined ? { remark: data.remark } : {}),
},
})
if (result.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '咨询记录不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+148 -1
View File
@@ -7,6 +7,18 @@ import {
getAttendanceConfirmations,
confirmAttendance,
getAttendanceStats,
getShifts,
createShift,
updateShift,
deleteShift,
getShiftAssignments,
batchAssignShifts,
deleteShiftAssignment,
getDailyAttendance,
getMonthlyReport,
getLeaveRecords,
createLeaveRecord,
deleteLeaveRecord,
} from '../services/attendance.service'
import { createEvidence } from '../services/evidence.service'
@@ -20,7 +32,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
}
const status = req.query.status as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status)
const department = req.query.department as string | undefined
const data = await getAttendanceConfirmations(req.user!.orgId, month, status, department)
res.json({ success: true, data })
} catch (err) {
next(err)
@@ -91,4 +104,138 @@ router.post('/confirm', authMiddleware, async (req: AuthRequest, res: Response,
}
})
// ========== 班次管理 ==========
router.get('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getShifts(req.user!.orgId)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/shifts', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
name: z.string().min(1),
startTime: z.string().regex(/^\d{2}:\d{2}$/),
endTime: z.string().regex(/^\d{2}:\d{2}$/),
flexibleMinutes: z.number().int().min(0).optional(),
restMinutes: z.number().int().min(0).optional(),
color: z.string().optional(),
})
const data = await createShift(req.user!.orgId, req.user!.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.put('/shifts/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
name: z.string().min(1).optional(),
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
flexibleMinutes: z.number().int().min(0).optional(),
restMinutes: z.number().int().min(0).optional(),
color: z.string().optional(),
})
const data = await updateShift(req.user!.orgId, req.params.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.delete('/shifts/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteShift(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 排班管理 ==========
router.get('/shift-assignments', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
if (!date) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 date 参数' } })
const data = await getShiftAssignments(req.user!.orgId, date)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/shift-assignments/batch', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
items: z.array(z.object({
employeeId: z.string(),
shiftId: z.string(),
date: z.string(),
})),
})
const { items } = schema.parse(req.body)
const result = await batchAssignShifts(req.user!.orgId, req.user!.id, items)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
router.delete('/shift-assignments/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteShiftAssignment(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
// ========== 每日出勤 ==========
router.get('/daily', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const date = req.query.date as string
if (!date) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 date 参数' } })
const data = await getDailyAttendance(req.user!.orgId, date)
res.json({ success: true, data })
} catch (err) { next(err) }
})
// ========== 月度出勤报表 ==========
router.get('/monthly-report', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = req.query.month as string
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
const data = await getMonthlyReport(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) { next(err) }
})
// ========== 休假记录 ==========
router.get('/leaves', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const employeeId = req.query.employeeId as string | undefined
const data = await getLeaveRecords(req.user!.orgId, employeeId)
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.post('/leaves', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({
employeeId: z.string(),
leaveType: z.enum(['SICK', 'PERSONAL', 'ANNUAL', 'MATERNITY', 'OTHER']),
startDate: z.string(),
endDate: z.string(),
days: z.number().min(0),
reason: z.string().optional(),
remark: z.string().optional(),
})
const data = await createLeaveRecord(req.user!.orgId, req.user!.id, schema.parse(req.body))
res.json({ success: true, data })
} catch (err) { next(err) }
})
router.delete('/leaves/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await deleteLeaveRecord(req.user!.orgId, req.params.id)
res.json({ success: true })
} catch (err) { next(err) }
})
export default router
+1 -1
View File
@@ -12,7 +12,7 @@ router.use(authMiddleware)
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const action = req.query.action as string | undefined
const entity = req.query.entity as string | undefined
const userId = req.query.userId as string | undefined
+120
View File
@@ -0,0 +1,120 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
const createEventSchema = z.object({
title: z.string().min(1).max(100),
date: z.string(), // ISO date string
endDate: z.string().optional(),
type: z.enum(['CUSTOM', 'MEETING', 'TEAM_BUILDING', 'TRAINING', 'INTERVIEW']).default('CUSTOM'),
priority: z.enum(['high', 'medium', 'low']).default('medium'),
location: z.string().optional(),
description: z.string().optional(),
employeeId: z.string().optional(),
})
// 获取当月日历事件(自定义 + 系统自动)
router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const typeFilter = req.query.type as string | undefined
const [year, mon] = month.split('-').map(Number)
const monthStart = new Date(year, mon - 1, 1)
const monthEnd = new Date(year, mon, 0, 23, 59, 59)
const where: any = {
orgId,
date: { gte: monthStart, lte: monthEnd },
}
if (typeFilter) where.type = typeFilter
const events = await prisma.calendarEvent.findMany({
where,
include: { employee: { select: { id: true, name: true } } },
orderBy: { date: 'asc' },
})
res.json({ success: true, data: events })
} catch (err) {
next(err)
}
})
// 获取事件详情
router.get('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const event = await prisma.calendarEvent.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: { employee: { select: { id: true, name: true } } },
})
if (!event) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true, data: event })
} catch (err) {
next(err)
}
})
// 创建日历事件
router.post('/', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createEventSchema.parse(req.body)
const event = await prisma.calendarEvent.create({
data: {
orgId: req.user!.orgId,
title: data.title,
date: new Date(data.date),
endDate: data.endDate ? new Date(data.endDate) : null,
type: data.type,
priority: data.priority,
location: data.location || null,
description: data.description || null,
employeeId: data.employeeId || null,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: event })
} catch (err) {
next(err)
}
})
// 更新日历事件
router.patch('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const updateSchema = createEventSchema.partial()
const data = updateSchema.parse(req.body)
const updateData: any = { ...data }
if (data.date) updateData.date = new Date(data.date)
if (data.endDate) updateData.endDate = new Date(data.endDate)
if (data.endDate === undefined) delete updateData.endDate
const event = await prisma.calendarEvent.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId },
data: updateData,
})
if (event.count === 0) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 删除日历事件
router.delete('/:id', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const result = await prisma.calendarEvent.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (result.count === 0) return res.status(404).json({ success: false, message: '事件不存在' })
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+68
View File
@@ -171,4 +171,72 @@ router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res
}
})
// 人力信息总览 — 员工分布统计
router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { gender: true, birthDate: true, hireDate: true, education: true },
})
const now = new Date()
// 性别分布
const genderDist: Record<string, number> = {}
for (const e of employees) {
const g = e.gender || '未知'
genderDist[g] = (genderDist[g] || 0) + 1
}
// 年龄段分布
const ageRanges = ['<25', '25-30', '31-35', '36-40', '41-50', '>50']
const ageDist: Record<string, number> = {}
for (const r of ageRanges) ageDist[r] = 0
for (const e of employees) {
if (!e.birthDate) continue
const age = now.getFullYear() - e.birthDate.getFullYear()
if (age < 25) ageDist['<25']++
else if (age <= 30) ageDist['25-30']++
else if (age <= 35) ageDist['31-35']++
else if (age <= 40) ageDist['36-40']++
else if (age <= 50) ageDist['41-50']++
else ageDist['>50']++
}
// 学历分布
const eduDist: Record<string, number> = {}
for (const e of employees) {
const edu = e.education || '未知'
eduDist[edu] = (eduDist[edu] || 0) + 1
}
// 司龄分布
const tenureRanges = ['<1年', '1-3年', '3-5年', '5-10年', '>10年']
const tenureDist: Record<string, number> = {}
for (const r of tenureRanges) tenureDist[r] = 0
for (const e of employees) {
const years = (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000)
if (years < 1) tenureDist['<1年']++
else if (years < 3) tenureDist['1-3年']++
else if (years < 5) tenureDist['3-5年']++
else if (years < 10) tenureDist['5-10年']++
else tenureDist['>10年']++
}
res.json({
success: true,
data: {
total: employees.length,
gender: Object.entries(genderDist).map(([name, value]) => ({ name, value })),
age: Object.entries(ageDist).map(([name, value]) => ({ name, value })),
education: Object.entries(eduDist).map(([name, value]) => ({ name, value })),
tenure: Object.entries(tenureDist).map(([name, value]) => ({ name, value })),
},
})
} catch (err) {
next(err)
}
})
export default router
+1 -1
View File
@@ -26,7 +26,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getEmployees(req.user!.orgId, {
page: parseInt(req.query.page as string) || 1,
pageSize: parseInt(req.query.pageSize as string) || 20,
pageSize: Math.min(parseInt(req.query.pageSize as string) || 20, 200),
search: req.query.search as string,
department: req.query.department as string,
})
+1 -1
View File
@@ -12,7 +12,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res: Response, next: Ne
try {
const category = (req.query.category as string) || 'ALL'
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const data = await getEvidenceList(req.user!.orgId, category, page, pageSize)
res.json({ success: true, data })
} catch (err) {
+148 -4
View File
@@ -9,6 +9,12 @@ import { Writable } from 'stream'
const router = Router()
// RFC 5987 编码中文文件名,兼容所有浏览器
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
// 敏感字段脱敏
function maskIdCard(idCard: string | null): string | null {
if (!idCard) return null
@@ -87,7 +93,7 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} else {
@@ -95,10 +101,10 @@ router.get('/all', authMiddleware, requireAdmin, async (req: AuthRequest, res: R
if (useGzip) {
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json.gz`))
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
res.setHeader('Content-Disposition', contentDisposition(`导出数据-${new Date().toISOString().slice(0, 10)}.json`))
}
const gzip = useGzip ? createGzip() : null
@@ -234,7 +240,145 @@ router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, n
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition(`薪税汇总-${month}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出花名册 Excel(支持筛选)
router.get('/roster', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const search = req.query.search as string | undefined
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const contractStatus = req.query.contractStatus as string | undefined
const where: any = { orgId }
if (department) where.department = department
if (status === 'RESIGNED') {
where.status = 'RESIGNED'
} else if (status === 'ACTIVE') {
where.status = 'ACTIVE'
}
if (search) {
where.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const employees = await prisma.employee.findMany({
where,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
orderBy: { createdAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('花名册')
ws.columns = [
{ header: '姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '状态', key: 'status', width: 10 },
{ header: '入职日期', key: 'hireDate', width: 12 },
{ header: '合同起始', key: 'contractStart', width: 12 },
{ header: '合同结束', key: 'contractEnd', width: 12 },
{ header: '联系方式', key: 'phone', width: 15 },
]
ws.getRow(1).font = { bold: true }
for (const e of employees) {
const contract = e.contracts[0]
ws.addRow({
name: e.name,
department: e.department,
status: e.status === 'ACTIVE' ? '在职' : e.status === 'RESIGNED' ? '离职' : '预入职',
hireDate: e.hireDate?.toISOString().slice(0, 10) || '',
contractStart: contract?.startDate?.toISOString().slice(0, 10) || '',
contractEnd: contract?.endDate?.toISOString().slice(0, 10) || '',
phone: e.phone || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`花名册-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
// 导出解聘记录 Excel(支持筛选)
router.get('/terminations', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const status = req.query.status as string | undefined
const department = req.query.department as string | undefined
const search = req.query.search as string | undefined
const where: any = { orgId }
if (status) where.status = status
if (department || search) {
where.employee = {}
if (department) where.employee.department = department
if (search) {
where.employee.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('解聘记录')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '解聘类型', key: 'type', width: 12 },
{ header: '解聘原因', key: 'reason', width: 20 },
{ header: '解聘日期', key: 'terminationDate', width: 12 },
{ header: '补偿金', key: 'compensation', width: 12 },
{ header: '状态', key: 'status', width: 10 },
{ header: '创建日期', key: 'createdAt', width: 12 },
]
ws.getRow(1).font = { bold: true }
const reasonLabels: Record<string, string> = {
NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除',
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职',
}
const statusLabels: Record<string, string> = {
DRAFT: '草稿', PENDING_APPROVAL: '待审批', APPROVED: '已审批',
REJECTED: '已驳回', EXECUTING: '执行中', COMPLETED: '已完成', CANCELLED: '已撤销',
}
for (const r of records) {
ws.addRow({
name: r.employee.name,
department: r.employee.department,
type: r.type === 'TERMINATION' ? '解聘' : '离职',
reason: reasonLabels[r.reason] || r.reason,
terminationDate: r.terminationDate?.toISOString().slice(0, 10) || '',
compensation: r.compensation || 0,
status: statusLabels[r.status] || r.status,
createdAt: r.createdAt?.toISOString().slice(0, 10) || '',
})
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', contentDisposition(`解聘记录-${new Date().toISOString().slice(0, 10)}.xlsx`))
await workbook.xlsx.write(res)
res.end()
} catch (err) {
+13 -6
View File
@@ -9,6 +9,12 @@ import { extractBirthDateFromIdCard, extractGenderFromIdCard } from '../services
import { calcBatchEntry } from '../services/payroll.service'
import { createEvidence } from '../services/evidence.service'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
@@ -101,7 +107,7 @@ router.post('/excel/preview', authMiddleware, requireAdmin, upload.single('file'
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), city: val(r['参保城市']) || '北京', status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
@@ -206,7 +212,7 @@ router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Re
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
res.setHeader('Content-Disposition', contentDisposition('导入错误日志.xlsx'))
res.send(buf)
} catch (err) {
next(err)
@@ -262,6 +268,7 @@ router.post('/excel', authMiddleware, requireAdmin, upload.single('file'), async
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
city: val(r['参保城市']) || '北京',
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
@@ -443,7 +450,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '参保城市': '北京', '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
@@ -469,7 +476,7 @@ router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response)
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('员工导入模板.xlsx'))
res.send(buf)
})
@@ -656,7 +663,7 @@ router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: R
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('月度增减员导入模板.xlsx'))
res.send(buf)
})
@@ -756,7 +763,7 @@ router.get('/payroll-template', authMiddleware, (_req: AuthRequest, res: Respons
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(data), '工资表')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="payroll-import-template.xlsx"')
res.setHeader('Content-Disposition', contentDisposition('工资表导入模板.xlsx'))
res.send(buf)
})
+1 -1
View File
@@ -57,7 +57,7 @@ router.put('/settings', async (req: AuthRequest, res: Response, next: NextFuncti
router.get('/logs', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const [logs, total] = await Promise.all([
prisma.notificationLog.findMany({
where: { orgId: req.user!.orgId },
+92
View File
@@ -576,4 +576,96 @@ router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFu
}
})
// ========== 薪资汇总表 & 明细表 ==========
// 薪资汇总表(按部门维度统计)
router.get('/batch/:id/summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
const entries = await prisma.batchEntry.findMany({
where: { batchId: req.params.id },
include: { employee: { select: { id: true, name: true, department: true } } },
})
// 按部门汇总
const deptMap = new Map<string, any>()
for (const e of entries) {
const dept = e.employee.department || '未分配'
if (!deptMap.has(dept)) {
deptMap.set(dept, { department: dept, headcount: 0, totalPay: 0, totalNetPay: 0, totalSocialEmp: 0, totalSocialOrg: 0, totalHousingEmp: 0, totalHousingOrg: 0, totalTax: 0 })
}
const d = deptMap.get(dept)
d.headcount++
d.totalPay += e.totalPay
d.totalNetPay += e.netPay
d.totalSocialEmp += e.socialEmp
d.totalSocialOrg += e.socialOrg
d.totalHousingEmp += e.housingEmp
d.totalHousingOrg += e.housingOrg
d.totalTax += e.tax
}
const departments = Array.from(deptMap.values())
const grandTotal = {
headcount: entries.length,
totalPay: entries.reduce((s, e) => s + e.totalPay, 0),
totalNetPay: entries.reduce((s, e) => s + e.netPay, 0),
totalSocialEmp: entries.reduce((s, e) => s + e.socialEmp, 0),
totalSocialOrg: entries.reduce((s, e) => s + e.socialOrg, 0),
totalHousingEmp: entries.reduce((s, e) => s + e.housingEmp, 0),
totalHousingOrg: entries.reduce((s, e) => s + e.housingOrg, 0),
totalTax: entries.reduce((s, e) => s + e.tax, 0),
}
res.json({ success: true, data: { batch, departments, grandTotal } })
} catch (err) { next(err) }
})
// 薪资明细表(全员明细)
router.get('/batch/:id/detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
const entries = await prisma.batchEntry.findMany({
where: { batchId: req.params.id },
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
orderBy: { employee: { department: 'asc' } },
})
const details = entries.map(e => ({
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
phone: e.employee.phone,
baseSalary: e.baseSalary,
positionSalary: e.positionSalary,
performanceSalary: e.performanceSalary,
senioritySalary: e.senioritySalary,
overtimePay: e.overtimePay,
transportAllowance: e.transportAllowance,
mealAllowance: e.mealAllowance,
housingAllowance: e.housingAllowance,
communicationAllowance: e.communicationAllowance,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
otherDeduction: e.otherDeduction,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
totalPay: e.totalPay,
netPay: e.netPay,
}))
res.json({ success: true, data: { batch, details } })
} catch (err) { next(err) }
})
export default router
+7 -1
View File
@@ -11,6 +11,12 @@ import {
prePayrollCheck,
} from '../services/payroll.service'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
const router = Router()
router.use(authMiddleware)
@@ -781,7 +787,7 @@ router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, n
const header = '姓名,银行账号,开户行,实发金额\n'
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
res.setHeader('Content-Disposition', contentDisposition(`银行代发文件-${batch.month}-批次${batch.batchNo}.csv`))
return res.send('\ufeff' + header + rows)
}
+48 -6
View File
@@ -19,14 +19,30 @@ function safeDecrypt(encrypted: string): number {
// ========== 花名册聚合 API ==========
// 获取部门列表(去重)
router.get('/departments', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
select: { department: true },
distinct: 'department',
})
const departments = employees.map((e) => e.department).filter(Boolean).sort()
res.json({ success: true, data: departments })
} catch (err) {
next(err)
}
})
// 花名册列表(含汇总信息,支持分页和过滤)
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 999)
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const department = req.query.department as string
const skip = (page - 1) * pageSize
// 使用本地日期午夜,避免时区问题导致当天入职被误判为预入职
@@ -38,8 +54,12 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
todayEnd.setDate(todayEnd.getDate() + 1)
// 先查询满足 orgId 和搜索条件的员工
const isIdCardSearch = search && /^\d{4}$/.test(search)
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
if (department) {
whereBase.department = department
}
if (search && !isIdCardSearch) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
@@ -62,8 +82,8 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
whereBase.contracts = { none: {} }
}
// 当有 contractStatus(非 unsigned)筛选时,需要先查全部再过滤后分页
const needPostFilter = !!contractStatus && contractStatus !== 'unsigned'
// 当有 contractStatus(非 unsigned)筛选或身份证号搜索时,需要先查全部再过滤后分页
const needPostFilter = (!!contractStatus && contractStatus !== 'unsigned') || isIdCardSearch
const [dbTotal, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
@@ -109,6 +129,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
const isResigned = e.terminations.some((t) => t.status === 'COMPLETED' && t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > todayEnd
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
// 身份证号脱敏显示
let idCardMasked: string | null = null
if (e.idCardNumber) {
try {
const idCard = decrypt(e.idCardNumber)
if (idCard.length >= 11) {
idCardMasked = idCard.slice(0, 3) + '****' + idCard.slice(-4)
} else {
idCardMasked = '****'
}
} catch {}
}
return {
id: e.id,
name: e.name,
@@ -123,6 +155,7 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
idCardMasked,
monthlySalary: safeDecrypt(e.monthlySalary),
isPregnant: e.isPregnant,
isInMedicalPeriod: e.isInMedicalPeriod,
@@ -140,9 +173,18 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
result = result.filter((e) => e.contractStatus === contractStatus)
}
// 身份证号后4位搜索:在内存中过滤
if (isIdCardSearch) {
result = result.filter((e: any) => {
if (!e.idCardMasked) return false
return e.idCardMasked.endsWith(search!)
})
}
// 计算过滤后的总数和分页
const filteredTotal = needPostFilter ? result.length : dbTotal
if (needPostFilter) {
const needMemoryPaging = needPostFilter || isIdCardSearch
const filteredTotal = needMemoryPaging ? result.length : dbTotal
if (needMemoryPaging) {
result = result.slice(skip, skip + pageSize)
}
+16 -14
View File
@@ -27,6 +27,7 @@ const socialConfigFields = {
const housingConfigFields = {
city: z.string().optional(),
accountType: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
@@ -458,23 +459,20 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
// ========== 公积金配置 ==========
// 获取当前公积金配置(支持按城市筛选)
// 获取当前公积金配置(支持按城市、账户类型筛选)
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
let config = await prisma.housingFundConfig.findFirst({
if (accountType) where.accountType = accountType
const configs = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
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 })
}
@@ -484,15 +482,17 @@ router.get('/housing-config', async (req: AuthRequest, res: Response, next: Next
}
})
// 公积金配置版本列表(支持按城市筛选)
// 公积金配置版本列表(支持按城市、账户类型筛选)
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: { effectiveFrom: 'desc' },
orderBy: [{ accountType: 'asc' }, { effectiveFrom: 'desc' }],
})
res.json({ success: true, data: versions })
} catch (err) {
@@ -511,15 +511,16 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
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, effectiveFrom: data.effectiveFrom },
where: { orgId, city: data.city, accountType: acctType, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有${acctType === 'SUPPLEMENTARY' ? '补充' : '基本'}公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
where: { orgId, city: data.city, accountType: acctType, isCurrent: true },
})
if (prevCurrent) {
const [year, mon] = data.effectiveFrom.split('-').map(Number)
@@ -535,6 +536,7 @@ router.post('/housing-config/versions', async (req: AuthRequest, res: Response,
const version = await prisma.housingFundConfig.create({
data: {
orgId,
accountType: acctType,
...data,
isCurrent: true,
createdBy: req.user!.id,
+4 -2
View File
@@ -11,7 +11,7 @@ const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const page = parseInt(req.query.page as string) || 1
const pageSize = parseInt(req.query.pageSize as string) || 20
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 200)
const result = await getTerminations(req.user!.orgId, page, pageSize)
res.json({ success: true, data: result })
} catch (err) {
@@ -160,7 +160,9 @@ router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status as string | undefined
const result = await getDrafts(req.user!.orgId, status)
const search = req.query.search as string | undefined
const department = req.query.department as string | undefined
const result = await getDrafts(req.user!.orgId, status, search, department)
res.json({ success: true, data: result })
} catch (err) {
next(err)