feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+21
View File
@@ -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
+3696
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"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",
"@types/multer": "^2.2.0",
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"exceljs": "^4.4.0",
"express": "^4.19.0",
"express-rate-limit": "^7.4.0",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"node-cron": "^3.0.3",
"openai": "^6.48.0",
"uuid": "^10.0.0",
"xlsx": "^0.18.5",
"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,16 @@
-- 解聘流程状态机:仅新增列,不删除/修改现有列
-- PostgreSQL 语法,安全执行不会丢失数据
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "status" TEXT NOT NULL DEFAULT 'DRAFT';
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "currentStep" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "compensationBreakdown" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "checklistOverrides" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "handoverItems" JSONB;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvedBy" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvedAt" TIMESTAMP(3);
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "approvalComment" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "updatedBy" TEXT;
ALTER TABLE "TerminationRecord" ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP;
-- 创建索引
CREATE INDEX IF NOT EXISTS "TerminationRecord_orgId_status_idx" ON "TerminationRecord"("orgId", "status");
@@ -0,0 +1,931 @@
-- 启用 pgvector 扩展(RAG 知识库需要 vector 类型)
CREATE EXTENSION IF NOT EXISTS vector;
-- CreateEnum
CREATE TYPE "Plan" AS ENUM ('FREE', 'PRO', 'ENTERPRISE');
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('ADMIN', 'HR', 'VIEWER');
-- CreateEnum
CREATE TYPE "EmployeeStatus" AS ENUM ('ACTIVE', 'RESIGNED');
-- CreateEnum
CREATE TYPE "ContractType" AS ENUM ('FIXED', 'UNFIXED', 'UNSIGNED');
-- CreateEnum
CREATE TYPE "SignMethod" AS ENUM ('PAPER', 'ELECTRONIC');
-- CreateEnum
CREATE TYPE "RiskType" AS ENUM ('CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING');
-- CreateEnum
CREATE TYPE "RiskLevel" AS ENUM ('HIGH', 'MEDIUM', 'LOW');
-- CreateEnum
CREATE TYPE "PayrollBatchType" AS ENUM ('REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE');
-- CreateEnum
CREATE TYPE "PayrollBatchStatus" AS ENUM ('DRAFT', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "PayslipItemType" AS ENUM ('INPUT', 'CALCULATED');
-- CreateEnum
CREATE TYPE "PayslipStatus" AS ENUM ('PENDING', 'PUBLISHED');
-- CreateEnum
CREATE TYPE "RiskStatus" AS ENUM ('PENDING', 'RESOLVED', 'IGNORED');
-- CreateEnum
CREATE TYPE "TerminationReason" AS ENUM ('NEGOTIATED', 'FAULT', 'NONFAULT', 'LAYOFF', 'EXPIRED', 'RESIGNATION');
-- CreateEnum
CREATE TYPE "RiskAssessment" AS ENUM ('SAFE', 'WARNING', 'DANGER');
-- CreateEnum
CREATE TYPE "OnboardingStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "ContractConfirmStatus" AS ENUM ('UNCONFIRMED', 'CONFIRMED', 'EXPIRED');
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"plan" "Plan" NOT NULL DEFAULT 'FREE',
"maxEmployees" INTEGER NOT NULL DEFAULT 20,
"city" TEXT,
"payrollFrequency" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'ADMIN',
"disabled" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastLoginAt" TIMESTAMP(3),
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Employee" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"department" TEXT NOT NULL,
"hireDate" TIMESTAMP(3) NOT NULL,
"monthlySalary" TEXT NOT NULL,
"status" "EmployeeStatus" NOT NULL DEFAULT 'ACTIVE',
"gender" TEXT,
"phone" TEXT,
"idCardNumber" TEXT,
"idCardHash" TEXT,
"emergencyContact" TEXT,
"emergencyPhone" TEXT,
"address" TEXT,
"bankAccount" TEXT,
"bankName" TEXT,
"passwordHash" TEXT,
"isPregnant" BOOLEAN NOT NULL DEFAULT false,
"isInMedicalPeriod" BOOLEAN NOT NULL DEFAULT false,
"isWorkInjured" BOOLEAN NOT NULL DEFAULT false,
"socialInsBase" DOUBLE PRECISION,
"housingFundBase" DOUBLE PRECISION,
"socialInsStartMonth" TEXT,
"socialInsEndMonth" TEXT,
"housingFundStartMonth" TEXT,
"housingFundEndMonth" TEXT,
"specialDeduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"city" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Employee_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LaborContract" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"signDate" TIMESTAMP(3),
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"contractType" "ContractType" NOT NULL,
"signMethod" "SignMethod" NOT NULL DEFAULT 'PAPER',
"contractYears" INTEGER NOT NULL DEFAULT 3,
"probationMonths" INTEGER NOT NULL DEFAULT 0,
"probationSalary" INTEGER NOT NULL DEFAULT 0,
"renewalCount" INTEGER NOT NULL DEFAULT 0,
"attachmentName" TEXT,
"attachmentUrl" TEXT,
"electronicContractNo" TEXT,
"electronicContractUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LaborContract_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OvertimeRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"weekdayHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekdayPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"batchId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OvertimeRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TerminationRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'TERMINATION',
"reason" "TerminationReason" NOT NULL,
"terminationDate" TIMESTAMP(3) NOT NULL,
"resignationReason" TEXT,
"compensation" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialInsEndMonth" TEXT,
"housingFundEndMonth" TEXT,
"riskLevel" "RiskAssessment" NOT NULL DEFAULT 'SAFE',
"checklist" JSONB NOT NULL,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TerminationRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RiskItem" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT,
"type" "RiskType" NOT NULL,
"level" "RiskLevel" NOT NULL,
"status" "RiskStatus" NOT NULL DEFAULT 'PENDING',
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"actionUrl" TEXT,
"resolvedAt" TIMESTAMP(3),
"resolvedBy" TEXT,
"remark" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RiskItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entityId" TEXT,
"detail" JSONB,
"ip" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SocialInsuranceConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"pensionOrg" DOUBLE PRECISION NOT NULL DEFAULT 16,
"pensionEmp" DOUBLE PRECISION NOT NULL DEFAULT 8,
"medicalOrg" DOUBLE PRECISION NOT NULL DEFAULT 9.8,
"medicalEmp" DOUBLE PRECISION NOT NULL DEFAULT 2,
"unemploymentOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"unemploymentEmp" DOUBLE PRECISION NOT NULL DEFAULT 0.5,
"injuryOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.2,
"maternityOrg" DOUBLE PRECISION NOT NULL DEFAULT 0.8,
"baseMin" DOUBLE PRECISION NOT NULL DEFAULT 6326,
"baseMax" DOUBLE PRECISION NOT NULL DEFAULT 33891,
"effectiveFrom" TEXT NOT NULL,
"effectiveTo" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"adjustmentDone" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SocialInsuranceConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "HousingFundConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"housingOrg" DOUBLE PRECISION NOT NULL DEFAULT 12,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 12,
"baseMin" DOUBLE PRECISION NOT NULL DEFAULT 6326,
"baseMax" DOUBLE PRECISION NOT NULL DEFAULT 33891,
"effectiveFrom" TEXT NOT NULL,
"effectiveTo" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"adjustmentDone" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HousingFundConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationSetting" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"contractExpiry" BOOLEAN NOT NULL DEFAULT true,
"expiryDays" INTEGER NOT NULL DEFAULT 30,
"contractUnsigned" BOOLEAN NOT NULL DEFAULT true,
"overtimeAlert" BOOLEAN NOT NULL DEFAULT true,
"payslipReady" BOOLEAN NOT NULL DEFAULT true,
"payrollDay" INTEGER NOT NULL DEFAULT 10,
"socialInsDay" INTEGER NOT NULL DEFAULT 15,
"housingFundDay" INTEGER NOT NULL DEFAULT 15,
"taxDay" INTEGER NOT NULL DEFAULT 15,
"wechatWebhook" TEXT,
"emailNotify" BOOLEAN NOT NULL DEFAULT false,
"email" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NotificationSetting_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OvertimeConfig" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"weekdayRate" DOUBLE PRECISION NOT NULL DEFAULT 1.5,
"weekendRate" DOUBLE PRECISION NOT NULL DEFAULT 2.0,
"holidayRate" DOUBLE PRECISION NOT NULL DEFAULT 3.0,
"monthlyDays" DOUBLE PRECISION NOT NULL DEFAULT 21.75,
"dailyHours" DOUBLE PRECISION NOT NULL DEFAULT 8,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OvertimeConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationLog" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'SENT',
"employeeId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NotificationLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeAttachment" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"fileType" TEXT NOT NULL,
"fileUrl" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL DEFAULT 0,
"uploadedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeAttachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DisciplinaryRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"violationDate" TIMESTAMP(3) NOT NULL,
"violationType" TEXT NOT NULL,
"description" TEXT NOT NULL,
"severity" TEXT NOT NULL DEFAULT 'WARNING',
"action" TEXT NOT NULL DEFAULT 'ORAL_WARNING',
"actionDetail" TEXT,
"employeeAck" BOOLEAN NOT NULL DEFAULT false,
"ackDate" TIMESTAMP(3),
"ackMethod" TEXT,
"witness" TEXT,
"attachmentUrl" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DisciplinaryRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AttendanceRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"checkInTime" TEXT,
"checkOutTime" TEXT,
"status" TEXT NOT NULL DEFAULT 'NORMAL',
"lateMinutes" INTEGER NOT NULL DEFAULT 0,
"earlyMinutes" INTEGER NOT NULL DEFAULT 0,
"workHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimeHours" DOUBLE PRECISION NOT NULL DEFAULT 0,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AttendanceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TrainingRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"trainingDate" TIMESTAMP(3) NOT NULL,
"topic" TEXT NOT NULL,
"content" TEXT,
"trainer" TEXT,
"duration" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ackStatus" TEXT NOT NULL DEFAULT 'PENDING',
"ackDate" TIMESTAMP(3),
"attachmentUrl" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TrainingRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PerformanceRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"period" TEXT NOT NULL,
"score" DOUBLE PRECISION NOT NULL DEFAULT 0,
"grade" TEXT NOT NULL DEFAULT 'B',
"result" TEXT NOT NULL DEFAULT 'QUALIFIED',
"summary" TEXT,
"improvementPlan" TEXT,
"employeeAck" BOOLEAN NOT NULL DEFAULT false,
"ackDate" TIMESTAMP(3),
"reviewer" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PerformanceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payslip" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"baseSalary" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekdayOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"weekendOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"holidayOvertimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"allowance" DOUBLE PRECISION NOT NULL DEFAULT 0,
"deduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"bonus" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"tax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"netPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdIncome" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdTaxDeducted" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdSocialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"ytdHousingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"status" "PayslipStatus" NOT NULL DEFAULT 'PENDING',
"confirmedAt" TIMESTAMP(3),
"confirmedIp" TEXT,
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payslip_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayrollBatch" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"month" TEXT NOT NULL,
"batchNo" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"type" "PayrollBatchType" NOT NULL DEFAULT 'REGULAR',
"status" "PayrollBatchStatus" NOT NULL DEFAULT 'DRAFT',
"employeeCount" INTEGER NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalNetPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalSocialOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalSocialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalHousingOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalHousingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalTax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"archivedAt" TIMESTAMP(3),
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayrollBatch_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BatchEntry" (
"id" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"baseSalary" DOUBLE PRECISION NOT NULL DEFAULT 0,
"overtimePay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"allowance" DOUBLE PRECISION NOT NULL DEFAULT 0,
"deduction" DOUBLE PRECISION NOT NULL DEFAULT 0,
"bonus" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"socialOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingEmp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"housingOrg" DOUBLE PRECISION NOT NULL DEFAULT 0,
"tax" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"netPay" DOUBLE PRECISION NOT NULL DEFAULT 0,
"riskWarnings" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BatchEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PayslipItem" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"type" "PayslipItemType" NOT NULL DEFAULT 'INPUT',
"formula" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isDefault" BOOLEAN NOT NULL DEFAULT true,
"isEditable" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PayslipItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalaryChangeRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"oldSalary" DOUBLE PRECISION NOT NULL,
"newSalary" DOUBLE PRECISION NOT NULL,
"effectiveDate" TIMESTAMP(3) NOT NULL,
"effectiveMonth" TEXT NOT NULL,
"endMonth" TEXT,
"changeType" TEXT NOT NULL DEFAULT 'SALARY_CHANGE',
"reason" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalaryChangeRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeSocialInsRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"startMonth" TEXT NOT NULL,
"endMonth" TEXT,
"base" DOUBLE PRECISION NOT NULL,
"changeType" TEXT NOT NULL,
"changeRefId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeSocialInsRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeHousingFundRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"city" TEXT NOT NULL DEFAULT '北京',
"startMonth" TEXT NOT NULL,
"endMonth" TEXT,
"base" DOUBLE PRECISION NOT NULL,
"changeType" TEXT NOT NULL,
"changeRefId" TEXT,
"remark" TEXT,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeHousingFundRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EmployeeDepartmentRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT NOT NULL,
"oldDepartment" TEXT NOT NULL,
"newDepartment" TEXT NOT NULL,
"effectiveMonth" TEXT NOT NULL,
"endMonth" TEXT,
"reason" TEXT,
"changeType" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EmployeeDepartmentRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OnboardingLink" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"employeeName" TEXT,
"phone" TEXT,
"status" "OnboardingStatus" NOT NULL DEFAULT 'PENDING',
"formData" JSONB,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OnboardingLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractConfirmLink" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"contractId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"status" "ContractConfirmStatus" NOT NULL DEFAULT 'UNCONFIRMED',
"confirmedAt" TIMESTAMP(3),
"confirmedIp" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ContractConfirmLink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AIConversation" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL DEFAULT '新对话',
"messages" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AIConversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AIReviewRecord" (
"id" TEXT NOT NULL,
"orgId" TEXT NOT NULL,
"employeeId" TEXT,
"type" TEXT NOT NULL,
"input" TEXT NOT NULL,
"result" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AIReviewRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rag_knowledge" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"source" TEXT NOT NULL,
"category" TEXT NOT NULL,
"embedding" vector(1536),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rag_knowledge_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Employee_orgId_idCardHash_key" ON "Employee"("orgId", "idCardHash");
-- CreateIndex
CREATE UNIQUE INDEX "OvertimeRecord_employeeId_month_key" ON "OvertimeRecord"("employeeId", "month");
-- CreateIndex
CREATE INDEX "RiskItem_orgId_status_idx" ON "RiskItem"("orgId", "status");
-- CreateIndex
CREATE INDEX "RiskItem_orgId_type_idx" ON "RiskItem"("orgId", "type");
-- CreateIndex
CREATE INDEX "AuditLog_orgId_createdAt_idx" ON "AuditLog"("orgId", "createdAt");
-- CreateIndex
CREATE INDEX "SocialInsuranceConfig_orgId_isCurrent_idx" ON "SocialInsuranceConfig"("orgId", "isCurrent");
-- CreateIndex
CREATE UNIQUE INDEX "SocialInsuranceConfig_orgId_city_effectiveFrom_key" ON "SocialInsuranceConfig"("orgId", "city", "effectiveFrom");
-- CreateIndex
CREATE INDEX "HousingFundConfig_orgId_isCurrent_idx" ON "HousingFundConfig"("orgId", "isCurrent");
-- CreateIndex
CREATE UNIQUE INDEX "HousingFundConfig_orgId_city_effectiveFrom_key" ON "HousingFundConfig"("orgId", "city", "effectiveFrom");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationSetting_orgId_key" ON "NotificationSetting"("orgId");
-- CreateIndex
CREATE UNIQUE INDEX "OvertimeConfig_orgId_key" ON "OvertimeConfig"("orgId");
-- CreateIndex
CREATE INDEX "NotificationLog_orgId_createdAt_idx" ON "NotificationLog"("orgId", "createdAt");
-- CreateIndex
CREATE INDEX "EmployeeAttachment_orgId_employeeId_idx" ON "EmployeeAttachment"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "DisciplinaryRecord_orgId_employeeId_idx" ON "DisciplinaryRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "AttendanceRecord_orgId_employeeId_idx" ON "AttendanceRecord"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "AttendanceRecord_employeeId_date_key" ON "AttendanceRecord"("employeeId", "date");
-- CreateIndex
CREATE INDEX "TrainingRecord_orgId_employeeId_idx" ON "TrainingRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "PerformanceRecord_orgId_employeeId_idx" ON "PerformanceRecord"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "PerformanceRecord_employeeId_period_key" ON "PerformanceRecord"("employeeId", "period");
-- CreateIndex
CREATE INDEX "Payslip_orgId_month_idx" ON "Payslip"("orgId", "month");
-- CreateIndex
CREATE INDEX "Payslip_orgId_status_idx" ON "Payslip"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "Payslip_employeeId_month_key" ON "Payslip"("employeeId", "month");
-- CreateIndex
CREATE INDEX "PayrollBatch_orgId_month_idx" ON "PayrollBatch"("orgId", "month");
-- CreateIndex
CREATE INDEX "PayrollBatch_orgId_status_idx" ON "PayrollBatch"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "PayrollBatch_orgId_month_batchNo_key" ON "PayrollBatch"("orgId", "month", "batchNo");
-- CreateIndex
CREATE INDEX "BatchEntry_orgId_employeeId_idx" ON "BatchEntry"("orgId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "BatchEntry_batchId_employeeId_key" ON "BatchEntry"("batchId", "employeeId");
-- CreateIndex
CREATE UNIQUE INDEX "PayslipItem_orgId_code_key" ON "PayslipItem"("orgId", "code");
-- CreateIndex
CREATE INDEX "SalaryChangeRecord_orgId_employeeId_idx" ON "SalaryChangeRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "SalaryChangeRecord_employeeId_effectiveMonth_endMonth_idx" ON "SalaryChangeRecord"("employeeId", "effectiveMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_orgId_employeeId_idx" ON "EmployeeSocialInsRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_employeeId_startMonth_endMonth_idx" ON "EmployeeSocialInsRecord"("employeeId", "startMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeSocialInsRecord_orgId_city_idx" ON "EmployeeSocialInsRecord"("orgId", "city");
-- CreateIndex
CREATE INDEX "EmployeeHousingFundRecord_orgId_employeeId_idx" ON "EmployeeHousingFundRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeHousingFundRecord_employeeId_startMonth_endMonth_idx" ON "EmployeeHousingFundRecord"("employeeId", "startMonth", "endMonth");
-- CreateIndex
CREATE INDEX "EmployeeDepartmentRecord_orgId_employeeId_idx" ON "EmployeeDepartmentRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "EmployeeDepartmentRecord_employeeId_effectiveMonth_endMonth_idx" ON "EmployeeDepartmentRecord"("employeeId", "effectiveMonth", "endMonth");
-- CreateIndex
CREATE UNIQUE INDEX "OnboardingLink_token_key" ON "OnboardingLink"("token");
-- CreateIndex
CREATE INDEX "OnboardingLink_orgId_status_idx" ON "OnboardingLink"("orgId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "ContractConfirmLink_token_key" ON "ContractConfirmLink"("token");
-- CreateIndex
CREATE INDEX "ContractConfirmLink_orgId_status_idx" ON "ContractConfirmLink"("orgId", "status");
-- CreateIndex
CREATE INDEX "AIConversation_orgId_userId_idx" ON "AIConversation"("orgId", "userId");
-- CreateIndex
CREATE INDEX "AIReviewRecord_orgId_employeeId_idx" ON "AIReviewRecord"("orgId", "employeeId");
-- CreateIndex
CREATE INDEX "rag_knowledge_category_idx" ON "rag_knowledge"("category");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Employee" ADD CONSTRAINT "Employee_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LaborContract" ADD CONSTRAINT "LaborContract_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeRecord" ADD CONSTRAINT "OvertimeRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeRecord" ADD CONSTRAINT "OvertimeRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TerminationRecord" ADD CONSTRAINT "TerminationRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TerminationRecord" ADD CONSTRAINT "TerminationRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RiskItem" ADD CONSTRAINT "RiskItem_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RiskItem" ADD CONSTRAINT "RiskItem_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SocialInsuranceConfig" ADD CONSTRAINT "SocialInsuranceConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HousingFundConfig" ADD CONSTRAINT "HousingFundConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationSetting" ADD CONSTRAINT "NotificationSetting_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OvertimeConfig" ADD CONSTRAINT "OvertimeConfig_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeAttachment" ADD CONSTRAINT "EmployeeAttachment_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeAttachment" ADD CONSTRAINT "EmployeeAttachment_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DisciplinaryRecord" ADD CONSTRAINT "DisciplinaryRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DisciplinaryRecord" ADD CONSTRAINT "DisciplinaryRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendanceRecord" ADD CONSTRAINT "AttendanceRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AttendanceRecord" ADD CONSTRAINT "AttendanceRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainingRecord" ADD CONSTRAINT "TrainingRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TrainingRecord" ADD CONSTRAINT "TrainingRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PerformanceRecord" ADD CONSTRAINT "PerformanceRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PerformanceRecord" ADD CONSTRAINT "PerformanceRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payslip" ADD CONSTRAINT "Payslip_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payslip" ADD CONSTRAINT "Payslip_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PayrollBatch" ADD CONSTRAINT "PayrollBatch_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchEntry" ADD CONSTRAINT "BatchEntry_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "PayrollBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BatchEntry" ADD CONSTRAINT "BatchEntry_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PayslipItem" ADD CONSTRAINT "PayslipItem_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryChangeRecord" ADD CONSTRAINT "SalaryChangeRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryChangeRecord" ADD CONSTRAINT "SalaryChangeRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeSocialInsRecord" ADD CONSTRAINT "EmployeeSocialInsRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeSocialInsRecord" ADD CONSTRAINT "EmployeeSocialInsRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeHousingFundRecord" ADD CONSTRAINT "EmployeeHousingFundRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeHousingFundRecord" ADD CONSTRAINT "EmployeeHousingFundRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeDepartmentRecord" ADD CONSTRAINT "EmployeeDepartmentRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EmployeeDepartmentRecord" ADD CONSTRAINT "EmployeeDepartmentRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OnboardingLink" ADD CONSTRAINT "OnboardingLink_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractConfirmLink" ADD CONSTRAINT "ContractConfirmLink_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractConfirmLink" ADD CONSTRAINT "ContractConfirmLink_contractId_fkey" FOREIGN KEY ("contractId") REFERENCES "LaborContract"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIConversation" ADD CONSTRAINT "AIConversation_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIReviewRecord" ADD CONSTRAINT "AIReviewRecord_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AIReviewRecord" ADD CONSTRAINT "AIReviewRecord_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+826
View File
@@ -0,0 +1,826 @@
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
ONBOARDING
}
enum RiskLevel {
HIGH
MEDIUM
LOW
}
enum PayrollBatchType {
REGULAR // 常规发薪
TERMINATION // 离职结算
BONUS // 年终奖/奖金
SEVERANCE // 补偿金按月发放(无社保,个税按政策处理)
}
enum PayrollBatchStatus {
DRAFT // 草稿(可编辑)
ARCHIVED // 归档(已发薪,锁定)
}
enum PayslipItemType {
INPUT // 手工输入项(计算依据)
CALCULATED // 计算项(公式自动计算)
}
enum PayslipStatus {
PENDING // 待发布
PUBLISHED // 已发布到员工端
}
enum RiskStatus {
PENDING
RESOLVED
IGNORED
}
enum TerminationReason {
NEGOTIATED
FAULT
NONFAULT
LAYOFF
EXPIRED
RESIGNATION
}
enum RiskAssessment {
SAFE
WARNING
DANGER
}
enum OnboardingStatus {
PENDING
APPROVED
REJECTED
CANCELLED
}
enum ContractConfirmStatus {
UNCONFIRMED
CONFIRMED
EXPIRED
}
// ========== 核心表 ==========
model Organization {
id String @id @default(cuid())
name String
plan Plan @default(FREE)
maxEmployees Int @default(20)
city String?
payrollFrequency Int @default(1) // 每月发薪次数(1=一次一批)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
employees Employee[]
contracts LaborContract[]
overtimeRecords OvertimeRecord[]
terminations TerminationRecord[]
riskItems RiskItem[]
auditLogs AuditLog[]
payslips Payslip[]
payrollBatches PayrollBatch[]
payslipItems PayslipItem[]
salaryChangeRecords SalaryChangeRecord[]
onboardingLinks OnboardingLink[]
confirmLinks ContractConfirmLink[]
aiConversations AIConversation[]
aiReviewRecords AIReviewRecord[]
socialInsuranceConfig SocialInsuranceConfig[]
housingFundConfigs HousingFundConfig[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
notificationSetting NotificationSetting?
overtimeConfig OvertimeConfig?
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)
disabled Boolean @default(false)
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 加密存储
idCardHash String? // SHA-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)
// 薪税扩展
socialInsBase Float? // 社保缴费基数(便捷字段,由Record同步)
housingFundBase Float? // 公积金缴费基数(便捷字段,由Record同步)
socialInsStartMonth String? // 当前社保开始年月(便捷字段)
socialInsEndMonth String? // 当前社保截止年月(便捷字段,null=在保)
housingFundStartMonth String? // 当前公积金开始年月(便捷字段)
housingFundEndMonth String? // 当前公积金截止年月(便捷字段)
specialDeduction Float @default(0) // 专项附加扣除(子女教育、赡养老人等,员工portal端填报)
city String? // 员工社保参保城市
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contracts LaborContract[]
overtimeRecords OvertimeRecord[]
terminations TerminationRecord[]
riskItems RiskItem[]
payslips Payslip[]
salaryChanges SalaryChangeRecord[]
batchEntries BatchEntry[]
attachments EmployeeAttachment[]
disciplinaryRecords DisciplinaryRecord[]
attendanceRecords AttendanceRecord[]
trainingRecords TrainingRecord[]
performanceRecords PerformanceRecord[]
socialInsRecords EmployeeSocialInsRecord[]
housingFundRecords EmployeeHousingFundRecord[]
departmentRecords EmployeeDepartmentRecord[]
aiReviewRecords AIReviewRecord[]
@@unique([orgId, idCardHash])
}
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)
batchId String? // 关联的发薪批次(加入后锁定,不可重复加入)
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)
type String @default("TERMINATION") // TERMINATION=公司解聘, RESIGNATION=员工主动离职
reason TerminationReason
terminationDate DateTime
resignationReason String? // 主动离职原因(type=RESIGNATION时使用)
compensation Float @default(0)
socialInsEndMonth String? // 社保截止缴费年月 YYYY-MM
housingFundEndMonth String? // 公积金截止缴费年月 YYYY-MM
riskLevel RiskAssessment @default(SAFE)
checklist Json
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 流程状态机
status String @default("DRAFT") // DRAFT|PENDING_APPROVAL|APPROVED|EXECUTING|COMPLETED|REJECTED|CANCELLED
currentStep Int @default(0) // 当前完成到第几步
// 补偿金分项明细 + 调整记录
compensationBreakdown Json? // { severance, noticePay, doublePay, other, adjustments: [{field, from, to, reason}] }
// 合规检查覆盖记录
checklistOverrides Json? // { key: { checked: bool, overrideReason: string } }
// 工作交接清单
handoverItems Json? // [{ key, label, done, remark }]
// 审批信息
approvedBy String?
approvedAt DateTime?
approvalComment String?
updatedBy String?
@@index([orgId, status])
}
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
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) // 生育保险 企业比例 %
baseMin Float @default(6326) // 社保缴费基数下限
baseMax Float @default(33891) // 社保缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过社保基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
model HousingFundConfig {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
city String @default("北京")
housingOrg Float @default(12) // 公积金 企业比例 %
housingEmp Float @default(12) // 公积金 个人比例 %
baseMin Float @default(6326) // 公积金缴费基数下限
baseMax Float @default(33891) // 公积金缴费基数上限
effectiveFrom String // 生效月份 YYYY-MM
effectiveTo String? // 失效月份 YYYY-MMnull=当前有效)
isCurrent Boolean @default(true) // 是否当前生效版本
adjustmentDone Boolean @default(false) // 是否已执行过公积金基数调整
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, city, effectiveFrom])
@@index([orgId, isCurrent])
}
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 OvertimeConfig {
id String @id @default(cuid())
orgId String @unique
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
weekdayRate Float @default(1.5) // 工作日加班倍率
weekendRate Float @default(2.0) // 休息日加班倍率
holidayRate Float @default(3.0) // 法定节假日加班倍率
monthlyDays Float @default(21.75) // 月计薪天数
dailyHours Float @default(8) // 每日工时
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)
bonus Float @default(0) // 奖金/年终奖
totalPay Float @default(0) // 应发合计
// 扣除项
socialEmp Float @default(0) // 个人社保
housingEmp Float @default(0) // 个人公积金
tax Float @default(0) // 个人所得税
netPay Float @default(0) // 实发工资 = totalPay - socialEmp - housingEmp - tax
// 累计预扣法
ytdIncome Float @default(0) // 当年累计收入
ytdTaxDeducted Float @default(0) // 当年累计已扣税
ytdSocialEmp Float @default(0) // 当年累计个人社保
ytdHousingEmp Float @default(0) // 当年累计个人公积金
// 状态
status PayslipStatus @default(PENDING) // PENDING → PUBLISHED
confirmedAt DateTime?
confirmedIp String?
publishedAt DateTime? // 工资条发布到员工端的时间
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([employeeId, month])
@@index([orgId, month])
@@index([orgId, status])
}
model PayrollBatch {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
month String // YYYY-MM
batchNo Int // 批次序号(1, 2, 3...
name String // 批次名称
type PayrollBatchType @default(REGULAR)
status PayrollBatchStatus @default(DRAFT)
employeeCount Int @default(0)
totalPay Float @default(0)
totalNetPay Float @default(0)
totalSocialOrg Float @default(0)
totalSocialEmp Float @default(0)
totalHousingOrg Float @default(0)
totalHousingEmp Float @default(0)
totalTax Float @default(0)
remark String?
createdBy String
createdAt DateTime @default(now())
archivedAt DateTime?
updatedAt DateTime @updatedAt
entries BatchEntry[]
@@unique([orgId, month, batchNo])
@@index([orgId, month])
@@index([orgId, status])
}
model BatchEntry {
id String @id @default(cuid())
batchId String
batch PayrollBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
orgId String
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
// 薪酬项(可编辑的输入项)
baseSalary Float @default(0)
overtimePay Float @default(0)
allowance Float @default(0)
deduction Float @default(0)
bonus Float @default(0)
// 自动计算项
socialEmp Float @default(0)
socialOrg Float @default(0)
housingEmp Float @default(0)
housingOrg Float @default(0)
tax Float @default(0)
totalPay Float @default(0) // 应发合计
netPay Float @default(0) // 实发工资
// 风险提示
riskWarnings Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([batchId, employeeId])
@@index([orgId, employeeId])
}
model PayslipItem {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String // 显示名称
code String // 字段代码
type PayslipItemType @default(INPUT)
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"
order Int @default(0)
isDefault Boolean @default(true) // 系统预置项不可删除
isEditable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([orgId, code])
}
model SalaryChangeRecord {
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)
oldSalary Float
newSalary Float
effectiveDate DateTime // 生效日期
effectiveMonth String // 生效年月 YYYY-MM(从 effectiveDate 转换)
endMonth String? // 失效年月 YYYY-MM(null=至今有效,被新版本覆盖时设置)
changeType String @default("SALARY_CHANGE") // ONBOARDING=入职, REHIRE=重新入职, SALARY_CHANGE=调薪
reason String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
model EmployeeSocialInsRecord {
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)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
@@index([orgId, city])
}
model EmployeeHousingFundRecord {
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)
city String @default("北京") // 参保城市
startMonth String // 开始缴费年月 YYYY-MM
endMonth String? // 截止缴费年月 YYYY-MMnull=至今有效)
base Float // 缴费基数
changeType String // ONBOARDING=入职, REHIRE=重新入职, ADJUST=调基, TERMINATION=离职/解聘
changeRefId String? // 关联的 TerminationRecord ID(离职/解聘时)
remark String?
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, startMonth, endMonth])
}
model EmployeeDepartmentRecord {
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)
oldDepartment String // 调整前部门
newDepartment String // 调整后部门
effectiveMonth String // 生效年月 YYYY-MM
endMonth String? // 失效年月 YYYY-MMnull=至今有效)
reason String? // 调部门原因
changeType String // ONBOARDING=入职, REHIRE=重新入职, TRANSFER=调部门
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
@@index([employeeId, effectiveMonth, endMonth])
}
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])
}
// ========== AI 会话 & 审查记录 ==========
model AIConversation {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
userId String
title String @default("新对话")
messages Json // [{ role, content }]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, userId])
}
model AIReviewRecord {
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 String // REVIEW=合同审查, CASE=案例匹配
input String // 用户输入的合同文本或争议情形
result String // AI 返回的审查/分析结果
createdBy String
createdAt DateTime @default(now())
@@index([orgId, employeeId])
}
// ========== RAG 知识库 ==========
model RagKnowledge {
id String @id
title String
content String
source String
category String
embedding Unsupported("vector(1536)")?
createdAt DateTime @default(now()) @map("created_at")
@@index([category])
@@map("rag_knowledge")
}
+120
View File
@@ -0,0 +1,120 @@
import prisma from '../src/lib/prisma'
const EID = 'cmrx61v6d001oqqcwb2pu2tih'
const ORGID = 'cmrx61v3l0000qqcwo3dr3h95'
const UID = 'cmrx61v5u0002qqcwqf4vlyth'
async function main() {
// 加班记录
const otMonths = [
{ month: '2025-03', wh: 8, weh: 4, hh: 0, wp: 600, wep: 600, hp: 0, pay: 1200 },
{ month: '2025-06', wh: 12, weh: 8, hh: 0, wp: 1200, wep: 1200, hp: 0, pay: 2400 },
{ month: '2025-09', wh: 6, weh: 0, hh: 8, wp: 600, wep: 0, hp: 1200, pay: 1800 },
]
for (const o of otMonths) {
const existing = await prisma.overtimeRecord.findUnique({ where: { employeeId_month: { employeeId: EID, month: o.month } } })
if (!existing) {
await prisma.overtimeRecord.create({ data: { orgId: ORGID, employeeId: EID, month: o.month, weekdayHours: o.wh, weekendHours: o.weh, holidayHours: o.hh, weekdayPay: o.wp, weekendPay: o.wep, holidayPay: o.hp, totalPay: o.pay } })
}
}
console.log('加班记录: 完成')
// 违纪记录
const discRecords = [
{ violationDate: new Date('2025-05-12'), violationType: 'LATE', description: '月度迟到超过5次,影响团队考勤', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '口头警告并谈话', employeeAck: true, ackDate: new Date('2025-05-13'), ackMethod: 'SIGN', witness: '王强' },
{ violationDate: new Date('2025-09-20'), violationType: 'ABSENT', description: '未经请假擅自旷工1天', severity: 'SERIOUS', action: 'DEDUCTION', actionDetail: '扣款200元', employeeAck: true, ackDate: new Date('2025-09-21'), ackMethod: 'SIGN', witness: '王强' },
]
for (const d of discRecords) {
const existing = await prisma.disciplinaryRecord.findFirst({ where: { employeeId: EID, violationDate: d.violationDate } })
if (!existing) {
await prisma.disciplinaryRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...d } })
}
}
console.log('违纪记录: 完成')
// 考勤记录 - 最近10个工作日
const attendance = [
{ date: '2026-07-10', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-11', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-14', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-15', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-16', status: 'LATE', late: 25, early: 0 },
{ date: '2026-07-17', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-18', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-21', status: 'NORMAL', late: 0, early: 0 },
{ date: '2026-07-22', status: 'EARLY_LEAVE', late: 0, early: 30 },
{ date: '2026-07-23', status: 'NORMAL', late: 0, early: 0 },
]
for (const a of attendance) {
const existing = await prisma.attendanceRecord.findUnique({ where: { employeeId_date: { employeeId: EID, date: new Date(a.date) } } })
if (!existing) {
await prisma.attendanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, date: new Date(a.date), checkInTime: '09:00', checkOutTime: '18:00', status: a.status, lateMinutes: a.late, earlyMinutes: a.early, workHours: 8, overtimeHours: 0 } })
}
}
console.log('考勤记录: 完成')
// 培训签收记录
const trainings = [
{ trainingDate: new Date('2025-03-15'), topic: '《员工手册》培训', content: '公司规章制度、考勤制度、奖惩条例', trainer: '赵敏', duration: 2, ackStatus: 'SIGNED', ackDate: new Date('2025-03-15'), remark: '新员工入职培训' },
{ trainingDate: new Date('2025-06-20'), topic: '销售技巧与合规培训', content: '销售话术规范、客户信息保护、合同签订注意事项', trainer: '王强', duration: 4, ackStatus: 'SIGNED', ackDate: new Date('2025-06-20') },
{ trainingDate: new Date('2026-01-10'), topic: '2026年度规章制度更新培训', content: '新版考勤制度、绩效考核办法、安全生产规范', trainer: '赵敏', duration: 3, ackStatus: 'PENDING', remark: '待员工签收确认' },
]
for (const t of trainings) {
const existing = await prisma.trainingRecord.findFirst({ where: { employeeId: EID, trainingDate: t.trainingDate } })
if (!existing) {
await prisma.trainingRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...t } })
}
}
console.log('培训记录: 完成')
// 绩效记录
const performances = [
{ period: '2025-Q1', score: 82, grade: 'B', result: 'QUALIFIED', summary: '销售业绩达标,客户维护良好,需提升新客户开发能力', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-04-10'), reviewer: '王强' },
{ period: '2025-Q2', score: 75, grade: 'B', result: 'QUALIFIED', summary: '业绩略有下滑,新客户开发不足,团队协作有待加强', improvementPlan: '', employeeAck: true, ackDate: new Date('2025-07-08'), reviewer: '王强' },
{ period: '2025-Q3', score: 68, grade: 'C', result: 'NEED_IMPROVE', summary: '连续3个月未完成销售目标,客户投诉1次', improvementPlan: '调岗至客户维护岗,加强销售技巧培训1个月', employeeAck: true, ackDate: new Date('2025-10-15'), reviewer: '王强' },
{ period: '2025-Q4', score: 78, grade: 'B', result: 'QUALIFIED', summary: '改进后业绩回升,客户满意度提升', improvementPlan: '', employeeAck: false, reviewer: '王强' },
]
for (const p of performances) {
const existing = await prisma.performanceRecord.findUnique({ where: { employeeId_period: { employeeId: EID, period: p.period } } })
if (!existing) {
await prisma.performanceRecord.create({ data: { orgId: ORGID, employeeId: EID, createdBy: UID, ...p } })
}
}
console.log('绩效记录: 完成')
// 附件
const attachments = [
{ fileName: '吴芳身份证扫描件.pdf', fileType: 'ID_CARD', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 102400 },
{ fileName: '吴芳银行卡复印件.jpg', fileType: 'BANK_CARD', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 51200 },
{ fileName: '吴芳劳动合同扫描件.pdf', fileType: 'CONTRACT_SCAN', fileUrl: 'data:application/pdf;base64,placeholder', fileSize: 204800 },
{ fileName: '吴芳学历证书.jpg', fileType: 'EDUCATION', fileUrl: 'data:image/jpeg;base64,placeholder', fileSize: 81920 },
]
for (const a of attachments) {
const existing = await prisma.employeeAttachment.findFirst({ where: { employeeId: EID, fileName: a.fileName } })
if (!existing) {
await prisma.employeeAttachment.create({ data: { ...a, orgId: ORGID, employeeId: EID, uploadedBy: UID } })
}
}
console.log('附件: 完成')
// 验证
const emp = await prisma.employee.findFirst({
where: { id: EID },
include: { contracts: true, payslips: true, overtimeRecords: true, disciplinaryRecords: true, attendanceRecords: true, trainingRecords: true, performanceRecords: true, terminations: true, attachments: true }
})
if (emp) {
console.log('--- 吴芳完整档案数据统计 ---')
console.log('contracts:', emp.contracts.length)
console.log('payslips:', emp.payslips.length)
console.log('overtimeRecords:', emp.overtimeRecords.length)
console.log('disciplinaryRecords:', emp.disciplinaryRecords.length)
console.log('attendanceRecords:', emp.attendanceRecords.length)
console.log('trainingRecords:', emp.trainingRecords.length)
console.log('performanceRecords:', emp.performanceRecords.length)
console.log('terminations:', emp.terminations.length)
console.log('attachments:', emp.attachments.length)
}
await prisma.$disconnect()
}
main().catch(console.error)
+298
View File
@@ -0,0 +1,298 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
import { encrypt } from '../src/lib/crypto'
const prisma = new PrismaClient()
// 社保计算(与 payroll.service.ts 一致)
function calcSocial(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
return { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100 }
}
function calcHousing(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingEmp = actualBase * config.housingEmp / 100
const housingOrg = actualBase * config.housingOrg / 100
return { housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100 }
}
function calcTax(taxableIncome: number): number {
if (taxableIncome <= 0) return 0
let tax = 0
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
else tax = taxableIncome * 0.45 - 181920
return Math.max(0, Math.round(tax * 100) / 100)
}
// 9名员工完整数据
const EMPLOYEES = [
{ name: '张伟', gender: '男', dept: '技术部', phone: '13900000001', idCard: '310101199001011234', salary: 18000, hireDate: '2023-03-01', socialBase: 18000, housingBase: 18000, specialDeduction: 2000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 14400, bank: '工商银行', account: '6222021234567890001', emergency: '张父', emergencyPhone: '13800001001', address: '上海市浦东新区张江路100号' },
{ name: '李娜', gender: '女', dept: '技术部', phone: '13900000002', idCard: '310102199203052345', salary: 15000, hireDate: '2023-06-15', socialBase: 15000, housingBase: 15000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 12000, bank: '建设银行', account: '6227001234567890002', emergency: '李母', emergencyPhone: '13800001002', address: '上海市徐汇区漕河泾50号', pregnant: true },
{ name: '王强', gender: '男', dept: '销售部', phone: '13900000003', idCard: '310103198812103456', salary: 12000, hireDate: '2024-01-10', socialBase: 12000, housingBase: 12000, specialDeduction: 3000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 9600, bank: '招商银行', account: '6225881234567890003', emergency: '王妻', emergencyPhone: '13800001003', address: '上海市闵行区莘庄路200号' },
{ name: '赵敏', gender: '女', dept: '人事部', phone: '13900000004', idCard: '310104199506154567', salary: 10000, hireDate: '2022-09-01', socialBase: 10000, housingBase: 10000, specialDeduction: 1500, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '农业银行', account: '6228481234567890004', emergency: '赵父', emergencyPhone: '13800001004', address: '上海市黄浦区南京东路300号' },
{ name: '陈刚', gender: '男', dept: '销售部', phone: '13900000005', idCard: '310105199907205678', salary: 8000, hireDate: '2024-07-01', socialBase: 8000, housingBase: 8000, specialDeduction: 0, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 6400, bank: '中国银行', account: '6217001234567890005', emergency: '陈母', emergencyPhone: '13800001005', address: '上海市杨浦区五角场400号' },
{ name: '刘洋', gender: '男', dept: '技术部', phone: '13900000006', idCard: '310106198504016789', salary: 22000, hireDate: '2021-04-01', socialBase: 33891, housingBase: 33891, specialDeduction: 4000, contractType: 'UNFIXED', years: 0, probation: 0, probationSalary: 0, bank: '交通银行', account: '6222601234567890006', emergency: '刘妻', emergencyPhone: '13800001006', address: '上海市长宁区中山公园500号' },
{ name: '周婷', gender: '女', dept: '财务部', phone: '13900000007', idCard: '310107199311157890', salary: 13000, hireDate: '2023-11-15', socialBase: 13000, housingBase: 13000, specialDeduction: 2500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 10400, bank: '浦发银行', account: '6225161234567890007', emergency: '周父', emergencyPhone: '13800001007', address: '上海市静安区南京西路600号' },
{ name: '孙磊', gender: '男', dept: '技术部', phone: '13900000008', idCard: '310108199008018901', salary: 16000, hireDate: '2022-06-01', socialBase: 16000, housingBase: 16000, specialDeduction: 1000, contractType: 'FIXED', years: 3, probation: 3, probationSalary: 12800, bank: '民生银行', account: '6226161234567890008', emergency: '孙母', emergencyPhone: '13800001008', address: '上海市虹口区四川北路700号' },
{ name: '吴芳', gender: '女', dept: '销售部', phone: '13900000009', idCard: '310109199702159012', salary: 9000, hireDate: '2025-02-15', socialBase: 9000, housingBase: 9000, specialDeduction: 500, contractType: 'FIXED', years: 3, probation: 2, probationSalary: 7200, bank: '光大银行', account: '6226621234567890009', emergency: '吴夫', emergencyPhone: '13800001009', address: '上海市宝山区牡丹江路800号' },
]
async function main() {
// 1. 清空所有数据(按依赖顺序删除)
console.log('清空现有数据...')
await prisma.notificationLog.deleteMany()
await prisma.auditLog.deleteMany()
await prisma.batchEntry.deleteMany()
await prisma.payrollBatch.deleteMany()
await prisma.payslipItem.deleteMany()
await prisma.salaryChangeRecord.deleteMany()
await prisma.payslip.deleteMany()
await prisma.overtimeRecord.deleteMany()
await prisma.terminationRecord.deleteMany()
await prisma.riskItem.deleteMany()
await prisma.employeeAttachment.deleteMany()
await prisma.disciplinaryRecord.deleteMany()
await prisma.attendanceRecord.deleteMany()
await prisma.trainingRecord.deleteMany()
await prisma.performanceRecord.deleteMany()
await prisma.laborContract.deleteMany()
await prisma.contractConfirmLink.deleteMany()
await prisma.onboardingLink.deleteMany()
await prisma.employee.deleteMany()
await prisma.socialInsuranceConfig.deleteMany()
await prisma.notificationSetting.deleteMany()
await prisma.user.deleteMany()
await prisma.organization.deleteMany()
console.log('数据已清空')
// 2. 创建企业
const org = await prisma.organization.create({
data: {
name: '智云科技有限公司',
plan: 'PRO',
maxEmployees: 50,
city: '上海',
payrollFrequency: 1,
},
})
console.log('企业已创建:', org.name)
// 3. 创建管理员
const passwordHash = await bcrypt.hash('12345678', 10)
const admin = await prisma.user.create({
data: {
orgId: org.id,
phone: '13800000001',
name: '管理员',
passwordHash,
role: 'ADMIN',
},
})
console.log('管理员已创建:', admin.phone)
// 4. 创建社保配置(上海标准)
await prisma.socialInsuranceConfig.create({
data: {
orgId: org.id,
city: '上海',
pensionOrg: 16,
pensionEmp: 8,
medicalOrg: 9.8,
medicalEmp: 2,
unemploymentOrg: 0.5,
unemploymentEmp: 0.5,
injuryOrg: 0.2,
maternityOrg: 0.8,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
createdBy: admin.id,
},
})
console.log('社保配置已创建')
// 4.5 创建公积金配置(上海标准)
await prisma.housingFundConfig.create({
data: {
orgId: org.id,
city: '上海',
housingOrg: 7,
housingEmp: 7,
baseMin: 7384,
baseMax: 36921,
effectiveFrom: '2025-07',
createdBy: admin.id,
},
})
console.log('公积金配置已创建')
// 5. 创建通知设置
await prisma.notificationSetting.create({
data: {
orgId: org.id,
contractExpiry: true,
expiryDays: 30,
contractUnsigned: true,
overtimeAlert: true,
payslipReady: true,
payrollDay: 10,
socialInsDay: 15,
housingFundDay: 15,
taxDay: 15,
},
})
// 6. 创建薪酬模版(预置项)
const defaultItems: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
]
for (const item of defaultItems) {
await prisma.payslipItem.create({
data: { orgId: org.id, ...item },
})
}
console.log('薪酬模版已创建')
// 7. 创建9名员工 + 合同
for (let i = 0; i < EMPLOYEES.length; i++) {
const e = EMPLOYEES[i]
const emp = await prisma.employee.create({
data: {
orgId: org.id,
name: e.name,
department: e.dept,
hireDate: new Date(e.hireDate),
monthlySalary: encrypt(String(e.salary)),
phone: e.phone,
idCardNumber: encrypt(e.idCard),
gender: e.gender,
socialInsBase: e.socialBase,
housingFundBase: e.housingBase,
specialDeduction: e.specialDeduction,
bankName: e.bank,
bankAccount: encrypt(e.account),
emergencyContact: e.emergency,
emergencyPhone: e.emergencyPhone,
address: e.address,
isPregnant: e.pregnant || false,
createdBy: admin.id,
},
})
// 创建合同
const startDate = new Date(e.hireDate)
const endDate = e.contractType === 'FIXED'
? new Date(startDate.getFullYear() + e.years, startDate.getMonth(), startDate.getDate() - 1)
: null
await prisma.laborContract.create({
data: {
orgId: org.id,
employeeId: emp.id,
signDate: new Date(e.hireDate),
startDate,
endDate,
contractType: e.contractType as any,
signMethod: 'PAPER',
contractYears: e.years,
probationMonths: e.probation,
probationSalary: e.probationSalary,
createdBy: admin.id,
},
})
console.log(`员工 ${i + 1}/9 已创建: ${e.name} - ${e.dept} - ¥${e.salary}/月`)
}
// 8. 生成 1-6 月历史工资条(已发布),使 7 月累计预扣个税有 YTD 数据
console.log('\n生成 1-6 月历史工资条...')
const socialConfig = await prisma.socialInsuranceConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
const housingConfig = await prisma.housingFundConfig.findFirst({ where: { orgId: org.id, isCurrent: true } })
const allEmployees = await prisma.employee.findMany({ where: { orgId: org.id } })
for (const emp of allEmployees) {
// 跳过 2026 年之后入职的员工
const hireYear = emp.hireDate.getFullYear()
if (hireYear > 2026) continue
const hireMonth = hireYear === 2026 ? emp.hireDate.getMonth() + 1 : 1
let ytdIncome = 0, ytdSocialEmp = 0, ytdHousingEmp = 0, ytdTaxDeducted = 0
for (let m = 1; m <= 6; m++) {
if (m < hireMonth) continue
const monthStr = `2026-${String(m).padStart(2, '0')}`
const baseSalary = emp.socialInsBase || 0 // 用社保基数作为基本工资(简化)
const social = calcSocial(emp.socialInsBase || baseSalary, socialConfig)
const housing = calcHousing(emp.housingFundBase || baseSalary, housingConfig || socialConfig)
const totalPay = baseSalary
const specialDeduction = emp.specialDeduction * m
ytdIncome += totalPay
ytdSocialEmp += social.socialEmp
ytdHousingEmp += housing.housingEmp
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * m - ytdSocialEmp - ytdHousingEmp - specialDeduction)
const ytdTax = calcTax(ytdTaxableIncome)
const monthTax = Math.max(0, Math.round((ytdTax - ytdTaxDeducted) * 100) / 100)
ytdTaxDeducted += monthTax
const netPay = Math.round((totalPay - social.socialEmp - housing.housingEmp - monthTax) * 100) / 100
await prisma.payslip.create({
data: {
org: { connect: { id: org.id } },
employee: { connect: { id: emp.id } },
month: monthStr,
baseSalary,
overtimePay: 0,
allowance: 0,
deduction: 0,
bonus: 0,
totalPay,
socialEmp: social.socialEmp,
housingEmp: housing.housingEmp,
tax: monthTax,
netPay,
ytdIncome,
ytdTaxDeducted,
ytdSocialEmp,
ytdHousingEmp,
status: 'PUBLISHED',
publishedAt: new Date(`${monthStr}-10T10:00:00Z`),
confirmedAt: new Date(`${monthStr}-12T10:00:00Z`),
},
})
}
console.log(` ${emp.name}: 1-6月工资条已生成`)
}
console.log('\n===== 示例数据创建完成 =====')
console.log(`企业: ${org.name}`)
console.log(`管理员: 13800000001 / 密码: 12345678`)
console.log(`员工: ${EMPLOYEES.length}`)
console.log('社保配置: 上海标准')
console.log('薪酬模版: 10项预置')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+150
View File
@@ -0,0 +1,150 @@
/**
* 一次性迁移脚本:为现有员工创建初始版本记录
* 运行方式:npx tsx scripts/migrate-records.ts
*/
import prisma from '../src/lib/prisma.js'
import { decrypt } from '../src/lib/crypto.js'
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
async function main() {
const employees = await prisma.employee.findMany({
include: {
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
salaryChanges: { orderBy: { createdAt: 'desc' }, take: 1 },
socialInsRecords: { take: 1 },
housingFundRecords: { take: 1 },
departmentRecords: { take: 1 },
},
})
console.log(`Found ${employees.length} employees to migrate`)
for (const emp of employees) {
const hireMonth = dateToMonth(emp.hireDate)
const termination = emp.terminations[0]
const endMonth = termination ? dateToMonth(termination.terminationDate) : null
// 解密月薪获取数值
let salaryNum = 0
try {
salaryNum = parseFloat(decrypt(emp.monthlySalary)) || 0
} catch {
salaryNum = parseFloat(emp.monthlySalary) || 0
}
const socialInsBase = emp.socialInsBase ?? salaryNum
const housingFundBase = emp.housingFundBase ?? salaryNum
// 1. 社保缴费记录(仅当尚无记录时创建)
if (emp.socialInsRecords.length === 0) {
await prisma.employeeSocialInsRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.socialInsStartMonth || hireMonth,
endMonth: endMonth || emp.socialInsEndMonth || null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 2. 公积金缴费记录
if (emp.housingFundRecords.length === 0) {
await prisma.employeeHousingFundRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
startMonth: emp.housingFundStartMonth || hireMonth,
endMonth: endMonth || emp.housingFundEndMonth || null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 3. 薪资变更记录(仅当尚无记录时创建)
if (emp.salaryChanges.length === 0) {
await prisma.salaryChangeRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: emp.hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
} else {
// 已有记录但缺少 effectiveMonth/endMonth/changeType,补充
const latest = emp.salaryChanges[0]
if (!latest.effectiveMonth || !latest.changeType) {
await prisma.salaryChangeRecord.update({
where: { id: latest.id },
data: {
effectiveMonth: dateToMonth(latest.effectiveDate),
changeType: latest.changeType || 'SALARY_CHANGE',
},
})
}
}
// 4. 部门变更记录
if (emp.departmentRecords.length === 0) {
await prisma.employeeDepartmentRecord.create({
data: {
orgId: emp.orgId,
employeeId: emp.id,
oldDepartment: '',
newDepartment: emp.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: emp.createdBy,
},
})
}
// 5. 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsStartMonth: emp.socialInsStartMonth || hireMonth,
socialInsEndMonth: endMonth || emp.socialInsEndMonth || null,
socialInsBase,
housingFundStartMonth: emp.housingFundStartMonth || hireMonth,
housingFundEndMonth: endMonth || emp.housingFundEndMonth || null,
housingFundBase,
},
})
console.log(`${emp.name} (${emp.department}) — records created/synced`)
}
console.log('\nMigration complete!')
}
main()
.catch((e) => {
console.error('Migration failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+68
View File
@@ -0,0 +1,68 @@
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 payroll2Routes from './routes/payroll2.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'
import exportRoutes from './routes/export.routes'
import importRoutes from './routes/import.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/payroll2', payroll2Routes)
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('/api/v1/export', exportRoutes)
app.use('/api/v1/import', importRoutes)
app.use(errorHandler)
// RAG 知识库自动初始化(异步,不阻塞启动)
import { seedKnowledgeBase } from './services/rag.service'
seedKnowledgeBase().catch((err) => {
console.warn('[RAG] 知识库初始化失败,AI 问答将不使用 RAG 检索:', err?.message || err)
})
export default app
+7
View File
@@ -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}`)
})
+26
View File
@@ -0,0 +1,26 @@
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
}
export function sha256(text: string): string {
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
}
+28
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma
+27
View File
@@ -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)
}
}
+28
View File
@@ -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()
}
+37
View File
@@ -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: '服务器内部错误' },
})
}
+19
View File
@@ -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: '请求过于频繁,请稍后再试' } },
})
+401
View File
@@ -0,0 +1,401 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { chat, chatStream, reviewContract, matchCase, predictRisks } from '../services/ai.service'
import { seedKnowledgeBase, addKnowledge, searchKnowledge, ensureRAGTable } from '../services/rag.service'
import prisma from '../lib/prisma'
import { z } from 'zod'
const router = Router()
const PLAN_LIMITS: Record<string, { chat: number; review: number; case: number }> = {
FREE: { chat: 10, review: 3, case: 3 },
PRO: { chat: 100, review: 20, case: 20 },
ENTERPRISE: { chat: 0, review: 0, case: 0 },
}
async function checkUsageLimit(orgId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const org = await prisma.organization.findUnique({ where: { id: orgId } })
if (!org) return
const limits = PLAN_LIMITS[org.plan] || PLAN_LIMITS.FREE
const limit = limits[type]
if (limit === 0) return
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const count = await prisma.auditLog.count({
where: {
orgId,
action: `AI_${type.toUpperCase()}`,
createdAt: { gte: monthStart },
},
})
if (count >= limit) {
throw { code: 'USAGE_LIMIT', message: `本月 AI${type === 'chat' ? '问答' : type === 'review' ? '合同审查' : '案例匹配'}次数已达上限(${limit}次),请升级套餐` }
}
}
async function recordUsage(orgId: string, userId: string, type: 'chat' | 'review' | 'case'): Promise<void> {
const month = new Date().toISOString().slice(0, 7)
await prisma.auditLog.create({
data: {
orgId,
userId,
action: `AI_${type.toUpperCase()}`,
entity: 'AI',
entityId: null,
detail: { month, type } as any,
ip: '',
},
})
}
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 now = new Date()
const empSummary = employees.map((e) => {
const contract = e.contracts[0]
const daysToExpire = contract?.endDate
? Math.floor((new Date(contract.endDate).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null
const specialStatus: string[] = []
if (e.isPregnant) specialStatus.push('孕期/哺乳期')
if (e.isInMedicalPeriod) specialStatus.push('医疗期')
if (e.isWorkInjured) specialStatus.push('工伤')
return `- ${e.name}${e.department}),入职${e.hireDate.toISOString().slice(0, 10)}${contract ? `合同:${contract.contractType}${contract.endDate ? `到期${contract.endDate.toISOString().slice(0, 10)}(剩余${daysToExpire}天)` : '无固定期限'}` : '未签合同'}${specialStatus.length > 0 ? `,特殊状态:${specialStatus.join('/')}` : ''}`
}).join('\n')
const riskSummary = risks.map((r) => `- ${r.title}${r.level}):${r.description || '无详细描述'}`).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 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
const reply = await chat(messages, orgContext)
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
res.json({ success: true, data: { reply } })
} catch (err) {
next(err)
}
})
router.post('/chat-stream', 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 参数' } })
}
await checkUsageLimit(req.user!.orgId, 'chat')
const orgContext = await buildOrgContext(req.user!.orgId)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
let usageRecorded = false
try {
for await (const delta of chatStream(messages, orgContext)) {
res.write(`data: ${JSON.stringify({ delta })}\n\n`)
}
res.write('data: [DONE]\n\n')
} finally {
if (!usageRecorded) {
await recordUsage(req.user!.orgId, req.user!.id, 'chat')
usageRecorded = true
}
}
res.end()
} catch (err) {
if (!res.headersSent) next(err)
else res.end()
}
})
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: '缺少合同文本' } })
}
await checkUsageLimit(req.user!.orgId, 'review')
const result = await reviewContract(contractText)
await recordUsage(req.user!.orgId, req.user!.id, 'review')
res.json({ success: true, data: { text: result.text, structured: result.structured } })
} 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: '缺少争议情形描述' } })
}
await checkUsageLimit(req.user!.orgId, 'case')
const result = await matchCase(scenario)
await recordUsage(req.user!.orgId, req.user!.id, 'case')
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// 案例匹配结果转待办(RiskItem)
router.post('/case-to-todo', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
level: z.enum(['HIGH', 'MEDIUM', 'LOW']).default('MEDIUM'),
type: z.enum(['CONTRACT', 'SALARY', 'TERMINATION', 'MONTHLY', 'ONBOARDING']).default('TERMINATION'),
})
const data = schema.parse(req.body)
const risk = await prisma.riskItem.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
title: data.title,
description: data.description,
level: data.level,
type: data.type,
status: 'PENDING',
},
})
res.json({ success: true, data: risk })
} catch (err) {
next(err)
}
})
router.get('/predict', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const department = req.query.department as string
const employeeId = req.query.employeeId as string
const riskType = req.query.riskType as string
let orgContext = await buildOrgContext(req.user!.orgId)
if (employeeId) {
const emp = await prisma.employee.findFirst({ where: { id: employeeId, orgId: req.user!.orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
if (emp) {
const contract = emp.contracts[0]
orgContext = `员工详情:
- 姓名:${emp.name}
- 部门:${emp.department}
- 入职日期:${emp.hireDate.toISOString().slice(0, 10)}
- 状态:${emp.status}
- 特殊状态:${emp.isPregnant ? '孕期/哺乳期 ' : ''}${emp.isInMedicalPeriod ? '医疗期 ' : ''}${emp.isWorkInjured ? '工伤' : '无'}
- 合同:${contract ? `${contract.contractType}${contract.startDate.toISOString().slice(0, 10)}${contract.endDate ? contract.endDate.toISOString().slice(0, 10) : '无固定期限'}` : '未签合同'}\n${orgContext}`
}
} else if (department) {
const employees = await prisma.employee.findMany({ where: { orgId: req.user!.orgId, department, status: 'ACTIVE' }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } })
const empSummary = employees.map(e => `- ${e.name},入职${e.hireDate.toISOString().slice(0, 10)}${e.contracts[0] ? e.contracts[0].contractType : '未签合同'}`).join('\n')
orgContext = `部门【${department}】员工列表(${employees.length}人):\n${empSummary}\n\n${orgContext}`
}
if (riskType && riskType !== 'all') {
orgContext = `请重点关注【${riskType === 'contract' ? '合同' : riskType === 'salary' ? '薪酬' : riskType === 'termination' ? '解聘' : riskType}】类风险。\n\n${orgContext}`
}
const result = await predictRisks(orgContext)
res.json({ success: true, data: { result } })
} catch (err) {
next(err)
}
})
// ========== AI 会话历史 ==========
router.get('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conversations = await prisma.aIConversation.findMany({
where: { orgId: req.user!.orgId, userId: req.user!.id },
orderBy: { updatedAt: 'desc' },
take: 50,
select: { id: true, title: true, createdAt: true, updatedAt: true },
})
res.json({ success: true, data: conversations })
} catch (err) {
next(err)
}
})
router.get('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (!conv) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.post('/conversations', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages: any[] }
const conv = await prisma.aIConversation.create({
data: {
orgId: req.user!.orgId,
userId: req.user!.id,
title: title || (messages.find(m => m.role === 'user')?.content.slice(0, 30) || '新对话'),
messages: messages || [],
},
})
res.json({ success: true, data: conv })
} catch (err) {
next(err)
}
})
router.put('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, messages } = req.body as { title?: string; messages?: any[] }
const conv = await prisma.aIConversation.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
data: {
...(title ? { title } : {}),
...(messages ? { messages } : {}),
},
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
router.delete('/conversations/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const conv = await prisma.aIConversation.deleteMany({
where: { id: req.params.id, orgId: req.user!.orgId, userId: req.user!.id },
})
if (conv.count === 0) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '会话不存在' } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== AI 审查记录保存到员工档案 ==========
router.post('/review/save', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const schema = z.object({
employeeId: z.string(),
type: z.enum(['REVIEW', 'CASE']),
input: z.string(),
result: z.string(),
})
const data = schema.parse(req.body)
const record = await prisma.aIReviewRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
type: data.type,
input: data.input,
result: data.result,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
router.get('/review/employee/:employeeId', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.aIReviewRecord.findMany({
where: { orgId: req.user!.orgId, employeeId: req.params.employeeId },
orderBy: { createdAt: 'desc' },
take: 20,
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// RAG 知识库管理
router.post('/rag/seed', authMiddleware, async (_req: AuthRequest, res, next) => {
try {
await seedKnowledgeBase()
res.json({ success: true, data: { message: '知识库初始化完成' } })
} catch (err) {
next(err)
}
})
router.post('/rag/add', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { title, content, source, category } = req.body
if (!title || !content) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 title 或 content' } })
}
const result = await addKnowledge(title, content, source || '自定义', category || '其他')
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.post('/rag/search', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { query, topK } = req.body
if (!query) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 query' } })
}
const results = await searchKnowledge(query, topK || 5)
res.json({ success: true, data: { results } })
} catch (err) {
next(err)
}
})
// 知识库列表
router.get('/rag/list', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
const category = req.query.category as string | undefined
const items = category
? await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge WHERE category = ${category} ORDER BY created_at DESC LIMIT 200` as any[]
: await prisma.$queryRaw`SELECT id, title, content, source, category, created_at FROM rag_knowledge ORDER BY created_at DESC LIMIT 200` as any[]
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 删除知识条目
router.delete('/rag/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
await ensureRAGTable()
await prisma.$executeRaw`DELETE FROM rag_knowledge WHERE id = ${req.params.id}`
res.json({ success: true })
} catch (err) {
next(err)
}
})
export default router
+63
View File
@@ -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
+91
View File
@@ -0,0 +1,91 @@
import { Router } from 'express'
import { registerSchema, loginSchema, refreshSchema, resetPasswordSchema, forgotPasswordSchema, verifyCodeSchema } from '../schemas/auth.schema'
import { register, login, refresh, resetPassword } from '../services/auth.service'
import { authLimiter, loginLimiter } from '../middleware/rateLimit'
import prisma from '../lib/prisma'
import bcrypt from 'bcryptjs'
const router = Router()
const codeStore = new Map<string, { code: string; expiresAt: number }>()
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('/forgot-password/send-code', authLimiter, async (req, res, next) => {
try {
const data = forgotPasswordSchema.parse(req.body)
const user = await prisma.user.findUnique({ where: { phone: data.phone } })
if (!user) {
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('/forgot-password/verify', authLimiter, async (req, res, next) => {
try {
const data = verifyCodeSchema.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 passwordHash = await bcrypt.hash(data.newPassword, 10)
await prisma.user.updateMany({
where: { phone: data.phone },
data: { passwordHash },
})
res.json({ success: true, data: { message: '密码重置成功' } })
} 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
+80
View File
@@ -0,0 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData } from '../services/risk.service'
import { z } from 'zod'
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)
}
})
// 批量标记待办为已完成
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
// 批量忽略待办
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
export default router
+200
View File
@@ -0,0 +1,200 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import {
createEmployeeSchema,
updateEmployeeSchema,
batchRenewSchema,
addContractSchema,
} from '../schemas/contract.schema'
import {
getEmployees,
getEmployeeDetail,
createEmployee,
rehireEmployee,
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.post('/:id/rehire', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await rehireEmployee(req.user!.orgId, req.user!.id, req.params.id, req.body)
await auditLog(req, 'REHIRE', 'EMPLOYEE', req.params.id, { hireDate: req.body.hireDate })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
if (err?.code === 'VALIDATION_ERROR') {
return res.status(400).json({ success: false, error: { code: err.code, message: err.message } })
}
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/preview-renew', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { contractIds } = req.body as { contractIds: string[] }
if (!contractIds || !Array.isArray(contractIds) || contractIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 contractIds' } })
}
const contracts = await prisma.laborContract.findMany({
where: { id: { in: contractIds }, orgId: req.user!.orgId },
include: { employee: true },
orderBy: { startDate: 'asc' },
})
if (contracts.length === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到符合条件的合同' } })
}
// 合规检查:按员工分组,检查历史固定期合同次数
const results = []
for (const contract of contracts) {
const employee = contract.employee
// 查找该员工所有历史固定期合同(按时间正序,用于判断续签次数)
const allFixedContracts = await prisma.laborContract.findMany({
where: {
employeeId: contract.employeeId,
orgId: req.user!.orgId,
contractType: 'FIXED',
},
orderBy: { startDate: 'asc' },
})
// 当前合同是第几次固定期(从1开始计数)
const currentIndex = allFixedContracts.findIndex((c) => c.id === contract.id)
const renewalCount = currentIndex + 1
// 判断是否应签无固定期限:
// 1. 已连续签订2次以上固定期限合同(第3次应签无固定期限)
// 2. 员工连续工作满10年
const shouldBeUnfixed = renewalCount >= 2
const yearsSinceHire = (Date.now() - new Date(employee.hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000)
const shouldBeUnfixedByTenure = yearsSinceHire >= 10
let warning: string | null = null
let suggestion: string | null = null
if (shouldBeUnfixed || shouldBeUnfixedByTenure) {
warning = shouldBeUnfixed
? `该员工已有 ${renewalCount} 次固定期限合同续签记录(《劳动合同法》第14条),第三次续签应订立无固定期限劳动合同`
: `该员工在本公司连续工作 ${Math.floor(yearsSinceHire)} 年(《劳动合同法》第14条),应订立无固定期限劳动合同`
suggestion = '建议与员工协商订立无固定期限劳动合同,以规避法律风险'
} else {
suggestion = `可续签固定期限(当前为第 ${renewalCount} 次续签)`
}
results.push({
contractId: contract.id,
employeeId: contract.employeeId,
employeeName: employee.name,
department: employee.department,
currentContractType: contract.contractType,
renewalCount,
yearsSinceHire: Math.floor(yearsSinceHire * 10) / 10,
warning,
suggestion,
canRenewFixed: !warning,
})
}
res.json({
success: true,
data: {
total: results.length,
warnings: results.filter((r) => r.warning).length,
results,
},
})
} 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
+243
View File
@@ -0,0 +1,243 @@
import { Router, Response } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import ExcelJS from 'exceljs'
import { createGzip } from 'zlib'
import { Writable } from 'stream'
const router = Router()
// 敏感字段脱敏
function maskIdCard(idCard: string | null): string | null {
if (!idCard) return null
if (idCard.length >= 11) return idCard.slice(0, 3) + '*'.repeat(idCard.length - 7) + idCard.slice(-4)
return idCard
}
function maskBankAccount(account: string | null): string | null {
if (!account) return null
if (account.length > 4) return '*'.repeat(account.length - 4) + account.slice(-4)
return account
}
// 导出全部数据(支持模块选择、格式选择、脱敏)
router.get('/all', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const format = (req.query.format as string) || 'json'
const mask = req.query.mask === 'true' || req.user!.role !== 'ADMIN'
const modules = (req.query.modules as string || 'employees,contracts,terminations,payrollBatches,payslips,socialRecords,housingRecords,riskItems').split(',')
const fetchMap: Record<string, () => Promise<any>> = {
employees: () => prisma.employee.findMany({ where: { orgId } }),
contracts: () => prisma.laborContract.findMany({ where: { orgId } }),
terminations: () => prisma.terminationRecord.findMany({ where: { orgId } }),
payrollBatches: () => prisma.payrollBatch.findMany({ where: { orgId } }),
payslips: () => prisma.payslip.findMany({ where: { orgId } }),
socialRecords: () => prisma.employeeSocialInsRecord.findMany({ where: { orgId } }),
housingRecords: () => prisma.employeeHousingFundRecord.findMany({ where: { orgId } }),
riskItems: () => prisma.riskItem.findMany({ where: { orgId } }),
}
const useGzip = req.query.gzip !== 'false'
const batchSize = 500
if (format === 'excel') {
const data: any = { exportedAt: new Date().toISOString(), orgId }
if (modules.includes('employees')) {
const employees = await fetchMap.employees()
data.employees = employees.map((e: any) => {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
return { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
})
}
for (const mod of modules) {
if (mod === 'employees') continue
if (fetchMap[mod]) {
data[mod] = await fetchMap[mod]()
}
}
const workbook = new ExcelJS.Workbook()
for (const mod of modules) {
if (!data[mod] || !data[mod].length) continue
const ws = workbook.addWorksheet(mod.slice(0, 31))
const rows = data[mod]
const keys = Object.keys(rows[0]).filter(k => typeof rows[0][k] !== 'object')
ws.columns = keys.map(k => ({ header: k, key: k, width: 18 }))
ws.getRow(1).font = { bold: true }
for (const row of rows) {
const flat: any = {}
for (const k of keys) flat[k] = typeof row[k] === 'object' ? JSON.stringify(row[k]) : row[k]
ws.addRow(flat)
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} else {
// JSON 流式导出 + gzip 压缩
if (useGzip) {
res.setHeader('Content-Encoding', 'gzip')
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json.gz"`)
} else {
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="export-${new Date().toISOString().slice(0, 10)}.json"`)
}
const gzip = useGzip ? createGzip() : null
const output: Writable = gzip || res
if (gzip) { gzip.pipe(res) }
const write = (chunk: string) => {
output.write(Buffer.from(chunk))
}
write('{"exportedAt":"' + new Date().toISOString() + '","orgId":"' + orgId + '"')
for (const mod of modules) {
write(',"' + mod + '":[')
if (mod === 'employees') {
// 员工数据分批查询,避免内存溢出
let skip = 0
let first = true
while (true) {
const batch = await prisma.employee.findMany({ where: { orgId }, skip, take: batchSize })
if (batch.length === 0) break
for (const e of batch) {
let salary = 0
try { salary = Number(decrypt(e.monthlySalary)) || 0 } catch { salary = Number(e.monthlySalary) || 0 }
let idCard: string | null = null
try { if (e.idCardNumber) idCard = decrypt(e.idCardNumber) } catch { idCard = e.idCardNumber }
let bankAccount: string | null = null
try { if (e.bankAccount) bankAccount = decrypt(e.bankAccount) } catch { bankAccount = e.bankAccount }
if (mask) {
idCard = maskIdCard(idCard)
bankAccount = maskBankAccount(bankAccount)
if (salary) salary = 0
}
const row = { ...e, monthlySalary: salary, idCardNumber: idCard, bankAccount }
write((first ? '' : ',') + JSON.stringify(row))
first = false
}
skip += batchSize
if (batch.length < batchSize) break
}
} else if (fetchMap[mod]) {
const rows = await fetchMap[mod]()
for (let i = 0; i < rows.length; i++) {
write((i === 0 ? '' : ',') + JSON.stringify(rows[i]))
}
}
write(']')
}
write('}')
if (gzip) gzip.end()
else res.end()
}
} catch (err) {
next(err)
}
})
// 导出本月薪税汇总 Excel
router.get('/payroll', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const orgId = req.user!.orgId
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const entries = await prisma.batchEntry.findMany({
where: { orgId, batch: { month, status: 'ARCHIVED' } },
include: { employee: true, batch: true },
orderBy: { employee: { name: 'asc' } },
})
const workbook = new ExcelJS.Workbook()
const ws = workbook.addWorksheet('薪税汇总')
ws.columns = [
{ header: '员工姓名', key: 'name', width: 12 },
{ header: '部门', key: 'department', width: 15 },
{ header: '基本工资', key: 'baseSalary', width: 12 },
{ header: '加班费', key: 'overtimePay', width: 12 },
{ header: '津贴补贴', key: 'allowance', width: 12 },
{ header: '奖金', key: 'bonus', width: 12 },
{ header: '扣款', key: 'deduction', width: 12 },
{ header: '应发合计', key: 'totalPay', width: 12 },
{ header: '个人社保', key: 'socialEmp', width: 12 },
{ header: '个人公积金', key: 'housingEmp', width: 12 },
{ header: '个人所得税', key: 'tax', width: 12 },
{ header: '实发工资', key: 'netPay', width: 12 },
{ header: '企业社保', key: 'socialOrg', width: 12 },
{ header: '企业公积金', key: 'housingOrg', width: 12 },
{ header: '企业总成本', key: 'orgCost', width: 12 },
]
ws.getRow(1).font = { bold: true }
for (const e of entries) {
ws.addRow({
name: e.employee.name,
department: e.employee.department,
baseSalary: e.baseSalary,
overtimePay: e.overtimePay,
allowance: e.allowance,
bonus: e.bonus,
deduction: e.deduction,
totalPay: e.totalPay,
socialEmp: e.socialEmp,
housingEmp: e.housingEmp,
tax: e.tax,
netPay: e.netPay,
socialOrg: e.socialOrg,
housingOrg: e.housingOrg,
orgCost: e.totalPay + e.socialOrg + e.housingOrg,
})
}
// 汇总行
const totalRow = ws.addRow({
name: '合计',
baseSalary: { formula: `SUM(C2:C${entries.length + 1})` },
overtimePay: { formula: `SUM(D2:D${entries.length + 1})` },
allowance: { formula: `SUM(E2:E${entries.length + 1})` },
bonus: { formula: `SUM(F2:F${entries.length + 1})` },
deduction: { formula: `SUM(G2:G${entries.length + 1})` },
totalPay: { formula: `SUM(H2:H${entries.length + 1})` },
socialEmp: { formula: `SUM(I2:I${entries.length + 1})` },
housingEmp: { formula: `SUM(J2:J${entries.length + 1})` },
tax: { formula: `SUM(K2:K${entries.length + 1})` },
netPay: { formula: `SUM(L2:L${entries.length + 1})` },
socialOrg: { formula: `SUM(M2:M${entries.length + 1})` },
housingOrg: { formula: `SUM(N2:N${entries.length + 1})` },
orgCost: { formula: `SUM(O2:O${entries.length + 1})` },
})
totalRow.font = { bold: true }
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${month}.xlsx"`)
await workbook.xlsx.write(res)
res.end()
} catch (err) {
next(err)
}
})
export default router
+608
View File
@@ -0,0 +1,608 @@
import { Router, Response } from 'express'
import multer from 'multer'
import * as XLSX from 'xlsx'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { encrypt, decrypt, sha256 } from '../lib/crypto'
import prisma from '../lib/prisma'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
// 身份证号格式校验(18位正则 + 校验位算法)
function validateIdCard(idCard: string): { valid: boolean; upgraded?: string; error?: string } {
if (!idCard) return { valid: true }
const s = idCard.trim()
// 15位身份证号升级为18位
if (/^\d{15}$/.test(s)) {
const upgraded = upgrade15To18(s)
return { valid: true, upgraded }
}
if (!/^\d{17}[\dXx]$/.test(s)) {
return { valid: false, error: '身份证号格式错误(应为18位)' }
}
// 校验位算法
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = s.substring(0, 17).split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
const expected = checkCodes[sum % 11]
if (s.charAt(17).toUpperCase() !== expected) {
return { valid: false, error: '身份证号校验位错误' }
}
return { valid: true }
}
function upgrade15To18(s15: string): string {
const born = '19' + s15.substring(6, 12)
const body = s15.substring(0, 6) + born + s15.substring(12)
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
const sum = body.split('').reduce((acc, ch, i) => acc + parseInt(ch) * weights[i], 0)
return body + checkCodes[sum % 11]
}
// 社保基数范围校验
const SOCIAL_INS_LIMITS: Record<string, { min: number; max: number }> = {
'北京': { min: 6326, max: 33891 },
'上海': { min: 7310, max: 36549 },
'广州': { min: 5284, max: 27501 },
'深圳': { min: 3523, max: 27501 },
'杭州': { min: 4812, max: 24060 },
}
function validateSocialBase(base: number, city?: string): { valid: boolean; warning?: string } {
if (!city || !SOCIAL_INS_LIMITS[city]) return { valid: true }
const limits = SOCIAL_INS_LIMITS[city]
if (base < limits.min) return { valid: true, warning: `基数${base}低于${city}下限${limits.min}` }
if (base > limits.max) return { valid: true, warning: `基数${base}高于${city}上限${limits.max}` }
return { valid: true }
}
function dateToMonth(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function parseDate(v: any): Date | null {
if (!v) return null
if (v instanceof Date) return v
if (typeof v === 'number') {
const d = XLSX.SSF.parse_date_code(v)
if (d) return new Date(d.y, d.m - 1, d.d)
}
const s = String(v).trim()
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s)
if (/^\d{4}\/\d{2}\/\d{2}/.test(s)) return new Date(s.replace(/\//g, '-'))
return null
}
function val(v: any): string {
if (v == null) return ''
return String(v).trim()
}
function num(v: any): number {
const n = Number(v)
return isNaN(n) ? 0 : n
}
// ========== 导入预览(不写入数据库) ==========
router.post('/excel/preview', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const preview: any = { employees: [], contracts: [], overtime: [], disciplinary: [], attendance: [], errors: [] as any[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), department: val(r['部门']) || '未分配', hireDate: r['入职日期'], salary: num(r['月工资']), phone: val(r['手机号']), idCard: val(r['身份证号']), status: 'normal', errors: [] as string[], warnings: [] as string[] }
if (!row.name) { row.status = 'error'; row.errors.push('姓名为空') }
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { row.status = 'error'; row.errors.push('入职日期格式错误') }
if (row.salary === 0) { row.status = 'error'; row.errors.push('月工资为空') }
if (row.idCard) {
const idCheck = validateIdCard(row.idCard)
if (!idCheck.valid) { row.status = row.status === 'normal' ? 'warning' : row.status; row.warnings.push(idCheck.error!) }
if (idCheck.upgraded) { row.idCard = idCheck.upgraded; row.warnings.push('15位身份证已升级为18位') }
}
if (row.status === 'error') preview.errors.push({ sheet: '员工信息', row: i + 2, name: row.name, errors: row.errors })
preview.employees.push(row)
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), contractType: val(r['合同类型']), startDate: r['合同开始日期'], endDate: r['合同结束日期'], status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const sd = parseDate(r['合同开始日期'])
if (!sd) { row.status = 'error'; row.errors.push('开始日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '劳动合同', row: i + 2, name: row.name, errors: row.errors })
preview.contracts.push(row)
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const otType = val(r['加班类型']) || '工作日加班'
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], hours: num(r['加班时长']), otType, status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '加班记录', row: i + 2, name: row.name, errors: row.errors })
preview.overtime.push(row)
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], violationType: val(r['违纪类型']), description: val(r['描述']), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
if (row.status === 'error') preview.errors.push({ sheet: '违纪记录', row: i + 2, name: row.name, errors: row.errors })
preview.disciplinary.push(row)
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const row: any = { rowNo: i + 2, name: val(r['姓名']), idCard: val(r['身份证号']), date: r['日期'], attStatus: val(r['考勤状态']), status: 'normal', errors: [] as string[] }
if (!row.name && !row.idCard) { row.status = 'error'; row.errors.push('姓名和身份证号都为空') }
const dt = parseDate(r['日期'])
if (!dt) { row.status = 'error'; row.errors.push('日期格式错误') }
if (row.status === 'error') preview.errors.push({ sheet: '考勤记录', row: i + 2, name: row.name, errors: row.errors })
preview.attendance.push(row)
}
}
const summary = {
totalRows: preview.employees.length + preview.contracts.length + preview.overtime.length + preview.disciplinary.length + preview.attendance.length,
normalRows: 0,
warningRows: 0,
errorRows: preview.errors.length,
sheets: Object.keys(wb.Sheets).filter(s => !s.startsWith('!')),
}
summary.normalRows = summary.totalRows - summary.errorRows
preview.summary = summary
res.json({ success: true, data: preview })
} catch (err) {
next(err)
}
})
// ========== 错误日志导出 ==========
router.post('/excel/error-log', authMiddleware, async (req: AuthRequest, res: Response, next) => {
try {
const { errors } = req.body as { errors: any[] }
if (!errors || !errors.length) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无错误数据' } })
}
const data = errors.map(e => ({
'Sheet': e.sheet || '',
'行号': e.row || '',
'员工姓名': e.name || '',
'错误类型': Array.isArray(e.errors) ? e.errors.join('; ') : (e.error || ''),
}))
const ws = XLSX.utils.json_to_sheet(data)
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '错误日志')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', `attachment; filename="import-errors-${Date.now()}.xlsx"`)
res.send(buf)
} catch (err) {
next(err)
}
})
router.post('/excel', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { employees: 0, contracts: 0, overtime: 0, disciplinary: 0, attendance: 0, errors: [] as string[] }
const empSheet = wb.Sheets['员工信息']
if (empSheet) {
const rows = XLSX.utils.sheet_to_json(empSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const name = val(r['姓名'])
if (!name) { result.errors.push(`员工第${i + 2}行:姓名为空,跳过`); continue }
const dept = val(r['部门']) || '未分配'
const hireDate = parseDate(r['入职日期'])
if (!hireDate) { result.errors.push(`员工第${i + 2}行:入职日期格式错误`); continue }
const salary = String(num(r['月工资']))
if (salary === '0') { result.errors.push(`员工第${i + 2}行:月工资为空`); continue }
let idCard = val(r['身份证号'])
if (idCard) {
const idCheck = validateIdCard(idCard)
if (!idCheck.valid) { result.errors.push(`员工第${i + 2}行:${idCheck.error}`); continue }
if (idCheck.upgraded) idCard = idCheck.upgraded
}
const emp = await prisma.employee.create({
data: {
orgId, name, department: dept, hireDate,
monthlySalary: encrypt(salary),
gender: val(r['性别']) || null,
phone: val(r['手机号']) || null,
idCardNumber: idCard ? encrypt(idCard) : null,
idCardHash: idCard ? sha256(idCard) : null,
emergencyContact: val(r['紧急联系人']) || null,
emergencyPhone: val(r['紧急联系电话']) || null,
address: val(r['住址']) || null,
bankName: val(r['开户行']) || null,
bankAccount: val(r['银行账号']) ? encrypt(val(r['银行账号'])) : null,
socialInsBase: num(r['社保基数']) || num(salary),
housingFundBase: num(r['公积金基数']) || num(salary),
specialDeduction: num(r['专项附加扣除']) || 0,
isPregnant: val(r['孕期']) === '是',
isInMedicalPeriod: val(r['医疗期']) === '是',
isWorkInjured: val(r['工伤']) === '是',
socialInsStartMonth: dateToMonth(hireDate),
housingFundStartMonth: dateToMonth(hireDate),
createdBy: userId,
},
})
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['社保基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: dateToMonth(hireDate), endMonth: null, base: num(r['公积金基数']) || num(salary), changeType: 'ONBOARDING', createdBy: userId } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary: 0, newSalary: num(salary), effectiveDate: hireDate, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
await prisma.employeeDepartmentRecord.create({ data: { orgId, employeeId: emp.id, oldDepartment: '', newDepartment: dept, effectiveMonth: dateToMonth(hireDate), endMonth: null, changeType: 'ONBOARDING', createdBy: userId } })
result.employees++
} catch (e: any) {
result.errors.push(`员工第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const contractSheet = wb.Sheets['劳动合同']
if (contractSheet) {
const rows = XLSX.utils.sheet_to_json(contractSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`合同第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const startDate = parseDate(r['合同开始日期'])
if (!startDate) { result.errors.push(`合同第${i + 2}行:开始日期格式错误`); continue }
const typeMap: any = { '固定期限': 'FIXED', '无固定期限': 'UNFIXED', '未签': 'UNSIGNED' }
const contractType = typeMap[val(r['合同类型'])] || 'FIXED'
if (contractType !== 'UNSIGNED') {
await prisma.laborContract.create({
data: {
orgId, employeeId: empId,
signDate: parseDate(r['签订日期']) || null,
startDate,
endDate: parseDate(r['合同结束日期']) || null,
contractType,
signMethod: val(r['签订方式']) === '电子' ? 'ELECTRONIC' : 'PAPER',
contractYears: num(r['合同年限']) || 3,
probationMonths: num(r['试用期月数']) || 0,
probationSalary: num(r['试用期工资']) || 0,
createdBy: userId,
},
})
result.contracts++
}
} catch (e: any) {
result.errors.push(`合同第${i + 2}行:${e?.message || '导入失败'}`)
}
}
}
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const month = dateToMonth(date)
const otType = val(r['加班类型']) || '工作日加班'
const hours = num(r['加班时长'])
const weekdayHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weekendHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const holidayHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.create({ data: { orgId, employeeId: empId, month, weekdayHours, weekendHours, holidayHours, createdBy: userId } as any })
result.overtime++
}
}
const discSheet = wb.Sheets['违纪记录']
if (discSheet) {
const rows = XLSX.utils.sheet_to_json(discSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`违纪第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const typeMap: any = { '迟到': 'LATE', '旷工': 'ABSENT', '不服从': 'INSUBORDINATION', '违纪': 'MISCONDUCT', '违规': 'VIOLATE_POLICY', '其他': 'OTHER' }
const sevMap: any = { '警告': 'WARNING', '严重': 'SERIOUS', '重度': 'SEVERE' }
const actMap: any = { '口头警告': 'ORAL_WARNING', '书面警告': 'WRITTEN_WARNING', '扣款': 'DEDUCTION', '降级': 'DEMOTION', '辞退': 'TERMINATION' }
await prisma.disciplinaryRecord.create({ data: { orgId, employeeId: empId, violationDate: date, violationType: typeMap[val(r['违纪类型'])] || 'OTHER', description: val(r['描述']), severity: sevMap[val(r['严重程度'])] || 'WARNING', action: actMap[val(r['处罚'])] || 'ORAL_WARNING', createdBy: userId } })
result.disciplinary++
}
}
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e.id]))
const empByName = new Map(employees.map(e => [e.name, e.id]))
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
const idCard = val(r['身份证号'])
const empId = idCard ? empByHash.get(sha256(idCard)) : empByName.get(val(r['姓名']))
if (!empId) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) continue
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.create({ data: { orgId, employeeId: empId, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId } })
result.attendance++
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const empData = [
{ '姓名': '张三', '部门': '技术部', '性别': '男', '手机号': '13800138000', '身份证号': '110101199001011234', '入职日期': '2023-03-01', '月工资': 10000, '社保基数': 10000, '公积金基数': 10000, '专项附加扣除': 1000, '紧急联系人': '李四', '紧急联系电话': '13900139000', '住址': '北京市朝阳区', '开户行': '工商银行', '银行账号': '6222021234567890', '孕期': '否', '医疗期': '否', '工伤': '否' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(empData), '员工信息')
const contractData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '合同类型': '固定期限', '签订日期': '2023-03-01', '合同开始日期': '2023-03-01', '合同结束日期': '2026-03-01', '合同年限': 3, '签订方式': '纸质', '试用期月数': 3, '试用期工资': 8000 },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(contractData), '劳动合同')
const otData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班类型': '工作日加班', '加班时长': 2, '倍率': 1.5, '是否审批': '是' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const discData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-10', '违纪类型': '警告', '描述': '迟到', '处罚': '口头警告' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(discData), '违纪记录')
const attData = [
{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-01-15', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' },
]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="import-template.xlsx"')
res.send(buf)
})
// ========== 月度导入 ==========
router.post('/monthly', authMiddleware, upload.single('file'), async (req: AuthRequest, res: Response, next) => {
try {
if (!req.file) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请上传文件' } })
const orgId = req.user!.orgId
const userId = req.user!.id
const month = val(req.body.month) || dateToMonth(new Date())
if (!/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '月份格式应为 YYYY-MM' } })
}
const wb = XLSX.read(req.file.buffer, { type: 'buffer', cellDates: true })
const result: any = { month, attendance: 0, overtime: 0, salaryChanges: 0, socialInsChanges: 0, housingFundChanges: 0, errors: [] as string[], strategies: { '考勤记录': '覆盖(同员工同日覆盖)', '加班记录': '累加(同员工同月累加)', '薪资调整': '覆盖(关闭旧记录,新建新记录)', '社保变动': '覆盖(关闭旧记录,新建新记录)', '公积金变动': '覆盖(关闭旧记录,新建新记录)' } }
const employees = await prisma.employee.findMany({ where: { orgId }, select: { id: true, name: true, monthlySalary: true, department: true, idCardHash: true } })
const empByHash = new Map(employees.filter(e => e.idCardHash).map(e => [e.idCardHash, e]))
const empByName = new Map(employees.map(e => [e.name, e]))
function findEmp(r: any) {
const idCard = val(r['身份证号'])
if (idCard) {
const emp = empByHash.get(sha256(idCard))
if (emp) return emp
}
return empByName.get(val(r['姓名']))
}
// 考勤记录
const attSheet = wb.Sheets['考勤记录']
if (attSheet) {
const rows = XLSX.utils.sheet_to_json(attSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`考勤第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`考勤第${i + 2}行:日期格式错误`); continue }
const statusMap: any = { '正常': 'NORMAL', '迟到': 'LATE', '早退': 'EARLY_LEAVE', '缺勤': 'ABSENT', '请假': 'LEAVE', '出差': 'BUSINESS_TRIP' }
await prisma.attendanceRecord.upsert({
where: { employeeId_date: { employeeId: emp.id, date } },
create: { orgId, employeeId: emp.id, date, status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null, createdBy: userId },
update: { status: statusMap[val(r['考勤状态'])] || 'NORMAL', checkInTime: val(r['上班时间']) || null, checkOutTime: val(r['下班时间']) || null, remark: val(r['备注']) || null },
})
result.attendance++
} catch (e: any) { result.errors.push(`考勤第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 加班记录
const otSheet = wb.Sheets['加班记录']
if (otSheet) {
const rows = XLSX.utils.sheet_to_json(otSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`加班第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const date = parseDate(r['日期'])
if (!date) { result.errors.push(`加班第${i + 2}行:日期格式错误`); continue }
const otMonth = dateToMonth(date)
const hours = num(r['加班时长'])
const otType = val(r['加班类型']) || '工作日加班'
const wdHours = num(r['工作日加班时长']) || (otType.includes('工作日') ? hours : 0)
const weHours = num(r['休息日加班时长']) || (otType.includes('休息日') ? hours : 0)
const hoHours = num(r['法定节假日加班时长']) || (otType.includes('法定') ? hours : 0)
await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: emp.id, month: otMonth } },
create: { orgId, employeeId: emp.id, month: otMonth, weekdayHours: wdHours, weekendHours: weHours, holidayHours: hoHours } as any,
update: {
weekdayHours: { increment: wdHours },
weekendHours: { increment: weHours },
holidayHours: { increment: hoHours },
},
})
result.overtime++
} catch (e: any) { result.errors.push(`加班第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 薪资调整
const salarySheet = wb.Sheets['薪资调整']
if (salarySheet) {
const rows = XLSX.utils.sheet_to_json(salarySheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`薪资第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const newSalary = num(r['调整后月薪'])
if (newSalary <= 0) { result.errors.push(`薪资第${i + 2}行:调整后月薪无效`); continue }
const effDate = parseDate(r['生效日期']) || new Date(month + '-01')
const effMonth = dateToMonth(effDate)
let oldSalary = 0
try { oldSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { oldSalary = 0 }
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: effMonth } })
await prisma.salaryChangeRecord.create({ data: { orgId, employeeId: emp.id, oldSalary, newSalary, effectiveDate: effDate, effectiveMonth: effMonth, endMonth: null, changeType: 'SALARY_CHANGE', reason: val(r['调薪原因']) || '月度导入', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { monthlySalary: encrypt(String(newSalary)) } })
result.salaryChanges++
} catch (e: any) { result.errors.push(`薪资第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 社保增减员
const socialSheet = wb.Sheets['社保变动']
if (socialSheet) {
const rows = XLSX.utils.sheet_to_json(socialSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`社保第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
const city = val(r['城市']) || '北京'
if (changeType === '增员' || changeType === '调基') {
const baseCheck = validateSocialBase(base, city)
if (baseCheck.warning) result.errors.push(`社保第${i + 2}行警告:${baseCheck.warning}`)
// 关闭之前有效记录
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsBase: base || 0, socialInsStartMonth: month, socialInsEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeSocialInsRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { socialInsEndMonth: month } })
}
result.socialInsChanges++
} catch (e: any) { result.errors.push(`社保第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
// 公积金增减员
const hfSheet = wb.Sheets['公积金变动']
if (hfSheet) {
const rows = XLSX.utils.sheet_to_json(hfSheet)
for (let i = 0; i < rows.length; i++) {
const r = rows[i] as any
try {
const emp = findEmp(r)
if (!emp) { result.errors.push(`公积金第${i + 2}行:找不到员工「${val(r['姓名'])}`); continue }
const changeType = val(r['变动类型'])
const base = num(r['缴费基数'])
if (changeType === '增员' || changeType === '调基') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month } })
await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: emp.id, startMonth: month, endMonth: null, base: base || 0, changeType: changeType === '增员' ? 'ONBOARDING' : 'ADJUST', createdBy: userId } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundBase: base || 0, housingFundStartMonth: month, housingFundEndMonth: null } })
} else if (changeType === '减员') {
await prisma.employeeHousingFundRecord.updateMany({ where: { employeeId: emp.id, endMonth: null }, data: { endMonth: month, changeType: 'TERMINATION' } })
await prisma.employee.update({ where: { id: emp.id }, data: { housingFundEndMonth: month } })
}
result.housingFundChanges++
} catch (e: any) { result.errors.push(`公积金第${i + 2}行:${e?.message || '导入失败'}`) }
}
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
router.get('/monthly-template', authMiddleware, async (_req: AuthRequest, res: Response) => {
const wb = XLSX.utils.book_new()
const attData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-01', '考勤状态': '正常', '上班时间': '09:00', '下班时间': '18:00', '备注': '' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(attData), '考勤记录')
const otData = [{ '姓名': '张三', '身份证号': '110101199001011234', '日期': '2024-06-15', '工作日加班时长': 2, '休息日加班时长': 0, '法定节假日加班时长': 0, '加班时长': 2, '加班类型': '工作日加班' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(otData), '加班记录')
const salaryData = [{ '姓名': '张三', '身份证号': '110101199001011234', '调整后月薪': 12000, '生效日期': '2024-06-01', '调薪原因': '年度调薪' }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(salaryData), '薪资调整')
const socialData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(socialData), '社保变动')
const hfData = [{ '姓名': '张三', '身份证号': '110101199001011234', '变动类型': '调基', '缴费基数': 12000 }]
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(hfData), '公积金变动')
const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' })
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
res.setHeader('Content-Disposition', 'attachment; filename="monthly-import-template.xlsx"')
res.send(buf)
})
export default router
+169
View File
@@ -0,0 +1,169 @@
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)
}
})
// 测试通知渠道
router.post('/test', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { channel } = req.body as { channel: 'wechat' | 'email' }
const setting = await prisma.notificationSetting.findUnique({ where: { orgId: req.user!.orgId } })
if (!setting) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '通知设置不存在' } })
if (channel === 'wechat') {
if (!setting.wechatWebhook) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置企业微信 Webhook' } })
try {
const resp = await fetch(setting.wechatWebhook, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ msgtype: 'text', text: { content: '【测试消息】通知渠道连接正常,配置有效。' } }),
})
const data = await resp.json() as any
if (data.errcode && data.errcode !== 0) {
return res.json({ success: false, error: { code: 'TEST_FAILED', message: `Webhook 返回错误: ${data.errmsg || data.errcode}` } })
}
res.json({ success: true, data: { message: '测试消息已发送到企业微信' } })
} catch (e: any) {
res.json({ success: false, error: { code: 'TEST_FAILED', message: `发送失败: ${e?.message || '网络错误'}` } })
}
} else if (channel === 'email') {
if (!setting.email) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '未配置通知邮箱' } })
// 邮件发送(开发阶段仅返回成功)
res.json({ success: true, data: { message: `测试邮件已发送到 ${setting.email}` } })
} else {
res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '不支持的通知渠道' } })
}
} catch (err) {
next(err)
}
})
export default router
+577
View File
@@ -0,0 +1,577 @@
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)
}
})
// 更新加班记录(按ID
const overtimeUpdateSchema = z.object({
weekdayHours: z.number().min(0).optional(),
weekendHours: z.number().min(0).optional(),
holidayHours: z.number().min(0).optional(),
monthlyWage: z.number().positive().optional(),
})
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const data = overtimeUpdateSchema.parse(req.body)
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
if (!existing) {
res.status(404).json({ success: false, message: '记录不存在' })
return
}
const monthlyWage = data.monthlyWage ?? 0
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
const weekendHours = data.weekendHours ?? existing.weekendHours
const holidayHours = data.holidayHours ?? existing.holidayHours
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.update({
where: { id },
data: {
weekdayHours,
weekendHours,
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 overtimeConfigSchema = z.object({
weekdayRate: z.number().min(1).default(1.5),
weekendRate: z.number().min(1).default(2.0),
holidayRate: z.number().min(1).default(3.0),
monthlyDays: z.number().min(1).default(21.75),
dailyHours: z.number().min(1).default(8),
})
// 获取加班费计算规则
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
if (!config) {
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 保存加班费计算规则
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeConfigSchema.parse(req.body)
const config = await prisma.overtimeConfig.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 batchOvertimeSchema = z.array(
z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
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 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: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
},
})
results.push(record)
}
res.json({ success: true, data: { imported: results.length } })
} catch (err) {
next(err)
}
})
// ========== 批次导入加班费 ==========
router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
// 获取加班费计算规则
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
// 获取该月未关联批次的加班记录
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, month: batch.month, batchId: null },
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
})
if (overtimeRecords.length === 0) {
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
}
const results: any[] = []
for (const ot of overtimeRecords) {
// 获取员工月工资
let monthlyWage = 0
try {
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
} catch {
monthlyWage = Number(ot.employee.monthlySalary) || 0
}
if (!monthlyWage) continue
// 根据规则计算加班费
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
// 更新加班记录:计算金额并锁定到批次
await prisma.overtimeRecord.update({
where: { id: ot.id },
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
})
// 更新批次条目的加班费
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
})
if (entry) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { overtimePay: totalPay },
})
// 重新计算条目
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
await prisma.batchEntry.update({
where: { id: entry.id },
data: { totalPay: newTotalPay },
})
}
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
}
res.json({ success: true, data: { imported: results.length, details: results } })
} catch (err) {
next(err)
}
})
// ========== 税率试算 ==========
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
const orgId = req.user!.orgId
// 获取员工和配置
const [employee, socialConfig, housingConfig] = await Promise.all([
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
const socialBase = emp.socialInsBase || baseSalary
const housingBase = emp.housingFundBase || baseSalary
// 计算社保公积金
let socialEmp = 0, housingEmp = 0
if (socialConfig) {
const { calcSocialInsurance } = await import('../services/payroll.service')
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
}
if (housingConfig) {
const { calcHousingFund } = await import('../services/payroll.service')
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
}
// 获取 YTD 数据计算累计个税
const year = month.slice(0, 4)
const ytdPayslips = employeeId
? await prisma.payslip.findMany({
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
orderBy: { month: 'asc' },
})
: []
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
const { calcCumulativeTax } = await import('../services/payroll.service')
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
const netPay = totalPay - socialEmp - housingEmp - tax
res.json({
success: true,
data: {
baseSalary: baseSalary || 0,
overtimePay: overtimePay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
bonus: bonus || 0,
totalPay,
socialEmp,
housingEmp,
specialDeduction: specialDeduction || 0,
taxableIncome,
estimatedTax: tax,
netPay,
ytdPayslipCount: ytdPayslips.length,
breakdown: [
{ label: '应发合计', value: totalPay },
{ label: '个人社保', value: -socialEmp },
{ label: '个人公积金', value: -housingEmp },
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
{ label: '应纳税所得额', value: taxableIncome },
{ label: '当月个税', value: -tax },
{ label: '实发工资', value: netPay },
],
},
})
} catch (err) {
next(err)
}
})
export default router
+673
View File
@@ -0,0 +1,673 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
import { decrypt } from '../lib/crypto'
import {
getTemplate,
calcBatchEntry,
getPayrollRiskWarnings,
generatePayslipFromBatches,
} from '../services/payroll.service'
const router = Router()
router.use(authMiddleware)
// ========== 薪酬模版 ==========
// 获取薪酬模版
router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const items = await getTemplate(req.user!.orgId)
res.json({ success: true, data: items })
} catch (err) {
next(err)
}
})
// 更新薪酬模版项
const updateTemplateItemSchema = z.object({
name: z.string().min(1).optional(),
formula: z.string().nullable().optional(),
order: z.number().int().optional(),
isEditable: z.boolean().optional(),
})
router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = updateTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
const updateData: any = {}
if (data.name !== undefined && !item.isDefault) updateData.name = data.name
if (data.formula !== undefined) updateData.formula = data.formula
if (data.order !== undefined) updateData.order = data.order
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 新增薪酬模版项
const createTemplateItemSchema = z.object({
name: z.string().min(1),
code: z.string().min(1),
type: z.enum(['INPUT', 'CALCULATED']),
formula: z.string().nullable().optional(),
order: z.number().int().default(99),
isEditable: z.boolean().default(true),
})
router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createTemplateItemSchema.parse(req.body)
const item = await prisma.payslipItem.create({
data: { ...data, orgId: req.user!.orgId, isDefault: false },
})
res.json({ success: true, data: item })
} catch (err) {
next(err)
}
})
// 删除薪酬模版项(仅非预置项)
router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.payslipItem.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } })
await prisma.payslipItem.delete({ where: { id: req.params.id } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== 发薪批次 ==========
// 检查本月是否已发薪
router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.query
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' },
})
const draftBatches = await prisma.payrollBatch.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' },
})
const publishedPayslips = await prisma.payslip.count({
where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' },
})
res.json({
success: true,
data: {
hasArchivedBatch: archivedBatches > 0,
archivedCount: archivedBatches,
draftCount: draftBatches,
payslipsPublished: publishedPayslips > 0,
},
})
} catch (err) {
next(err)
}
})
// 获取可复制的归档批次列表
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const batches = await prisma.payrollBatch.findMany({
where: { orgId, status: 'ARCHIVED' },
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
take: 20,
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次列表
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, monthFrom, monthTo, status, type } = req.query
const batches = await prisma.payrollBatch.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
...(status ? { status: String(status) as any } : {}),
...(type ? { type: String(type) as any } : {}),
},
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
})
res.json({ success: true, data: batches })
} catch (err) {
next(err)
}
})
// 获取批次详情
router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
include: {
entries: {
include: {
employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } },
},
orderBy: { employee: { name: 'asc' } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
// 重命名批次
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { name } = req.body
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
}
const batch = await prisma.payrollBatch.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
}
const updated = await prisma.payrollBatch.update({
where: { id: req.params.id },
data: { name: name.trim() },
})
res.json({ success: true, data: { id: updated.id, name: updated.name } })
} catch (err) {
next(err)
}
})
// 创建批次
const createBatchSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).default('REGULAR'),
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
sourceBatchId: z.string().optional(),
name: z.string().optional(),
remark: z.string().optional(),
})
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月已有批次数
const existingBatches = await prisma.payrollBatch.count({
where: { orgId, month },
})
const batchNo = existingBatches + 1
// 获取在职员工 + 本月离职员工
const monthStart = new Date(`${month}-01`)
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
// 获取上月发薪数据
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
const batchName = name || `${month}${batchNo}${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
// 根据模式确定员工列表和数据来源
let employees: any[] = []
let sourceEntries: any[] | null = null
if (mode === 'blank_all') {
// 全空白:不拉入员工
employees = []
} else if (mode === 'copy_batch' && sourceBatchId) {
// 复制指定批次:从源批次复制条目
const sourceBatch = await prisma.payrollBatch.findFirst({
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
include: { entries: true },
})
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
sourceEntries = sourceBatch.entries
// 提取员工 ID,后续按此创建条目
const employeeIds = sourceEntries.map(e => e.employeeId)
employees = await prisma.employee.findMany({
where: { id: { in: employeeIds }, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
} else {
// copy_last 或 blank_employees:拉入员工
if (type === 'TERMINATION' || type === 'SEVERANCE') {
const terminations = await prisma.terminationRecord.findMany({
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
})
employees = terminations.map(t => t.employee)
} else {
employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ status: 'ACTIVE' },
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
],
},
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
}
}
// 创建批次
const batch = await prisma.payrollBatch.create({
data: {
orgId,
month,
batchNo,
name: batchName,
type,
remark,
createdBy: req.user!.id,
employeeCount: employees.length,
},
})
// 创建批次条目
const entries: any[] = []
for (const emp of employees) {
let baseSalary = 0
let overtimePay = 0
let allowance = 0
let deduction = 0
let bonus = 0
if (mode === 'copy_batch' && sourceEntries) {
// 复制指定批次:从源条目复制数据
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
if (srcEntry) {
baseSalary = srcEntry.baseSalary
overtimePay = srcEntry.overtimePay
allowance = srcEntry.allowance
deduction = srcEntry.deduction
bonus = srcEntry.bonus
}
} else if (mode === 'copy_last') {
// 复制上月:从上月工资条复制
const prevPayslip = await prisma.payslip.findUnique({
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
})
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId: emp.id, month } },
})
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 }
}
if (prevPayslip) baseSalary = prevPayslip.baseSalary
overtimePay = overtime?.totalPay || 0
allowance = prevPayslip?.allowance || 0
deduction = prevPayslip?.deduction || 0
}
// blank_employees 和 blank_all: 所有金额默认 0
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
})
// 计算社保、个税等
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
const skipSocial = type !== 'BONUS' && type !== 'SEVERANCE' && hasArchivedRegularBatch > 0
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
// 风险提示
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
const entry = await prisma.batchEntry.create({
data: {
batchId: batch.id,
orgId,
employeeId: emp.id,
baseSalary,
overtimePay,
allowance,
deduction,
bonus,
socialEmp: calcResult.socialEmp,
socialOrg: calcResult.socialOrg,
housingEmp: calcResult.housingEmp,
housingOrg: calcResult.housingOrg,
tax: calcResult.tax,
totalPay: calcResult.totalPay,
netPay: calcResult.netPay,
riskWarnings,
},
})
entries.push(entry)
}
// 更新批次汇总
const totals = entries.reduce((acc, e) => ({
totalPay: acc.totalPay + e.totalPay,
totalNetPay: acc.totalNetPay + e.netPay,
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
totalTax: acc.totalTax + e.tax,
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
const updatedBatch = await prisma.payrollBatch.update({
where: { id: batch.id },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
})
res.json({ success: true, data: updatedBatch })
} catch (err) {
next(err)
}
})
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
const updateEntrySchema = z.object({
baseSalary: z.number().min(0).optional(),
overtimePay: z.number().min(0).optional(),
allowance: z.number().min(0).optional(),
deduction: z.number().min(0).optional(),
bonus: z.number().min(0).optional(),
socialEmp: z.number().min(0).optional(),
socialOrg: z.number().min(0).optional(),
housingEmp: z.number().min(0).optional(),
housingOrg: z.number().min(0).optional(),
})
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const data = updateEntrySchema.parse(req.body)
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
// 合并输入项
const inputs = {
baseSalary: data.baseSalary ?? entry.baseSalary,
overtimePay: data.overtimePay ?? entry.overtimePay,
allowance: data.allowance ?? entry.allowance,
deduction: data.deduction ?? entry.deduction,
bonus: data.bonus ?? entry.bonus,
}
// 构建社保覆盖参数(如果请求中包含社保字段)
const overrideSocial: any = {}
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
// 重新计算
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...calcResult },
})
// 更新批次汇总
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = allEntries.reduce((acc, e) => ({
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
totalPay: Math.round(totals.totalPay * 100) / 100,
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
totalTax: Math.round(totals.totalTax * 100) / 100,
},
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
// 批次增加人员
router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const { employeeIds } = req.body as { employeeIds: string[] }
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
const results: any[] = []
for (const employeeId of employeeIds) {
// 检查是否已在批次中
const existing = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId } },
})
if (existing) continue
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
if (!emp) continue
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 overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId, month: batch.month } },
})
const overtimePay = overtime?.totalPay || 0
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...calcResult, riskWarnings,
},
})
results.push(entry)
}
// 更新批次人数
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true, data: { added: results.length } })
} catch (err) {
next(err)
}
})
// 批次移除人员
router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId, employeeId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可编辑' } })
await prisma.batchEntry.deleteMany({ where: { batchId, employeeId } })
const count = await prisma.batchEntry.count({ where: { batchId } })
await prisma.payrollBatch.update({ where: { id: batchId }, data: { employeeCount: count } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 删除批次(仅限草稿状态)
router.delete('/batches/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } })
await prisma.batchEntry.deleteMany({ where: { batchId } })
await prisma.payrollBatch.delete({ where: { id: batchId } })
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 归档批次
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次已归档' } })
await prisma.payrollBatch.update({
where: { id: batchId },
data: { status: 'ARCHIVED', archivedAt: new Date() },
})
res.json({ success: true, data: { archived: true } })
} catch (err) {
next(err)
}
})
// 从已归档批次汇总生成工资条
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.body
const orgId = req.user!.orgId
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM' } })
}
// 检查是否有已归档批次
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId, month, status: 'ARCHIVED' },
})
if (archivedBatches === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } })
}
const result = await generatePayslipFromBatches(orgId, month)
// 自动标记"生成工资条"待办为已完成
await prisma.riskItem.updateMany({
where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { generated: result.generated } })
} catch (err) {
next(err)
}
})
// 银行代发文件导出(接口预留)
router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const { format = 'csv' } = req.query
const batch = await prisma.payrollBatch.findFirst({
where: { id: batchId, orgId },
include: {
entries: {
include: { employee: { select: { name: true, bankAccount: true, bankName: true } } },
},
},
})
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
if (batch.status !== 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '仅归档批次可导出' } })
if (format === 'csv') {
const header = '姓名,银行账号,开户行,实发金额\n'
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
return res.send('\ufeff' + header + rows)
}
res.json({ success: true, data: batch })
} catch (err) {
next(err)
}
})
export default router
+425
View File
@@ -0,0 +1,425 @@
import { Router, Request, Response, NextFunction } from 'express'
import bcrypt from 'bcryptjs'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import prisma from '../lib/prisma'
import { signAccessToken, verifyAccessToken } from '../lib/jwt'
import { portalLoginSchema, portalSendCodeSchema, portalVerifyCodeSchema, onboardingSchema, contractConfirmSchema, contractSendCodeSchema } from '../schemas/portal.schema'
const router = Router()
// 验证码临时存储(生产环境应使用 Redis)
const codeStore = new Map<string, { code: string; expiresAt: number; failCount: number; lastSentAt: 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: '该手机号未在系统中登记' } })
}
// 频率限制:60秒内不可重复发送
const existing = codeStore.get(data.phone)
if (existing && existing.lastSentAt && Date.now() - existing.lastSentAt < 60 * 1000) {
return res.status(429).json({ success: false, error: { code: 'RATE_LIMIT', message: '验证码发送过于频繁,请60秒后重试' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(data.phone, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
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: '验证码已过期,请重新获取' } })
}
// 错误次数限制:5次后锁定
if (stored.failCount >= 5) {
codeStore.delete(data.phone)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.code) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
}
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)
}
})
// 工资条历史(最近6个月)
router.get('/payslip/history', portalAuth, async (req: any, res, next) => {
try {
const payslips = await prisma.payslip.findMany({
where: { employeeId: req.employee.id, orgId: req.employee.orgId },
orderBy: { month: 'desc' },
take: 6,
})
res.json({ success: true, data: payslips })
} 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 },
include: { employee: true },
})
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 },
})
// 通知 HR
await prisma.notificationLog.create({
data: {
orgId: req.employee.orgId,
title: '工资条确认通知',
content: `员工 ${payslip.employee.name} 已确认 ${payslip.month} 月工资条(IP: ${req.ip}`,
type: 'PAYSLIP_CONFIRM',
channel: 'IN_APP',
},
})
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/send-code', async (req, res, next) => {
try {
const data = contractSendCodeSchema.parse(req.body)
const link = await prisma.contractConfirmLink.findFirst({
where: { token: data.token, status: 'UNCONFIRMED', expiresAt: { gt: new Date() } },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
const phone = link.contract.employee.phone
if (!phone) {
return res.status(400).json({ success: false, error: { code: 'NO_PHONE', message: '员工手机号未登记,无法发送验证码' } })
}
const code = Math.random().toString().slice(2, 8)
codeStore.set(`contract-${data.token}`, { code, expiresAt: Date.now() + 5 * 60 * 1000, failCount: 0, lastSentAt: Date.now() })
res.json({ success: true, data: { code, message: '验证码已生成(开发阶段直接返回,生产环境将发送短信)' } })
} 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() } },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
// 验证码校验
const stored = codeStore.get(`contract-${data.token}`)
if (!stored || stored.expiresAt < Date.now()) {
return res.status(400).json({ success: false, error: { code: 'CODE_EXPIRED', message: '验证码已过期,请重新获取' } })
}
if (stored.failCount >= 5) {
codeStore.delete(`contract-${data.token}`)
return res.status(400).json({ success: false, error: { code: 'TOO_MANY_ATTEMPTS', message: '验证码错误次数过多,请重新获取验证码' } })
}
if (stored.code !== data.verifyCode) {
stored.failCount++
return res.status(400).json({ success: false, error: { code: 'CODE_WRONG', message: `验证码错误(剩余${5 - stored.failCount}次机会)` } })
}
codeStore.delete(`contract-${data.token}`)
const userAgent = req.headers['user-agent'] || ''
const signEvidence = JSON.stringify({
ip: req.ip,
userAgent,
timestamp: new Date().toISOString(),
})
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()}|evidence:${signEvidence}` },
})
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)
}
})
// 撤回入职链接(HR 端调用,需要认证)
router.post('/onboarding/:id/revoke', portalAuth, async (req: any, res, next) => {
try {
const link = await prisma.onboardingLink.findFirst({
where: { id: req.params.id, orgId: req.employee.orgId },
})
if (!link) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
}
if (link.status !== 'PENDING') {
return res.status(400).json({ success: false, error: { code: 'INVALID_STATUS', message: '仅待填报状态的链接可撤回' } })
}
await prisma.onboardingLink.update({
where: { id: link.id },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: { message: '入职链接已撤回' } })
} 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)
}
})
// 重发合同确认链接(HR 端调用,需要认证)
router.post('/contract-confirm/:id/resend', portalAuth, async (req: any, res, next) => {
try {
const link = await prisma.contractConfirmLink.findFirst({
where: { id: req.params.id, orgId: req.employee.orgId },
include: { contract: { include: { employee: true } } },
})
if (!link) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '链接不存在' } })
}
if (link.status === 'CONFIRMED') {
return res.status(400).json({ success: false, error: { code: 'ALREADY_CONFIRMED', message: '合同已确认,无需重发' } })
}
// 生成新 token 并延长过期时间
const crypto = await import('crypto')
const newToken = crypto.randomUUID()
await prisma.contractConfirmLink.update({
where: { id: link.id },
data: {
token: newToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
status: 'UNCONFIRMED',
},
})
res.json({ success: true, data: { token: newToken, message: '确认链接已重发,有效期7天' } })
} catch (err) {
next(err)
}
})
// 入职文件上传
const uploadDir = path.join(process.cwd(), 'uploads', 'onboarding')
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true })
const onboardingUpload = multer({
storage: multer.diskStorage({
destination: uploadDir,
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname)
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp']
const ext = path.extname(file.originalname).toLowerCase()
if (allowed.includes(ext)) cb(null, true)
else cb(new Error('仅支持 JPG/PNG/PDF/BMP 格式'))
},
})
router.post('/onboarding/:token/upload', onboardingUpload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
}
const link = await prisma.onboardingLink.findFirst({
where: { token: req.params.token, status: 'PENDING', expiresAt: { gt: new Date() } },
})
if (!link) {
fs.unlinkSync(req.file.path)
return res.status(400).json({ success: false, error: { code: 'LINK_INVALID', message: '链接无效或已过期' } })
}
const fileType = (req.body.fileType as string) || 'OTHER'
const fileUrl = `/uploads/onboarding/${req.file.filename}`
res.json({ success: true, data: { fileName: req.file.originalname, fileUrl, fileType, fileSize: req.file.size } })
} catch (err) {
next(err)
}
})
export default router
+791
View File
@@ -0,0 +1,791 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import prisma from '../lib/prisma'
import { decrypt, encrypt } from '../lib/crypto'
import { getContractStatus } from '../services/contract.service'
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 page = parseInt(req.query.page as string) || 1
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100)
const search = req.query.search as string
const status = req.query.status as string // ACTIVE | PRE_HIRE | RESIGNED
const contractStatus = req.query.contractStatus as string // active | expiring | expired | unsigned | etc.
const skip = (page - 1) * pageSize
const today = new Date()
today.setHours(0, 0, 0, 0)
// 先查询满足 orgId 和搜索条件的员工
const whereBase: any = { orgId: req.user!.orgId }
if (search) {
whereBase.OR = [
{ name: { contains: search } },
{ department: { contains: search } },
]
}
const [total, employees] = await Promise.all([
prisma.employee.count({ where: whereBase }),
prisma.employee.findMany({
where: whereBase,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { terminationDate: 'desc' }, take: 1 },
_count: {
select: {
disciplinaryRecords: true,
attendanceRecords: true,
trainingRecords: true,
performanceRecords: true,
payslips: true,
overtimeRecords: true,
},
},
},
}),
])
// 计算动态状态和合同状态
let result = employees.map((e) => {
const latestContract = e.contracts[0] || null
const contractInfo = latestContract
? getContractStatus({
signDate: latestContract.signDate,
startDate: latestContract.startDate,
endDate: latestContract.endDate,
contractType: latestContract.contractType,
hireDate: e.hireDate,
})
: getContractStatus({
signDate: null,
startDate: e.hireDate,
endDate: null,
contractType: 'UNSIGNED',
hireDate: e.hireDate,
})
const isResigned = e.terminations.some((t) => t.terminationDate <= today)
const isPreHire = !isResigned && e.hireDate > today
const dynamicStatus = isResigned ? 'RESIGNED' : (isPreHire ? 'PRE_HIRE' : 'ACTIVE')
return {
id: e.id,
name: e.name,
department: e.department,
city: e.city,
status: dynamicStatus,
hasTermination: e.terminations.length > 0,
latestTerminationDate: e.terminations[0]?.terminationDate || null,
latestTerminationType: e.terminations[0]?.type || null,
latestTerminationId: e.terminations[0]?.id || null,
hireDate: e.hireDate,
gender: e.gender,
phone: e.phone,
monthlySalary: safeDecrypt(e.monthlySalary),
latestContract,
contractStatus: contractInfo.status,
contractStatusText: contractInfo.statusText,
riskLevel: contractInfo.riskLevel,
counts: e._count,
}
})
// 前端过滤:状态和合同状态(因为合同状态需要后处理,不适合放 Prisma where
if (status) {
result = result.filter((e) => e.status === status)
}
if (contractStatus) {
result = result.filter((e) => e.contractStatus === contractStatus)
}
res.json({
success: true,
data: result,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
})
} 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, bankAccount, idCardNumber, ...rest } = employee
const today = new Date()
today.setHours(0, 0, 0, 0)
const dynamicStatus = employee.terminations.some((t) => t.terminationDate <= today) ? 'RESIGNED' : 'ACTIVE'
res.json({
success: true,
data: {
...rest,
status: dynamicStatus,
monthlySalary: safeDecrypt(monthlySalary),
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
},
})
} 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.terminations.some((t) => t.terminationDate <= new Date()) ? 'RESIGNED' : 'ACTIVE',
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) }
})
// ========== 调薪/调部门 API ==========
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
// 调薪
router.post('/:id/salary-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newSalary, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldSalary = safeDecrypt(employee.monthlySalary)
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新薪资记录
const record = await prisma.salaryChangeRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldSalary,
newSalary: Number(newSalary),
effectiveDate: new Date(`${effMonth}-01`),
effectiveMonth: effMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { monthlySalary: encrypt(String(newSalary)) },
})
await auditLog(req, 'CREATE', 'SALARY_CHANGE', record.id, { employeeId: req.params.id, oldSalary, newSalary })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调薪历史
router.get('/:id/salary-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.salaryChangeRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveDate: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 调部门
router.post('/:id/department-change', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { newDepartment, effectiveMonth, reason } = req.body
const employee = await prisma.employee.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
}
const oldDepartment = employee.department
const effMonth = effectiveMonth || dateToMonth(new Date())
const prevEffMonth = prevMonth(effMonth)
// 关闭之前有效记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: req.params.id, endMonth: null },
data: { endMonth: prevEffMonth },
})
// 创建新部门记录
const record = await prisma.employeeDepartmentRecord.create({
data: {
orgId: req.user!.orgId,
employeeId: req.params.id,
oldDepartment,
newDepartment,
effectiveMonth: effMonth,
endMonth: null,
changeType: 'TRANSFER',
reason: reason || null,
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: req.params.id },
data: { department: newDepartment },
})
await auditLog(req, 'CREATE', 'DEPARTMENT_CHANGE', record.id, { employeeId: req.params.id, oldDepartment, newDepartment })
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 调部门历史
router.get('/:id/department-records', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const records = await prisma.employeeDepartmentRecord.findMany({
where: { employeeId: req.params.id, orgId: req.user!.orgId },
orderBy: { effectiveMonth: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 30天内合同到期列表
router.get('/contracts/expiring', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const days = parseInt(req.query.days as string) || 30
const today = new Date()
today.setHours(0, 0, 0, 0)
const future = new Date(today)
future.setDate(future.getDate() + days)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: {
where: {
endDate: { gte: today, lte: future },
contractType: 'FIXED',
},
orderBy: { endDate: 'asc' },
take: 1,
},
},
})
const result = employees
.filter(e => e.contracts.length > 0)
.map(e => {
const contract = e.contracts[0]
const endDate = new Date(contract.endDate!)
const daysLeft = Math.ceil((endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
return {
employeeId: e.id,
employeeName: e.name,
department: e.department,
contractEndDate: contract.endDate,
daysLeft,
}
})
.sort((a, b) => a.daysLeft - b.daysLeft)
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
export default router
+188
View File
@@ -0,0 +1,188 @@
import { Router } 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(),
role: z.enum(['ADMIN', 'HR', 'VIEWER']).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, payrollFrequency } = req.body as { name?: string; payrollFrequency?: number }
const updateData: any = {}
if (name) updateData.name = name
if (payrollFrequency !== undefined) updateData.payrollFrequency = payrollFrequency
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: updateData,
select: { id: true, name: true, plan: true, maxEmployees: true, payrollFrequency: 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, disabled: true, createdAt: true, lastLoginAt: 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, email: true, role: true, disabled: 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)
}
})
// 禁用/启用用户
router.patch('/users/:id/toggle-disable', 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: '不能禁用自己' } })
}
const existing = await prisma.user.findUnique({ where: { id: req.params.id } })
if (!existing) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '用户不存在' } })
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { disabled: !existing.disabled },
select: { id: true, name: true, disabled: true },
})
res.json({ success: true, data: user })
} catch (err) {
next(err)
}
})
// 切换套餐
router.put('/plan', async (req: AuthRequest, res, next) => {
try {
const { plan } = req.body as { plan: 'FREE' | 'PRO' | 'ENTERPRISE' }
if (!['FREE', 'PRO', 'ENTERPRISE'].includes(plan)) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '无效的套餐' } })
}
const maxEmployees = plan === 'FREE' ? 10 : plan === 'PRO' ? 100 : 999999
const org = await prisma.organization.update({
where: { id: req.user!.orgId },
data: { plan, maxEmployees },
select: { id: true, name: true, plan: true, maxEmployees: true },
})
res.json({ success: true, data: org })
} catch (err) {
next(err)
}
})
// 用量统计
router.get('/usage', async (req: AuthRequest, res, next) => {
try {
const orgId = req.user!.orgId
const [employeeCount, aiConversations, contracts] = await Promise.all([
prisma.employee.count({ where: { orgId } }),
prisma.aIConversation.count({ where: { orgId } }),
prisma.laborContract.count({ where: { orgId } }),
])
const org = await prisma.organization.findUnique({ where: { id: orgId }, select: { plan: true, maxEmployees: true } })
res.json({
success: true,
data: {
plan: org?.plan || 'FREE',
maxEmployees: org?.maxEmployees || 10,
employeeCount,
aiConversations,
contracts,
employeeUsage: `${employeeCount}/${org?.maxEmployees || 10}`,
},
})
} catch (err) {
next(err)
}
})
export default router
+956
View File
@@ -0,0 +1,956 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { decrypt } from '../lib/crypto'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
const socialConfigFields = {
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(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
}
const housingConfigFields = {
city: z.string().optional(),
housingOrg: z.number().optional(),
housingEmp: z.number().optional(),
baseMin: z.number().optional(),
baseMax: z.number().optional(),
}
// 获取当前生效版本(支持按城市筛选)
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.socialInsuranceConfig.findFirst({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
try {
config = await prisma.socialInsuranceConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
city: city || '北京',
isCurrent: true,
createdBy: req.user!.id,
},
})
} catch {
// 唯一约束冲突,查询同城市任意配置
config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, city: city || '北京' },
orderBy: { effectiveFrom: 'desc' },
})
}
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 获取所有城市列表(从配置中提取)
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const configs = await prisma.socialInsuranceConfig.findMany({
where: { orgId: req.user!.orgId },
select: { city: true },
distinct: ['city'],
})
const cities = configs.map(c => c.city).filter(Boolean)
if (!cities.includes('北京')) cities.unshift('北京')
res.json({ success: true, data: cities })
} catch (err) {
next(err)
}
})
// 获取所有版本列表(支持按城市筛选)
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
const versions = await prisma.socialInsuranceConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 按月份获取适用版本
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month } = req.params
const config = await prisma.socialInsuranceConfig.findFirst({
where: {
orgId: req.user!.orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
if (!config) {
// 回退到当前版本
const current = await prisma.socialInsuranceConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
})
return res.json({ success: true, data: current })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 新建版本(年度调基/比例变更)
const createVersionSchema = z.object({
...socialConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createVersionSchema.parse(req.body)
const orgId = req.user!.orgId
// 检查同一城市同一生效月份是否已有版本
const existing = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
}
// 将之前当前版本标记为失效
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, isCurrent: true },
})
if (prevCurrent) {
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.socialInsuranceConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
// 创建新版本
const version = await prisma.socialInsuranceConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: {
orgId,
month: { gte: lastYearStart, lte: lastYearEnd },
},
select: { employeeId: true, totalPay: true },
})
// 按员工汇总上年月均工资
const avgSalaryMap = new Map<string, number>()
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
for (const [empId, pays] of empPayslipMap) {
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
avgSalaryMap.set(empId, avg)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldSocialBase = emp.socialInsBase ?? monthlyWage
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase: oldSocialBase,
avgSalary,
monthlyWage,
suggestedBase: suggestedSocialBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行员工基数调整(接收用户编辑后的数据)
const adjustApplySchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
const { items } = adjustApplySchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧社保记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新社保记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base: socialBase,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
})
adjusted++
}
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置社保基数调整(撤销本次调整,重新来过)
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.socialInsuranceConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.socialInsuranceConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有社保记录变更(按城市筛选)
await prisma.employeeSocialInsRecord.deleteMany({
where: {
orgId,
city: config.city,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
socialInsBase: prevRecord?.base ?? null,
socialInsStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// 社保计算(使用当前版本或指定月份版本)
const calcSchema = z.object({
base: z.number().positive(),
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
city: z.string().optional(),
})
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.socialInsuranceConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.socialInsuranceConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
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 totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
const total = totalOrg + totalEmp
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
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 },
],
totalOrg,
totalEmp,
total,
},
})
} catch (err) {
next(err)
}
})
// ========== 公积金配置 ==========
// 获取当前公积金配置(支持按城市筛选)
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId, isCurrent: true }
if (city) where.city = city
let config = await prisma.housingFundConfig.findFirst({
where,
orderBy: { effectiveFrom: 'desc' },
})
// 未指定城市时,返回任意当前配置
if (!config && !city) {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
try {
config = await prisma.housingFundConfig.create({
data: {
orgId: req.user!.orgId,
effectiveFrom: new Date().toISOString().slice(0, 7),
city: city || '北京',
createdBy: req.user!.id,
},
})
} catch {
config = await prisma.housingFundConfig.findFirst({
where: { orgId: req.user!.orgId, city: city || '北京' },
orderBy: { effectiveFrom: 'desc' },
})
}
}
if (!config) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到公积金配置' } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 公积金配置版本列表(支持按城市筛选)
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const city = req.query.city as string | undefined
const where: any = { orgId: req.user!.orgId }
if (city) where.city = city
const versions = await prisma.housingFundConfig.findMany({
where,
orderBy: { effectiveFrom: 'desc' },
})
res.json({ success: true, data: versions })
} catch (err) {
next(err)
}
})
// 新建公积金配置版本
const createHousingVersionSchema = z.object({
...housingConfigFields,
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
})
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createHousingVersionSchema.parse(req.body)
const orgId = req.user!.orgId
const existing = await prisma.housingFundConfig.findFirst({
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
})
if (existing) {
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
}
const prevCurrent = await prisma.housingFundConfig.findFirst({
where: { orgId, isCurrent: true },
})
if (prevCurrent) {
const [year, mon] = data.effectiveFrom.split('-').map(Number)
const prevMonth = mon === 1
? `${year - 1}-12`
: `${year}-${String(mon - 1).padStart(2, '0')}`
await prisma.housingFundConfig.update({
where: { id: prevCurrent.id },
data: { isCurrent: false, effectiveTo: prevMonth },
})
}
const version = await prisma.housingFundConfig.create({
data: {
orgId,
...data,
isCurrent: true,
createdBy: req.user!.id,
},
})
res.json({ success: true, data: version })
} catch (err) {
next(err)
}
})
// 公积金计算
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { base, month, city } = calcSchema.parse(req.body)
const orgId = req.user!.orgId
let config
const whereBase: any = { orgId }
if (city) whereBase.city = city
if (month) {
config = await prisma.housingFundConfig.findFirst({
where: {
...whereBase,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
})
}
if (!config) {
config = await prisma.housingFundConfig.findFirst({
where: { ...whereBase, isCurrent: true },
})
}
if (!config) {
config = await prisma.housingFundConfig.create({
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
})
}
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingOrg = actualBase * config.housingOrg / 100
const housingEmp = actualBase * config.housingEmp / 100
res.json({
success: true,
data: {
actualBase,
originalBase: base,
capped: base > config.baseMax,
floored: base < config.baseMin,
configVersion: config.effectiveFrom,
housingOrg,
housingEmp,
total: housingOrg + housingEmp,
},
})
} catch (err) {
next(err)
}
})
// 公积金调基预览
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', city: config.city },
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
orderBy: { name: 'asc' },
})
const now = new Date()
const lastYearStart = `${now.getFullYear() - 1}-01`
const lastYearEnd = `${now.getFullYear() - 1}-12`
const lastYearPayslips = await prisma.payslip.findMany({
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
select: { employeeId: true, totalPay: true },
})
const empPayslipMap = new Map<string, number[]>()
for (const p of lastYearPayslips) {
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
}
const items = employees.map((emp) => {
let monthlyWage = 0
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
const oldBase = emp.housingFundBase ?? monthlyWage
const payslips = empPayslipMap.get(emp.id)
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
return {
employeeId: emp.id,
name: emp.name,
department: emp.department,
oldBase,
avgSalary,
monthlyWage,
suggestedBase,
}
})
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
} catch (err) {
next(err)
}
})
// 执行公积金调基
const adjustHousingSchema = z.object({
items: z.array(z.object({
employeeId: z.string(),
newBase: z.number(),
})),
})
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
const { items } = adjustHousingSchema.parse(req.body)
const adjustMonth = config.effectiveFrom
const prevAdjustMonth = (() => {
const [y, m] = adjustMonth.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
})()
let adjusted = 0
for (const item of items) {
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
// 关闭旧记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: prevAdjustMonth },
})
// 创建新记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: item.employeeId,
city: config.city,
startMonth: adjustMonth,
endMonth: null,
base,
changeType: 'ADJUST',
createdBy: req.user!.id,
},
})
// 同步 Employee 便捷字段
await prisma.employee.update({
where: { id: item.employeeId },
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
})
adjusted++
}
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: true },
})
res.json({ success: true, data: { adjusted, total: items.length } })
} catch (err) {
next(err)
}
})
// 重置公积金基数调整(撤销本次调整,重新来过)
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const orgId = req.user!.orgId
const config = await prisma.housingFundConfig.findFirst({
where: { id, orgId },
})
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
// 恢复 adjustmentDone 标志
await prisma.housingFundConfig.update({
where: { id },
data: { adjustmentDone: false },
})
// 删除该版本创建的所有公积金记录变更
await prisma.employeeHousingFundRecord.deleteMany({
where: {
orgId,
changeType: 'ADJUST',
startMonth: config.effectiveFrom,
},
})
// 恢复员工公积金基数为调整前
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { id: true },
})
for (const emp of employees) {
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
orderBy: { startMonth: 'desc' },
})
await prisma.employee.update({
where: { id: emp.id },
data: {
housingFundBase: prevRecord?.base ?? null,
housingFundStartMonth: prevRecord?.startMonth ?? null,
},
})
}
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
} catch (err) {
next(err)
}
})
// ========== 月度增减员 ==========
// 社保月度增减员
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
// 增员:startMonth == month
const additions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
// 减员:endMonth == month 且 changeType == TERMINATION
const reductions = await prisma.employeeSocialInsRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
additions: additions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
changeType: r.changeType,
})),
reductions: reductions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金月度增减员
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const additions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, startMonth: month },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
const reductions = await prisma.employeeHousingFundRecord.findMany({
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
additions: additions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
changeType: r.changeType,
})),
reductions: reductions.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// ========== 在职申报 ==========
// 社保在保人员
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeSocialInsRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
items: records.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
// 公积金在保人员
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const orgId = req.user!.orgId
const records = await prisma.employeeHousingFundRecord.findMany({
where: {
orgId,
startMonth: { lte: month },
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
},
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
orderBy: { createdAt: 'asc' },
})
res.json({
success: true,
data: {
month,
items: records.map((r) => ({
employeeId: r.employeeId,
name: r.employee.name,
department: r.employee.department,
base: r.base,
startMonth: r.startMonth,
endMonth: r.endMonth,
changeType: r.changeType,
})),
},
})
} catch (err) {
next(err)
}
})
export default router
+272
View File
@@ -0,0 +1,272 @@
import { Router } from 'express'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { auditLog } from '../middleware/auditLog'
import { terminationChecklistSchema } from '../schemas/termination.schema'
import { createTermination, createResignation, revokeTermination, getTerminations, getChecklistForReason, assessRisk, batchTerminatePreview, batchTerminate, createDraft, updateDraft, submitForApproval, approveTermination, rejectTermination, executeTermination, cancelTermination, getDrafts, getTerminationDetail, getDefaultHandoverItems } from '../services/termination.service'
import prisma from '../lib/prisma'
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, async (req: AuthRequest, res, next) => {
try {
const employeeId = req.query.employeeId as string
let employee: any = undefined
if (employeeId) {
const emp = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId },
include: {
trainingRecords: true,
},
})
if (emp) {
employee = {
isInMedicalPeriod: emp.isInMedicalPeriod,
trainingRecords: emp.trainingRecords,
}
}
}
const checklist = getChecklistForReason(req.params.reason, employee)
res.json({ success: true, data: checklist })
} catch (err) {
next(err)
}
})
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)
}
})
router.post('/resignation', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { employeeId, terminationDate, resignationReason, remark } = req.body
if (!employeeId || !terminationDate) {
return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: '缺少必填字段' } })
}
const result = await createResignation(req.user!.orgId, req.user!.id, { employeeId, terminationDate, resignationReason, remark })
await auditLog(req, 'RESIGN', 'EMPLOYEE', employeeId, { resignationReason })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
router.delete('/:id/revoke', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await revokeTermination(req.user!.orgId, req.params.id)
await auditLog(req, 'REVOKE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT') {
return res.status(409).json({ success: false, error: { code: err.code, message: err.message } })
}
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 批量解聘预检
router.post('/batch/preview', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const results = await batchTerminatePreview(req.user!.orgId, items)
res.json({ success: true, data: { total: results.length, warnings: results.filter(r => r.warnings.length > 0).length, results } })
} catch (err) {
next(err)
}
})
// 批量解聘执行
router.post('/batch', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { items } = req.body as {
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
}
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 items' } })
}
const result = await batchTerminate(req.user!.orgId, req.user!.id, items)
for (const id of result.success) {
await auditLog(req, 'TERMINATE', 'EMPLOYEE', id, { batch: true })
}
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// ============================================================
// 解聘流程状态机 API
// ============================================================
// 获取草稿/流程列表
router.get('/drafts', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const status = req.query.status as string | undefined
const result = await getDrafts(req.user!.orgId, status)
res.json({ success: true, data: result })
} catch (err) {
next(err)
}
})
// 获取单条记录详情
router.get('/detail/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await getTerminationDetail(req.user!.orgId, req.params.id)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 获取默认工作交接清单模板
router.get('/handover-template', authMiddleware, async (req: AuthRequest, res) => {
res.json({ success: true, data: getDefaultHandoverItems() })
})
// 创建草稿
router.post('/draft', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await createDraft(req.user!.orgId, req.user!.id, req.body)
await auditLog(req, 'CREATE_DRAFT', 'TERMINATION_RECORD', result.id, { reason: req.body.reason })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'NOT_FOUND') {
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 更新草稿
router.put('/draft/:id', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await updateDraft(req.user!.orgId, req.params.id, req.user!.id, req.body)
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 提交审批
router.post('/draft/:id/submit', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await submitForApproval(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'SUBMIT_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 审批通过
router.post('/draft/:id/approve', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { comment } = req.body
const result = await approveTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
await auditLog(req, 'APPROVE_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 审批驳回
router.post('/draft/:id/reject', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const { comment } = req.body
const result = await rejectTermination(req.user!.orgId, req.params.id, req.user!.id, comment || '')
await auditLog(req, 'REJECT_TERMINATION', 'TERMINATION_RECORD', req.params.id, { comment })
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 执行解聘
router.post('/draft/:id/execute', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await executeTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'EXECUTE_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
// 撤销
router.post('/draft/:id/cancel', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const result = await cancelTermination(req.user!.orgId, req.params.id, req.user!.id)
await auditLog(req, 'CANCEL_TERMINATION', 'TERMINATION_RECORD', req.params.id, {})
res.json({ success: true, data: result })
} catch (err: any) {
if (err?.code === 'CONFLICT' || err?.code === 'NOT_FOUND') {
return res.status(err.code === 'NOT_FOUND' ? 404 : 409).json({ success: false, error: { code: err.code, message: err.message } })
}
next(err)
}
})
export default router
+35
View File
@@ -0,0 +1,35 @@
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位'),
})
export const verifyCodeSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
code: z.string().length(6, '验证码为6位数字'),
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
})
+62
View File
@@ -0,0 +1,62 @@
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),
city: z.string().max(20).optional(),
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(),
bankName: z.string().max(50).optional(),
bankAccount: z.string().max(30).optional(),
emergencyContact: z.string().max(30).optional(),
emergencyPhone: z.string().max(20).optional(),
address: z.string().max(200).optional(),
isPregnant: z.boolean().optional(),
isInMedicalPeriod: z.boolean().optional(),
isWorkInjured: z.boolean().optional(),
socialInsBase: z.number().min(0).nullable().optional(),
housingFundBase: z.number().min(0).nullable().optional(),
specialDeduction: z.number().min(0).optional(),
city: z.string().max(20).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),
})
+37
View File
@@ -0,0 +1,37 @@
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, '请勾选确认签署'),
verifyCode: z.string().length(6, '验证码为6位数字'),
})
export const contractSendCodeSchema = z.object({
token: z.string().min(1, '缺少 token'),
})
+15
View File
@@ -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),
})
+195
View File
@@ -0,0 +1,195 @@
import OpenAI from 'openai'
import { searchKnowledge } from './rag.service'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 })
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
1.
2.
3.
4.
-
-
-
-
- `
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
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* chatStream(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
let ragContext = ''
if (lastUserMsg) {
try {
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
if (knowledge.length > 0) {
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
}
} catch { /* RAG not available, continue without */ }
}
const systemMessage = orgContext
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
: `${SYSTEM_PROMPT}${ragContext}`
const stream = await client.chat.completions.create({
model: 'qwen-plus',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
temperature: 0.7,
max_tokens: 2000,
stream: true,
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content
if (delta) yield delta
}
}
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: 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,
})
const text = response.choices[0]?.message?.content || ''
// 解析结构化数据
const riskItems: { level: string; title: string; description: string; suggestion: string }[] = []
const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g
let match
while ((match = riskRegex.exec(text)) !== null) {
riskItems.push({
level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN',
title: match[2],
description: match[3],
suggestion: match[4],
})
}
const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/)
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0
const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/)
const summary = summaryMatch ? summaryMatch[1].trim() : ''
return { text, structured: { riskItems, score, summary } }
}
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 || ''
}
+103
View File
@@ -0,0 +1,103 @@
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: '手机号或密码错误' }
}
if (user.disabled) {
throw { code: 'ACCOUNT_DISABLED', 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 }
}
+611
View File
@@ -0,0 +1,611 @@
import prisma from '../lib/prisma'
import { encrypt, decrypt, sha256 } 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))
}
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
function prevMonth(month: string): string {
const [y, m] = month.split('-').map(Number)
const d = new Date(y, m - 2, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
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()
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
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: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
} else if (daysToExpire <= 30) {
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
}
return { status: 'active', statusText: `${typeLabel}·正常`, 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 org = await prisma.organization.findUnique({ where: { id: orgId } })
if (org && org.maxEmployees > 0) {
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
if (activeCount >= org.maxEmployees) {
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
}
}
const hireDate = new Date(data.hireDate)
const hireMonth = dateToMonth(hireDate)
const salaryNum = Number(data.monthlySalary) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
const employee = await prisma.employee.create({
data: {
orgId,
name: data.name,
department: data.department,
hireDate,
monthlySalary: encrypt(data.monthlySalary),
gender: data.gender,
phone: data.phone,
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
isPregnant: data.isPregnant || false,
isInMedicalPeriod: data.isInMedicalPeriod || false,
isWorkInjured: data.isWorkInjured || false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
housingFundStartMonth,
createdBy: userId,
city: data.city || '北京',
},
})
// 创建社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: employee.id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'ONBOARDING',
createdBy: userId,
city: data.city || '北京',
},
})
// 创建初始薪资变更记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: employee.id,
oldSalary: 0,
newSalary: salaryNum,
effectiveDate: hireDate,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
createdBy: userId,
},
})
// 创建初始部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: employee.id,
oldDepartment: '',
newDepartment: data.department,
effectiveMonth: hireMonth,
endMonth: null,
changeType: 'ONBOARDING',
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 rehireEmployee(orgId: string, userId: string, id: string, data: any) {
const employee = await prisma.employee.findFirst({
where: { id, orgId },
include: { terminations: { orderBy: { terminationDate: 'desc' }, take: 1 } },
})
if (!employee) {
throw { code: 'NOT_FOUND', message: '员工不存在' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = employee.terminations.some((t) => t.terminationDate <= today)
if (!isResigned) {
throw { code: 'CONFLICT', message: '该员工当前在职,无需重新入职' }
}
const newHireDate = new Date(data.hireDate)
const latestTerm = employee.terminations[0]
if (latestTerm && newHireDate <= latestTerm.terminationDate) {
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
}
const newHireMonth = dateToMonth(newHireDate)
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
const prevHireMonth = prevMonth(newHireMonth)
// 关闭旧社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧薪资记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
// 关闭旧部门记录
await prisma.employeeDepartmentRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevHireMonth },
})
await prisma.employee.update({
where: { id },
data: {
hireDate: newHireDate,
status: 'ACTIVE',
department: data.department || employee.department,
isPregnant: false,
isInMedicalPeriod: false,
isWorkInjured: false,
socialInsBase,
housingFundBase,
socialInsStartMonth,
socialInsEndMonth: null,
housingFundStartMonth,
housingFundEndMonth: null,
city: data.city || employee.city || '北京',
},
})
// 创建新社保缴费记录
await prisma.employeeSocialInsRecord.create({
data: {
orgId,
employeeId: id,
startMonth: socialInsStartMonth,
endMonth: null,
base: socialInsBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新公积金缴费记录
await prisma.employeeHousingFundRecord.create({
data: {
orgId,
employeeId: id,
startMonth: housingFundStartMonth,
endMonth: null,
base: housingFundBase,
changeType: 'REHIRE',
createdBy: userId,
city: data.city || employee.city || '北京',
},
})
// 创建新薪资记录
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary: salaryNum,
newSalary: salaryNum,
effectiveDate: newHireDate,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
createdBy: userId,
},
})
// 创建新部门记录
await prisma.employeeDepartmentRecord.create({
data: {
orgId,
employeeId: id,
oldDepartment: employee.department,
newDepartment: data.department || employee.department,
effectiveMonth: newHireMonth,
endMonth: null,
changeType: 'REHIRE',
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: 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 }
}
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) {
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
const newSalary = Number(data.monthlySalary) || 0
updateData.monthlySalary = encrypt(data.monthlySalary)
// 记录薪资变更
if (oldSalary !== newSalary) {
const now = new Date()
const nowMonth = dateToMonth(now)
// 关闭之前有效记录
await prisma.salaryChangeRecord.updateMany({
where: { employeeId: id, endMonth: null },
data: { endMonth: prevMonth(nowMonth) },
})
await prisma.salaryChangeRecord.create({
data: {
orgId,
employeeId: id,
oldSalary,
newSalary,
effectiveDate: now,
effectiveMonth: nowMonth,
endMonth: null,
changeType: 'SALARY_CHANGE',
reason: data.salaryChangeReason || '手动调整',
createdBy: '',
},
})
}
}
if (data.gender !== undefined) updateData.gender = data.gender
if (data.phone !== undefined) updateData.phone = data.phone
if (data.bankName !== undefined) updateData.bankName = data.bankName
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
if (data.address !== undefined) updateData.address = data.address
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
if (data.city !== undefined) updateData.city = data.city
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,
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
attachmentUrl: data.attachmentUrl || null,
electronicContractNo: data.electronicContractNo || null,
electronicContractUrl: data.electronicContractUrl || null,
createdBy: userId,
},
})
await runRiskDetection(orgId)
return { id: contract.id }
}
+342
View File
@@ -0,0 +1,342 @@
import prisma from '../lib/prisma'
// ========== 薪酬模版 ==========
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false },
]
export async function ensureDefaultTemplate(orgId: string) {
const existing = await prisma.payslipItem.count({ where: { orgId } })
if (existing === 0) {
await prisma.payslipItem.createMany({
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
})
}
}
export async function getTemplate(orgId: string) {
await ensureDefaultTemplate(orgId)
return prisma.payslipItem.findMany({
where: { orgId },
orderBy: { order: 'asc' },
})
}
// ========== 社保计算 ==========
export function calcSocialInsurance(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100
const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100
return { actualBase, socialEmp, socialOrg }
}
export function calcHousingFund(base: number, config: any) {
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
const housingEmp = actualBase * config.housingEmp / 100
const housingOrg = actualBase * config.housingOrg / 100
return { actualBase, housingEmp, housingOrg }
}
// ========== 累计预扣个税 ==========
export function calcTax(taxableIncome: number): number {
if (taxableIncome <= 0) return 0
let tax = 0
if (taxableIncome <= 36000) tax = taxableIncome * 0.03
else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520
else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920
else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920
else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920
else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920
else tax = taxableIncome * 0.45 - 181920
return Math.max(0, Math.round(tax * 100) / 100)
}
/**
*
* @param ytdTaxableIncome
* @param ytdTaxDeducted
* @returns
*/
export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number {
const ytdTax = calcTax(ytdTaxableIncome)
const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted)
return Math.round(currentMonthTax * 100) / 100
}
/**
*
* @param bonusAmount
* @returns
*/
export function calcBonusTax(bonusAmount: number): number {
if (bonusAmount <= 0) return 0
const monthlyBonus = bonusAmount / 12
let rate = 0.03
let quickDeduction = 0
if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 }
else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 }
else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 }
else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 }
else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 }
else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 }
else { rate = 0.45; quickDeduction = 15160 }
const tax = bonusAmount * rate - quickDeduction
return Math.max(0, Math.round(tax * 100) / 100)
}
// ========== 批次计算 ==========
export async function calcBatchEntry(
orgId: string,
employeeId: string,
month: string,
inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number },
batchType: string = 'REGULAR',
options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } },
) {
const [employee, socialConfig, housingConfig] = await Promise.all([
prisma.employee.findFirst({ where: { id: employeeId, orgId } }),
prisma.socialInsuranceConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: {
orgId,
effectiveFrom: { lte: month },
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
},
orderBy: { effectiveFrom: 'desc' },
}),
])
if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' }
// 社保基数:优先用员工核定基数,否则用基本工资
const socialBase = employee.socialInsBase || inputs.baseSalary
const housingBase = employee.housingFundBase || inputs.baseSalary
let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0
// 年终奖/奖金批次、补偿金批次:不扣社保公积金
if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) {
if (socialConfig) {
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
socialOrg = social.socialOrg
}
if (housingConfig) {
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
housingOrg = housing.housingOrg
}
}
// 手动覆盖社保值
if (options?.overrideSocial) {
if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp
if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg
if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp
if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg
}
const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction
// 个税计算
let tax = 0
if (batchType === 'BONUS') {
// 年终奖单独计税
tax = calcBonusTax(inputs.bonus)
} else {
// 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除)
const year = month.slice(0, 4)
const prevPayslips = await prisma.payslip.findMany({
where: {
orgId,
employeeId,
month: { startsWith: year, lt: month },
},
select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true },
})
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp
const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7))
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0)
const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction)
tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted)
}
const netPay = totalPay - socialEmp - housingEmp - tax
return {
socialEmp: Math.round(socialEmp * 100) / 100,
socialOrg: Math.round(socialOrg * 100) / 100,
housingEmp: Math.round(housingEmp * 100) / 100,
housingOrg: Math.round(housingOrg * 100) / 100,
tax,
totalPay: Math.round(totalPay * 100) / 100,
netPay: Math.round(netPay * 100) / 100,
}
}
// ========== 风险提示 ==========
export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise<string[]> {
const warnings: string[] = []
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { orderBy: { createdAt: 'desc' }, take: 1 },
},
})
if (!employee) return warnings
if (employee.status === 'RESIGNED') {
warnings.push('该员工已离职,需进行离职结算')
}
if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') {
warnings.push('未签订书面劳动合同')
}
if (employee.contracts.length) {
const contract = employee.contracts[0]
if (contract.endDate) {
const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
if (daysToExpiry <= 30 && daysToExpiry > 0) {
warnings.push(`合同将于 ${daysToExpiry} 天后到期`)
}
}
if (contract.probationMonths > 0 && contract.startDate) {
const probationEnd = new Date(contract.startDate)
probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths)
if (probationEnd > new Date()) {
warnings.push('试用期员工,薪资可能不同')
}
}
}
if (!employee.socialInsBase) {
warnings.push('未设置社保缴费基数')
}
if (!employee.housingFundBase) {
warnings.push('未设置公积金缴费基数')
}
if (employee.terminations.length) {
warnings.push('已有解聘记录,请注意结算')
}
return warnings
}
// ========== 工资条汇总生成 ==========
export async function generatePayslipFromBatches(orgId: string, month: string) {
// 获取当月所有已归档批次
const batches = await prisma.payrollBatch.findMany({
where: { orgId, month, status: 'ARCHIVED' },
include: { entries: true },
})
if (batches.length === 0) return { generated: 0 }
// 按员工汇总
const employeeMap = new Map<string, any>()
for (const batch of batches) {
for (const entry of batch.entries) {
const existing = employeeMap.get(entry.employeeId) || {
baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0,
socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0,
totalPay: 0, netPay: 0,
}
existing.baseSalary += entry.baseSalary
existing.overtimePay += entry.overtimePay
existing.allowance += entry.allowance
existing.deduction += entry.deduction
existing.bonus += entry.bonus
existing.socialEmp += entry.socialEmp
existing.socialOrg += entry.socialOrg
existing.housingEmp += entry.housingEmp
existing.housingOrg += entry.housingOrg
existing.tax += entry.tax
existing.totalPay += entry.totalPay
existing.netPay += entry.netPay
employeeMap.set(entry.employeeId, existing)
}
}
// 计算累计数据
const year = month.slice(0, 4)
let generated = 0
for (const [employeeId, summary] of employeeMap) {
// 获取当年之前月份的累计数据
const prevPayslips = await prisma.payslip.findMany({
where: { orgId, employeeId, month: { startsWith: year, lt: month } },
select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true },
})
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + summary.housingEmp
await prisma.payslip.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
baseSalary: Math.round(summary.baseSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
bonus: Math.round(summary.bonus * 100) / 100,
totalPay: Math.round(summary.totalPay * 100) / 100,
socialEmp: Math.round(summary.socialEmp * 100) / 100,
housingEmp: Math.round(summary.housingEmp * 100) / 100,
tax: Math.round(summary.tax * 100) / 100,
netPay: Math.round(summary.netPay * 100) / 100,
ytdIncome: Math.round(ytdIncome * 100) / 100,
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
status: 'PUBLISHED',
publishedAt: new Date(),
},
create: {
orgId,
employeeId,
month,
baseSalary: Math.round(summary.baseSalary * 100) / 100,
overtimePay: Math.round(summary.overtimePay * 100) / 100,
allowance: Math.round(summary.allowance * 100) / 100,
deduction: Math.round(summary.deduction * 100) / 100,
bonus: Math.round(summary.bonus * 100) / 100,
totalPay: Math.round(summary.totalPay * 100) / 100,
socialEmp: Math.round(summary.socialEmp * 100) / 100,
housingEmp: Math.round(summary.housingEmp * 100) / 100,
tax: Math.round(summary.tax * 100) / 100,
netPay: Math.round(summary.netPay * 100) / 100,
ytdIncome: Math.round(ytdIncome * 100) / 100,
ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100,
ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100,
ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100,
status: 'PUBLISHED',
publishedAt: new Date(),
},
})
generated++
}
return { generated }
}
+97
View File
@@ -0,0 +1,97 @@
import OpenAI from 'openai'
import prisma from '../lib/prisma'
const apiKey = process.env.DASHSCOPE_API_KEY || ''
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
const client = new OpenAI({ apiKey, baseURL })
const EMBEDDING_MODEL = 'text-embedding-v2'
interface KnowledgeSeed {
title: string
content: string
source: string
category: string
}
const SEED_DATA: KnowledgeSeed[] = [
{ title: '劳动合同法 第十条 建立劳动关系应当订立书面合同', content: '建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第八十二条 未签书面合同双倍工资', content: '用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十四条 无固定期限劳动合同', content: '连续订立二次固定期限劳动合同续订的,应当订立无固定期限劳动合同。劳动者在该用人单位连续工作满十年的,应当订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
{ title: '劳动合同法 第十九条 试用期期限', content: '三个月以上不满一年试用期不得超过一个月;一年以上不满三年不得超过二个月;三年以上不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第二十条 试用期工资', content: '试用期工资不得低于本单位相同岗位最低档工资或劳动合同约定工资的百分之八十,并不得低于最低工资标准。', source: '劳动合同法', category: '试用期' },
{ title: '劳动合同法 第三十九条 过失性辞退', content: '严重违反规章制度、严重失职造成重大损害、被依法追究刑事责任等情形,用人单位可以解除劳动合同。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十条 无过失性辞退', content: '提前三十日书面通知或额外支付一个月工资后可解除:医疗期满不能从事原工作、不能胜任经培训仍不胜任、客观情况重大变化未能协商一致。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十一条 经济性裁员', content: '裁减二十人以上或占职工总数百分之十以上,需提前三十日向工会说明,方案报劳动行政部门。优先留用长期合同、无固定期限合同、家庭无其他就业人员。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十二条 不得解除的情形', content: '职业病、因工负伤丧失劳动能力、医疗期内、孕期产期哺乳期、连续工作满十五年距退休不足五年等情形,不得依第四十条第四十一条解除。', source: '劳动合同法', category: '解除终止' },
{ title: '劳动合同法 第四十七条 经济补偿计算', content: '每满一年支付一个月工资。六个月以上不满一年按一年计算;不满六个月支付半个月工资。月工资指解除前十二个月平均工资。高于社平工资三倍的按三倍计,年限最高十二年。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动合同法 第八十七条 违法解除赔偿金', content: '用人单位违反本法规定解除或终止劳动合同的,应当依照第四十七条经济补偿标准的二倍向劳动者支付赔偿金。', source: '劳动合同法', category: '经济补偿' },
{ title: '劳动法 第四十一条 加班时间上限', content: '一般每日不得超过一小时;特殊原因每日不得超过三小时,每月不得超过三十六小时。', source: '劳动法', category: '加班' },
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
]
let initialized = false
export async function ensureRAGTable() {
if (initialized) return
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS vector`
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS rag_knowledge (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
category TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
)
`
await prisma.$executeRaw`CREATE INDEX IF NOT EXISTS rag_knowledge_embedding_idx ON rag_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`
initialized = true
}
async function getEmbedding(text: string): Promise<number[]> {
const res = await client.embeddings.create({ model: EMBEDDING_MODEL, input: text })
return res.data[0]?.embedding || []
}
export async function seedKnowledgeBase() {
await ensureRAGTable()
const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge` as any
if (count[0]?.c > 0) return
for (let i = 0; i < SEED_DATA.length; i++) {
const item = SEED_DATA[i]
const embedding = await getEmbedding(`${item.title} ${item.content}`)
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${`rag-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector)
`
}
}
export async function searchKnowledge(query: string, topK: number = 3): Promise<string[]> {
await ensureRAGTable()
const queryEmbedding = await getEmbedding(query)
const results = await prisma.$queryRaw`
SELECT title, content, source, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM rag_knowledge
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${topK}
` as any[]
return results
.filter((r) => r.similarity > 0.3)
.map((r) => `${r.title}\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%`)
}
export async function addKnowledge(title: string, content: string, source: string, category: string) {
await ensureRAGTable()
const embedding = await getEmbedding(`${title} ${content}`)
const id = `rag-${Date.now()}`
await prisma.$executeRaw`
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
VALUES (${id}, ${title}, ${content}, ${source}, ${category}, ${embedding}::vector)
`
return { id }
}
+525
View File
@@ -0,0 +1,525 @@
import prisma from '../lib/prisma'
import type { 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 today = new Date()
today.setHours(0, 0, 0, 0)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
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 detectOnboardingRisks(orgId: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
terminations: { where: { terminationDate: { lte: today } }, take: 1 },
},
})
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
for (const emp of employees) {
// 已离职的跳过
if (emp.terminations.length > 0) continue
const latestContract = emp.contracts[0]
const hasSignedContract = latestContract && latestContract.contractType !== 'UNSIGNED'
if (!hasSignedContract) {
const daysSinceHire = daysBetween(today, emp.hireDate)
risks.push({
employeeId: emp.id,
type: 'ONBOARDING',
level: daysSinceHire > 30 ? 'HIGH' : 'MEDIUM',
title: `${emp.name}入职手续未完成${daysSinceHire > 30 ? `(已超${daysSinceHire}天)` : ''}`,
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},尚未签订劳动合同,请尽快完成入职手续。`,
actionUrl: `/roster?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,
})
}
}
// 工资条生成提醒:当月有已归档批次时提醒生成工资条
const archivedBatches = await prisma.payrollBatch.count({
where: { orgId, month: currentMonth, status: 'ARCHIVED' },
})
if (archivedBatches > 0) {
risks.push({
employeeId: null,
type: 'SALARY',
level: 'MEDIUM',
title: `${currentMonth}月 生成工资条`,
description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`,
actionUrl: '/money',
})
}
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.type}:${r.actionUrl}`))
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
const monthlyExisting = await prisma.riskItem.findMany({
where: { orgId, 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 onboardingRisks = await detectOnboardingRisks(orgId)
const monthlyTasks = await detectMonthlyTasks(orgId)
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
...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, batchEntries, socialConfig, housingConfig,
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
monthSeverancePay,
] = await Promise.all([
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }),
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.batchEntry.findMany({
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
}),
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
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 archivedEntries = batchEntries
const useArchivedData = archivedEntries.length > 0
let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number
let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number
let payslipCount: number, confirmedPayslips: number
if (useArchivedData) {
// 从已归档批次条目汇总(同一员工多批次的金额累加)
const empMap = new Map<string, any>()
for (const e of archivedEntries) {
const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 }
ex.baseSalary += e.baseSalary
ex.overtimePay += e.overtimePay
ex.allowance += e.allowance
ex.deduction += e.deduction
ex.bonus += e.bonus
ex.totalPay += e.totalPay
ex.socialEmp += e.socialEmp
ex.socialOrg += e.socialOrg
ex.housingEmp += e.housingEmp
ex.housingOrg += e.housingOrg
ex.tax += e.tax
ex.netPay += e.netPay
empMap.set(e.employeeId, ex)
}
const summary = Array.from(empMap.values())
totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0)
totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0)
totalAllowance = summary.reduce((s, e) => s + e.allowance, 0)
totalDeduction = summary.reduce((s, e) => s + e.deduction, 0)
totalPay = summary.reduce((s, e) => s + e.totalPay, 0)
totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0)
totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0)
totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0)
totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0)
totalTax = summary.reduce((s, e) => s + e.tax, 0)
totalNetPay = summary.reduce((s, e) => s + e.netPay, 0)
payslipCount = summary.length
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
} else {
// fallback:从工资条表汇总
totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
totalSocialOrg = 0
totalSocialEmp = 0
totalHousingOrg = 0
totalHousingEmp = 0
totalTax = 0
totalNetPay = 0
payslipCount = payslips.length
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
}
// 社保公积金:优先用归档批次的实际计算值,否则估算
let socialOrgTotal = 0
let socialEmpTotal = 0
let housingOrgTotal = 0
let housingEmpTotal = 0
if (useArchivedData) {
socialOrgTotal = totalSocialOrg
socialEmpTotal = totalSocialEmp
housingOrgTotal = totalHousingOrg
housingEmpTotal = totalHousingEmp
} else 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 * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount
housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount
}
// 个税:优先用归档批次的实际计算值,否则估算
let estimatedTax = 0
if (useArchivedData) {
estimatedTax = totalTax
} else {
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
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,
confirmedPayslips,
unconfirmedPayslips: payslipCount - 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: useArchivedData ? totalNetPay : 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 topRisks = riskItems
.filter((r: typeof riskItems[number]) => r.level === 'HIGH')
.slice(0, 5)
.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as string,
level: r.level.toLowerCase() as string,
title: r.title,
description: r.description,
employeeName: r.employee?.name || null,
actionUrl: r.actionUrl || '/',
}))
const todos = riskItems.map((r: typeof riskItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
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,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
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,
topRisks,
aiPrediction: null,
payrollSummary,
monthlyActivities,
}
}
+827
View File
@@ -0,0 +1,827 @@
import prisma from '../lib/prisma'
import { RiskAssessment, TerminationReason } from '@prisma/client'
function dateToMonth(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
return `${y}-${m}`
}
export interface ChecklistItem {
key: string
label: string
autoChecked?: boolean | null // null=无法自动判断,true/false=系统判断结果
autoSource?: string // 系统判断依据说明
suggestion?: string // 系统建议说明
suggestionType?: 'info' | 'warning' | 'required'
}
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] {
switch (reason) {
case 'NEGOTIATED':
return [
{
key: 'compensation_paid', label: '是否已支付经济补偿金',
autoChecked: null,
suggestion: '协商解除需支付经济补偿金(N),建议在协商协议中明确金额',
suggestionType: 'required',
},
{ key: 'agreement_signed', label: '是否签署协商解除协议', autoChecked: null },
{ key: 'final_pay_ready', label: '是否结清最后工资', autoChecked: null },
]
case 'FAULT':
return [
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
{ key: 'notify_union', label: '是否事先通知工会', autoChecked: null },
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
]
case 'NONFAULT': {
const items: ChecklistItem[] = []
// 医疗期是否已届满 — 系统自动判断
if (employee?.isInMedicalPeriod) {
items.push({
key: 'medical_period_end', label: '医疗期是否已届满',
autoChecked: false,
autoSource: '系统记录显示该员工正处于医疗期内,医疗期未届满',
suggestion: '医疗期内不得以非过错理由解除,需等待医疗期届满',
suggestionType: 'warning',
})
} else {
items.push({
key: 'medical_period_end', label: '医疗期是否已届满',
autoChecked: null,
autoSource: '系统未记录该员工处于医疗期,如实际已届满请勾选确认',
})
}
// 是否经过培训或调岗 — 系统自动判断
const hasTraining = employee?.trainingRecords?.length > 0
items.push({
key: 'training_given', label: '是否经过培训或调岗',
autoChecked: hasTraining ? true : null,
autoSource: hasTraining
? `系统记录显示该员工有${employee.trainingRecords.length}条培训记录`
: '系统未找到培训或调岗记录,请人工确认',
suggestion: hasTraining
? '已有培训记录,满足"不胜任工作经培训或调岗"的前提条件'
: '以不胜任工作为由解除前,必须先经过培训或调岗,否则违法解除风险极高',
suggestionType: hasTraining ? 'info' : 'warning',
})
// 是否支付经济补偿金 — 系统建议
items.push({
key: 'compensation_paid', label: '是否支付经济补偿金',
autoChecked: null,
suggestion: '非过错解除需支付经济补偿金(N),并在Step 4费用结算中确认金额',
suggestionType: 'required',
})
// 是否提前30天通知或支付代通知金 — 系统建议
items.push({
key: 'advance_notice', label: '是否提前30天通知或支付代通知金',
autoChecked: null,
suggestion: '非过错解除需提前30天书面通知,或额外支付1个月工资作为代通知金(N+1)',
suggestionType: 'required',
})
return items
}
case 'LAYOFF':
return [
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
{
key: 'compensation_paid', label: '是否支付经济补偿金',
autoChecked: null,
suggestion: '裁员需支付经济补偿金(N)',
suggestionType: 'required',
},
]
case 'EXPIRED':
return [
{
key: 'compensation_paid', label: '是否支付经济补偿金(如需)',
autoChecked: null,
suggestion: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
suggestionType: 'info',
},
{ key: 'written_notice', label: '是否提前通知员工不续签', autoChecked: null },
]
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 latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' }
}
const { level } = assessRisk(employee, data.reason)
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'TERMINATION',
reason: data.reason,
terminationDate: termDate,
compensation: data.compensation || 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark,
createdBy: userId,
},
})
// 关闭社保缴费记录(设置 endMonth)
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据解聘日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
}
// 员工主动离职
export async function createResignation(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 latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: data.employeeId },
orderBy: { terminationDate: 'desc' },
})
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
}
const termDate = new Date(data.terminationDate)
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = data.socialInsEndMonth || termMonth
const housingFundEndMonth = data.housingFundEndMonth || termMonth
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: 'RESIGNATION',
reason: 'RESIGNATION',
terminationDate: termDate,
resignationReason: data.resignationReason || null,
compensation: 0,
socialInsEndMonth,
housingFundEndMonth,
riskLevel: 'SAFE',
checklist: {},
remark: data.remark || null,
createdBy: userId,
},
})
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: data.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 根据离职日期判断在职/离职状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: data.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
await prisma.riskItem.updateMany({
where: { employeeId: data.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
return { id: record.id }
}
// 撤回离职/解聘(仅未到日期可撤回)
export async function revokeTermination(orgId: string, recordId: string) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '离职/解聘记录不存在' }
}
const today = new Date()
today.setHours(0, 0, 0, 0)
if (record.terminationDate <= today) {
throw { code: 'CONFLICT', message: '离职/解聘日期已到或已过,无法撤回' }
}
await prisma.terminationRecord.delete({ where: { id: recordId } })
// 恢复员工状态为 ACTIVE
await prisma.employee.update({
where: { id: record.employeeId },
data: { status: 'ACTIVE' },
})
return { id: recordId }
}
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,
type: r.type,
reason: r.reason,
resignationReason: r.resignationReason,
terminationDate: r.terminationDate.toISOString().slice(0, 10),
compensation: r.compensation,
riskLevel: r.riskLevel,
remark: r.remark,
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 }
}
// 批量解聘:支持合规预检和执行
export interface BatchTerminatePreview {
employeeId: string
employeeName: string
department: string
reason: string
terminationDate: string
riskLevel: RiskAssessment | null
warnings: string[]
canTerminate: boolean
}
export async function batchTerminatePreview(
orgId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
): Promise<BatchTerminatePreview[]> {
const results: BatchTerminatePreview[] = []
for (const item of items) {
const employee = await prisma.employee.findFirst({
where: { id: item.employeeId, orgId },
})
if (!employee) {
results.push({
employeeId: item.employeeId,
employeeName: '(未找到)',
department: '',
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: null,
warnings: ['员工不存在或无权操作'],
canTerminate: false,
})
continue
}
const { level, warnings } = assessRisk(employee, item.reason)
results.push({
employeeId: item.employeeId,
employeeName: employee.name,
department: employee.department,
reason: item.reason,
terminationDate: item.terminationDate,
riskLevel: level,
warnings,
canTerminate: warnings.length === 0,
})
}
return results
}
export interface BatchTerminateResult {
success: string[]
failed: Array<{ employeeId: string; reason: string }>
total: number
}
export async function batchTerminate(
orgId: string,
userId: string,
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
): Promise<BatchTerminateResult> {
const success: string[] = []
const failed: Array<{ employeeId: string; reason: string }> = []
for (const item of items) {
try {
const termDate = new Date(item.terminationDate)
const termMonth = dateToMonth(termDate)
// 校验:已有离职/解聘记录
const latestTerm = await prisma.terminationRecord.findFirst({
where: { employeeId: item.employeeId },
orderBy: { terminationDate: 'desc' },
})
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
if (!employee) {
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
continue
}
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
continue
}
const { level } = assessRisk(employee, item.reason)
await prisma.terminationRecord.create({
data: {
orgId,
employeeId: item.employeeId,
type: 'TERMINATION',
reason: item.reason as TerminationReason,
terminationDate: termDate,
compensation: item.compensation || 0,
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
riskLevel: level,
checklist: {},
remark: '批量解聘',
createdBy: userId,
},
})
// 关闭社保和公积金
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: item.employeeId, endMonth: null },
data: { endMonth: termMonth },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: item.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth: termMonth,
housingFundEndMonth: termMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: item.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
success.push(item.employeeId)
} catch (err: any) {
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
}
}
return { success, failed, total: items.length }
}
// ============================================================
// 解聘流程状态机:DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
// ↘ REJECTED → 可修改重新提交
// 任意非 COMPLETED → CANCELLED
// ============================================================
/** 标准工作交接清单模板 */
export function getDefaultHandoverItems(): Array<{ key: string; label: string; done: boolean; remark: string }> {
return [
{ key: 'work_handover', label: '工作交接完成', done: false, remark: '' },
{ key: 'equipment_return', label: '办公设备归还', done: false, remark: '' },
{ key: 'access_revoke', label: '系统权限收回', done: false, remark: '' },
{ key: 'docs_signed', label: '离职文件签署', done: false, remark: '' },
{ key: 'finance_settled', label: '财务结算完成', done: false, remark: '' },
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
]
}
/** 创建草稿 */
export async function createDraft(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 || 'NEGOTIATED')
const record = await prisma.terminationRecord.create({
data: {
orgId,
employeeId: data.employeeId,
type: data.type || 'TERMINATION',
reason: data.reason || 'NEGOTIATED',
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
resignationReason: data.resignationReason || null,
compensation: data.compensation || 0,
socialInsEndMonth: data.socialInsEndMonth || null,
housingFundEndMonth: data.housingFundEndMonth || null,
riskLevel: level,
checklist: data.checklist || {},
remark: data.remark || null,
createdBy: userId,
status: 'DRAFT',
currentStep: data.currentStep || 0,
compensationBreakdown: data.compensationBreakdown || null,
checklistOverrides: data.checklistOverrides || null,
handoverItems: data.handoverItems || getDefaultHandoverItems(),
},
})
return { id: record.id }
}
/** 更新草稿(仅 DRAFT/REJECTED 状态可编辑) */
export async function updateDraft(orgId: string, recordId: string, userId: string, data: any) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
throw { code: 'CONFLICT', message: '当前状态不可编辑' }
}
const updateData: any = { updatedBy: userId }
if (data.reason !== undefined) {
updateData.reason = data.reason
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId, orgId } })
if (employee) {
const { level } = assessRisk(employee, data.reason)
updateData.riskLevel = level
}
}
if (data.terminationDate !== undefined) updateData.terminationDate = new Date(data.terminationDate)
if (data.compensation !== undefined) updateData.compensation = data.compensation
if (data.socialInsEndMonth !== undefined) updateData.socialInsEndMonth = data.socialInsEndMonth
if (data.housingFundEndMonth !== undefined) updateData.housingFundEndMonth = data.housingFundEndMonth
if (data.checklist !== undefined) updateData.checklist = data.checklist
if (data.remark !== undefined) updateData.remark = data.remark
if (data.currentStep !== undefined) updateData.currentStep = data.currentStep
if (data.compensationBreakdown !== undefined) updateData.compensationBreakdown = data.compensationBreakdown
if (data.checklistOverrides !== undefined) updateData.checklistOverrides = data.checklistOverrides
if (data.handoverItems !== undefined) updateData.handoverItems = data.handoverItems
if (data.resignationReason !== undefined) updateData.resignationReason = data.resignationReason
await prisma.terminationRecord.update({ where: { id: recordId }, data: updateData })
return { id: recordId }
}
/** 提交审批 */
export async function submitForApproval(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
throw { code: 'CONFLICT', message: '仅草稿状态可提交审批' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'PENDING_APPROVAL', updatedBy: userId },
})
return { id: recordId }
}
/** 审批通过 */
export async function approveTermination(orgId: string, recordId: string, userId: string, comment: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'PENDING_APPROVAL') {
throw { code: 'CONFLICT', message: '仅待审批状态可审批' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: {
status: 'APPROVED',
approvedBy: userId,
approvedAt: new Date(),
approvalComment: comment || null,
updatedBy: userId,
},
})
return { id: recordId }
}
/** 审批驳回 */
export async function rejectTermination(orgId: string, recordId: string, userId: string, comment: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'PENDING_APPROVAL') {
throw { code: 'CONFLICT', message: '仅待审批状态可驳回' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: {
status: 'REJECTED',
approvalComment: comment || '驳回',
updatedBy: userId,
},
})
return { id: recordId }
}
/** 执行解聘(APPROVED → EXECUTING → COMPLETED */
export async function executeTermination(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status !== 'APPROVED' && record.status !== 'DRAFT') {
throw { code: 'CONFLICT', message: '仅已审批或草稿状态可执行' }
}
// 标记为执行中
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'EXECUTING', updatedBy: userId },
})
const termDate = record.terminationDate
const termMonth = dateToMonth(termDate)
const socialInsEndMonth = record.socialInsEndMonth || termMonth
const housingFundEndMonth = record.housingFundEndMonth || termMonth
// 关闭社保缴费记录
await prisma.employeeSocialInsRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
})
// 关闭公积金缴费记录
await prisma.employeeHousingFundRecord.updateMany({
where: { employeeId: record.employeeId, endMonth: null },
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
})
// 更新员工状态
const today = new Date()
today.setHours(0, 0, 0, 0)
const isResigned = termDate <= today
await prisma.employee.update({
where: { id: record.employeeId },
data: {
status: isResigned ? 'RESIGNED' : 'ACTIVE',
socialInsEndMonth,
housingFundEndMonth,
},
})
// 关闭风险项
await prisma.riskItem.updateMany({
where: { employeeId: record.employeeId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date() },
})
// 标记为已完成
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'COMPLETED', updatedBy: userId },
})
return { id: recordId }
}
/** 撤销(状态→CANCELLED,不删除记录) */
export async function cancelTermination(orgId: string, recordId: string, userId: string) {
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
if (record.status === 'COMPLETED') {
throw { code: 'CONFLICT', message: '已完成的解聘不可撤销' }
}
await prisma.terminationRecord.update({
where: { id: recordId },
data: { status: 'CANCELLED', updatedBy: userId },
})
// 如果之前已执行(社保已关闭),恢复员工状态
if (record.status === 'EXECUTING' || record.status === 'COMPLETED') {
await prisma.employee.update({
where: { id: record.employeeId },
data: { status: 'ACTIVE' },
})
}
return { id: recordId }
}
/** 获取草稿列表 */
export async function getDrafts(orgId: string, status?: string) {
const where: any = { orgId }
if (status) {
where.status = status
} else {
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED'] }
}
const records = await prisma.terminationRecord.findMany({
where,
include: { employee: true },
orderBy: { updatedAt: 'desc' },
})
return records.map((r) => ({
id: r.id,
employeeId: r.employeeId,
employeeName: r.employee.name,
department: r.employee.department,
type: r.type,
reason: r.reason,
terminationDate: r.terminationDate.toISOString().slice(0, 10),
compensation: r.compensation,
riskLevel: r.riskLevel,
status: r.status,
currentStep: r.currentStep,
remark: r.remark,
createdAt: r.createdAt.toISOString().slice(0, 10),
updatedAt: r.updatedAt.toISOString().slice(0, 10),
}))
}
/** 获取单条记录详情(含所有流程字段) */
export async function getTerminationDetail(orgId: string, recordId: string) {
const record = await prisma.terminationRecord.findFirst({
where: { id: recordId, orgId },
include: { employee: true },
})
if (!record) {
throw { code: 'NOT_FOUND', message: '记录不存在' }
}
return {
id: record.id,
employeeId: record.employeeId,
employeeName: record.employee.name,
department: record.employee.department,
type: record.type,
reason: record.reason,
terminationDate: record.terminationDate.toISOString().slice(0, 10),
resignationReason: record.resignationReason,
compensation: record.compensation,
socialInsEndMonth: record.socialInsEndMonth,
housingFundEndMonth: record.housingFundEndMonth,
riskLevel: record.riskLevel,
checklist: record.checklist,
remark: record.remark,
status: record.status,
currentStep: record.currentStep,
compensationBreakdown: record.compensationBreakdown,
checklistOverrides: record.checklistOverrides,
handoverItems: record.handoverItems,
approvedBy: record.approvedBy,
approvedAt: record.approvedAt?.toISOString().slice(0, 10),
approvalComment: record.approvalComment,
createdBy: record.createdBy,
createdAt: record.createdAt.toISOString().slice(0, 10),
updatedAt: record.updatedAt.toISOString().slice(0, 10),
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"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"],
"ignoreDeprecations": "6.0"
}