init: AI HR Compliance Assistant
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/hr_compliance?schema=public
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-jwt-secret-change-in-production
|
||||
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
|
||||
|
||||
# DashScope (通义千问)
|
||||
DASHSCOPE_API_KEY=sk-xxx
|
||||
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
|
||||
# 加密
|
||||
ENCRYPTION_KEY=your-32-byte-encryption-key-here
|
||||
|
||||
# 存储
|
||||
SUPABASE_URL=
|
||||
SUPABASE_KEY=
|
||||
|
||||
# 部署
|
||||
PORT=3000
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
Generated
+2725
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "hr-compliance-backend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.18.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.0",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"morgan": "^1.10.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"openai": "^6.48.0",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ========== 枚举 ==========
|
||||
|
||||
enum Plan {
|
||||
FREE
|
||||
PRO
|
||||
ENTERPRISE
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMIN
|
||||
HR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
enum EmployeeStatus {
|
||||
ACTIVE
|
||||
RESIGNED
|
||||
}
|
||||
|
||||
enum ContractType {
|
||||
FIXED
|
||||
UNFIXED
|
||||
UNSIGNED
|
||||
}
|
||||
|
||||
enum SignMethod {
|
||||
PAPER
|
||||
ELECTRONIC
|
||||
}
|
||||
|
||||
enum RiskType {
|
||||
CONTRACT
|
||||
SALARY
|
||||
TERMINATION
|
||||
MONTHLY
|
||||
}
|
||||
|
||||
enum RiskLevel {
|
||||
HIGH
|
||||
MEDIUM
|
||||
LOW
|
||||
}
|
||||
|
||||
enum RiskStatus {
|
||||
PENDING
|
||||
RESOLVED
|
||||
IGNORED
|
||||
}
|
||||
|
||||
enum TerminationReason {
|
||||
NEGOTIATED
|
||||
FAULT
|
||||
NONFAULT
|
||||
LAYOFF
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum RiskAssessment {
|
||||
SAFE
|
||||
WARNING
|
||||
DANGER
|
||||
}
|
||||
|
||||
enum OnboardingStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ContractConfirmStatus {
|
||||
UNCONFIRMED
|
||||
CONFIRMED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
// ========== 核心表 ==========
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
plan Plan @default(FREE)
|
||||
maxEmployees Int @default(20)
|
||||
city String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
users User[]
|
||||
employees Employee[]
|
||||
contracts LaborContract[]
|
||||
overtimeRecords OvertimeRecord[]
|
||||
terminations TerminationRecord[]
|
||||
riskItems RiskItem[]
|
||||
auditLogs AuditLog[]
|
||||
payslips Payslip[]
|
||||
onboardingLinks OnboardingLink[]
|
||||
confirmLinks ContractConfirmLink[]
|
||||
socialInsuranceConfig SocialInsuranceConfig?
|
||||
notificationSetting NotificationSetting?
|
||||
notificationLogs NotificationLog[]
|
||||
employeeAttachments EmployeeAttachment[]
|
||||
disciplinaryRecords DisciplinaryRecord[]
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
phone String @unique
|
||||
email String?
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
lastLoginAt DateTime?
|
||||
}
|
||||
|
||||
// ========== 业务表 ==========
|
||||
|
||||
model Employee {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
department String
|
||||
hireDate DateTime
|
||||
monthlySalary String // AES-256 加密存储
|
||||
status EmployeeStatus @default(ACTIVE)
|
||||
gender String?
|
||||
phone String?
|
||||
idCardNumber String? // AES-256 加密存储
|
||||
emergencyContact String?
|
||||
emergencyPhone String?
|
||||
address String?
|
||||
bankAccount String? // AES-256 加密存储
|
||||
bankName String?
|
||||
passwordHash String? // 员工端登录密码
|
||||
isPregnant Boolean @default(false)
|
||||
isInMedicalPeriod Boolean @default(false)
|
||||
isWorkInjured Boolean @default(false)
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
contracts LaborContract[]
|
||||
overtimeRecords OvertimeRecord[]
|
||||
terminations TerminationRecord[]
|
||||
riskItems RiskItem[]
|
||||
payslips Payslip[]
|
||||
attachments EmployeeAttachment[]
|
||||
disciplinaryRecords DisciplinaryRecord[]
|
||||
attendanceRecords AttendanceRecord[]
|
||||
trainingRecords TrainingRecord[]
|
||||
performanceRecords PerformanceRecord[]
|
||||
}
|
||||
|
||||
model LaborContract {
|
||||
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)
|
||||
signDate DateTime?
|
||||
startDate DateTime
|
||||
endDate DateTime?
|
||||
contractType ContractType
|
||||
signMethod SignMethod @default(PAPER)
|
||||
contractYears Int @default(3)
|
||||
probationMonths Int @default(0)
|
||||
probationSalary Int @default(0)
|
||||
renewalCount Int @default(0)
|
||||
attachmentName String?
|
||||
attachmentUrl String?
|
||||
electronicContractNo String?
|
||||
electronicContractUrl String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
confirmLinks ContractConfirmLink[]
|
||||
}
|
||||
|
||||
model OvertimeRecord {
|
||||
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)
|
||||
month String // YYYY-MM
|
||||
weekdayHours Float @default(0)
|
||||
weekendHours Float @default(0)
|
||||
holidayHours Float @default(0)
|
||||
weekdayPay Float @default(0)
|
||||
weekendPay Float @default(0)
|
||||
holidayPay Float @default(0)
|
||||
totalPay Float @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, month])
|
||||
}
|
||||
|
||||
model TerminationRecord {
|
||||
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)
|
||||
reason TerminationReason
|
||||
terminationDate DateTime
|
||||
compensation Float @default(0)
|
||||
riskLevel RiskAssessment @default(SAFE)
|
||||
checklist Json
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model RiskItem {
|
||||
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: SetNull)
|
||||
type RiskType
|
||||
level RiskLevel
|
||||
status RiskStatus @default(PENDING)
|
||||
title String
|
||||
description String
|
||||
actionUrl String?
|
||||
resolvedAt DateTime?
|
||||
resolvedBy String?
|
||||
remark String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
@@index([orgId, type])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
action String
|
||||
entity String
|
||||
entityId String?
|
||||
detail Json?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
// ========== 社保 & 通知 & 附件 ==========
|
||||
|
||||
model SocialInsuranceConfig {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
city String @default("北京")
|
||||
pensionOrg Float @default(16) // 养老保险 企业比例 %
|
||||
pensionEmp Float @default(8) // 养老保险 个人比例 %
|
||||
medicalOrg Float @default(9.8) // 医疗保险 企业比例 %
|
||||
medicalEmp Float @default(2) // 医疗保险 个人比例 %
|
||||
unemploymentOrg Float @default(0.5) // 失业保险 企业比例 %
|
||||
unemploymentEmp Float @default(0.5) // 失业保险 个人比例 %
|
||||
injuryOrg Float @default(0.2) // 工伤保险 企业比例 %
|
||||
maternityOrg Float @default(0.8) // 生育保险 企业比例 %
|
||||
housingOrg Float @default(12) // 公积金 企业比例 %
|
||||
housingEmp Float @default(12) // 公积金 个人比例 %
|
||||
baseMin Float @default(6326) // 缴费基数下限
|
||||
baseMax Float @default(33891) // 缴费基数上限
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotificationSetting {
|
||||
id String @id @default(cuid())
|
||||
orgId String @unique
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
contractExpiry Boolean @default(true)
|
||||
expiryDays Int @default(30)
|
||||
contractUnsigned Boolean @default(true)
|
||||
overtimeAlert Boolean @default(true)
|
||||
payslipReady Boolean @default(true)
|
||||
// 月度事务提醒日(每月几号)
|
||||
payrollDay Int @default(10) // 发薪日
|
||||
socialInsDay Int @default(15) // 社保缴纳日
|
||||
housingFundDay Int @default(15) // 公积金缴纳日
|
||||
taxDay Int @default(15) // 个税申报日
|
||||
wechatWebhook String?
|
||||
emailNotify Boolean @default(false)
|
||||
email String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
type String // CONTRACT_EXPIRY / CONTRACT_UNSIGNED / OVERTIME / PAYSLIP
|
||||
title String
|
||||
content String
|
||||
channel String // WECHAT / EMAIL / IN_APP
|
||||
status String @default("SENT") // SENT / FAILED
|
||||
employeeId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, createdAt])
|
||||
}
|
||||
|
||||
model EmployeeAttachment {
|
||||
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)
|
||||
fileName String
|
||||
fileType String // ID_CARD / BANK_CARD / CONTRACT_SCAN / EDUCATION / OTHER
|
||||
fileUrl String
|
||||
fileSize Int @default(0)
|
||||
uploadedBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== 仲裁证据链 ==========
|
||||
|
||||
model DisciplinaryRecord {
|
||||
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)
|
||||
violationDate DateTime
|
||||
violationType String // LATE/ABSENT/INSUBORDINATION/MISCONDUCT/VIOLATE_POLICY/OTHER
|
||||
description String
|
||||
severity String @default("WARNING") // WARNING/SERIOUS/SEVERE
|
||||
action String @default("ORAL_WARNING") // ORAL_WARNING/WRITTEN_WARNING/DEDUCTION/DEMOTION/TERMINATION
|
||||
actionDetail String?
|
||||
employeeAck Boolean @default(false) // 员工是否签字确认
|
||||
ackDate DateTime?
|
||||
ackMethod String? // SIGN/ELECTRONIC/REFUSED
|
||||
witness String? // 见证人
|
||||
attachmentUrl String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model AttendanceRecord {
|
||||
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)
|
||||
date DateTime
|
||||
checkInTime String? // HH:mm
|
||||
checkOutTime String? // HH:mm
|
||||
status String @default("NORMAL") // NORMAL/LATE/EARLY_LEAVE/ABSENT/LEAVE/BUSINESS_TRIP
|
||||
lateMinutes Int @default(0)
|
||||
earlyMinutes Int @default(0)
|
||||
workHours Float @default(0)
|
||||
overtimeHours Float @default(0)
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, date])
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model TrainingRecord {
|
||||
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)
|
||||
trainingDate DateTime
|
||||
topic String // 培训主题/制度名称
|
||||
content String? // 培训内容摘要
|
||||
trainer String?
|
||||
duration Float @default(0) // 培训时长(小时)
|
||||
ackStatus String @default("PENDING") // PENDING/SIGNED/REFUSED
|
||||
ackDate DateTime?
|
||||
attachmentUrl String? // 签收单扫描件
|
||||
remark String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
model PerformanceRecord {
|
||||
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)
|
||||
period String // 考核周期 YYYY-MM 或 YYYY-Q1
|
||||
score Float @default(0) // 考核得分
|
||||
grade String @default("B") // A/B/C/D
|
||||
result String @default("QUALIFIED") // EXCELLENT/QUALIFIED/NEED_IMPROVE/UNQUALIFIED
|
||||
summary String? // 考核评语
|
||||
improvementPlan String? // 改进计划(不胜任时)
|
||||
employeeAck Boolean @default(false)
|
||||
ackDate DateTime?
|
||||
reviewer String?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([employeeId, period])
|
||||
@@index([orgId, employeeId])
|
||||
}
|
||||
|
||||
// ========== 员工端表 ==========
|
||||
|
||||
model Payslip {
|
||||
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)
|
||||
month String // YYYY-MM
|
||||
baseSalary Float @default(0)
|
||||
overtimePay Float @default(0)
|
||||
weekdayOvertimePay Float @default(0)
|
||||
weekendOvertimePay Float @default(0)
|
||||
holidayOvertimePay Float @default(0)
|
||||
allowance Float @default(0)
|
||||
deduction Float @default(0)
|
||||
totalPay Float @default(0)
|
||||
confirmedAt DateTime?
|
||||
confirmedIp String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([employeeId, month])
|
||||
@@index([orgId, month])
|
||||
}
|
||||
|
||||
model OnboardingLink {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
employeeName String?
|
||||
phone String?
|
||||
status OnboardingStatus @default(PENDING)
|
||||
formData Json?
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
}
|
||||
|
||||
model ContractConfirmLink {
|
||||
id String @id @default(cuid())
|
||||
orgId String
|
||||
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||
contractId String
|
||||
contract LaborContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
token String @unique
|
||||
status ContractConfirmStatus @default(UNCONFIRMED)
|
||||
confirmedAt DateTime?
|
||||
confirmedIp String?
|
||||
expiresAt DateTime
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([orgId, status])
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// 创建测试企业
|
||||
let org = await prisma.organization.findFirst({ where: { name: '测试科技有限公司' } })
|
||||
if (!org) {
|
||||
org = await prisma.organization.create({
|
||||
data: {
|
||||
name: '测试科技有限公司',
|
||||
plan: 'FREE',
|
||||
maxEmployees: 20,
|
||||
city: '上海',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 创建管理员用户
|
||||
const passwordHash = await bcrypt.hash('12345678', 10)
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { phone: '13800000001' },
|
||||
update: {},
|
||||
create: {
|
||||
orgId: org.id,
|
||||
phone: '13800000001',
|
||||
name: '管理员',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建测试员工
|
||||
const salaryHash = randomBytes(32).toString('hex')
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
name: '张三',
|
||||
department: '技术部',
|
||||
hireDate: new Date('2026-01-15'),
|
||||
monthlySalary: 'encrypted:' + salaryHash,
|
||||
phone: '13900000001',
|
||||
gender: '男',
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建测试合同
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
employeeId: employee.id,
|
||||
signDate: new Date('2026-01-20'),
|
||||
startDate: new Date('2026-02-01'),
|
||||
endDate: new Date('2029-01-31'),
|
||||
contractType: 'FIXED',
|
||||
signMethod: 'PAPER',
|
||||
contractYears: 3,
|
||||
probationMonths: 2,
|
||||
probationSalary: 6400,
|
||||
createdBy: admin.id,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Seed data created:', { org: org.id, admin: admin.id, employee: employee.id })
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import compression from 'compression'
|
||||
import { errorHandler } from './middleware/errorHandler'
|
||||
import { apiLimiter } from './middleware/rateLimit'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(helmet())
|
||||
app.use(compression())
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}),
|
||||
)
|
||||
app.use(express.json())
|
||||
app.use(morgan('dev'))
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ success: true, data: { status: 'ok', timestamp: new Date().toISOString() } })
|
||||
})
|
||||
|
||||
app.use('/api/v1', apiLimiter)
|
||||
|
||||
// 路由挂载
|
||||
import authRoutes from './routes/auth.routes'
|
||||
import dashboardRoutes from './routes/dashboard.routes'
|
||||
import employeeRoutes from './routes/employee.routes'
|
||||
import terminationRoutes from './routes/termination.routes'
|
||||
import aiRoutes from './routes/ai.routes'
|
||||
import portalRoutes from './routes/portal.routes'
|
||||
import settingsRoutes from './routes/settings.routes'
|
||||
import payrollRoutes from './routes/payroll.routes'
|
||||
import socialRoutes from './routes/social.routes'
|
||||
import notificationRoutes from './routes/notification.routes'
|
||||
import attachmentRoutes from './routes/attachment.routes'
|
||||
import rosterRoutes from './routes/roster.routes'
|
||||
app.use('/api/v1/auth', authRoutes)
|
||||
app.use('/api/v1/dashboard', dashboardRoutes)
|
||||
app.use('/api/v1/employees', employeeRoutes)
|
||||
app.use('/api/v1/termination', terminationRoutes)
|
||||
app.use('/api/v1/ai', aiRoutes)
|
||||
app.use('/api/v1/portal', portalRoutes)
|
||||
app.use('/api/v1/settings', settingsRoutes)
|
||||
app.use('/api/v1/payroll', payrollRoutes)
|
||||
app.use('/api/v1/social', socialRoutes)
|
||||
app.use('/api/v1/notifications', notificationRoutes)
|
||||
app.use('/api/v1/attachments', attachmentRoutes)
|
||||
app.use('/api/v1/roster', rosterRoutes)
|
||||
|
||||
app.use(errorHandler)
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,7 @@
|
||||
import app from './app'
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-32-byte-encryption-key!!'
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const KEY = Buffer.from(ENCRYPTION_KEY.padEnd(32, '0').slice(0, 32), 'utf8')
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const [ivHex, encrypted] = encryptedText.split(':')
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv)
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret'
|
||||
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret'
|
||||
|
||||
export function signAccessToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
return jwt.sign(payload, JWT_SECRET, { expiresIn: '2h' })
|
||||
}
|
||||
|
||||
export function signRefreshToken(payload: { id: string; orgId: string; role: string }): string {
|
||||
return jwt.sign(payload, JWT_REFRESH_SECRET, { expiresIn: '7d' })
|
||||
}
|
||||
|
||||
export function verifyAccessToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as { id: string; orgId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(token: string): { id: string; orgId: string; role: string } | null {
|
||||
try {
|
||||
return jwt.verify(token, JWT_REFRESH_SECRET) as { id: string; orgId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export default prisma
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AuthRequest } from './auth'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
export async function auditLog(
|
||||
req: AuthRequest,
|
||||
action: string,
|
||||
entity: string,
|
||||
entityId?: string,
|
||||
detail?: Record<string, unknown>,
|
||||
) {
|
||||
if (!req.user) return
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orgId: req.user.orgId,
|
||||
userId: req.user.id,
|
||||
action,
|
||||
entity,
|
||||
entityId,
|
||||
detail: detail ? JSON.parse(JSON.stringify(detail)) : undefined,
|
||||
ip: req.ip,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Audit log error:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { verifyAccessToken } from '../lib/jwt'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: { id: string; orgId: string; role: string }
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未提供认证令牌' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload) {
|
||||
return res.status(401).json({ success: false, error: { code: 'TOKEN_INVALID', message: '令牌无效或已过期' } })
|
||||
}
|
||||
req.user = payload
|
||||
next()
|
||||
}
|
||||
|
||||
export function orgFilterMiddleware(req: AuthRequest, _res: Response, next: NextFunction) {
|
||||
if (req.user) {
|
||||
req.orgId = req.user.orgId
|
||||
}
|
||||
next()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { ZodError } from 'zod'
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'
|
||||
|
||||
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
|
||||
if (err instanceof ZodError) {
|
||||
return res.status(422).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: '输入校验失败',
|
||||
details: err.errors.map((e) => ({ path: e.path.join('.'), message: e.message })),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (err instanceof PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { code: 'DUPLICATE', message: '数据已存在,请勿重复操作' },
|
||||
})
|
||||
}
|
||||
if (err.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: '记录不存在' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '操作过于频繁,请稍后再试' } },
|
||||
})
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 5,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '登录尝试过于频繁,请稍后再试' } },
|
||||
})
|
||||
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
message: { success: false, error: { code: 'RATE_LIMIT', message: '请求过于频繁,请稍后再试' } },
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { chat, reviewContract, matchCase, predictRisks } from '../services/ai.service'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const router = Router()
|
||||
|
||||
async function buildOrgContext(orgId: string): Promise<string> {
|
||||
const [employees, risks] = await Promise.all([
|
||||
prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const empSummary = employees.map((e) => {
|
||||
const contract = e.contracts[0]
|
||||
return `- ${e.name}(${e.department}),入职${e.hireDate.toISOString().slice(0, 10)},${contract ? `合同类型:${contract.contractType}` : '未签合同'}`
|
||||
}).join('\n')
|
||||
|
||||
const riskSummary = risks.map((r) => `- ${r.title}(${r.level})`).join('\n')
|
||||
|
||||
return `员工列表(${employees.length}人):
|
||||
${empSummary}
|
||||
|
||||
当前风险项(${risks.length}项):
|
||||
${riskSummary}`
|
||||
}
|
||||
|
||||
router.post('/chat', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { messages } = req.body as { messages: { role: 'user' | 'assistant'; content: string }[] }
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 messages 参数' } })
|
||||
}
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const reply = await chat(messages, orgContext)
|
||||
res.json({ success: true, data: { reply } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/review', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { contractText } = req.body as { contractText: string }
|
||||
if (!contractText) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少合同文本' } })
|
||||
}
|
||||
const result = await reviewContract(contractText)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/match-case', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { scenario } = req.body as { scenario: string }
|
||||
if (!scenario) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少争议情形描述' } })
|
||||
}
|
||||
const result = await matchCase(scenario)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const orgContext = await buildOrgContext(req.user!.orgId)
|
||||
const result = await predictRisks(orgContext)
|
||||
res.json({ success: true, data: { result } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取员工附件列表
|
||||
router.get('/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachments = await prisma.employeeAttachment.findMany({
|
||||
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: attachments })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加附件记录(文件URL由前端上传后传入)
|
||||
const attachmentSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
fileName: z.string().min(1),
|
||||
fileType: z.enum(['ID_CARD', 'BANK_CARD', 'CONTRACT_SCAN', 'EDUCATION', 'OTHER']),
|
||||
fileUrl: z.string().min(1),
|
||||
fileSize: z.number().int().default(0),
|
||||
})
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = attachmentSchema.parse(req.body)
|
||||
const attachment = await prisma.employeeAttachment.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
...data,
|
||||
uploadedBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: attachment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除附件
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const attachment = await prisma.employeeAttachment.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '附件不存在' } })
|
||||
}
|
||||
await prisma.employeeAttachment.delete({ where: { id: attachment.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express'
|
||||
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema } from '../schemas/auth.schema'
|
||||
import { register, login, refresh, resetPassword } from '../services/auth.service'
|
||||
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.post('/register', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = registerSchema.parse(req.body)
|
||||
const result = await register(data.orgName, data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/login', loginLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = loginSchema.parse(req.body)
|
||||
const result = await login(data.phone, data.password)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/refresh', async (req, res, next) => {
|
||||
try {
|
||||
const data = refreshSchema.parse(req.body)
|
||||
const result = await refresh(data.refreshToken)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reset-password', authLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = resetPasswordSchema.parse(req.body)
|
||||
const result = await resetPassword(data.phone, data.newPassword)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { getDashboardData } from '../services/risk.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = await getDashboardData(req.user!.orgId)
|
||||
res.json({ success: true, data })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 标记待办为已完成
|
||||
router.patch('/todos/:id/resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.count === 0) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
|
||||
}
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 忽略待办
|
||||
router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const item = await prisma.riskItem.updateMany({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
|
||||
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||||
})
|
||||
if (item.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
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import {
|
||||
createEmployeeSchema,
|
||||
updateEmployeeSchema,
|
||||
batchRenewSchema,
|
||||
addContractSchema,
|
||||
} from '../schemas/contract.schema'
|
||||
import {
|
||||
getEmployees,
|
||||
getEmployeeDetail,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
batchRenew,
|
||||
addContract,
|
||||
} from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
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,
|
||||
search: req.query.search as string,
|
||||
department: req.query.department as string,
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await getEmployeeDetail(req.user!.orgId, req.params.id)
|
||||
res.json({ success: true, data: employee })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createEmployeeSchema.parse(req.body)
|
||||
const result = await createEmployee(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'CREATE', 'EMPLOYEE', result.id, { name: data.name })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateEmployeeSchema.parse(req.body)
|
||||
const result = await updateEmployee(req.user!.orgId, req.params.id, data)
|
||||
await auditLog(req, 'UPDATE', 'EMPLOYEE', req.params.id, data)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const result = await deleteEmployee(req.user!.orgId, req.params.id)
|
||||
await auditLog(req, 'DELETE', 'EMPLOYEE', req.params.id)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts/batch-renew', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = batchRenewSchema.parse(req.body)
|
||||
const result = await batchRenew(req.user!.orgId, req.user!.id, data.contractIds, data.years)
|
||||
await auditLog(req, 'BATCH_RENEW', 'CONTRACT', undefined, { count: data.contractIds.length })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = addContractSchema.parse(req.body)
|
||||
const result = await addContract(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'ADD_CONTRACT', 'CONTRACT', result.id, { employeeId: data.employeeId })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取通知设置
|
||||
router.get('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!setting) {
|
||||
setting = await prisma.notificationSetting.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新通知设置
|
||||
const settingSchema = z.object({
|
||||
contractExpiry: z.boolean().optional(),
|
||||
expiryDays: z.number().int().min(1).max(365).optional(),
|
||||
contractUnsigned: z.boolean().optional(),
|
||||
overtimeAlert: z.boolean().optional(),
|
||||
payslipReady: z.boolean().optional(),
|
||||
payrollDay: z.number().int().min(1).max(28).optional(),
|
||||
socialInsDay: z.number().int().min(1).max(28).optional(),
|
||||
housingFundDay: z.number().int().min(1).max(28).optional(),
|
||||
taxDay: z.number().int().min(1).max(28).optional(),
|
||||
wechatWebhook: z.string().url().nullable().optional(),
|
||||
emailNotify: z.boolean().optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
})
|
||||
|
||||
router.put('/settings', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = settingSchema.parse(req.body)
|
||||
const setting = await prisma.notificationSetting.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: setting })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取通知列表
|
||||
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 [logs, total] = await Promise.all([
|
||||
prisma.notificationLog.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.notificationLog.count({ where: { orgId: req.user!.orgId } }),
|
||||
])
|
||||
res.json({ success: true, data: { items: logs, total, page, pageSize, totalPages: Math.ceil(total / pageSize) } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发合同到期检查
|
||||
router.post('/check-contracts', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const setting = await prisma.notificationSetting.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
const expiryDays = setting?.expiryDays || 30
|
||||
const now = new Date()
|
||||
const threshold = new Date(now.getTime() + expiryDays * 24 * 60 * 60 * 1000)
|
||||
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
endDate: { lte: threshold, gte: now },
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
})
|
||||
|
||||
const logs: any[] = []
|
||||
for (const contract of contracts) {
|
||||
const daysLeft = Math.ceil((contract.endDate!.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const title = `${contract.employee.name}的合同将在${daysLeft}天后到期`
|
||||
const content = `员工 ${contract.employee.name}(${contract.employee.department})的合同将于 ${contract.endDate!.toISOString().slice(0, 10)} 到期,请及时处理续签或终止事宜。`
|
||||
|
||||
const log = await prisma.notificationLog.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
type: 'CONTRACT_EXPIRY',
|
||||
title,
|
||||
content,
|
||||
channel: 'IN_APP',
|
||||
employeeId: contract.employeeId,
|
||||
},
|
||||
})
|
||||
logs.push(log)
|
||||
|
||||
if (setting?.wechatWebhook) {
|
||||
try {
|
||||
await fetch(setting.wechatWebhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'text',
|
||||
text: { content: `【合同到期提醒】${title}\n${content}` },
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
// webhook 发送失败不阻断流程
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { checked: contracts.length, notified: logs.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,338 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// ========== 加班费记录 ==========
|
||||
|
||||
const overtimeSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取加班费记录列表
|
||||
router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { employeeId, month } = req.query
|
||||
const records = await prisma.overtimeRecord.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
...(month ? { month: String(month) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 保存加班费记录
|
||||
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = overtimeSchema.parse(req.body)
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay,
|
||||
weekendPay,
|
||||
holidayPay,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 工资条管理 ==========
|
||||
|
||||
const payslipSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
baseSalary: z.number().min(0).default(0),
|
||||
overtimePay: z.number().min(0).default(0),
|
||||
weekdayOvertimePay: z.number().min(0).default(0),
|
||||
weekendOvertimePay: z.number().min(0).default(0),
|
||||
holidayOvertimePay: z.number().min(0).default(0),
|
||||
allowance: z.number().min(0).default(0),
|
||||
deduction: z.number().min(0).default(0),
|
||||
})
|
||||
|
||||
// 获取工资条列表
|
||||
router.get('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId } = req.query
|
||||
const payslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
...(month ? { month: String(month) } : {}),
|
||||
...(employeeId ? { employeeId: String(employeeId) } : {}),
|
||||
},
|
||||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||||
orderBy: [{ month: 'desc' }, { employee: { name: 'asc' } }],
|
||||
})
|
||||
res.json({ success: true, data: payslips })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 创建/更新工资条
|
||||
router.post('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = payslipSchema.parse(req.body)
|
||||
const totalPay = data.baseSalary + data.overtimePay + data.allowance - data.deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: {
|
||||
employeeId_month: { employeeId: data.employeeId, month: data.month },
|
||||
},
|
||||
update: {
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
baseSalary: data.baseSalary,
|
||||
overtimePay: data.overtimePay,
|
||||
weekdayOvertimePay: data.weekdayOvertimePay,
|
||||
weekendOvertimePay: data.weekendOvertimePay,
|
||||
holidayOvertimePay: data.holidayOvertimePay,
|
||||
allowance: data.allowance,
|
||||
deduction: data.deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 从加班费记录自动生成工资条
|
||||
router.post('/payslip/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, employeeId, baseSalary, allowance, deduction } = req.body as {
|
||||
month: string
|
||||
employeeId: string
|
||||
baseSalary: number
|
||||
allowance?: number
|
||||
deduction?: number
|
||||
}
|
||||
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const totalPay = baseSalary + overtimePay + (allowance || 0) - (deduction || 0)
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId, month } },
|
||||
update: {
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance: allowance || 0,
|
||||
deduction: deduction || 0,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除工资条
|
||||
router.delete('/payslip/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await prisma.payslip.delete({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量生成工资条 ==========
|
||||
|
||||
const batchGenerateSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
allowances: z.record(z.string(), z.number().default(0)).optional(),
|
||||
deductions: z.record(z.string(), z.number().default(0)).optional(),
|
||||
})
|
||||
|
||||
// 批量生成全员工资条
|
||||
router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, allowances = {}, deductions = {} } = batchGenerateSchema.parse(req.body)
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const results: any[] = []
|
||||
for (const emp of employees) {
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const allowance = allowances[emp.id] || 0
|
||||
const deduction = deductions[emp.id] || 0
|
||||
|
||||
let baseSalary = 0
|
||||
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
|
||||
baseSalary = emp.contracts[0].probationSalary
|
||||
} else if (emp.monthlySalary) {
|
||||
try {
|
||||
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
baseSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
}
|
||||
|
||||
const totalPay = baseSalary + overtimePay + allowance - deduction
|
||||
|
||||
const payslip = await prisma.payslip.upsert({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
update: { baseSalary, overtimePay, allowance, deduction, totalPay },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: emp.id,
|
||||
month,
|
||||
baseSalary,
|
||||
overtimePay,
|
||||
weekdayOvertimePay: overtime?.weekdayPay || 0,
|
||||
weekendOvertimePay: overtime?.weekendPay || 0,
|
||||
holidayOvertimePay: overtime?.holidayPay || 0,
|
||||
allowance,
|
||||
deduction,
|
||||
totalPay,
|
||||
},
|
||||
})
|
||||
results.push(payslip)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { generated: results.length, payslips: results } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班数据 ==========
|
||||
|
||||
const batchOvertimeSchema = z.array(
|
||||
z.object({
|
||||
employeeId: z.string().min(1),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
monthlyWage: z.number().positive(),
|
||||
weekdayHours: z.number().min(0).default(0),
|
||||
weekendHours: z.number().min(0).default(0),
|
||||
holidayHours: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
|
||||
router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const items = batchOvertimeSchema.parse(req.body)
|
||||
const results: any[] = []
|
||||
|
||||
for (const data of items) {
|
||||
const hourlyWage = data.monthlyWage / 21.75 / 8
|
||||
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
|
||||
const weekendPay = hourlyWage * 2.0 * data.weekendHours
|
||||
const holidayPay = hourlyWage * 3.0 * data.holidayHours
|
||||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||||
|
||||
const record = await prisma.overtimeRecord.upsert({
|
||||
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
|
||||
update: {
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
month: data.month,
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
})
|
||||
results.push(record)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { imported: results.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
|
||||
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema } from '../schemas/portal.schema'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// 验证码临时存储(生产环境应使用 Redis)
|
||||
const codeStore = new Map<string, { code: string; expiresAt: number }>()
|
||||
|
||||
// 员工端认证中间件
|
||||
function portalAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '未登录' } })
|
||||
}
|
||||
const token = authHeader.substring(7)
|
||||
try {
|
||||
const payload = verifyAccessToken(token)
|
||||
if (!payload || payload.role !== 'EMPLOYEE') {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: '无效的员工端 Token' } })
|
||||
}
|
||||
;(req as any).employee = { id: payload.id, orgId: payload.orgId }
|
||||
next()
|
||||
} catch {
|
||||
return res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Token 无效或已过期' } })
|
||||
}
|
||||
}
|
||||
|
||||
// 密码登录
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalLoginSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee || !employee.passwordHash) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const valid = await bcrypt.compare(data.password, employee.passwordHash)
|
||||
if (!valid) {
|
||||
return res.status(400).json({ success: false, error: { code: 'AUTH_FAILED', message: '手机号或密码错误' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 发送验证码(页面内显示)
|
||||
router.post('/send-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalSendCodeSchema.parse(req.body)
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { phone: data.phone, status: 'ACTIVE' },
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '该手机号未在系统中登记' } })
|
||||
}
|
||||
const code = Math.random().toString().slice(2, 8)
|
||||
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000 })
|
||||
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 验证码登录
|
||||
router.post('/verify-code', async (req, res, next) => {
|
||||
try {
|
||||
const data = portalVerifyCodeSchema.parse(req.body)
|
||||
const stored = codeStore.get(data.phone)
|
||||
if (!stored || stored.expiresAt < Date.now()) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
|
||||
}
|
||||
if (stored.code !== data.code) {
|
||||
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: '验证码错误' } })
|
||||
}
|
||||
codeStore.delete(data.phone)
|
||||
const employee = await prisma.employee.findFirst({ where: { phone: data.phone, status: 'ACTIVE' } })
|
||||
if (!employee) {
|
||||
return res.status(400).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const token = signAccessToken({ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE' })
|
||||
res.json({ success: true, data: { token, employee: { id: employee.id, name: employee.name, department: employee.department } } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条
|
||||
router.get('/payslip', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const month = req.query.month as string || new Date().toISOString().slice(0, 7)
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId, month },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: payslip })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 工资条确认已阅
|
||||
router.post('/payslip/:id/confirm', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const payslip = await prisma.payslip.findFirst({
|
||||
where: { id: req.params.id, orgId: req.employee.orgId, employeeId: req.employee.id },
|
||||
})
|
||||
if (!payslip) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '工资条不存在' } })
|
||||
}
|
||||
await prisma.payslip.update({
|
||||
where: { id: req.params.id },
|
||||
data: { confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 我的合同
|
||||
router.get('/contract', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const contract = await prisma.laborContract.findFirst({
|
||||
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
if (!contract) {
|
||||
return res.json({ success: true, data: null })
|
||||
}
|
||||
res.json({ success: true, data: contract })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 入职填报提交
|
||||
router.post('/onboarding', async (req, res, next) => {
|
||||
try {
|
||||
const data = onboardingSchema.parse(req.body)
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: data.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.onboardingLink.update({
|
||||
where: { id: link.id },
|
||||
data: {
|
||||
employeeName: data.name,
|
||||
phone: data.phone,
|
||||
formData: {
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
idCard: data.idCard,
|
||||
emergencyContact: data.emergencyContact,
|
||||
emergencyPhone: data.emergencyPhone,
|
||||
address: data.address,
|
||||
bankCard: data.bankCard,
|
||||
bankName: data.bankName,
|
||||
},
|
||||
status: 'APPROVED',
|
||||
usedAt: new Date(),
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: { message: '信息提交成功,HR 将审核您的信息' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 合同签署确认
|
||||
router.post('/contract-confirm', async (req, res, next) => {
|
||||
try {
|
||||
const data = contractConfirmSchema.parse(req.body)
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
await prisma.contractConfirmLink.update({
|
||||
where: { id: link.id },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), confirmedIp: req.ip },
|
||||
})
|
||||
await prisma.laborContract.update({
|
||||
where: { id: link.contractId },
|
||||
data: { attachmentName: `confirmed:${new Date().toISOString()}` },
|
||||
})
|
||||
res.json({ success: true, data: { message: '合同签署确认成功' } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取入职填报信息(通过 token)
|
||||
router.get('/onboarding/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.onboardingLink.findFirst({
|
||||
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
|
||||
include: { org: { select: { name: true } } },
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({ success: true, data: { orgName: link.org.name } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取合同确认信息(通过 token)
|
||||
router.get('/contract-confirm/:token', async (req, res, next) => {
|
||||
try {
|
||||
const link = await prisma.contractConfirmLink.findFirst({
|
||||
where: { token: req.params.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
|
||||
include: {
|
||||
contract: {
|
||||
include: {
|
||||
employee: { select: { name: true, org: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!link) {
|
||||
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
orgName: link.contract.employee.org.name,
|
||||
employeeName: link.contract.employee.name,
|
||||
contract: link.contract,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,533 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
|
||||
const router = Router()
|
||||
|
||||
function safeDecrypt(encrypted: string): number {
|
||||
try {
|
||||
if (!encrypted || !encrypted.includes(':')) return Number(encrypted) || 0
|
||||
return Number(decrypt(encrypted))
|
||||
} catch {
|
||||
return Number(encrypted) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 花名册聚合 API ==========
|
||||
|
||||
// 花名册列表(含汇总信息)
|
||||
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
_count: {
|
||||
select: {
|
||||
disciplinaryRecords: true,
|
||||
attendanceRecords: true,
|
||||
trainingRecords: true,
|
||||
performanceRecords: true,
|
||||
payslips: true,
|
||||
overtimeRecords: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = employees.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract: e.contracts[0] || null,
|
||||
counts: e._count,
|
||||
}))
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 员工完整档案(花名册详情)
|
||||
router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' }, take: 90 },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: { orderBy: { createdAt: 'desc' } },
|
||||
attachments: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const { monthlySalary, ...rest } = employee
|
||||
res.json({
|
||||
success: true,
|
||||
data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) },
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 仲裁证据链导出
|
||||
router.get('/:id/evidence-chain', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
payslips: { orderBy: { month: 'desc' } },
|
||||
overtimeRecords: { orderBy: { month: 'desc' } },
|
||||
disciplinaryRecords: { orderBy: { violationDate: 'desc' } },
|
||||
attendanceRecords: { orderBy: { date: 'desc' } },
|
||||
trainingRecords: { orderBy: { trainingDate: 'desc' } },
|
||||
performanceRecords: { orderBy: { period: 'desc' } },
|
||||
terminations: true,
|
||||
},
|
||||
})
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
|
||||
const evidence: any[] = []
|
||||
const empName = employee.name
|
||||
const empDept = employee.department
|
||||
const hireDate = employee.hireDate.toISOString().slice(0, 10)
|
||||
|
||||
// 1. 劳动关系证据
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: '入职登记',
|
||||
date: hireDate,
|
||||
description: `${empName}于${hireDate}入职${empDept},建立劳动关系。`,
|
||||
evidenceType: 'EMPLOYMENT',
|
||||
})
|
||||
employee.contracts.forEach((c) => {
|
||||
evidence.push({
|
||||
category: '劳动关系',
|
||||
title: `劳动合同(${c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签订'})`,
|
||||
date: c.signDate ? c.signDate.toISOString().slice(0, 10) : c.startDate.toISOString().slice(0, 10),
|
||||
description: `合同期限:${c.startDate.toISOString().slice(0, 10)} 至 ${c.endDate ? c.endDate.toISOString().slice(0, 10) : '无固定期限'},试用期${c.probationMonths}个月,试用期工资¥${c.probationSalary}。`,
|
||||
evidenceType: 'CONTRACT',
|
||||
signed: !!c.signDate,
|
||||
})
|
||||
})
|
||||
|
||||
// 2. 薪酬证据
|
||||
employee.payslips.forEach((p) => {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${p.month}月工资条`,
|
||||
date: p.month,
|
||||
description: `基本工资¥${p.baseSalary.toFixed(2)},加班费¥${p.overtimePay.toFixed(2)},津贴¥${p.allowance.toFixed(2)},扣款¥${p.deduction.toFixed(2)},应发合计¥${p.totalPay.toFixed(2)}。${p.confirmedAt ? '员工已确认。' : '员工未确认。'}`,
|
||||
evidenceType: 'PAYSLIP',
|
||||
confirmed: !!p.confirmedAt,
|
||||
})
|
||||
})
|
||||
employee.overtimeRecords.forEach((o) => {
|
||||
if (o.totalPay > 0) {
|
||||
evidence.push({
|
||||
category: '薪酬发放',
|
||||
title: `${o.month}月加班费记录`,
|
||||
date: o.month,
|
||||
description: `工作日加班${o.weekdayHours}h,休息日加班${o.weekendHours}h,节假日加班${o.holidayHours}h,加班费合计¥${o.totalPay.toFixed(2)}。`,
|
||||
evidenceType: 'OVERTIME',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 3. 考勤证据
|
||||
const abnormalAttendance = employee.attendanceRecords.filter((a) => a.status !== 'NORMAL')
|
||||
abnormalAttendance.forEach((a) => {
|
||||
const statusMap: Record<string, string> = { LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||||
evidence.push({
|
||||
category: '考勤记录',
|
||||
title: `${a.date.toISOString().slice(0, 10)} 考勤异常`,
|
||||
date: a.date.toISOString().slice(0, 10),
|
||||
description: `状态:${statusMap[a.status] || a.status}${a.lateMinutes ? `,迟到${a.lateMinutes}分钟` : ''}${a.earlyMinutes ? `,早退${a.earlyMinutes}分钟` : ''}。${a.remark || ''}`,
|
||||
evidenceType: 'ATTENDANCE',
|
||||
})
|
||||
})
|
||||
|
||||
// 4. 违纪证据
|
||||
employee.disciplinaryRecords.forEach((d) => {
|
||||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||||
evidence.push({
|
||||
category: '违纪处理',
|
||||
title: `${d.violationDate.toISOString().slice(0, 10)} ${typeMap[d.violationType] || d.violationType}`,
|
||||
date: d.violationDate.toISOString().slice(0, 10),
|
||||
description: `违纪事实:${d.description}。处理结果:${actionMap[d.action] || d.action}。${d.employeeAck ? `员工已签字确认(${d.ackDate ? d.ackDate.toISOString().slice(0, 10) : ''})。` : '员工未签字。'}${d.witness ? `见证人:${d.witness}。` : ''}`,
|
||||
evidenceType: 'DISCIPLINARY',
|
||||
acknowledged: d.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 5. 培训签收证据
|
||||
employee.trainingRecords.forEach((t) => {
|
||||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||||
evidence.push({
|
||||
category: '培训签收',
|
||||
title: `${t.trainingDate.toISOString().slice(0, 10)} ${t.topic}`,
|
||||
date: t.trainingDate.toISOString().slice(0, 10),
|
||||
description: `培训主题:${t.topic}。时长:${t.duration}小时。${t.content ? `内容:${t.content}。` : ''}签收状态:${ackMap[t.ackStatus] || t.ackStatus}。`,
|
||||
evidenceType: 'TRAINING',
|
||||
acknowledged: t.ackStatus === 'SIGNED',
|
||||
})
|
||||
})
|
||||
|
||||
// 6. 绩效证据
|
||||
employee.performanceRecords.forEach((p) => {
|
||||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||||
evidence.push({
|
||||
category: '绩效考核',
|
||||
title: `${p.period} 绩效考核`,
|
||||
date: p.period,
|
||||
description: `得分:${p.score},等级:${p.grade},结果:${resultMap[p.result] || p.result}。${p.summary ? `评语:${p.summary}。` : ''}${p.improvementPlan ? `改进计划:${p.improvementPlan}。` : ''}${p.employeeAck ? '员工已签字确认。' : '员工未签字。'}`,
|
||||
evidenceType: 'PERFORMANCE',
|
||||
acknowledged: p.employeeAck,
|
||||
})
|
||||
})
|
||||
|
||||
// 7. 解聘证据
|
||||
employee.terminations.forEach((t) => {
|
||||
const reasonMap: Record<string, string> = { NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除', LAYOFF: '经济性裁员', EXPIRED: '合同到期' }
|
||||
evidence.push({
|
||||
category: '解聘记录',
|
||||
title: `${t.terminationDate.toISOString().slice(0, 10)} 解聘记录`,
|
||||
date: t.terminationDate.toISOString().slice(0, 10),
|
||||
description: `解聘原因:${reasonMap[t.reason] || t.reason}。经济补偿金:¥${t.compensation.toFixed(2)}。${t.remark || ''}`,
|
||||
evidenceType: 'TERMINATION',
|
||||
})
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
employee: {
|
||||
name: empName,
|
||||
department: empDept,
|
||||
hireDate,
|
||||
status: employee.status,
|
||||
gender: employee.gender,
|
||||
phone: employee.phone,
|
||||
},
|
||||
evidence,
|
||||
summary: {
|
||||
total: evidence.length,
|
||||
signed: evidence.filter((e) => e.acknowledged === true).length,
|
||||
unsigned: evidence.filter((e) => e.acknowledged === false).length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 违纪记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.disciplinaryRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { violationDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/disciplinary', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
violationDate: new Date(violationDate),
|
||||
violationType,
|
||||
description,
|
||||
severity: severity || 'WARNING',
|
||||
action: action || 'ORAL_WARNING',
|
||||
actionDetail,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'DISCIPLINARY', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { violationDate, violationType, description, severity, action, actionDetail, employeeAck, ackDate, ackMethod, witness, attachmentUrl } = req.body
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.disciplinaryRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
violationDate: violationDate ? new Date(violationDate) : undefined,
|
||||
violationType,
|
||||
description,
|
||||
severity,
|
||||
action,
|
||||
actionDetail,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
ackMethod,
|
||||
witness,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/disciplinary/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.disciplinaryRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.disciplinaryRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 考勤记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.attendanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 90,
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/attendance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { date, checkInTime, checkOutTime, status, lateMinutes, earlyMinutes, workHours, overtimeHours, remark } = req.body
|
||||
const record = await prisma.attendanceRecord.upsert({
|
||||
where: { employeeId_date: { employeeId: req.params.employeeId, date: new Date(date) } },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
date: new Date(date),
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status: status || 'NORMAL',
|
||||
lateMinutes: lateMinutes || 0,
|
||||
earlyMinutes: earlyMinutes || 0,
|
||||
workHours: workHours || 0,
|
||||
overtimeHours: overtimeHours || 0,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
checkInTime,
|
||||
checkOutTime,
|
||||
status,
|
||||
lateMinutes,
|
||||
earlyMinutes,
|
||||
workHours,
|
||||
overtimeHours,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/attendance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.attendanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.attendanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 培训签收记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.trainingRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { trainingDate: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/training', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
const record = await prisma.trainingRecord.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
trainingDate: new Date(trainingDate),
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration: duration || 0,
|
||||
ackStatus: ackStatus || 'PENDING',
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'TRAINING', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { trainingDate, topic, content, trainer, duration, ackStatus, ackDate, attachmentUrl, remark } = req.body
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.trainingRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
trainingDate: trainingDate ? new Date(trainingDate) : undefined,
|
||||
topic,
|
||||
content,
|
||||
trainer,
|
||||
duration,
|
||||
ackStatus,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
attachmentUrl,
|
||||
remark,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/training/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.trainingRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.trainingRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 绩效记录 CRUD ==========
|
||||
|
||||
router.get('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const records = await prisma.performanceRecord.findMany({
|
||||
where: { employeeId: req.params.employeeId, orgId: req.user!.orgId },
|
||||
orderBy: { period: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:employeeId/performance', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const record = await prisma.performanceRecord.upsert({
|
||||
where: { employeeId_period: { employeeId: req.params.employeeId, period } },
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: req.params.employeeId,
|
||||
period,
|
||||
score: score || 0,
|
||||
grade: grade || 'B',
|
||||
result: result || 'QUALIFIED',
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck: employeeAck || false,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
await auditLog(req, 'CREATE', 'PERFORMANCE', record.id, { employeeId: req.params.employeeId })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.put('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { period, score, grade, result, summary, improvementPlan, employeeAck, ackDate, reviewer } = req.body
|
||||
const record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
const updated = await prisma.performanceRecord.update({
|
||||
where: { id: req.params.recordId },
|
||||
data: {
|
||||
period,
|
||||
score,
|
||||
grade,
|
||||
result,
|
||||
summary,
|
||||
improvementPlan,
|
||||
employeeAck,
|
||||
ackDate: ackDate ? new Date(ackDate) : null,
|
||||
reviewer,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.delete('/:employeeId/performance/:recordId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const record = await prisma.performanceRecord.findFirst({
|
||||
where: { id: req.params.recordId, orgId: req.user!.orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '记录不存在' } })
|
||||
await prisma.performanceRecord.delete({ where: { id: req.params.recordId } })
|
||||
res.json({ success: true })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
email: z.string().email().optional(),
|
||||
})
|
||||
|
||||
const createUserSchema = z.object({
|
||||
name: z.string().min(1, '姓名不能为空'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(6, '密码至少6位'),
|
||||
role: z.enum(['ADMIN', 'HR', 'VIEWER']).default('HR'),
|
||||
})
|
||||
|
||||
// 获取企业信息
|
||||
router.get('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const org = await prisma.organization.findUnique({
|
||||
where: { id: req.user!.orgId },
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true, createdAt: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新企业信息
|
||||
router.put('/org', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name } = req.body as { name?: string }
|
||||
const org = await prisma.organization.update({
|
||||
where: { id: req.user!.orgId },
|
||||
data: name ? { name } : {},
|
||||
select: { id: true, name: true, plan: true, maxEmployees: true },
|
||||
})
|
||||
res.json({ success: true, data: org })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
router.get('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { id: true, name: true, phone: true, email: true, role: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
res.json({ success: true, data: users })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加用户
|
||||
router.post('/users', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = createUserSchema.parse(req.body)
|
||||
const existing = await prisma.user.findFirst({ where: { phone: data.phone, orgId: req.user!.orgId } })
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, error: { code: 'DUPLICATE', message: '该手机号已存在' } })
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(data.password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
passwordHash,
|
||||
role: data.role,
|
||||
},
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新用户
|
||||
router.put('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = updateUserSchema.parse(req.body)
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: data,
|
||||
select: { id: true, name: true, phone: true, role: true },
|
||||
})
|
||||
res.json({ success: true, data: user })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 删除用户
|
||||
router.delete('/users/:id', async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (req.params.id === req.user!.id) {
|
||||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不能删除自己' } })
|
||||
}
|
||||
await prisma.user.delete({ where: { id: req.params.id } })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
// 获取社保配置
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新社保配置
|
||||
const configSchema = z.object({
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
})
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = configSchema.parse(req.body)
|
||||
const config = await prisma.socialInsuranceConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
})
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base } = calcSchema.parse(req.body)
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg + housingOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp + housingEmp
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
items: [
|
||||
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
|
||||
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
{ name: '住房公积金', orgRate: config.housingOrg, empRate: config.housingEmp, orgAmount: housingOrg, empAmount: housingEmp },
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
total,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Router } from 'express'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import { terminationChecklistSchema } from '../schemas/termination.schema'
|
||||
import { createTermination, getTerminations, getChecklistForReason, assessRisk, calculateCompensation } from '../services/termination.service'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
|
||||
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 result = await getTerminations(req.user!.orgId, page, pageSize)
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/checklist/:reason', authMiddleware, (req: AuthRequest, res) => {
|
||||
const checklist = getChecklistForReason(req.params.reason)
|
||||
res.json({ success: true, data: checklist })
|
||||
})
|
||||
|
||||
router.get('/assess/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: req.params.employeeId, orgId: req.user!.orgId } })
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const assessment = assessRisk(employee, req.query.reason as string || '')
|
||||
res.json({ success: true, data: assessment })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const data = terminationChecklistSchema.parse(req.body)
|
||||
const result = await createTermination(req.user!.orgId, req.user!.id, data)
|
||||
await auditLog(req, 'TERMINATE', 'EMPLOYEE', data.employeeId, { reason: data.reason })
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const registerSchema = z.object({
|
||||
orgName: z.string().min(2, '企业名称至少2个字').max(50, '企业名称最多50个字'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
message: '两次密码不一致',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export const loginSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1, '缺少 refreshToken'),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createEmployeeSchema = z.object({
|
||||
name: z.string().min(1, '姓名不能为空').max(30, '姓名最多30个字'),
|
||||
department: z.string().min(1, '部门不能为空').max(50, '部门最多50个字'),
|
||||
hireDate: z.string().datetime(),
|
||||
monthlySalary: z.string().min(1, '月薪不能为空'),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
isPregnant: z.boolean().default(false),
|
||||
isInMedicalPeriod: z.boolean().default(false),
|
||||
isWorkInjured: z.boolean().default(false),
|
||||
contract: z.object({
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
probationSalary: z.number().min(0).default(0),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const updateEmployeeSchema = z.object({
|
||||
name: z.string().min(1).max(30).optional(),
|
||||
department: z.string().min(1).max(50).optional(),
|
||||
hireDate: z.string().datetime().optional(),
|
||||
monthlySalary: z.string().min(1).optional(),
|
||||
gender: z.enum(['男', '女']).optional(),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/).optional(),
|
||||
isPregnant: z.boolean().optional(),
|
||||
isInMedicalPeriod: z.boolean().optional(),
|
||||
isWorkInjured: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const batchRenewSchema = z.object({
|
||||
contractIds: z.array(z.string()).min(1, '至少选择一个合同'),
|
||||
years: z.number().int().min(1).max(5).default(3),
|
||||
})
|
||||
|
||||
export const addContractSchema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
signDate: z.string().datetime().nullable(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime().nullable(),
|
||||
contractType: z.enum(['FIXED', 'UNFIXED', 'UNSIGNED']),
|
||||
signMethod: z.enum(['PAPER', 'ELECTRONIC']).default('PAPER'),
|
||||
contractYears: z.number().int().min(1).max(10).default(3),
|
||||
probationMonths: z.number().int().min(0).max(6).default(0),
|
||||
probationSalary: z.number().min(0).default(0),
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const portalLoginSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
password: z.string().min(6, '密码至少6位'),
|
||||
})
|
||||
|
||||
export const portalSendCodeSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
})
|
||||
|
||||
export const portalVerifyCodeSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
code: z.string().length(6, '验证码为6位数字'),
|
||||
})
|
||||
|
||||
export const onboardingSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
name: z.string().min(1, '姓名不能为空'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
idCard: z.string().min(15, '身份证号格式不正确').max(18),
|
||||
emergencyContact: z.string().optional(),
|
||||
emergencyPhone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
bankCard: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
})
|
||||
|
||||
export const contractConfirmSchema = z.object({
|
||||
token: z.string().min(1, '缺少 token'),
|
||||
agreed: z.boolean().refine((v) => v === true, '请勾选确认签署'),
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const terminationChecklistSchema = z.object({
|
||||
employeeId: z.string().min(1, '请选择员工'),
|
||||
reason: z.enum(['NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF', 'EXPIRED']),
|
||||
terminationDate: z.string().datetime(),
|
||||
compensation: z.number().min(0).default(0),
|
||||
checklist: z.record(z.boolean()).default({}),
|
||||
remark: z.string().max(500).optional(),
|
||||
})
|
||||
|
||||
export const terminationQuerySchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
pageSize: z.coerce.number().min(1).max(50).default(20),
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import OpenAI from 'openai'
|
||||
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
const client = new OpenAI({ apiKey, baseURL })
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
|
||||
|
||||
你的职责:
|
||||
1. 回答用户关于劳动用工的合规问题
|
||||
2. 基于企业实际数据给出针对性建议
|
||||
3. 引用具体法律条文作为依据
|
||||
4. 用通俗易懂的语言解释法律问题
|
||||
|
||||
回答要求:
|
||||
- 先给出直接结论,再展开解释
|
||||
- 引用法律条文时标注具体法律名称和条款号
|
||||
- 涉及金额时给出计算过程
|
||||
- 如有关联的企业数据,在回答中提及
|
||||
- 回答简洁有力,避免冗长`
|
||||
|
||||
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
|
||||
const systemMessage = orgContext
|
||||
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}`
|
||||
: SYSTEM_PROMPT
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function reviewContract(contractText: string) {
|
||||
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
|
||||
|
||||
合同文本:
|
||||
${contractText}
|
||||
|
||||
请按以下格式输出:
|
||||
【风险项】
|
||||
🔴/🟡/🟢 [问题标题] - [说明] - [修改建议]
|
||||
|
||||
【合规评分】XX/100
|
||||
|
||||
【总体建议】
|
||||
一段话总结`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function matchCase(scenario: string) {
|
||||
const prompt = `作为一个劳动法案例匹配专家,请分析以下劳动争议情形,匹配相似的仲裁/诉讼案例,评估败诉风险。
|
||||
|
||||
争议情形:
|
||||
${scenario}
|
||||
|
||||
请按以下格式输出:
|
||||
【相似案例】
|
||||
案例1:[案例标题]
|
||||
- 情形:[简要描述]
|
||||
- 结果:[判决结果]
|
||||
- 赔偿金额:[金额]
|
||||
- 相似度:XX%
|
||||
|
||||
案例2:...
|
||||
|
||||
【败诉风险评估】
|
||||
风险等级:高/中/低(XX%)
|
||||
原因:[分析]
|
||||
|
||||
【建议】
|
||||
[降低风险的具体建议]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法案例分析专家,熟悉劳动仲裁和诉讼案例。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function predictRisks(orgContext: string) {
|
||||
const prompt = `基于以下企业用工数据,预测未来30天可能出现的合规风险,并给出优先级建议。
|
||||
|
||||
企业数据:
|
||||
${orgContext}
|
||||
|
||||
请按以下格式输出:
|
||||
【未来30天预计风险】
|
||||
- [员工姓名/风险描述] → [建议措施]
|
||||
|
||||
【优先级建议】
|
||||
[先处理什么,再处理什么]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动用工风险预测专家,能基于企业数据分析未来风险趋势。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 1500,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/jwt'
|
||||
|
||||
export async function register(orgName: string, phone: string, password: string) {
|
||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE', message: '该手机号已注册' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: orgName,
|
||||
plan: 'FREE',
|
||||
maxEmployees: 20,
|
||||
},
|
||||
})
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
phone,
|
||||
name: '管理员',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(phone: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string) {
|
||||
const payload = verifyRefreshToken(refreshToken)
|
||||
if (!payload) {
|
||||
throw { code: 'TOKEN_INVALID', message: 'Refresh Token 无效或已过期' }
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '用户不存在' }
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
return { accessToken }
|
||||
}
|
||||
|
||||
export async function resetPassword(phone: string, newPassword: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号未注册' }
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt, decrypt } from '../lib/crypto'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function getContractStatus(contract: {
|
||||
signDate: Date | null
|
||||
startDate: Date
|
||||
endDate: Date | null
|
||||
contractType: string
|
||||
hireDate: Date
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
if (days > 365) {
|
||||
return { status: 'unsigned_over_year', statusText: '已视为无固定期限', riskLevel: 'high' }
|
||||
} else if (days > 30) {
|
||||
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
|
||||
}
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
return { status: 'expired', statusText: '已到期未续签', riskLevel: 'high' }
|
||||
} else if (daysToExpire <= 30) {
|
||||
return { status: 'expiring', statusText: `即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
return { status: 'active', statusText: '正常', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
return { status: 'unfixed', statusText: '无固定期限', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
|
||||
let max = 0
|
||||
if (contractMonths >= 36) max = 6
|
||||
else if (contractMonths >= 12) max = 2
|
||||
else if (contractMonths >= 3) max = 1
|
||||
|
||||
if (probationMonths > max) {
|
||||
return {
|
||||
valid: false,
|
||||
max,
|
||||
message: `${contractMonths}个月合同试用期最多${max}个月,当前${probationMonths}个月不合法`,
|
||||
}
|
||||
}
|
||||
return { valid: true, max }
|
||||
}
|
||||
|
||||
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 20
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const where: any = { orgId, status: 'ACTIVE' }
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search } },
|
||||
{ phone: { contains: params.search } },
|
||||
]
|
||||
}
|
||||
if (params.department) {
|
||||
where.department = params.department
|
||||
}
|
||||
|
||||
const [total, employees] = await Promise.all([
|
||||
prisma.employee.count({ where }),
|
||||
prisma.employee.findMany({
|
||||
where,
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
const latestContract = emp.contracts[0]
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: emp.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||
status: emp.status,
|
||||
monthlySalary: decryptedSalary,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
isPregnant: emp.isPregnant,
|
||||
isInMedicalPeriod: emp.isInMedicalPeriod,
|
||||
isWorkInjured: emp.isWorkInjured,
|
||||
}
|
||||
})
|
||||
|
||||
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
|
||||
export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id, orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
riskItems: { where: { status: 'PENDING' }, orderBy: { level: 'asc' } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(employee.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
monthlySalary: decryptedSalary,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: data.name,
|
||||
department: data.department,
|
||||
hireDate: new Date(data.hireDate),
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
gender: data.gender,
|
||||
phone: data.phone,
|
||||
isPregnant: data.isPregnant || false,
|
||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||
isWorkInjured: data.isWorkInjured || false,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
|
||||
const contractMonths = data.contract.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
|
||||
: data.contract.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
|
||||
startDate: new Date(data.contract.startDate),
|
||||
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
|
||||
contractType: data.contract.contractType,
|
||||
signMethod: data.contract.signMethod || 'PAPER',
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: employee.id }
|
||||
}
|
||||
|
||||
export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.department !== undefined) updateData.department = data.department
|
||||
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
|
||||
if (data.monthlySalary !== undefined) updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
if (data.gender !== undefined) updateData.gender = data.gender
|
||||
if (data.phone !== undefined) updateData.phone = data.phone
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function deleteEmployee(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: { status: 'RESIGNED' } })
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: id, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function batchRenew(orgId: string, userId: string, contractIds: string[], years: number) {
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: { id: { in: contractIds }, orgId },
|
||||
})
|
||||
|
||||
if (contracts.length === 0) {
|
||||
throw { code: 'NOT_FOUND', message: '未找到符合条件的合同' }
|
||||
}
|
||||
|
||||
for (const contract of contracts) {
|
||||
const newStartDate = contract.endDate || new Date()
|
||||
const newEndDate = new Date(newStartDate)
|
||||
newEndDate.setFullYear(newEndDate.getFullYear() + years)
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: contract.employeeId,
|
||||
signDate: new Date(),
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
contractType: contract.contractType,
|
||||
signMethod: contract.signMethod,
|
||||
contractYears: years,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
renewalCount: contract.renewalCount + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { renewed: contracts.length }
|
||||
}
|
||||
|
||||
export async function addContract(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const contractMonths = data.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
|
||||
: data.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
signDate: data.signDate ? new Date(data.signDate) : null,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
contractType: data.contractType,
|
||||
signMethod: data.signMethod || 'PAPER',
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: contract.id }
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import type { ContractType, RiskLevel, RiskType } from '@prisma/client'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export async function detectContractRisks(orgId: string) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' } } },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
const latestContract = emp.contracts[0]
|
||||
|
||||
if (!latestContract || latestContract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(new Date(), emp.hireDate)
|
||||
if (days > 365) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同,已视为无固定期限`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过1年未签订书面合同,法律上已视为无固定期限劳动合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (days > 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过30天未签订书面合同,需尽快补签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'LOW',
|
||||
title: `${emp.name}入职${days}天,尚未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},30天内需签订书面合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (latestContract.endDate) {
|
||||
const daysToExpire = daysBetween(latestContract.endDate, new Date())
|
||||
if (daysToExpire < 0) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}的合同已到期${Math.abs(daysToExpire)}天未续签`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},已过期未续签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (latestContract.probationMonths > 0) {
|
||||
const contractMonths = latestContract.endDate
|
||||
? Math.ceil(daysBetween(latestContract.endDate, latestContract.startDate) / 30.44)
|
||||
: 36
|
||||
let maxProbation = 0
|
||||
if (contractMonths >= 36) maxProbation = 6
|
||||
else if (contractMonths >= 12) maxProbation = 2
|
||||
else if (contractMonths >= 3) maxProbation = 1
|
||||
|
||||
if (latestContract.probationMonths > maxProbation) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}试用期${latestContract.probationMonths}个月可能不合法`,
|
||||
description: `${contractMonths}个月合同试用期最多${maxProbation}个月,当前${latestContract.probationMonths}个月超出法定上限。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectTerminationRisks(orgId: string) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
if (emp.isPregnant) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isInMedicalPeriod) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isWorkInjured) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}工伤期间,解聘受限`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectMonthlyTasks(orgId: string) {
|
||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId } })
|
||||
if (!setting) return []
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const today = now.getDate()
|
||||
|
||||
const tasks = [
|
||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
|
||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
|
||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
|
||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
|
||||
]
|
||||
|
||||
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const task of tasks) {
|
||||
// 当月已过截止日或正好到截止日时生成提醒
|
||||
if (today >= task.day) {
|
||||
risks.push({
|
||||
employeeId: null,
|
||||
type: 'MONTHLY',
|
||||
level: today > task.day + 3 ? 'HIGH' : 'MEDIUM',
|
||||
title: task.title,
|
||||
description: task.desc,
|
||||
actionUrl: task.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function runRiskDetection(orgId: string) {
|
||||
const existingRisks = await prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
// 月度任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的月度任务被重新创建
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyExisting = await prisma.riskItem.findMany({
|
||||
where: { orgId, type: 'MONTHLY', title: { startsWith: `${currentMonth}月` } },
|
||||
select: { employeeId: true, title: true },
|
||||
})
|
||||
const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
const contractRisks = await detectContractRisks(orgId)
|
||||
const terminationRisks = await detectTerminationRisks(orgId)
|
||||
const monthlyTasks = await detectMonthlyTasks(orgId)
|
||||
|
||||
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks]
|
||||
const toCreate = [
|
||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
]
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.riskItem.createMany({
|
||||
data: toCreate.map((r) => ({
|
||||
orgId,
|
||||
employeeId: r.employeeId,
|
||||
type: r.type,
|
||||
level: r.level,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return toCreate.length
|
||||
}
|
||||
|
||||
export async function getDashboardData(orgId: string) {
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
const [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, socialConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
||||
take: 10,
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'RESOLVED' },
|
||||
include: { employee: true },
|
||||
orderBy: { resolvedAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true },
|
||||
}),
|
||||
prisma.payslip.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true },
|
||||
}),
|
||||
prisma.socialInsuranceConfig.findUnique({ where: { orgId } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.disciplinaryRecord.count({
|
||||
where: { orgId, violationDate: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.attendanceRecord.count({
|
||||
where: { orgId, date: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.aggregate({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
_sum: { compensation: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0)
|
||||
|
||||
// 本月薪税汇总
|
||||
const totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
|
||||
const totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
|
||||
const totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
|
||||
const totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
|
||||
const totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
|
||||
const confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
|
||||
// 社保公积金估算(基于在职员工数 × 社保配置)
|
||||
let socialOrgTotal = 0
|
||||
let socialEmpTotal = 0
|
||||
let housingOrgTotal = 0
|
||||
let housingEmpTotal = 0
|
||||
if (socialConfig && employeeCount > 0) {
|
||||
// 用平均工资作为估算基数
|
||||
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
|
||||
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
|
||||
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
|
||||
housingOrgTotal = avgBase * socialConfig.housingOrg / 100 * employeeCount
|
||||
housingEmpTotal = avgBase * socialConfig.housingEmp / 100 * employeeCount
|
||||
}
|
||||
|
||||
// 个税估算(简化:应纳税所得额 = 税前工资 - 5000起征点 - 社保个人部分 - 公积金个人部分)
|
||||
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
|
||||
// 累计预扣法简化:月度个税估算
|
||||
let estimatedTax = 0
|
||||
if (taxableIncome > 0) {
|
||||
if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03
|
||||
else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1
|
||||
else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2
|
||||
else if (taxableIncome <= 35000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + (taxableIncome - 25000) * 0.25
|
||||
else if (taxableIncome <= 55000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + (taxableIncome - 35000) * 0.3
|
||||
else if (taxableIncome <= 80000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + (taxableIncome - 55000) * 0.35
|
||||
else estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + 25000 * 0.35 + (taxableIncome - 80000) * 0.45
|
||||
}
|
||||
|
||||
const payrollSummary = {
|
||||
month: currentMonth,
|
||||
employeeCount,
|
||||
payslipCount: payslips.length,
|
||||
confirmedPayslips,
|
||||
unconfirmedPayslips: payslips.length - confirmedPayslips,
|
||||
baseSalary: totalBaseSalary,
|
||||
overtimePay: totalOvertimePay,
|
||||
allowance: totalAllowance,
|
||||
deduction: totalDeduction,
|
||||
totalPay,
|
||||
socialOrg: socialOrgTotal,
|
||||
socialEmp: socialEmpTotal,
|
||||
housingOrg: housingOrgTotal,
|
||||
housingEmp: housingEmpTotal,
|
||||
estimatedTax,
|
||||
severancePay: monthSeverancePay._sum.compensation || 0,
|
||||
// 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金
|
||||
orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0),
|
||||
// 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税
|
||||
empNetPay: totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
|
||||
}
|
||||
|
||||
// 本月工作动态
|
||||
const monthlyActivities = {
|
||||
month: currentMonth,
|
||||
newContracts: monthContracts,
|
||||
terminations: monthTerminations,
|
||||
disciplinaryActions: monthDisciplinary,
|
||||
attendanceRecords: monthAttendance,
|
||||
overtimeHours: overtimeRecords.reduce((s: number, r: typeof overtimeRecords[number]) => s + r.weekdayHours + r.weekendHours + r.holidayHours, 0),
|
||||
overtimePay: monthlyOvertimePay,
|
||||
}
|
||||
|
||||
const riskDistribution = {
|
||||
contract: riskItems.filter((r: typeof riskItems[number]) => r.type === 'CONTRACT').length,
|
||||
salary: riskItems.filter((r: typeof riskItems[number]) => r.type === 'SALARY').length,
|
||||
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
const todos = riskItems.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
}))
|
||||
|
||||
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
|
||||
id: r.id,
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
resolvedAt: r.resolvedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12
|
||||
? `早上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: hour < 18
|
||||
? `下午好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: `晚上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
|
||||
return {
|
||||
greeting,
|
||||
stats: {
|
||||
employeeCount,
|
||||
highRiskCount: highRisks,
|
||||
todoCount: pendingRisks,
|
||||
monthlyOvertimePay,
|
||||
},
|
||||
todos,
|
||||
resolvedTodos,
|
||||
riskDistribution,
|
||||
aiPrediction: null,
|
||||
payrollSummary,
|
||||
monthlyActivities,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { RiskAssessment } from '@prisma/client'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export function getChecklistForReason(reason: string): { key: string; label: string }[] {
|
||||
switch (reason) {
|
||||
case 'NEGOTIATED':
|
||||
return [
|
||||
{ key: 'compensation_paid', label: '是否已支付经济补偿金' },
|
||||
{ key: 'agreement_signed', label: '是否签署协商解除协议' },
|
||||
{ key: 'final_pay_ready', label: '是否结清最后工资' },
|
||||
]
|
||||
case 'FAULT':
|
||||
return [
|
||||
{ key: 'has_rules', label: '是否有规章制度依据' },
|
||||
{ key: 'has_evidence', label: '是否有违纪证据' },
|
||||
{ key: 'notify_union', label: '是否事先通知工会' },
|
||||
{ key: 'written_notice', label: '是否出具书面解除通知' },
|
||||
]
|
||||
case 'NONFAULT':
|
||||
return [
|
||||
{ key: 'medical_period_end', label: '医疗期是否已届满' },
|
||||
{ key: 'training_given', label: '是否经过培训或调岗' },
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金' },
|
||||
{ key: 'advance_notice', label: '是否提前30天通知或支付代通知金' },
|
||||
]
|
||||
case 'LAYOFF':
|
||||
return [
|
||||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明' },
|
||||
{ key: 'listen_opinions', label: '是否听取工会或职工意见' },
|
||||
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告' },
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金' },
|
||||
]
|
||||
case 'EXPIRED':
|
||||
return [
|
||||
{ key: 'compensation_paid', label: '是否支付经济补偿金(如需)' },
|
||||
{ key: 'written_notice', label: '是否提前通知员工不续签' },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function assessRisk(employee: any, reason: string): { level: RiskAssessment; warnings: string[] } {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (employee.isPregnant) {
|
||||
warnings.push('该员工在孕期/哺乳期,法律禁止以非过错理由解除')
|
||||
}
|
||||
if (employee.isWorkInjured) {
|
||||
warnings.push('工伤期间不得解除劳动合同')
|
||||
}
|
||||
if (employee.isInMedicalPeriod && reason !== 'FAULT') {
|
||||
warnings.push('医疗期内不得解除劳动合同(非过错理由)')
|
||||
}
|
||||
|
||||
let level: RiskAssessment = 'SAFE'
|
||||
if (warnings.length > 0) {
|
||||
level = 'DANGER'
|
||||
}
|
||||
|
||||
return { level, warnings }
|
||||
}
|
||||
|
||||
export async function createTermination(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const { level } = assessRisk(employee, data.reason)
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
reason: data.reason,
|
||||
terminationDate: new Date(data.terminationDate),
|
||||
compensation: data.compensation || 0,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: { status: 'RESIGNED' },
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: data.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
export async function getTerminations(orgId: string, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [total, records] = await Promise.all([
|
||||
prisma.terminationRecord.count({ where: { orgId } }),
|
||||
prisma.terminationRecord.findMany({
|
||||
where: { orgId },
|
||||
include: { employee: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items: records.map((r) => ({
|
||||
id: r.id,
|
||||
employeeName: r.employee.name,
|
||||
department: r.employee.department,
|
||||
reason: r.reason,
|
||||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||||
compensation: r.compensation,
|
||||
riskLevel: r.riskLevel,
|
||||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWage: number, socialAvgWage: number = 0): {
|
||||
years: number
|
||||
remainingMonths: number
|
||||
compMonths: number
|
||||
totalPay: number
|
||||
capped: boolean
|
||||
} {
|
||||
const totalMonths = (leaveDate.getFullYear() - hireDate.getFullYear()) * 12 + (leaveDate.getMonth() - hireDate.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
let wage = monthlyWage
|
||||
let capped = false
|
||||
if (socialAvgWage > 0 && monthlyWage > socialAvgWage * 3) {
|
||||
wage = socialAvgWage * 3
|
||||
compMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "prisma/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user