e1b5ae9aab
P0紧急修复(6项): - 草稿保存完整恢复所有字段(含socialAvgWage) - 补偿金批次从compensationBreakdown读取 - 违法解除风险确认UI - 合同结束日期前后校验(前后端双保险) P1高优先级(14项): - 离职日期联动社保/公积金截止月(15号规则) - 合规检查+工作交接改为软阻断(生成待办) - 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义) - 解聘并入花名册操作栏(类型选择跳转向导) - 合同续签开始日期自动推导(原合同结束日+1天) - 年龄合规筛查(童工阻断/未成年工/退休警告) - 编辑入职日期后状态联动(待入职↔在职) - 转正移植到花名册操作栏+薪资回写 - 男职工无法选择三期 P2体验优化(9项): - "劳动合同"调整为"用工关系" - 费用结算新增剩余年假折算(300%日工资) - 身份证号全域改为"证件号码"(前后端18个文件) - 手机号查重 - 开具证明+合同续签移植到花名册操作栏 - 批量转正+批量开具证明 - 去掉用工办理模块 P3规划(2项): - 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance) - 客服工作台(Ticket/ChatSession+SUPPORT角色) 新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage 新增字段: Employee.departmentId/supervisorId 新增角色: SUPPORT Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
238 lines
7.4 KiB
TypeScript
238 lines
7.4 KiB
TypeScript
/**
|
|
* 客服工作台路由
|
|
* 提供工单管理、客户会话、租户数据穿透
|
|
*/
|
|
import { Router } from 'express'
|
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
|
import prisma from '../lib/prisma'
|
|
import { z } from 'zod'
|
|
|
|
const router = Router()
|
|
|
|
// 客服权限中间件
|
|
const supportMiddleware = (req: AuthRequest, res: any, next: any) => {
|
|
if (req.user!.role !== 'SUPPORT' && req.user!.role !== 'SUPER_ADMIN') {
|
|
return res.status(403).json({ success: false, error: { code: 'FORBIDDEN', message: '仅客服或超级管理员可访问' } })
|
|
}
|
|
next()
|
|
}
|
|
|
|
const createTicketSchema = z.object({
|
|
title: z.string().min(1, '标题必填'),
|
|
content: z.string().min(1, '内容必填'),
|
|
category: z.string().optional(),
|
|
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).default('NORMAL'),
|
|
})
|
|
|
|
const createMessageSchema = z.object({
|
|
content: z.string().min(1, '内容必填'),
|
|
})
|
|
|
|
// ==================== 工单管理 ====================
|
|
|
|
/** 获取工单列表(客服看全部,企业用户看自己租户的) */
|
|
router.get('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
|
const where = isSupport ? {} : { orgId: req.user!.orgId! }
|
|
const tickets = await prisma.ticket.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
org: { select: { id: true, name: true } },
|
|
_count: { select: { messages: true } },
|
|
},
|
|
})
|
|
res.json({ success: true, data: tickets })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 获取工单详情 */
|
|
router.get('/tickets/:id', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { id },
|
|
include: {
|
|
org: { select: { id: true, name: true } },
|
|
messages: { orderBy: { createdAt: 'asc' } },
|
|
},
|
|
})
|
|
if (!ticket) throw { code: 'NOT_FOUND', message: '工单不存在' }
|
|
res.json({ success: true, data: ticket })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 创建工单(企业用户提交) */
|
|
router.post('/tickets', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const data = createTicketSchema.parse(req.body)
|
|
const ticket = await prisma.ticket.create({
|
|
data: {
|
|
...data,
|
|
orgId: req.user!.orgId!,
|
|
createdBy: req.user!.id,
|
|
},
|
|
})
|
|
res.json({ success: true, data: ticket })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 回复工单 */
|
|
router.post('/tickets/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
const data = createMessageSchema.parse(req.body)
|
|
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
|
const message = await prisma.ticketMessage.create({
|
|
data: {
|
|
ticketId: id,
|
|
content: data.content,
|
|
senderId: req.user!.id,
|
|
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
|
},
|
|
})
|
|
// 客服回复时更新工单状态为处理中
|
|
if (isSupport) {
|
|
await prisma.ticket.update({ where: { id }, data: { status: 'IN_PROGRESS', assigneeId: req.user!.id } })
|
|
}
|
|
res.json({ success: true, data: message })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 关闭工单 */
|
|
router.post('/tickets/:id/close', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
await prisma.ticket.update({ where: { id }, data: { status: 'CLOSED' } })
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 转派工单 */
|
|
router.post('/tickets/:id/assign', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
const { assigneeId } = req.body
|
|
await prisma.ticket.update({ where: { id }, data: { assigneeId } })
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
// ==================== 客户会话 ====================
|
|
|
|
/** 获取会话列表 */
|
|
router.get('/chat/sessions', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
|
const where = isSupport ? { supportUserId: req.user!.id } : { orgId: req.user!.orgId! }
|
|
const sessions = await prisma.chatSession.findMany({
|
|
where,
|
|
orderBy: { lastMessageAt: 'desc' },
|
|
include: { org: { select: { id: true, name: true } } },
|
|
})
|
|
res.json({ success: true, data: sessions })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 获取会话消息 */
|
|
router.get('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
const messages = await prisma.chatMessage.findMany({
|
|
where: { sessionId: id },
|
|
orderBy: { createdAt: 'asc' },
|
|
})
|
|
res.json({ success: true, data: messages })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 发送会话消息 */
|
|
router.post('/chat/sessions/:id/messages', authMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { id } = req.params
|
|
const { content } = req.body as { content: string }
|
|
if (!content) throw { code: 'VALIDATION_ERROR', message: '内容必填' }
|
|
const isSupport = req.user!.role === 'SUPPORT' || req.user!.role === 'SUPER_ADMIN'
|
|
const session = await prisma.chatSession.findUnique({ where: { id } })
|
|
if (!session) throw { code: 'NOT_FOUND', message: '会话不存在' }
|
|
|
|
const message = await prisma.chatMessage.create({
|
|
data: {
|
|
sessionId: id,
|
|
content,
|
|
senderId: req.user!.id,
|
|
senderRole: isSupport ? 'SUPPORT' : 'USER',
|
|
},
|
|
})
|
|
await prisma.chatSession.update({
|
|
where: { id },
|
|
data: {
|
|
lastMessage: content,
|
|
lastMessageAt: new Date(),
|
|
unreadBySupport: isSupport ? session.unreadBySupport : session.unreadBySupport + 1,
|
|
unreadByUser: isSupport ? session.unreadByUser + 1 : session.unreadByUser,
|
|
},
|
|
})
|
|
res.json({ success: true, data: message })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
// ==================== 租户数据穿透 ====================
|
|
|
|
/** 获取租户列表(客服用) */
|
|
router.get('/tenants', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const tenants = await prisma.organization.findMany({
|
|
select: {
|
|
id: true, name: true, plan: true, maxEmployees: true,
|
|
contactName: true, contactPhone: true, createdAt: true,
|
|
_count: { select: { employees: true, users: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
})
|
|
res.json({ success: true, data: tenants })
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
/** 获取租户数据概览(客服代查看) */
|
|
router.get('/tenants/:orgId/overview', authMiddleware, supportMiddleware, async (req: AuthRequest, res, next) => {
|
|
try {
|
|
const { orgId } = req.params
|
|
const [employeeCount, activeContracts, pendingApprovals, openTickets] = await Promise.all([
|
|
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
|
prisma.laborContract.count({ where: { orgId } }),
|
|
prisma.approvalInstance.count({ where: { orgId, status: 'PENDING' } }),
|
|
prisma.ticket.count({ where: { orgId, status: { in: ['OPEN', 'IN_PROGRESS'] } } }),
|
|
])
|
|
res.json({
|
|
success: true,
|
|
data: { employeeCount, activeContracts, pendingApprovals, openTickets },
|
|
})
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
export default router
|