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
+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,