feat: 商业保险/员工福利独立页面+易签宝电子签署框架

- 商业保险:从社公商保中拆出为独立页面,侧边栏新增「福利保障」分组
- 员工福利:新建完整模块(方案管理+批量参保+员工汇总),Prisma模型+后端路由+前端页面
- 电子签署:搭建易签宝对接框架(ESignRecord模型+创建/查询/取消/回调接口+前端签署管理页面)
- 侧边栏新增「福利保障」分组:商业保险、员工福利、电子签署
- Prisma schema 新增6个模型:CommercialInsurancePlan/Enrollment, EmployeeBenefitPlan/Enrollment, ESignRecord
This commit is contained in:
freedakgmail
2026-08-04 23:08:49 +08:00
parent 5604d02de9
commit c328172e7d
12 changed files with 1348 additions and 9 deletions
+119
View File
@@ -183,6 +183,11 @@ model Organization {
attendancePublishes AttendancePublish[]
specialStatuses EmployeeSpecialStatus[]
companyFiles CompanyFile[]
commercialInsPlans CommercialInsurancePlan[]
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitPlans EmployeeBenefitPlan[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
}
model User {
@@ -268,6 +273,9 @@ model Employee {
calendarEvents CalendarEvent[]
workProcesses WorkProcess[]
specialStatuses EmployeeSpecialStatus[]
commercialInsEnrollments CommercialInsuranceEnrollment[]
benefitEnrollments EmployeeBenefitEnrollment[]
eSignRecords ESignRecord[]
@@unique([orgId, idCardHash])
}
@@ -1365,3 +1373,114 @@ model AcceptanceTest {
@@unique([orgId, verifierName])
@@index([orgId, status])
}
// ========== 商业保险 ==========
model CommercialInsurancePlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
type String // ACCIDENT | SUPPLEMENTARY_MEDICAL | EMPLOYER_LIABILITY | CRITICAL_ILLNESS | GROUP_LIFE | OTHER
provider String // 保险公司
policyNo String?
premium Float // 年保费
coverageAmount Float // 保额
effectiveFrom String // YYYY-MM-DD
effectiveTo String? // null = 长期
description String?
status String @default("ACTIVE") // ACTIVE | EXPIRED | CANCELLED
enrollments CommercialInsuranceEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, type])
}
model CommercialInsuranceEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan CommercialInsurancePlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
premium Float // 个人保费
effectiveFrom String // YYYY-MM-DD
effectiveTo String?
status String @default("ACTIVE") // ACTIVE | TERMINATED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 员工福利 ==========
model EmployeeBenefitPlan {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
name String
category String // TRANSPORT | MEAL | HOUSING | COMMUNICATION | HEALTH_CHECK | HOLIDAY | BIRTHDAY | OTHER
amount Float // 每月金额(或每次金额)
frequency String @default("MONTHLY") // MONTHLY | QUARTERLY | YEARLY | ONE_TIME
taxDeductible Boolean @default(false) // 是否税前扣除
description String?
status String @default("ACTIVE")
enrollments EmployeeBenefitEnrollment[]
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([orgId, category])
}
model EmployeeBenefitEnrollment {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
planId String
plan EmployeeBenefitPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
effectiveFrom String // YYYY-MM
effectiveTo String? // null = 至今
amount Float? // 覆盖默认金额(个别调整)
status String @default("ACTIVE")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, planId])
@@index([employeeId])
}
// ========== 电子签署(易签宝) ==========
model ESignRecord {
id String @id @default(cuid())
orgId String
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
contractId String? // 关联 LaborContract
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
flowId String? // 易签宝流程ID
documentTitle String // 文件标题
documentContent String? // 文件内容(HTML/PDF base64
status String @default("PENDING") // PENDING | SIGNING | COMPLETED | REJECTED | EXPIRED | CANCELLED
signUrl String? // 签署链接
signedPdfUrl String? // 签署完成后的PDF链接
initiatedBy String // 发起人(HR用户ID
completedAt DateTime?
expiredAt DateTime?
callbackData Json? // 易签宝回调数据
remark String?
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([orgId, status])
@@index([employeeId])
@@index([contractId])
}
+6
View File
@@ -66,6 +66,9 @@ import companyFileRoutes from './routes/company-file.routes'
import acceptanceTestRoutes from './routes/acceptance-test.routes'
import leaveRoutes from './routes/leave.routes'
import salaryRoutes from './routes/salary.routes'
import commercialInsuranceRoutes from './routes/commercial-insurance.routes'
import benefitRoutes from './routes/benefit.routes'
import esignRoutes from './routes/esign.routes'
app.use('/api/v1/auth', authRoutes)
app.use('/api/v1/dashboard', dashboardRoutes)
app.use('/api/v1/employees', employeeRoutes)
@@ -95,6 +98,9 @@ app.use('/api/v1/company-files', companyFileRoutes)
app.use('/api/v1/acceptance-tests', acceptanceTestRoutes)
app.use('/api/v1/leaves', leaveRoutes)
app.use('/api/v1/salary', salaryRoutes)
app.use('/api/v1/commercial-insurance', commercialInsuranceRoutes)
app.use('/api/v1/benefits', benefitRoutes)
app.use('/api/v1/esign', esignRoutes)
app.use(errorHandler)
+173
View File
@@ -0,0 +1,173 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
category: z.string(),
amount: z.number().default(0),
frequency: z.string().default('MONTHLY'),
taxDeductible: z.boolean().default(false),
description: z.string().optional(),
})
const BENEFIT_CATEGORIES: Record<string, string> = {
TRANSPORT: '交通补贴',
MEAL: '餐补',
HOUSING: '住房补贴',
COMMUNICATION: '通讯补贴',
HEALTH_CHECK: '体检',
HOLIDAY: '节日福利',
BIRTHDAY: '生日福利',
OTHER: '其他',
}
// 福利方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.employeeBenefitPlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 创建福利方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.employeeBenefitPlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新福利方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.employeeBenefitPlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除福利方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.employeeBenefitPlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 方案参保人员
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
amount: e.amount,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, effectiveFrom } = req.body as { employeeIds: string[]; effectiveFrom: string }
const plan = await prisma.employeeBenefitPlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.employeeBenefitEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.employeeBenefitEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
effectiveFrom: effectiveFrom || new Date().toISOString().slice(0, 7),
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退福利
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.employeeBenefitEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 7) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
// 员工福利汇总(按员工维度)
router.get('/employee-summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.employeeBenefitEnrollment.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
employee: { select: { id: true, name: true, department: true } },
plan: { select: { id: true, name: true, category: true, amount: true } },
},
})
const summary: Record<string, any> = {}
for (const e of enrollments) {
if (!summary[e.employeeId]) {
summary[e.employeeId] = {
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
benefits: [],
totalMonthly: 0,
}
}
const amount = e.amount ?? e.plan.amount
summary[e.employeeId].benefits.push({
planId: e.planId,
planName: e.plan.name,
category: e.plan.category,
categoryLabel: BENEFIT_CATEGORIES[e.plan.category] || e.plan.category,
amount,
})
if (e.plan.frequency === 'MONTHLY') {
summary[e.employeeId].totalMonthly += amount
}
}
res.json({ success: true, data: Object.values(summary) })
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,132 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const planSchema = z.object({
name: z.string().min(1),
type: z.string(),
provider: z.string().min(1),
policyNo: z.string().optional(),
premium: z.number().default(0),
coverageAmount: z.number().default(0),
effectiveFrom: z.string(),
effectiveTo: z.string().optional(),
description: z.string().optional(),
})
// 方案列表
router.get('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const plans = await prisma.commercialInsurancePlan.findMany({
where: { orgId: req.user!.orgId },
orderBy: { createdAt: 'desc' },
include: { _count: { select: { enrollments: { where: { status: 'ACTIVE' } } } } },
})
res.json({ success: true, data: plans })
} catch (err) { next(err) }
})
// 方案详情(含参保人员)
router.get('/plans/:planId/enrollments', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const enrollments = await prisma.commercialInsuranceEnrollment.findMany({
where: { orgId: req.user!.orgId, planId: req.params.planId },
include: {
employee: { select: { id: true, name: true, department: true, idCardNumber: true } },
},
orderBy: { createdAt: 'desc' },
})
const data = enrollments.map((e: any) => ({
id: e.id,
employeeId: e.employeeId,
name: e.employee.name,
department: e.employee.department,
idCardMasked: e.employee.idCardNumber ? e.employee.idCardNumber.slice(0, 3) + '****' + e.employee.idCardNumber.slice(-4) : null,
premium: e.premium,
effectiveFrom: e.effectiveFrom,
effectiveTo: e.effectiveTo,
status: e.status,
}))
res.json({ success: true, data })
} catch (err) { next(err) }
})
// 创建方案
router.post('/plans', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.parse(req.body)
const plan = await prisma.commercialInsurancePlan.create({
data: { ...data, orgId: req.user!.orgId, createdBy: req.user!.id },
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 更新方案
router.put('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = planSchema.partial().parse(req.body)
const plan = await prisma.commercialInsurancePlan.update({
where: { id: req.params.planId, orgId: req.user!.orgId },
data,
})
res.json({ success: true, data: plan })
} catch (err) { next(err) }
})
// 删除方案
router.delete('/plans/:planId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.commercialInsurancePlan.delete({
where: { id: req.params.planId, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) { next(err) }
})
// 批量参保
router.post('/plans/:planId/enroll', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeIds, premium, effectiveFrom } = req.body as { employeeIds: string[]; premium: number; effectiveFrom: string }
const plan = await prisma.commercialInsurancePlan.findFirst({ where: { id: req.params.planId, orgId: req.user!.orgId } })
if (!plan) return res.status(404).json({ success: false, error: { message: '方案不存在' } })
const existing = await prisma.commercialInsuranceEnrollment.findMany({
where: { planId: plan.id, employeeId: { in: employeeIds }, status: 'ACTIVE' },
select: { employeeId: true },
})
const existingIds = new Set(existing.map((e: any) => e.employeeId))
const newIds = employeeIds.filter((id) => !existingIds.has(id))
if (newIds.length > 0) {
await prisma.commercialInsuranceEnrollment.createMany({
data: newIds.map((empId) => ({
orgId: req.user!.orgId,
planId: plan.id,
employeeId: empId,
premium: premium || plan.premium,
effectiveFrom: effectiveFrom || plan.effectiveFrom,
})),
})
}
res.json({ success: true, data: { enrolled: newIds.length, skipped: existingIds.size } })
} catch (err) { next(err) }
})
// 退保
router.post('/enrollments/:enrollmentId/terminate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { effectiveTo } = req.body as { effectiveTo: string }
const enrollment = await prisma.commercialInsuranceEnrollment.update({
where: { id: req.params.enrollmentId, orgId: req.user!.orgId },
data: { status: 'TERMINATED', effectiveTo: effectiveTo || new Date().toISOString().slice(0, 10) },
})
res.json({ success: true, data: enrollment })
} catch (err) { next(err) }
})
export default router
+149
View File
@@ -0,0 +1,149 @@
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
const router = Router()
router.use(authMiddleware)
const createSignSchema = z.object({
contractId: z.string().optional(),
employeeId: z.string().min(1),
documentTitle: z.string().min(1),
documentContent: z.string().optional(),
remark: z.string().optional(),
})
// 签署记录列表
router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined
const records = await prisma.eSignRecord.findMany({
where: {
orgId: req.user!.orgId,
...(status && { status }),
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) { next(err) }
})
// 发起签署
// TODO: 对接易签宝 API — 此处为框架预留
// 1. 调用易签宝创建签署流程
// 2. 获取签署链接
// 3. 保存 flowId 和 signUrl
router.post('/create', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = createSignSchema.parse(req.body)
// 预留:调用易签宝 API 创建签署流程
// const esignResult = await esignApi.createFlow({
// title: data.documentTitle,
// signerPhone: employee.phone,
// signerName: employee.name,
// content: data.documentContent,
// })
// const flowId = esignResult.flowId
// const signUrl = esignResult.signUrl
const record = await prisma.eSignRecord.create({
data: {
orgId: req.user!.orgId,
contractId: data.contractId || null,
employeeId: data.employeeId,
documentTitle: data.documentTitle,
documentContent: data.documentContent || null,
// flowId: esignResult.flowId, // TODO: 易签宝对接后启用
// signUrl: esignResult.signUrl, // TODO: 易签宝对接后启用
status: 'PENDING',
initiatedBy: req.user!.id,
createdBy: req.user!.id,
remark: data.remark || null,
expiredAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30天过期
},
})
res.json({
success: true,
data: record,
message: '签署记录已创建。对接易签宝API后,将自动生成签署链接并发送给员工。',
})
} catch (err) { next(err) }
})
// 查询签署状态
// TODO: 对接易签宝 API — 查询流程状态并同步
router.get('/:id/status', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId },
})
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
// 预留:调用易签宝 API 查询流程状态
// const esignStatus = await esignApi.getFlowStatus(record.flowId)
// if (esignStatus !== record.status) {
// await prisma.eSignRecord.update({ where: { id: record.id }, data: { status: esignStatus } })
// }
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
// 易签宝回调接口(无需认证)
router.post('/callback', async (req, res: Response, next: NextFunction) => {
try {
const { flowId, status, signedPdfUrl, ...rest } = req.body
// TODO: 验证易签宝回调签名
// if (!verifyEsignCallback(req.headers, req.body)) {
// return res.status(401).json({ success: false, error: { message: '无效回调' } })
// }
if (flowId) {
const record = await prisma.eSignRecord.findFirst({ where: { flowId } })
if (record) {
await prisma.eSignRecord.update({
where: { id: record.id },
data: {
status: status || 'COMPLETED',
signedPdfUrl: signedPdfUrl || null,
completedAt: status === 'COMPLETED' ? new Date() : null,
callbackData: rest as any,
},
})
// 如果关联了合同,更新合同的签署方式和电子合同URL
if (record.contractId && status === 'COMPLETED') {
await prisma.laborContract.update({
where: { id: record.contractId },
data: {
signMethod: 'ELECTRONIC',
electronicContractUrl: signedPdfUrl || null,
},
})
}
}
}
res.json({ success: true })
} catch (err) { next(err) }
})
// 取消签署
router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const record = await prisma.eSignRecord.update({
where: { id: req.params.id, orgId: req.user!.orgId },
data: { status: 'CANCELLED' },
})
res.json({ success: true, data: record })
} catch (err) { next(err) }
})
export default router