feat: 工作日历/考勤管理重构/AI人力报告/工作台员工分布/筛选优化/导入导出增强
- 新增工作日历页面(月历视图、事件管理、自定义事件) - 考勤管理重构为6 Tab模块(班次/排班/每日出勤/月度报表/休假记录) - AI顾问新增人力报告Tab,支持流式生成+Word导出 - 工作台总览新增员工分布统计(性别/年龄/学历/司龄饼图)+部门成本拆分 - 花名册/合同/解聘补偿新增部门和状态筛选 - 薪税管理新增工资表导入模板下载、银行代发CSV导出 - 社保公积金支持多公积金账户类型显示 - 数据导出新增花名册/解聘记录导出,中文文件名编码修复 - 数据导入新增模板下载(员工/增减员/工资表)+错误日志导出 - 移除工作台日历卡片(已迁移至独立工作日历页面) - 新增20260728/20260729更新测试指导文档
This commit is contained in:
@@ -56,6 +56,7 @@ import policyRoutes from './routes/policy.routes'
|
||||
import attendanceRoutes from './routes/attendance.routes'
|
||||
import templateRoutes from './routes/template.routes'
|
||||
import auditRoutes from './routes/audit.routes'
|
||||
import calendarRoutes from './routes/calendar.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
@@ -76,6 +77,7 @@ app.use('/api/v1/policies', policyRoutes)
|
||||
app.use('/api/v1/attendance', attendanceRoutes)
|
||||
app.use('/api/v1/templates', templateRoutes)
|
||||
app.use('/api/v1/audit', auditRoutes)
|
||||
app.use('/api/v1/calendar', calendarRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,7 @@ export const createEmployeeSchema = z.object({
|
||||
isInMedicalPeriod: z.boolean().default(false),
|
||||
isWorkInjured: z.boolean().default(false),
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
@@ -44,6 +45,7 @@ export const updateEmployeeSchema = z.object({
|
||||
housingFundBase: z.number().min(0).nullable().optional(),
|
||||
specialDeduction: z.number().min(0).optional(),
|
||||
city: z.string().max(20).optional(),
|
||||
education: z.string().max(20).optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
|
||||
@@ -347,3 +347,59 @@ ${orgContext}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 人力分析报告:基于企业数据自动生成结构化报告
|
||||
*/
|
||||
export async function* generateHRReportStream(orgData: string) {
|
||||
const prompt = `请基于以下企业人力数据,生成一份结构化的 HR 人力分析报告。请使用 Markdown 格式输出,包含以下部分:
|
||||
|
||||
## 一、人力概况
|
||||
- 员工总数、部门分布、性别比例、年龄段分布、学历分布、司龄分布
|
||||
|
||||
## 二、风险提示
|
||||
- 当前存在的用工风险(合同到期、试用期、特殊状态员工等)
|
||||
- 风险等级和紧急程度
|
||||
|
||||
## 三、成本分析
|
||||
- 人力成本概况(工资、社保、公积金等)
|
||||
- 人均成本、部门成本差异
|
||||
- 成本趋势分析
|
||||
|
||||
## 四、合规建议
|
||||
- 合同管理建议
|
||||
- 社保公积金合规建议
|
||||
- 规章制度完善建议
|
||||
|
||||
## 五、改进方向
|
||||
- 人才结构优化建议
|
||||
- 成本控制建议
|
||||
- 管理流程改进建议
|
||||
|
||||
报告要求:
|
||||
- 数据驱动的分析,引用具体数字
|
||||
- 每个部分给出 2-3 条具体可操作的建议
|
||||
- 语言简洁专业,避免空话套话
|
||||
|
||||
企业数据:
|
||||
${orgData}`
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的人力资源分析师,精通中国劳动法规和人力资源管理。请基于企业实际数据生成专业、客观、可操作的人力分析报告。使用 Markdown 格式输出。',
|
||||
},
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 8000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content
|
||||
if (delta) yield delta
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,10 @@ export async function batchCreateAttendanceConfirmations(orgId: string, userId:
|
||||
/**
|
||||
* 获取月度考勤确认列表
|
||||
*/
|
||||
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string) {
|
||||
export async function getAttendanceConfirmations(orgId: string, month: string, status?: string, department?: string) {
|
||||
const where: any = { orgId, month }
|
||||
if (status) where.status = status
|
||||
if (department) where.employee = { department }
|
||||
|
||||
return prisma.attendanceConfirmation.findMany({
|
||||
where,
|
||||
@@ -148,3 +149,260 @@ export async function getAttendanceStats(orgId: string, month: string) {
|
||||
disputed: records.filter(r => r.status === 'DISPUTED').length,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 班次管理 ==========
|
||||
|
||||
export async function getShifts(orgId: string) {
|
||||
return prisma.shift.findMany({
|
||||
where: { orgId },
|
||||
orderBy: { startTime: 'asc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createShift(orgId: string, userId: string, data: {
|
||||
name: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
flexibleMinutes?: number
|
||||
restMinutes?: number
|
||||
color?: string
|
||||
}) {
|
||||
return prisma.shift.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: data.name,
|
||||
startTime: data.startTime,
|
||||
endTime: data.endTime,
|
||||
flexibleMinutes: data.flexibleMinutes || 0,
|
||||
restMinutes: data.restMinutes || 0,
|
||||
color: data.color || '#3b82f6',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateShift(orgId: string, id: string, data: {
|
||||
name?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
flexibleMinutes?: number
|
||||
restMinutes?: number
|
||||
color?: string
|
||||
}) {
|
||||
return prisma.shift.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
export async function deleteShift(orgId: string, id: string) {
|
||||
return prisma.shift.delete({ where: { id } })
|
||||
}
|
||||
|
||||
// ========== 排班管理 ==========
|
||||
|
||||
export async function getShiftAssignments(orgId: string, date: string) {
|
||||
const day = new Date(date)
|
||||
day.setHours(0, 0, 0, 0)
|
||||
const nextDay = new Date(day)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
|
||||
return prisma.shiftAssignment.findMany({
|
||||
where: { orgId, date: { gte: day, lt: nextDay } },
|
||||
include: {
|
||||
employee: { select: { id: true, name: true, department: true } },
|
||||
shift: true,
|
||||
},
|
||||
orderBy: { employee: { name: 'asc' } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function batchAssignShifts(orgId: string, userId: string, items: Array<{
|
||||
employeeId: string
|
||||
shiftId: string
|
||||
date: string
|
||||
}>) {
|
||||
const results: Array<{ employeeId: string; date: string; success: boolean; error?: string }> = []
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const date = new Date(item.date)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
|
||||
const existing = await prisma.shiftAssignment.findUnique({
|
||||
where: { employeeId_date: { employeeId: item.employeeId, date } },
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
await prisma.shiftAssignment.update({
|
||||
where: { id: existing.id },
|
||||
data: { shiftId: item.shiftId },
|
||||
})
|
||||
} else {
|
||||
await prisma.shiftAssignment.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
shiftId: item.shiftId,
|
||||
date,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
results.push({ employeeId: item.employeeId, date: item.date, success: true })
|
||||
} catch (err: any) {
|
||||
results.push({ employeeId: item.employeeId, date: item.date, success: false, error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return { total: items.length, success: results.filter(r => r.success).length, results }
|
||||
}
|
||||
|
||||
export async function deleteShiftAssignment(orgId: string, id: string) {
|
||||
return prisma.shiftAssignment.delete({ where: { id } })
|
||||
}
|
||||
|
||||
// ========== 每日出勤 ==========
|
||||
|
||||
export async function getDailyAttendance(orgId: string, date: string) {
|
||||
const day = new Date(date)
|
||||
day.setHours(0, 0, 0, 0)
|
||||
const nextDay = new Date(day)
|
||||
nextDay.setDate(nextDay.getDate() + 1)
|
||||
|
||||
const [records, assignments, employees] = await Promise.all([
|
||||
prisma.attendanceRecord.findMany({
|
||||
where: { orgId, date: { gte: day, lt: nextDay } },
|
||||
}),
|
||||
prisma.shiftAssignment.findMany({
|
||||
where: { orgId, date: { gte: day, lt: nextDay } },
|
||||
include: { shift: true },
|
||||
}),
|
||||
prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true },
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const recordMap = new Map(records.map(r => [r.employeeId, r]))
|
||||
const shiftMap = new Map(assignments.map(a => [a.employeeId, a.shift]))
|
||||
|
||||
return employees.map(emp => {
|
||||
const record = recordMap.get(emp.id)
|
||||
const shift = shiftMap.get(emp.id)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
shift: shift ? { name: shift.name, startTime: shift.startTime, endTime: shift.endTime, color: shift.color } : null,
|
||||
checkInTime: record?.checkInTime || null,
|
||||
checkOutTime: record?.checkOutTime || null,
|
||||
status: record?.status || 'UNREGISTERED',
|
||||
lateMinutes: record?.lateMinutes || 0,
|
||||
earlyMinutes: record?.earlyMinutes || 0,
|
||||
workHours: record?.workHours || 0,
|
||||
overtimeHours: record?.overtimeHours || 0,
|
||||
remark: record?.remark || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 月度出勤报表 ==========
|
||||
|
||||
export async function getMonthlyReport(orgId: string, month: string) {
|
||||
const monthStart = new Date(month + '-01')
|
||||
const monthEnd = new Date(monthStart)
|
||||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||||
|
||||
const [records, confirmations, overtimes, leaves] = await Promise.all([
|
||||
prisma.attendanceRecord.findMany({
|
||||
where: { orgId, date: { gte: monthStart, lt: monthEnd } },
|
||||
}),
|
||||
prisma.attendanceConfirmation.findMany({
|
||||
where: { orgId, month },
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
}),
|
||||
prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month },
|
||||
}),
|
||||
prisma.leaveRecord.findMany({
|
||||
where: { orgId, startDate: { lt: monthEnd }, endDate: { gte: monthStart } },
|
||||
}),
|
||||
])
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true, name: true, department: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
const otMap = new Map<string, number>()
|
||||
for (const ot of overtimes) {
|
||||
const totalHours = (ot.weekdayHours || 0) + (ot.weekendHours || 0) + (ot.holidayHours || 0)
|
||||
otMap.set(ot.employeeId, (otMap.get(ot.employeeId) || 0) + totalHours)
|
||||
}
|
||||
|
||||
const leaveMap = new Map<string, number>()
|
||||
for (const lv of leaves) {
|
||||
leaveMap.set(lv.employeeId, (leaveMap.get(lv.employeeId) || 0) + lv.days)
|
||||
}
|
||||
|
||||
return employees.map(emp => {
|
||||
const empRecords = records.filter(r => r.employeeId === emp.id)
|
||||
const confirmation = confirmations.find(c => c.employeeId === emp.id)
|
||||
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
workDays: confirmation?.workDays || empRecords.filter(r => r.status === 'NORMAL').length,
|
||||
lateCount: empRecords.filter(r => r.status === 'LATE').length,
|
||||
earlyLeaveCount: empRecords.filter(r => r.status === 'EARLY_LEAVE').length,
|
||||
absentDays: empRecords.filter(r => r.status === 'ABSENT').length,
|
||||
leaveDays: leaveMap.get(emp.id) || 0,
|
||||
overtimeHours: confirmation ? (confirmation.weekdayHours + confirmation.weekendHours + confirmation.holidayHours) : (otMap.get(emp.id) || 0),
|
||||
overtimePay: confirmation?.overtimePay || 0,
|
||||
confirmationStatus: confirmation?.status || null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 休假记录 ==========
|
||||
|
||||
export async function getLeaveRecords(orgId: string, employeeId?: string) {
|
||||
const where: any = { orgId }
|
||||
if (employeeId) where.employeeId = employeeId
|
||||
|
||||
return prisma.leaveRecord.findMany({
|
||||
where,
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { startDate: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createLeaveRecord(orgId: string, userId: string, data: {
|
||||
employeeId: string
|
||||
leaveType: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
days: number
|
||||
reason?: string
|
||||
remark?: string
|
||||
}) {
|
||||
return prisma.leaveRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
leaveType: data.leaveType,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
days: data.days,
|
||||
reason: data.reason || null,
|
||||
remark: data.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteLeaveRecord(orgId: string, id: string) {
|
||||
return prisma.leaveRecord.delete({ where: { id } })
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
housingFundStartMonth,
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
education: data.education || null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -521,6 +522,7 @@ export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
if (data.education !== undefined) updateData.education = data.education
|
||||
|
||||
// 参保城市变更:关闭旧城市在保记录,创建新城市记录
|
||||
if (data.city !== undefined && data.city !== employee.city) {
|
||||
|
||||
@@ -4,15 +4,23 @@ import prisma from '../lib/prisma'
|
||||
|
||||
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
|
||||
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
|
||||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
|
||||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
|
||||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
|
||||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
|
||||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
|
||||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
|
||||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
|
||||
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, order: 2, isDefault: true, isEditable: true },
|
||||
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
|
||||
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
|
||||
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 5, isDefault: true, isEditable: false },
|
||||
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, order: 6, isDefault: true, isEditable: true },
|
||||
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, order: 7, isDefault: true, isEditable: true },
|
||||
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, order: 8, isDefault: true, isEditable: true },
|
||||
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, order: 9, isDefault: true, isEditable: true },
|
||||
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 10, isDefault: true, isEditable: true },
|
||||
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 11, isDefault: true, isEditable: true },
|
||||
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 12, isDefault: true, isEditable: true },
|
||||
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, order: 13, isDefault: true, isEditable: true },
|
||||
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', order: 14, isDefault: true, isEditable: false },
|
||||
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 15, isDefault: true, isEditable: false },
|
||||
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 16, isDefault: true, isEditable: false },
|
||||
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 17, isDefault: true, isEditable: false },
|
||||
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 18, isDefault: true, isEditable: false },
|
||||
]
|
||||
|
||||
export async function ensureDefaultTemplate(orgId: string) {
|
||||
@@ -127,7 +135,7 @@ export async function calcBatchEntry(
|
||||
orgId: string,
|
||||
employeeId: string,
|
||||
month: string,
|
||||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
|
||||
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number; positionSalary?: number; performanceSalary?: number; senioritySalary?: number; transportAllowance?: number; mealAllowance?: number; housingAllowance?: number; communicationAllowance?: number; otherDeduction?: number },
|
||||
batchType: string = 'REGULAR',
|
||||
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
|
||||
) {
|
||||
@@ -205,7 +213,19 @@ export async function calcBatchEntry(
|
||||
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
|
||||
}
|
||||
|
||||
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
|
||||
const totalPay = inputs.baseSalary
|
||||
+ (inputs.positionSalary || 0)
|
||||
+ (inputs.performanceSalary || 0)
|
||||
+ (inputs.senioritySalary || 0)
|
||||
+ inputs.overtimePay
|
||||
+ (inputs.transportAllowance || 0)
|
||||
+ (inputs.mealAllowance || 0)
|
||||
+ (inputs.housingAllowance || 0)
|
||||
+ (inputs.communicationAllowance || 0)
|
||||
+ inputs.allowance
|
||||
+ inputs.bonus
|
||||
- inputs.deduction
|
||||
- (inputs.otherDeduction || 0)
|
||||
|
||||
// 个税计算
|
||||
let tax = 0
|
||||
@@ -313,6 +333,9 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
for (const entry of batch.entries) {
|
||||
const existing = employeeMap.get(entry.employeeId) || {
|
||||
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
|
||||
positionSalary: 0, performanceSalary: 0, senioritySalary: 0,
|
||||
transportAllowance: 0, mealAllowance: 0, housingAllowance: 0, communicationAllowance: 0,
|
||||
otherDeduction: 0,
|
||||
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
|
||||
totalPay: 0, netPay: 0,
|
||||
}
|
||||
@@ -321,6 +344,14 @@ export async function generatePayslipFromBatches(orgId: string, month: string) {
|
||||
existing.allowance += entry.allowance
|
||||
existing.deduction += entry.deduction
|
||||
existing.bonus += entry.bonus
|
||||
existing.positionSalary += entry.positionSalary || 0
|
||||
existing.performanceSalary += entry.performanceSalary || 0
|
||||
existing.senioritySalary += entry.senioritySalary || 0
|
||||
existing.transportAllowance += entry.transportAllowance || 0
|
||||
existing.mealAllowance += entry.mealAllowance || 0
|
||||
existing.housingAllowance += entry.housingAllowance || 0
|
||||
existing.communicationAllowance += entry.communicationAllowance || 0
|
||||
existing.otherDeduction += entry.otherDeduction || 0
|
||||
existing.socialEmp += entry.socialEmp
|
||||
existing.socialOrg += entry.socialOrg
|
||||
existing.housingEmp += entry.housingEmp
|
||||
|
||||
@@ -906,6 +906,25 @@ export async function getMonthlyCalendar(orgId: string, month: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 自定义日历事件
|
||||
const customEvents = await prisma.calendarEvent.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
date: { gte: monthStart, lte: monthEnd },
|
||||
},
|
||||
include: { employee: { select: { name: true } } },
|
||||
})
|
||||
for (const ev of customEvents) {
|
||||
events.push({
|
||||
date: ev.date.toISOString().slice(0, 10),
|
||||
type: ev.type,
|
||||
title: ev.title + (ev.employee ? ` — ${ev.employee.name}` : ''),
|
||||
employeeName: ev.employee?.name,
|
||||
actionUrl: '/dashboard',
|
||||
priority: ev.priority as 'high' | 'medium' | 'low',
|
||||
})
|
||||
}
|
||||
|
||||
// 按日期排序
|
||||
events.sort((a, b) => a.date.localeCompare(b.date))
|
||||
|
||||
@@ -1000,6 +1019,35 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 按部门拆分成本
|
||||
const deptEntries = await prisma.batchEntry.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
batch: { month, status: 'ARCHIVED' },
|
||||
},
|
||||
include: { employee: { select: { department: true } } },
|
||||
})
|
||||
const deptMap: Record<string, { totalPay: number; socialOrg: number; housingOrg: number; headcount: number }> = {}
|
||||
for (const e of deptEntries) {
|
||||
const dept = e.employee?.department || '未分配'
|
||||
if (!deptMap[dept]) deptMap[dept] = { totalPay: 0, socialOrg: 0, housingOrg: 0, headcount: 0 }
|
||||
deptMap[dept].totalPay += e.totalPay
|
||||
deptMap[dept].socialOrg += e.socialOrg
|
||||
deptMap[dept].housingOrg += e.housingOrg
|
||||
deptMap[dept].headcount += 1
|
||||
}
|
||||
const departmentCost = Object.entries(deptMap)
|
||||
.map(([dept, v]) => ({
|
||||
department: dept,
|
||||
totalCost: v.totalPay + v.socialOrg + v.housingOrg,
|
||||
totalPay: v.totalPay,
|
||||
socialOrg: v.socialOrg,
|
||||
housingOrg: v.housingOrg,
|
||||
headcount: v.headcount,
|
||||
perCapita: v.headcount > 0 ? (v.totalPay + v.socialOrg + v.housingOrg) / v.headcount : 0,
|
||||
}))
|
||||
.sort((a, b) => b.totalCost - a.totalCost)
|
||||
|
||||
return {
|
||||
month,
|
||||
current: {
|
||||
@@ -1026,6 +1074,7 @@ export async function getCostAnalysis(orgId: string, month: string) {
|
||||
changePercent: yoyChange,
|
||||
},
|
||||
factors,
|
||||
departmentCost,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -738,13 +738,25 @@ export async function cancelTermination(orgId: string, recordId: string, userId:
|
||||
}
|
||||
|
||||
/** 获取草稿列表 */
|
||||
export async function getDrafts(orgId: string, status?: string) {
|
||||
export async function getDrafts(orgId: string, status?: string, search?: string, department?: string) {
|
||||
const where: any = { orgId }
|
||||
if (status) {
|
||||
where.status = status
|
||||
} else {
|
||||
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED', 'EXECUTING', 'COMPLETED', 'CANCELLED'] }
|
||||
}
|
||||
if (department) {
|
||||
where.employee = { department }
|
||||
}
|
||||
if (search) {
|
||||
where.employee = {
|
||||
...where.employee,
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ department: { contains: search } },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const records = await prisma.terminationRecord.findMany({
|
||||
where,
|
||||
|
||||
Reference in New Issue
Block a user