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
@@ -0,0 +1,11 @@
-- P1-9: 多公积金比例账户
-- 给 HousingFundConfig 增加 accountType 字段,支持基本公积金/补充公积金
-- 1. 添加列,默认值 BASIC
ALTER TABLE "HousingFundConfig" ADD COLUMN "accountType" TEXT NOT NULL DEFAULT 'BASIC';
-- 2. 删除旧唯一约束
ALTER TABLE "HousingFundConfig" DROP CONSTRAINT "HousingFundConfig_orgId_city_effectiveFrom_key";
-- 3. 添加新唯一约束(包含 accountType
ALTER TABLE "HousingFundConfig" ADD CONSTRAINT "HousingFundConfig_orgId_city_accountType_effectiveFrom_key" UNIQUE ("orgId", "city", "accountType", "effectiveFrom");
@@ -0,0 +1,27 @@
-- P2-10: 自定义日历事件
CREATE TABLE "CalendarEvent" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"type" TEXT NOT NULL DEFAULT 'CUSTOM',
"priority" TEXT NOT NULL DEFAULT 'medium',
"location" TEXT,
"description" TEXT,
"employeeId" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CalendarEvent_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CalendarEvent_orgId_date_idx" ON "CalendarEvent"("orgId", "date");
CREATE INDEX "CalendarEvent_orgId_type_idx" ON "CalendarEvent"("orgId", "type");
ALTER TABLE "CalendarEvent" ADD CONSTRAINT "CalendarEvent_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
ALTER TABLE "CalendarEvent" ADD CONSTRAINT "CalendarEvent_employeeId_fkey"
FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL;
@@ -0,0 +1,25 @@
-- P2-15: 人工咨询服务 Consultation 表
CREATE TABLE "Consultation" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"contactName" TEXT NOT NULL,
"contactPhone" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"aiConversationId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Consultation_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "Consultation_orgId_status_idx" ON "Consultation"("orgId", "status");
CREATE INDEX "Consultation_orgId_type_idx" ON "Consultation"("orgId", "type");
-- 外键
ALTER TABLE "Consultation" ADD CONSTRAINT "Consultation_orgId_fkey"
FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE;
+126 -1
View File
@@ -166,6 +166,11 @@ model Organization {
healthCheckReports HealthCheckReport[]
annualValueReports AnnualValueReport[]
specialDeductionRecords SpecialDeductionRecord[]
shifts Shift[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
calendarEvents CalendarEvent[]
consultations Consultation[]
}
model User {
@@ -216,6 +221,7 @@ model Employee {
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市
birthDate DateTime? // 出生日期(从身份证号提取)
education String? // 学历(博士/硕士/本科/大专/高中/其他)
femaleWorkerType FemaleWorkerType? // 女性岗位类型(CADRE=干部/WORKER=工人,仅女性需要区分)
retirementDaysLeft Int? // 距退休天数(便捷字段,定期计算)
createdBy String
@@ -242,6 +248,9 @@ model Employee {
attendanceConfirmations AttendanceConfirmation[]
policyReadRecords PolicyReadRecord[]
specialDeductionRecords SpecialDeductionRecord[]
shiftAssignments ShiftAssignment[]
leaveRecords LeaveRecord[]
calendarEvents CalendarEvent[]
@@unique([orgId, idCardHash])
}
@@ -410,6 +419,7 @@ model HousingFundConfig {
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
accountType String @default("BASIC") // BASIC=基本公积金, SUPPLEMENTARY=补充公积金
housingOrg Float @default(12) // 公积金 企业比例 %
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 公积金缴费基数下限
@@ -422,7 +432,7 @@ model HousingFundConfig {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@unique([orgId, city, accountType, effectiveFrom])
@@index([orgId, isCurrent])
}
@@ -668,6 +678,15 @@ model BatchEntry {
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0)
// 细化薪资项
positionSalary Float @default(0) // 岗位工资
performanceSalary Float @default(0) // 绩效工资
senioritySalary Float @default(0) // 工龄工资
transportAllowance Float @default(0) // 交通补贴
mealAllowance Float @default(0) // 餐补
housingAllowance Float @default(0) // 住房补贴
communicationAllowance Float @default(0) // 通讯补贴
otherDeduction Float @default(0) // 其他扣款
// 自动计算项
socialEmp Float @default(0)
socialOrg Float @default(0)
@@ -1045,3 +1064,109 @@ model SpecialDeductionRecord {
@@unique([employeeId, month])
@@index([orgId, month])
}
// ========== 班次管理 ==========
model Shift {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 班次名称,如"早班"、"白班"、"夜班"
startTime String // 上班时间 HH:mm
endTime String // 下班时间 HH:mm
flexibleMinutes Int @default(0) // 弹性时长(分钟)
restMinutes Int @default(0) // 休息时长(分钟)
color String @default("#3b82f6") // 日历显示颜色
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignments ShiftAssignment[]
@@index([orgId])
}
// ========== 排班记录 ==========
model ShiftAssignment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
shiftId String
shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade)
date DateTime // 排班日期
createdBy String
createdAt DateTime @default(now())
@@unique([employeeId, date])
@@index([orgId, date])
@@index([orgId, employeeId])
}
// ========== 休假记录(直接记录,无审批流程) ==========
model LeaveRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
leaveType String // SICK=病假 / PERSONAL=事假 / ANNUAL=年假 / MATERNITY=产假 / OTHER=其他
startDate DateTime
endDate DateTime
days Float // 请假天数
reason String?
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, employeeId])
@@index([orgId, startDate])
}
// ========== 自定义日历事件 ==========
model CalendarEvent {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
title String
date DateTime
endDate DateTime?
type String @default("CUSTOM") // CUSTOM=自定义, MEETING=会议, TEAM_BUILDING=团建, TRAINING=培训, INTERVIEW=面试
priority String @default("medium") // high/medium/low
location String?
description String?
employeeId String?
employee Employee? @relation(fields: [employeeId], references: [id], onDelete: SetNull)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, date])
@@index([orgId, type])
}
// ========== 人工咨询服务 ==========
model Consultation {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
type String // LEGAL=法律咨询, ARBITRATION=仲裁代理, COURT=出庭服务
title String
description String
contactName String
contactPhone String
status String @default("PENDING") // PENDING=待处理, CONTACTED=已联系, COMPLETED=已完成, CANCELLED=已取消
aiConversationId String? // 关联 AI 会话
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
+2
View File
@@ -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)
+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)
+2
View File
@@ -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({
+56
View File
@@ -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
}
}
+259 -1
View File
@@ -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 } })
}
+2
View File
@@ -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) {
+42 -11
View File
@@ -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
+49
View File
@@ -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,
}
}
+13 -1
View File
@@ -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,