diff --git a/20260728-优化.md b/20260728-优化.md index 1176ba0..0606aa8 100644 --- a/20260728-优化.md +++ b/20260728-优化.md @@ -34,24 +34,30 @@ ### 1. 模板下载 401 错误(对应第19项) -**根因**:前端 `Settings.tsx` 使用 `window.open()` 下载模板,新窗口不携带 JWT token,后端 `authMiddleware` 返回 401。 +**现状**:`Settings.tsx` 中的 `GET /import/template` 和 `GET /import/monthly-template` 已修复(使用 `fetch` + Authorization header)。但 `Money.tsx:657` 中下载工资表模板仍使用 `window.open('/api/v1/import/payroll-template', '_blank')`,新窗口不携带 JWT token,后端 `authMiddleware` 返回 401。 -**修复方案**:前端改为 `fetch` + `Blob` 方式下载,携带 Authorization header。 +**修复方案**:将 `Money.tsx` 中的 `window.open()` 改为 `fetch` + `Blob` 方式下载,携带 Authorization header。 **涉及文件**: -- `frontend/src/pages/Settings.tsx` — 修改下载模板按钮的 onClick 逻辑 -- 涉及3个端点:`GET /import/template`、`GET /import/payroll-template`、`GET /import/monthly-template` +- `frontend/src/pages/Money.tsx:657` — 修改工资表模板下载按钮的 onClick 逻辑 +- 涉及端点:`GET /import/payroll-template` **实现要点**: ```typescript -// 替换 window.open(url) 为: -const res = await api.get('/import/template', { responseType: 'blob' }) -const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) -const link = document.createElement('a') -link.href = URL.createObjectURL(blob) -link.download = 'import-template.xlsx' -link.click() -URL.revokeObjectURL(link.href) +// Money.tsx 中替换 window.open() 为: +const handleDownloadPayrollTemplate = async () => { + const token = useAuthStore.getState().accessToken + const res = await fetch('/api/v1/import/payroll-template', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'payroll-import-template.xlsx' + a.click() + URL.revokeObjectURL(url) +} ``` --- @@ -72,13 +78,15 @@ URL.revokeObjectURL(link.href) ### 3. 分页上限50条过少(对应第17项) +**现状**:前端 `Pagination.tsx` 默认 `pageSizeOptions = [10, 20, 50]`,最大选项仅50条。后端 `roster.routes.ts` 已支持 `Math.min(pageSize, 999)`,但其他路由(`audit.routes.ts`、`notification.routes.ts` 等)默认20条且无上限校验。 + **修复方案**: -- 前端 Pagination 组件 pageSize 选项增加 100、200 选项 -- 后端各列表接口 pageSize 上限从 50 调整为 200 +- 前端 Pagination 组件 `pageSizeOptions` 增加 100、200 选项 +- 后端各列表接口统一增加 `Math.min(pageSize, 200)` 上限校验 **涉及文件**: -- `frontend/src/components/ui/Pagination.tsx` — pageSizeOptions 增加 100/200 -- 后端各路由文件中 `pageSize` 限制调整 +- `frontend/src/components/ui/Pagination.tsx:19` — `pageSizeOptions` 默认值改为 `[10, 20, 50, 100, 200]` +- 后端各路由文件 — 统一增加 pageSize 上限校验(`roster.routes.ts` 已支持,其余需补充) --- @@ -230,16 +238,22 @@ URL.revokeObjectURL(link.href) ## P2:增强优化 -### 10. 工作台日历功能(对应第12项) +### 10. 自定义工作日历功能(对应第12项) + +**现状**:Dashboard 已有只读的"本月关键日期"列表(展示合同到期、试用期到期等系统自动生成的事件),但不支持自定义工作日历,HR 无法手动添加、编辑或管理日历事件。 **实施方案**: -- Dashboard 页面增加日历组件 -- 日历事件来源:合同到期、试用期到期、社保公积金调基月、员工生日、考勤确认截止日 -- 点击日期查看当日事项列表 +- 新增 `CalendarEvent` 模型:标题、日期、类型(自定义/系统)、提醒、关联人 +- HR 可手动创建/编辑/删除日历事件(如会议、团建、培训、面试等) +- 日历视图从列表升级为月历网格视图,支持点击日期添加事件 +- 系统自动事件与自定义事件合并展示,用颜色区分 +- 支持按类型筛选 **涉及文件**: -- `frontend/src/pages/Dashboard.tsx` — 增加日历组件 -- `backend/src/routes/dashboard.routes.ts` — 新增 `GET /dashboard/calendar-events?month=YYYY-MM` +- `backend/prisma/schema.prisma` — 新增 CalendarEvent 模型 +- `backend/src/routes/calendar.routes.ts` — 新建 CRUD 端点 +- `frontend/src/pages/Dashboard.tsx` — 日历列表升级为月历网格 + 自定义事件管理 +- `frontend/src/components/Calendar.tsx` — 新建日历组件 --- @@ -335,7 +349,7 @@ URL.revokeObjectURL(link.href) | 优先级 | 编号 | 项目 | 预估工作量 | |--------|------|------|-----------| -| P0 | 1 | 模板下载 401 修复 | 0.5天 | +| P0 | 1 | 工资表模板下载 401 修复(仅剩 Money.tsx) | 0.5天 | | P0 | 2 | 花名册身份证号显示+筛选 | 1天 | | P0 | 3 | 分页上限扩展 | 0.5天 | | P0 | 7 | 批量导入参保城市 | 0.5天 | @@ -344,7 +358,7 @@ URL.revokeObjectURL(link.href) | P1 | 5 | 人事审批流程 | 3-5天 | | P1 | 6 | 薪资调薪审核+报表 | 3-5天 | | P1 | 9 | 多公积金比例账户 | 2天 | -| P2 | 10 | 工作台日历 | 1-2天 | +| P2 | 10 | 自定义工作日历 | 2-3天 | | P2 | 11 | 人力信息总览增强 | 2天 | | P2 | 12 | 人力成本分析维度 | 2-3天 | | P2 | 13 | 年度价值报告简化 | 1天 | diff --git a/backend/prisma/migrations/add_account_type.sql b/backend/prisma/migrations/add_account_type.sql new file mode 100644 index 0000000..87abef0 --- /dev/null +++ b/backend/prisma/migrations/add_account_type.sql @@ -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"); diff --git a/backend/prisma/migrations/add_calendar_event.sql b/backend/prisma/migrations/add_calendar_event.sql new file mode 100644 index 0000000..ac9ab2e --- /dev/null +++ b/backend/prisma/migrations/add_calendar_event.sql @@ -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; diff --git a/backend/prisma/migrations/add_consultation_table.sql b/backend/prisma/migrations/add_consultation_table.sql new file mode 100644 index 0000000..c4af695 --- /dev/null +++ b/backend/prisma/migrations/add_consultation_table.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b512cfb..0fe3d40 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts index 712fb76..b9476a5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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) diff --git a/backend/src/routes/ai.routes.ts b/backend/src/routes/ai.routes.ts index 5bc436a..2ddac23 100644 --- a/backend/src/routes/ai.routes.ts +++ b/backend/src/routes/ai.routes.ts @@ -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 = {} + const eduDist: Record = {} + const deptDist: Record = {} + 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 diff --git a/backend/src/routes/attendance.routes.ts b/backend/src/routes/attendance.routes.ts index b5e99ab..e6d6e1e 100644 --- a/backend/src/routes/attendance.routes.ts +++ b/backend/src/routes/attendance.routes.ts @@ -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 diff --git a/backend/src/routes/audit.routes.ts b/backend/src/routes/audit.routes.ts index eba50b0..572f2a7 100644 --- a/backend/src/routes/audit.routes.ts +++ b/backend/src/routes/audit.routes.ts @@ -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 diff --git a/backend/src/routes/calendar.routes.ts b/backend/src/routes/calendar.routes.ts new file mode 100644 index 0000000..05c68fd --- /dev/null +++ b/backend/src/routes/calendar.routes.ts @@ -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 diff --git a/backend/src/routes/dashboard.routes.ts b/backend/src/routes/dashboard.routes.ts index 76666ae..349ee50 100644 --- a/backend/src/routes/dashboard.routes.ts +++ b/backend/src/routes/dashboard.routes.ts @@ -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 = {} + 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 = {} + 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 = {} + 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 = {} + 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 diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index dda3451..1ca3c1e 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -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, }) diff --git a/backend/src/routes/evidence.routes.ts b/backend/src/routes/evidence.routes.ts index 5b87214..2ef2649 100644 --- a/backend/src/routes/evidence.routes.ts +++ b/backend/src/routes/evidence.routes.ts @@ -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) { diff --git a/backend/src/routes/export.routes.ts b/backend/src/routes/export.routes.ts index 4d6365d..f543ebe 100644 --- a/backend/src/routes/export.routes.ts +++ b/backend/src/routes/export.routes.ts @@ -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 = { + NEGOTIATED: '协商解除', FAULT: '过错解除', NONFAULT: '非过错解除', + LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除', RESIGNATION: '员工离职', + } + const statusLabels: Record = { + 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) { diff --git a/backend/src/routes/import.routes.ts b/backend/src/routes/import.routes.ts index 486c0a4..3a3e98b 100644 --- a/backend/src/routes/import.routes.ts +++ b/backend/src/routes/import.routes.ts @@ -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) }) diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts index 8ca1b8e..869cb3b 100644 --- a/backend/src/routes/notification.routes.ts +++ b/backend/src/routes/notification.routes.ts @@ -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 }, diff --git a/backend/src/routes/payroll.routes.ts b/backend/src/routes/payroll.routes.ts index 9f5a78e..cdeebc1 100644 --- a/backend/src/routes/payroll.routes.ts +++ b/backend/src/routes/payroll.routes.ts @@ -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() + 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 diff --git a/backend/src/routes/payroll2.routes.ts b/backend/src/routes/payroll2.routes.ts index 45b8518..9b9d717 100644 --- a/backend/src/routes/payroll2.routes.ts +++ b/backend/src/routes/payroll2.routes.ts @@ -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) } diff --git a/backend/src/routes/roster.routes.ts b/backend/src/routes/roster.routes.ts index 93067e3..600ee40 100644 --- a/backend/src/routes/roster.routes.ts +++ b/backend/src/routes/roster.routes.ts @@ -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) } diff --git a/backend/src/routes/social.routes.ts b/backend/src/routes/social.routes.ts index d7ecf1a..a8cb813 100644 --- a/backend/src/routes/social.routes.ts +++ b/backend/src/routes/social.routes.ts @@ -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, diff --git a/backend/src/routes/termination.routes.ts b/backend/src/routes/termination.routes.ts index 4798eac..8c736ee 100644 --- a/backend/src/routes/termination.routes.ts +++ b/backend/src/routes/termination.routes.ts @@ -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) diff --git a/backend/src/schemas/contract.schema.ts b/backend/src/schemas/contract.schema.ts index 1fb8bbf..d0a57f2 100644 --- a/backend/src/schemas/contract.schema.ts +++ b/backend/src/schemas/contract.schema.ts @@ -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({ diff --git a/backend/src/services/ai.service.ts b/backend/src/services/ai.service.ts index ec6570e..ef5204d 100644 --- a/backend/src/services/ai.service.ts +++ b/backend/src/services/ai.service.ts @@ -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 + } +} + diff --git a/backend/src/services/attendance.service.ts b/backend/src/services/attendance.service.ts index 0f62e8a..7da1817 100644 --- a/backend/src/services/attendance.service.ts +++ b/backend/src/services/attendance.service.ts @@ -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() + 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() + 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 } }) +} diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 357ccca..7e3ee8a 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -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) { diff --git a/backend/src/services/payroll.service.ts b/backend/src/services/payroll.service.ts index 1a86806..f5e9148 100644 --- a/backend/src/services/payroll.service.ts +++ b/backend/src/services/payroll.service.ts @@ -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 diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 600a25d..aedd593 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -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 = {} + 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, } } diff --git a/backend/src/services/termination.service.ts b/backend/src/services/termination.service.ts index d754730..b3f946d 100644 --- a/backend/src/services/termination.service.ts +++ b/backend/src/services/termination.service.ts @@ -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, diff --git a/docs/20260728-更新测试指导.md b/docs/20260728-更新测试指导.md new file mode 100644 index 0000000..df40fcb --- /dev/null +++ b/docs/20260728-更新测试指导.md @@ -0,0 +1,121 @@ +# 企业用工专家 — 2026年7月28日更新测试指导 + +> 部署地址:https://on.hr8ai.top/ +> 更新日期:2026-07-28 + +--- + +## 一、功能调查问卷(新增) + +### 功能说明 +系统新增「功能调查问卷」模块,覆盖 **41 个页面、218 项功能**,用户可对每项功能进行打分(1-5 星)、标注有用性、填写备注。问卷支持搜索、分组折叠、暂存恢复。 + +### 入口位置 +- **顶部导航栏右侧** — 点击 📋(剪贴板图标)打开问卷弹窗 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 登录系统,点击顶部导航栏 📋 图标 | 弹出功能调查问卷弹窗,显示分组列表 | +| 2 | 查看分组结构 | 按页面分组(如"设置页面"、"花名册"等),每组显示功能数量 | +| 3 | 展开某一分组 | 显示该页面下所有功能项,每项有名称、描述、星级评分、有用性选择、备注框 | +| 4 | 为某项功能打 4 星评分 | 星星高亮显示,评分即时记录 | +| 5 | 选择有用性为"有用" | 选项被选中记录 | +| 6 | 在备注框输入文字 | 文字正常输入 | +| 7 | 在搜索框输入关键词(如"合同") | 列表过滤显示包含关键词的功能项 | +| 8 | 点击「暂存」按钮 | 提示"暂存成功",显示保存时间 | +| 9 | 关闭弹窗后重新打开 | 自动恢复上次填写的评分、有用性、备注 | +| 10 | 点击「提交」按钮 | 问卷结果提交,提示成功 | +| 11 | 点击「重置」按钮 | 清空所有填写内容 | + +### 重点关注 +- 暂存数据存储在浏览器 `localStorage`,关闭浏览器不会丢失 +- 每项功能显示了所属菜单路径(如"系统 > 设置"),方便定位功能位置 + +--- + +## 二、审计日志优化(改进) + +### 功能说明 +操作日志页面优化:补充关键操作详情字段,前端以中文可读格式展示,不再显示原始 JSON。 + +### 入口位置 +- **侧边栏 → 系统 → 操作日志**(路径:`/audit`) + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入「操作日志」页面 | 显示日志列表,顶部有操作类型分布统计 | +| 2 | 查看日志条目 | 每条日志显示:操作名称(中文)、实体类型(中文标签)、操作详情(可读格式)、时间、IP | +| 3 | 执行一次操作(如编辑员工、生成工资条等) | 返回操作日志页面,新日志出现在列表顶部 | +| 4 | 查看新日志的详情字段 | 显示如"员工: 张三 \| 部门: 技术部 \| 补偿金: ¥5000",而非原始 JSON | +| 5 | 使用筛选功能 — 按操作类型筛选 | 列表仅显示对应类型的日志 | +| 6 | 使用筛选功能 — 按实体类型筛选 | 列表仅显示对应实体的日志 | +| 7 | 使用筛选功能 — 按日期范围筛选 | 列表仅显示指定时间段的日志 | +| 8 | 点击「清除筛选」 | 恢复显示全部日志 | + +### 重点关注 +- 解聘相关操作详情是否完整(离职日期、补偿金、离职原因等) +- 日期字段显示为 `YYYY-MM-DD` 格式,非时间戳 +- 补偿金字段显示 ¥ 符号前缀 + +--- + +## 三、问卷结果导出(新增) + +### 功能说明 +在设置页面的「数据导出」区域新增问卷结果分析导出功能,将功能调查问卷的填写结果导出为 Markdown 文档。 + +### 入口位置 +- **侧边栏 → 系统 → 设置 → 数据导出 Tab** + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 先填写部分问卷内容并暂存(见第一节) | 问卷数据保存到浏览器 | +| 2 | 进入「设置」页面,切换到「数据导出」Tab | 页面底部显示"问卷结果分析导出"区域 | +| 3 | 点击「导出问卷结果(MD)」按钮 | 浏览器下载 `survey-results-YYYY-MM-DD.md` 文件 | +| 4 | 打开下载的 MD 文件 | 内容包含:每项功能的评分、有用性、备注,按页面分组展示 | +| 5 | 未填写问卷时点击导出 | 提示"暂无问卷数据" | + +### 重点关注 +- 导出文件为 Markdown 格式,可直接用编辑器或 Typora 打开 +- 导出内容包含所有已填写的功能项,未填写的不包含 + +--- + +## 四、问卷菜单路径与 Tab 信息(改进) + +### 功能说明 +问卷中每项功能新增了菜单路径和 Tab 信息,方便用户在系统中定位对应功能。 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 打开功能调查问卷 | 展开任一分组 | +| 2 | 查看功能项描述 | 每项功能下方显示菜单路径,如"菜单:员工管理 > 花名册",部分还显示 Tab 信息 | +| 3 | 按菜单路径在系统中导航 | 能找到对应的功能页面和 Tab | + +--- + +## 本次更新涉及的菜单入口汇总 + +| 功能 | 菜单路径 | 备注 | +|------|----------|------| +| 功能调查问卷 | 顶部导航栏 📋 图标 | 全局可用 | +| 操作日志 | 侧边栏 → 系统 → 操作日志 | 已有功能,本次优化展示 | +| 问卷结果导出 | 设置 → 数据导出 Tab | 新增导出按钮 | + +--- + +## 浏览器缓存提示 + +更新后首次访问请 **强制刷新**(Mac: `Cmd + Shift + R`,Windows: `Ctrl + Shift + R`),确保加载最新前端代码。 + +--- + +*如有问题或反馈,请联系开发团队。* diff --git a/docs/20260729-更新测试指导.md b/docs/20260729-更新测试指导.md new file mode 100644 index 0000000..044f1fc --- /dev/null +++ b/docs/20260729-更新测试指导.md @@ -0,0 +1,239 @@ +# 企业用工专家 — 2026年7月29日更新测试指导 + +> 部署地址:https://on.hr8ai.top/ +> 更新日期:2026-07-29 + +--- + +## 更新内容概览 + +| 序号 | 模块 | 更新类型 | 说明 | +|------|------|----------|------| +| 1 | 工作日历 | 新增 | 独立日历页面,支持月历视图、事件管理 | +| 2 | 考勤管理 | 重构 | 新增班次管理、排班、每日出勤、月度报表、休假记录 5 个 Tab | +| 3 | AI 顾问 | 新增 | 新增「人力报告」Tab,AI 自动生成结构化人力分析报告并支持 Word 导出 | +| 4 | 工作台总览 | 优化 | 新增员工分布统计(性别/年龄/学历/司龄),部门成本拆分,移除日历卡片 | +| 5 | 花名册 | 优化 | 新增部门筛选、合同状态筛选 | +| 6 | 合同管理 | 优化 | 新增部门筛选、合同状态筛选 | +| 7 | 解聘补偿 | 优化 | 新增状态筛选、部门筛选、关键词搜索 | +| 8 | 薪税管理 | 优化 | 新增工资表导入模板下载、银行代发文件 CSV 导出 | +| 9 | 社保公积金 | 优化 | 支持多公积金账户类型(基本/补充)显示 | +| 10 | 数据导出 | 优化 | 新增花名册导出、解聘记录导出,中文文件名编码修复 | +| 11 | 数据导入 | 优化 | 导入模板下载(员工/月度增减员/工资表),错误日志导出 | + +--- + +## 一、工作日历(新增页面) + +### 入口位置 +- **侧边栏 → 工作台 → 工作日历**(路径:`/calendar`) + +### 功能说明 +独立的月历页面,展示合同到期、试用期到期、离职解聘、入职周年等关键日期,支持自定义事件(会议、团建、培训、面试)。 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 点击侧边栏「工作日历」 | 进入日历页面,显示当月月历网格 | +| 2 | 查看月历网格 | 日期格内显示当日事件标签(如"合同到期"),高优先级红色圆点 | +| 3 | 点击「←」/「→」按钮 | 切换上/下个月 | +| 4 | 点击「今天」按钮 | 回到当前月份 | +| 5 | 点击类型筛选标签(如"合同到期") | 仅显示该类型事件 | +| 6 | 点击日历中某一天 | 弹出新建事件弹窗,日期已自动填入 | +| 7 | 填写标题、类型、优先级,点击「创建」 | 事件创建成功,日历刷新显示新事件 | +| 8 | 查看右侧事件列表 | 按日期排序列出本月所有事件 | +| 9 | 鼠标悬停自定义事件,点击删除图标 | 事件删除成功 | + +--- + +## 二、考勤管理(重构升级) + +### 入口位置 +- **侧边栏 → 员工管理 → 考勤确认**(路径:`/attendance`) + +### 功能说明 +原考勤确认页面升级为完整考勤管理模块,新增 5 个 Tab:班次管理、排班、每日出勤、月度报表、休假记录。 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入「考勤确认」页面 | 显示 6 个 Tab:考勤确认、班次管理、排班、每日出勤、月度报表、休假记录 | +| 2 | **考勤确认 Tab** — 选择月份,查看列表 | 显示员工考勤确认状态(待确认/已确认/有异议),支持按部门筛选 | +| 3 | 点击「确认」按钮 | 员工考勤状态变为已确认 | +| 4 | **班次管理 Tab** — 点击「新增班次」 | 弹出表单,可设置班次名称、上下班时间 | +| 5 | 创建班次后查看列表 | 新班次出现在列表中 | +| 6 | 编辑班次时间 | 班次信息更新成功 | +| 7 | 删除班次 | 班次从列表移除 | +| 8 | **排班 Tab** — 选择日期和员工,分配班次 | 排班记录创建成功 | +| 9 | **每日出勤 Tab** — 选择日期查询 | 显示当日所有员工出勤状态(正常/迟到/早退/缺勤/请假/出差) | +| 10 | **月度报表 Tab** — 选择月份 | 显示月度出勤统计汇总 | +| 11 | **休假记录 Tab** — 点击「新增休假」 | 弹出表单,可选择员工、休假类型(病假/事假/年假/产假/其他)、日期范围 | +| 12 | 创建休假记录后查看列表 | 休假记录显示在列表中,可删除 | + +--- + +## 三、AI 人力报告(新增 Tab) + +### 入口位置 +- **侧边栏 → AI 辅助 → AI 顾问**(路径:`/ai-assistant`),切换到「人力报告」Tab + +### 功能说明 +AI 基于企业当前数据(员工人数、部门分布、薪资成本、合同状态等)自动生成结构化人力分析报告,支持导出为 Word 文档。 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入 AI 顾问页面,点击「人力报告」Tab | 显示人力报告生成界面 | +| 2 | 点击「生成报告」按钮 | 流式输出 AI 生成的人力分析报告(Markdown 格式) | +| 3 | 等待报告生成完成 | 报告包含:人员概况、部门分析、成本分析、风险提示等结构化内容 | +| 4 | 点击「导出 Word」按钮 | 浏览器下载 `人力分析报告_YYYY-MM-DD.docx` 文件 | +| 5 | 打开下载的 Word 文件 | 内容包含标题、段落、表格、列表,格式正确 | + +--- + +## 四、工作台总览优化 + +### 入口位置 +- **侧边栏 → 工作台 → 总览**(路径:`/`) + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入总览页面 | 显示概览 Tab 内容 | +| 2 | 查看人力成本分析区域 | 显示环比/同比数据,下方新增**部门成本拆分**进度条(按部门展示工资/社保/公积金/人均) | +| 3 | 查看员工分布统计区域 | 显示 4 个饼图卡片:性别分布、年龄段分布、学历分布、司龄分布 | +| 4 | 检查性别分布饼图 | 显示男/女人数及图例 | +| 5 | 检查年龄段分布饼图 | 按年龄段(如 20-25、26-30、31-35 等)展示人数 | +| 6 | 检查学历分布饼图 | 按学历(如大专、本科、硕士等)展示人数 | +| 7 | 检查司龄分布饼图 | 按司龄段(如 <1年、1-3年、3-5年 等)展示人数 | +| 8 | 确认原"本月关键日期"日历卡片 | **已移除**,不再显示 | + +--- + +## 五、花名册 / 合同管理 — 筛选优化 + +### 入口位置 +- **侧边栏 → 员工管理 → 花名册**(路径:`/roster`) +- 合同管理在花名册详情中 + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入花名册页面 | 搜索栏右侧新增「全部部门」和「全部状态」下拉筛选 | +| 2 | 选择某个部门筛选 | 列表仅显示该部门员工 | +| 3 | 选择合同状态筛选(如"在职") | 列表仅显示对应合同状态的员工 | +| 4 | 同时使用搜索 + 部门 + 状态筛选 | 三种筛选条件叠加生效 | +| 5 | 点击清除筛选条件 | 恢复显示全部员工 | + +--- + +## 六、解聘补偿 — 筛选优化 + +### 入口位置 +- **侧边栏 → 员工管理 → 解聘补偿**(路径:`/termination`) + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入解聘补偿页面 | 新增状态筛选、部门筛选、搜索框 | +| 2 | 按状态筛选(如"草稿"/"待审批"/"已执行") | 列表仅显示对应状态的记录 | +| 3 | 按部门筛选 | 列表仅显示该部门的解聘记录 | +| 4 | 在搜索框输入员工姓名 | 列表过滤显示匹配的记录 | + +--- + +## 七、薪税管理 — 导出功能增强 + +### 入口位置 +- **侧边栏 → 薪税社保 → 薪税管理**(路径:`/money`) + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入薪税管理页面 | 工具栏新增「下载模板」和「银行代发文件」按钮 | +| 2 | 点击「下载模板」 | 浏览器下载 `工资表导入模板.xlsx` | +| 3 | 打开模板文件 | 包含姓名、部门、月工资等列标题 | +| 4 | 选择某批次,点击「银行代发文件」 | 浏览器下载 `银行代发文件-YYYY-MM-批次N.csv` | +| 5 | 打开 CSV 文件 | 包含银行账号、姓名、金额等代发信息 | + +--- + +## 八、社保公积金 — 多账户支持 + +### 入口位置 +- **侧边栏 → 薪税社保 → 社保公积金**(路径:`/social`) + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入社保公积金页面,选择某城市 | 公积金配置区域显示当前生效的账户类型标签 | +| 2 | 查看多账户情况 | 若有基本公积金 + 补充公积金,分别显示蓝色/紫色标签及各自比例 | +| 3 | 查看单账户情况 | 仅显示基本公积金标签 | + +--- + +## 九、数据导出 — 新增导出类型 + +### 入口位置 +- **侧边栏 → 系统 → 设置 → 数据导出 Tab** + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入设置页面,切换到「数据导出」Tab | 导出选项中新增花名册导出、解聘记录导出 | +| 2 | 勾选「花名册」,点击导出 | 下载 Excel 文件,包含员工基本信息 | +| 3 | 勾选「解聘记录」,点击导出 | 下载 Excel 文件,包含解聘记录详情 | +| 4 | 检查导出文件名 | 中文文件名正常显示,无乱码 | + +--- + +## 十、数据导入 — 模板下载与错误日志 + +### 入口位置 +- **侧边栏 → 系统 → 设置 → 数据导入 Tab** + +### 测试步骤 + +| 步骤 | 操作 | 预期结果 | +|------|------|----------| +| 1 | 进入数据导入页面 | 显示可下载的模板类型 | +| 2 | 下载「员工导入模板」 | 下载 `员工导入模板.xlsx`,包含姓名/部门/身份证号/入职日期等列 | +| 3 | 下载「月度增减员导入模板」 | 下载 `月度增减员导入模板.xlsx` | +| 4 | 下载「工资表导入模板」 | 下载 `工资表导入模板.xlsx` | +| 5 | 上传含错误数据的文件 | 导入完成后提示错误,可下载错误日志 Excel | + +--- + +## 本次更新涉及的菜单入口汇总 + +| 功能 | 菜单路径 | 备注 | +|------|----------|------| +| 工作日历 | 工作台 → 工作日历 | 新增页面 | +| 考勤管理 | 员工管理 → 考勤确认 | 重构,新增 5 个 Tab | +| AI 人力报告 | AI 辅助 → AI 顾问 → 人力报告 Tab | 新增 Tab | +| 员工分布统计 | 工作台 → 总览 | 新增 4 个分布图表 | +| 花名册筛选 | 员工管理 → 花名册 | 新增筛选下拉 | +| 解聘补偿筛选 | 员工管理 → 解聘补偿 | 新增筛选和搜索 | +| 薪税导出 | 薪税社保 → 薪税管理 | 新增模板下载和银行代发 | +| 社保多账户 | 薪税社保 → 社保公积金 | 显示多公积金账户 | +| 数据导出 | 系统 → 设置 → 数据导出 | 新增导出类型 | +| 数据导入 | 系统 → 设置 → 数据导入 | 新增模板下载 | + +--- + +## 浏览器缓存提示 + +更新后首次访问请 **强制刷新**(Mac: `Cmd + Shift + R`,Windows: `Ctrl + Shift + R`),确保加载最新前端代码。 + +--- + +*如有问题或反馈,请联系开发团队。* diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2b4090..cf8a62c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ const AutoLogin = lazy(() => import('./pages/portal/AutoLogin')) const MedicalPeriodCalculator = lazy(() => import('./pages/tools/MedicalPeriodCalculator')) const HealthCheck = lazy(() => import('./pages/tools/HealthCheck')) const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport')) +const CalendarPage = lazy(() => import('./pages/Calendar')) function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated) @@ -103,6 +104,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 242f267..fbb023e 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -13,7 +13,7 @@ import { Bot, BookMarked, Bell, ScrollText, Settings, ChevronDown, ChevronRight, - Building2, + Building2, CalendarDays, } from 'lucide-react' import Logo from '../ui/Logo' @@ -33,6 +33,7 @@ const navGroups: NavGroup[] = [ title: '工作台', items: [ { path: '/', label: '总览', icon: LayoutDashboard }, + { path: '/calendar', label: '工作日历', icon: CalendarDays }, ], }, { diff --git a/frontend/src/components/ui/Pagination.tsx b/frontend/src/components/ui/Pagination.tsx index dbe9239..7b4f06e 100644 --- a/frontend/src/components/ui/Pagination.tsx +++ b/frontend/src/components/ui/Pagination.tsx @@ -16,7 +16,7 @@ export default function Pagination({ total, onPageChange, onPageSizeChange, - pageSizeOptions = [10, 20, 50], + pageSizeOptions = [10, 20, 50, 100, 200], }: PaginationProps) { const totalPages = Math.max(1, Math.ceil(total / pageSize)) const start = total === 0 ? 0 : (page - 1) * pageSize + 1 diff --git a/frontend/src/pages/AIAssistant.tsx b/frontend/src/pages/AIAssistant.tsx index 308aae7..7b1129e 100644 --- a/frontend/src/pages/AIAssistant.tsx +++ b/frontend/src/pages/AIAssistant.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download } from 'lucide-react' +import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic, Plus, MessageSquare, Trash2, Save, BookOpen, History, Database, User, AlertTriangle, FileText, Shield, Download, TrendingUp, UserCheck, Phone } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeRaw from 'rehype-raw' @@ -14,7 +14,89 @@ import Button from '../components/ui/Button' import { Input, Label, Select } from '../components/ui/Input' import Modal from '../components/ui/Modal' -type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' +type Tab = 'chat' | 'predict' | 'review' | 'case' | 'knowledge' | 'hr-report' + +/** 解析 markdown 内联格式(**bold** 和 `code`)为 TextRun 数组 */ +function parseInlineBold(text: string): TextRun[] { + const runs: TextRun[] = [] + const regex = /(\*\*(.+?)\*\*|`(.+?)`)/g + let lastIndex = 0 + let match + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + runs.push(new TextRun({ text: text.slice(lastIndex, match.index) })) + } + if (match[2]) { + runs.push(new TextRun({ text: match[2], bold: true })) + } else if (match[3]) { + runs.push(new TextRun({ text: match[3], font: 'Courier New', size: 20 })) + } + lastIndex = regex.lastIndex + } + if (lastIndex < text.length) { + runs.push(new TextRun({ text: text.slice(lastIndex) })) + } + return runs.length ? runs : [new TextRun({ text })] +} + +/** 导出 Markdown 文本为 Word 文档 */ +async function exportMarkdownToWord(markdown: string, fileName: string) { + const lines = markdown.split('\n') + const children: (Paragraph | Table)[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i] + if (!line.trim()) { i++; continue } + if (line.includes('|') && i + 1 < lines.length && lines[i + 1].includes('---')) { + const headerCells = line.split('|').map(c => c.trim()).filter(Boolean) + i += 2 + const rows: TableRow[] = [] + rows.push(new TableRow({ + children: headerCells.map(text => new TableCell({ + children: [new Paragraph({ children: [new TextRun({ text, bold: true })] })], + shading: { fill: 'F3F4F6' }, + })), + })) + while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { + const cells = lines[i].split('|').map(c => c.trim()).filter(Boolean) + rows.push(new TableRow({ + children: cells.map(text => new TableCell({ + children: [new Paragraph({ children: [new TextRun({ text })] })], + })), + })) + i++ + } + children.push(new Table({ rows, width: { size: 100, type: WidthType.PERCENTAGE } })) + continue + } + if (line.startsWith('### ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_3, children: [new TextRun({ text: line.slice(4), bold: true })] })) + } else if (line.startsWith('## ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.slice(3), bold: true })] })) + } else if (line.startsWith('# ')) { + children.push(new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.slice(2), bold: true })] })) + } else if (line.startsWith('> ')) { + children.push(new Paragraph({ children: [new TextRun({ text: line.slice(2), italics: true })], indent: { left: 720 } })) + } else if (line.startsWith('- ') || line.startsWith('* ')) { + children.push(new Paragraph({ children: parseInlineBold(line.slice(2)), bullet: { level: 0 } })) + } else if (/^\d+\.\s/.test(line)) { + children.push(new Paragraph({ children: parseInlineBold(line.replace(/^\d+\.\s/, '')), numbering: { reference: 'default-numbering', level: 0 } })) + } else if (line === '---' || line === '***') { + children.push(new Paragraph({ children: [], border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'E5E7EB' } } })) + } else { + children.push(new Paragraph({ children: parseInlineBold(line) })) + } + i++ + } + + const doc = new Document({ + numbering: { config: [{ reference: 'default-numbering', levels: [{ level: 0, format: 'decimal', text: '%1.', alignment: AlignmentType.START }] }] }, + sections: [{ children }], + }) + const blob = await Packer.toBlob(doc) + saveAs(blob, fileName) +} // 通用 AI 历史记录 hook function useAIHistory(type: 'predict' | 'review' | 'case') { @@ -94,6 +176,7 @@ export default function AIAssistant() { { key: 'predict', label: '风险预测', icon: Sparkles }, { key: 'review', label: '合同审查', icon: FileSearch }, { key: 'case', label: '案例匹配', icon: Scale }, + { key: 'hr-report', label: '人力报告', icon: TrendingUp }, { key: 'knowledge', label: '知识库', icon: BookOpen }, ] @@ -129,6 +212,7 @@ export default function AIAssistant() { {tab === 'predict' && } {tab === 'review' && } {tab === 'case' && } + {tab === 'hr-report' && } {tab === 'knowledge' && } ) @@ -144,6 +228,8 @@ function ChatTab() { const [recording, setRecording] = useState(false) const [showHistory, setShowHistory] = useState(false) const [currentConvId, setCurrentConvId] = useState(null) + const [showConsultModal, setShowConsultModal] = useState(false) + const [consultForm, setConsultForm] = useState({ type: 'LEGAL' as string, title: '', description: '', contactName: '', contactPhone: '', remark: '' }) const scrollRef = useRef(null) const recognitionRef = useRef(null) const saveTimerRef = useRef(null) @@ -161,6 +247,21 @@ function ChatTab() { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-conversations'] }), }) + const consultMutation = useMutation({ + mutationFn: async (data: typeof consultForm) => { + const res = await api.post('/ai/consultation', data) as any + return res.data + }, + onSuccess: () => { + toast.success('已提交咨询请求,专业律师将尽快与您联系') + setShowConsultModal(false) + setConsultForm({ type: 'LEGAL', title: '', description: '', contactName: '', contactPhone: '', remark: '' }) + }, + onError: (err: any) => { + toast.error(err?.message || '提交失败,请稍后重试') + }, + }) + useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight) }, [messages]) @@ -331,6 +432,7 @@ function ChatTab() {
+ {conversations && conversations.length > 0 && ( {conversations.length} 条历史 )} @@ -422,6 +524,66 @@ function ChatTab() { {loading ? : }
+ + {/* 转人工咨询 Modal */} + {showConsultModal && ( + setShowConsultModal(false)}> +
+
+

服务说明

+

· 法律咨询:专业律师在线解答劳动法问题

+

· 仲裁代理:律师代理劳动仲裁案件(付费服务)

+

· 出庭服务:律师代理法院诉讼(付费服务)

+

提交后律师将在 24 小时内与您联系。

+
+
+ + +
+
+ + setConsultForm({ ...consultForm, title: e.target.value })} placeholder="简要描述您的问题" /> +
+
+ +