补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)

## 问题
`svc_handover_ticket` 的 DDL 与状态机在 `docs/02` §7.2 早就定好了
(pending → assigned → processing → resolved → closed,未解决可 cancelled),
但平台**只有 handover:read(只读队列)**:没有任何入口能改状态、assigned_to /
accepted_at / resolved_at / closed_at / resolution 五列**全库 0 非空**,
于是 40 张工单永远停在 pending —— 用户看到的就是"工单全都长一样"。

## 改了什么
后端:
- 新增 `app/service/customer_service_handover_action_service.py`:五个动作
  (分配/接单/解决/关闭/取消),`SELECT ... FOR UPDATE` 锁单后判状态;
  接单允许从 pending 自助接管(同时记受理人);取消不写 closed_at(该列属 closed 状态);
  每次流转写一条 interaction_audit(handover.assigned/accepted/resolved/closed/cancelled);
  非法流转 409、坐席不存在 422、工单不存在 404;回包不含 customer_id/session_id。
- 只读服务保持只读(读侧与写侧是两条边界,单测守着"读侧不许长出写方法"),
  但列表支持 `?status=` 六态筛选、详情补上受理人与流转时间(坐席侧路由信息,非客户数据)。
- `app/api/controllers/admin.py`:五个 action 端点 A049–A053
  (assignments / acceptances / resolutions / closures / cancellations),
  走 `ApiTransactionService.execute_in` —— 幂等记录与业务写入同事务、重复键回放。
- 权限:新增 `handover:write`(9069,只授 admin),已并进种子
  `tools/seed_test_rbac.py`;配套幂等脚本 `tools/grant_handover_write_permission.py`。

前端(管理员工作台 · 转人工工单页):
- 按状态给按钮(待处理→分配/直接接单、已分配→接单、处理中→解决、已解决→关闭、
  未解决都可取消),加了状态筛选与"刷新";摘要弹窗补上受理人与四个时间点、处置结论。
- api-client 注册五个端点;workspace.js 的 api-client 引用与页面自身的 ?v= 一并升版,
  避免浏览器拿旧缓存(旧缓存里没有这些端点)。

冒烟与测试:
- `tools/e2e_smoke_test.py`:B 段建的测试工单由 F 段走完 分配→接单→解决→关闭 收尾
  —— 既不再把测试件堆在 pending 队列里(此前每次冒烟攒一张),又让每次冒烟都覆盖一遍状态机。
  总数 40 → 44 项,实测 44/44 全绿。
- 新增单测 24 条(状态机合法/非法路径、越权、坐席不存在、审计、视图不泄漏客户标识)
  与一条真机集成用例(HTTP 十步 + 数据库侧审计证据 + 自动清理)。
- 读侧那条"详情不得返回 assigned_to"的旧断言按新口径更新,并写清为什么。

## 验证
- `pytest tests/unit tests/contract` → 1489 passed, 2 skipped, 0 failed
- 新增集成用例通过;`tests/integration` 全量跑时
  `test_memory_extraction` / `test_run_cancellation_mysql` 两条偶发红 —— 单独跑都通过,
  是 AGENTS.md 已登记的"常驻 Worker 抢队列"(跑验收前须先停 Worker)
- `tools/portal_api_check.py` → 41 项通过 39、失败 0
- `tools/e2e_smoke_test.py` → 44/44 全通过
- `python tools/check_rbac_seed_consistency.py` → 通过(种子 63 条权限)
- 真机 HTTP 实测:分配→接单→解决→关闭四步 200 且时间戳齐全;取消路径 200 且 closed_at 为空;
  同键重发回放不二次推进;对已关闭工单再分配 409;风控账号处置 403

## 文档
`docs/44-演示流程.md`(场景 4/8 + 命令 + 44 项)、`docs/演示用/后端接口文档`(新增 §11.4b 与
A049–A053)、`docs/演示用/全功能流程-大白话版.md`(工单页签改"读写"+ 已知偏差)、
`AGENTS.md`(9066-9069 号段演进 + 冒烟 44 项)
This commit is contained in:
2026-09-15 00:41:59 +08:00
parent ed59e93b53
commit 076d786bc6
17 changed files with 1413 additions and 28 deletions
+8
View File
@@ -49,6 +49,14 @@ const ENDPOINTS = Object.freeze({
A033: { method: 'GET', path: '/api/v1/admin/audit-records' },
ADMIN_HANDOVERS: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets' },
ADMIN_HANDOVER_DETAIL: { method: 'GET', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}' },
// 转人工工单处置(状态机见 docs/02 §7.2):
// pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。
// 五个都要 `handover:write` + admin,且都带 Idempotency-Key(同键重发只回放结果)。
ADMIN_HANDOVER_ASSIGN: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/assignments', idempotent: true },
ADMIN_HANDOVER_ACCEPT: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/acceptances', idempotent: true },
ADMIN_HANDOVER_RESOLVE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/resolutions', idempotent: true },
ADMIN_HANDOVER_CLOSE: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/closures', idempotent: true },
ADMIN_HANDOVER_CANCEL: { method: 'POST', path: '/api/v1/admin/customer-service/handover-tickets/{ticketNo}/cancellations', idempotent: true },
ADMIN_ADVISOR_PENDING: { method: 'GET', path: '/api/v1/admin/advisor/pending-contents' },
ADMIN_ADVISOR_REVIEW: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/reviews', idempotent: true },
ADMIN_ADVISOR_PUBLISH: { method: 'POST', path: '/api/v1/admin/advisor/recommendations/{contentId}/publications', idempotent: true },
@@ -36,7 +36,7 @@
<section><div class="panel__header"><h2 class="panel__title">模型端点</h2><span class="section-heading__meta">密钥引用不会在前端暴露</span></div><div data-model-table></div></section>
</section>
<section class="operations-view admin-view" data-admin-view="audit" hidden><div class="panel__header"><h2 class="panel__title">最近审计记录</h2><button class="button table-action" type="button" data-reload-audit>刷新</button></div><div data-audit-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要</span></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="handover" hidden><div class="panel__header"><h2 class="panel__title">客服转人工工单</h2><span class="section-heading__meta">仅展示二次脱敏摘要;处置动作按状态给出</span><div class="admin-release-actions"><label class="form-field"><span class="form-field__label">状态</span><select class="form-field__input" data-handover-status><option value="">全部</option><option value="pending">待处理</option><option value="assigned">已分配</option><option value="processing">处理中</option><option value="resolved">已解决</option><option value="closed">已关闭</option><option value="cancelled">已取消</option></select></label><button class="button table-action" type="button" data-reload-handover>刷新</button></div></div><div data-handover-table></div></section>
<section class="operations-view admin-view" data-admin-view="candidates" hidden><div class="panel__header"><h2 class="panel__title">客户画像候选</h2><span class="section-heading__meta">审核后方可进入正式记忆</span></div><div data-candidate-table></div></section>
<section class="operations-view admin-view" data-admin-view="advisor" hidden><div class="panel__header"><h2 class="panel__title">待审投顾内容</h2><span class="section-heading__meta">推荐方案与投资方案书;审核通过后再发布</span><button class="button table-action" type="button" data-reload-advisor>刷新</button></div><div data-advisor-table></div></section>
<section class="operations-view admin-view" data-admin-view="knowledge" hidden><div class="panel__header"><h2 class="panel__title">知识库文档</h2><span class="section-heading__meta">上传后自动切分入库并投向量同步;客服据此作答</span><button class="button table-action" type="button" data-reload-knowledge>刷新</button></div><form class="admin-inline-form" data-knowledge-form><label class="form-field"><span class="form-field__label">文档文件</span><input class="form-field__input" type="file" name="file" accept=".txt,.md,.docx" required></label><label class="form-field"><span class="form-field__label">知识类型</span><select class="form-field__input" name="knowledge_type"><option value="faq">faq(问答)</option><option value="product" selected>product(产品资料)</option><option value="policy">policy(制度规则)</option></select></label><button class="button button--primary" type="submit">上传并入库</button><span class="admin-inline-form__status" data-knowledge-status></span></form><div data-knowledge-table></div></section>
@@ -50,6 +50,6 @@
浏览器按**完整 URL** 去重,两条不同 query 会被当成两个模块、**各执行一次**,
于是入口里的 `mountShell()` 跑两遍,页面上出现**两份顶部导航与页脚**。
改版本号时是**替换**这一行,不是新增一行。 -->
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914-3"></script>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914-4"></script>
</body>
</html>
@@ -1,4 +1,4 @@
import { apiClient } from '/static/portal/common/api-client.js?v=20260914';
import { apiClient } from '/static/portal/common/api-client.js?v=20260914-handover';
import { getAuthContext, requireAdmin, updateAuthPermissions } from '/static/portal/common/auth.js?v=20260913';
import { escapeHtml, formatDateTime } from '/static/portal/common/formatters.js';
import { mountShell } from '/static/portal/common/layout/app-shell.js';
@@ -151,15 +151,59 @@ if (requireAdmin()) {
} catch (error) { apiClient.reportError(error); renderError(targets.audits, error, loadAudits); }
}
// ---- 转人工工单:只读队列 + 处置(分配/接单/解决/关闭/取消)----
//
// 状态机照基线 `docs/02` §7.2:pending -> assigned -> processing -> resolved -> closed,
// 未解决可 cancelled。按钮按**当前状态**给(后端还会再判一次,前端只是不给非法入口)。
const HANDOVER_STATUS_LABELS = {
pending: '待处理',
assigned: '已分配',
processing: '处理中',
resolved: '已解决',
closed: '已关闭',
cancelled: '已取消',
};
function handoverActions(item) {
const buttons = [];
if (item.status === 'pending') {
buttons.push(['assign', '分配']);
buttons.push(['accept', '直接接单']);
} else if (item.status === 'assigned') {
buttons.push(['accept', '接单']);
} else if (item.status === 'processing') {
buttons.push(['resolve', '解决']);
} else if (item.status === 'resolved') {
buttons.push(['close', '关闭']);
}
// 取消只在"未解决"的三个状态里给(与后端 CANCELLABLE_STATUSES 一致)。
if (['pending', 'assigned', 'processing'].includes(item.status)) {
buttons.push(['cancel', '取消', 'button--danger']);
}
return `<div class="admin-release-actions"><button class="button table-action" type="button" data-ticket="${escapeHtml(item.ticket_no)}">摘要</button>${buttons
.map(([action, label, extra]) => `<button class="button table-action ${extra || ''}" type="button" data-handover-action="${action}" data-handover-ticket="${escapeHtml(item.ticket_no)}">${label}</button>`)
.join('')}</div>`;
}
async function loadHandovers() {
renderLoading(targets.handovers, 3);
try {
const response = await apiClient.get('ADMIN_HANDOVERS', { query: { limit: 20 } });
const status = document.querySelector('[data-handover-status]')?.value || '';
const response = await apiClient.get('ADMIN_HANDOVERS', {
query: { limit: 20, ...(status ? { status } : {}) },
});
state.handovers = Array.isArray(response.data) ? response.data : [];
if (!state.handovers.length) renderEmpty(targets.handovers, '暂无转人工工单', '当前没有待处理的客服转人工事项。');
else {
targets.handovers.innerHTML = table(state.handovers, [['ticket_no', '工单编号'], ['source_agent', '来源 Agent'], ['priority', '优先级'], ['reason_code', '原因'], ['status', '状态'], ['created_at', '创建时间']], (item) => `<button class="button table-action" type="button" data-ticket="${escapeHtml(item.ticket_no)}">查看摘要</button>`);
const scope = status ? `(筛:${HANDOVER_STATUS_LABELS[status] || status})` : '';
if (!state.handovers.length) {
renderEmpty(targets.handovers, '暂无转人工工单', `当前没有符合条件的客服转人工事项${scope}。`);
} else {
targets.handovers.innerHTML = table(
state.handovers,
[['ticket_no', '工单编号'], ['source_agent', '来源 Agent'], ['priority', '优先级'], ['reason_code', '原因'], ['status', '状态'], ['assigned_to', '受理人'], ['created_at', '创建时间']],
handoverActions,
);
targets.handovers.querySelectorAll('[data-ticket]').forEach((button) => button.addEventListener('click', () => openHandover(button.dataset.ticket)));
targets.handovers.querySelectorAll('[data-handover-action]').forEach((button) => button.addEventListener('click', () => openHandoverAction(button.dataset.handoverAction, button.dataset.handoverTicket)));
}
} catch (error) { apiClient.reportError(error); renderError(targets.handovers, error, loadHandovers); }
}
@@ -169,10 +213,30 @@ if (requireAdmin()) {
try {
const response = await apiClient.get('ADMIN_HANDOVER_DETAIL', { pathParams: { ticketNo } });
const item = response.data;
showDetail(`工单 ${item.ticket_no}`, `<dl class="detail-grid"><div><dt>状态</dt><dd>${escapeHtml(item.status)}</dd></div><div><dt>优先级</dt><dd>${escapeHtml(item.priority)}</dd></div><div><dt>识别意图</dt><dd>${escapeHtml(item.intent || '--')}</dd></div><div><dt>置信度</dt><dd>${escapeHtml(value(item.confidence))}</dd></div></dl><section><h3 class="section-heading__title">转接原因</h3><p class="admin-detail-copy">${escapeHtml(item.reason_detail || '--')}</p></section><section><h3 class="section-heading__title">脱敏会话摘要</h3><p class="admin-detail-copy">${escapeHtml(item.conversation_summary || '--')}</p></section>`);
const row = (label, value) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value ?? '--')}</dd></div>`;
showDetail(`工单 ${item.ticket_no}`, `<dl class="detail-grid">${row('状态', HANDOVER_STATUS_LABELS[item.status] || item.status)}${row('优先级', item.priority)}${row('识别意图', item.intent)}${row('置信度', value(item.confidence))}${row('受理人', item.assigned_to)}${row('分配时间', item.assigned_at)}${row('接单时间', item.accepted_at)}${row('解决时间', item.resolved_at)}${row('关闭时间', item.closed_at)}</dl><section><h3 class="section-heading__title">转接原因</h3><p class="admin-detail-copy">${escapeHtml(item.reason_detail || '--')}</p></section><section><h3 class="section-heading__title">脱敏会话摘要</h3><p class="admin-detail-copy">${escapeHtml(item.conversation_summary || '--')}</p></section><section><h3 class="section-heading__title">处置结论</h3><p class="admin-detail-copy">${escapeHtml(item.resolution || '(尚未填写)')}</p></section>`);
} catch (error) { showDetail('转人工工单', `<div class="form-alert form-alert--visible">${escapeHtml(error.message)}</div>`); }
}
function openHandoverAction(action, ticketNo) {
const item = state.handovers.find((rowdata) => String(rowdata.ticket_no) === String(ticketNo));
const label = HANDOVER_STATUS_LABELS[item?.status] || item?.status || '';
const copy = {
assign: `把工单 ${ticketNo} 分配给坐席 9003(本演示只提供"分配给我自己"),状态将变为「已分配」。`,
accept: `接管工单 ${ticketNo}(当前「${label}」),状态将变为「处理中」,受理人记为你。`,
resolve: `填写解决结论后工单 ${ticketNo} 变为「已解决」(结论会落库,可在摘要里回看)。`,
close: `把工单 ${ticketNo} 归档为「已关闭」;可留一条关闭补充说明。`,
cancel: `取消工单 ${ticketNo}(当前「${label}」)。取消只允许未解决的工单,原因会写进处置结论。`,
}[action];
state.action = { type: 'handover', action, ticketNo };
openActionDialog(
{ assign: '分配工单', accept: '接单', resolve: '解决工单', close: '关闭工单', cancel: '取消工单' }[action],
copy,
true,
{ assign: '处置说明', accept: '', resolve: '解决结论', close: '关闭补充(可空)', cancel: '取消原因' }[action],
);
}
async function loadCandidates() {
renderLoading(targets.candidates, 3);
try {
@@ -593,10 +657,12 @@ if (requireAdmin()) {
);
}
function openActionDialog(title, copy, showComment) {
function openActionDialog(title, copy, showComment, commentLabel = '审核意见') {
document.querySelector('[data-admin-action-title]').textContent = title;
document.querySelector('[data-admin-action-copy]').textContent = copy;
document.querySelector('[data-admin-comment-field]').hidden = !showComment;
const label = document.querySelector('[data-admin-comment-field] .form-field__label');
if (label) label.textContent = commentLabel;
document.querySelector('[data-admin-action-form]').elements.comment.value = '';
document.querySelector('[data-admin-action-alert]').classList.remove('form-alert--visible');
actionDialog.showModal();
@@ -610,6 +676,31 @@ if (requireAdmin()) {
const comment = form.elements.comment.value.trim();
submit.disabled = true;
try {
if (state.action.type === 'handover') {
const { action, ticketNo } = state.action;
// 五个动作各自的端点与载荷;`assign` 只提供"分配给我自己"(演示口径,
// 需要选具体坐席时把 assignee_id 换成下拉里的人)。
const spec = {
// 「分配」在本页只提供"分配给我自己":管理员就是当前登录人(`context.userId`)。
// 需要把工单派给别的坐席时,把这里换成一个人选下拉即可(后端收的是
// `sys_user.id`,并且会校验该用户存在)。
assign: ['ADMIN_HANDOVER_ASSIGN', { assignee_id: Number(context.userId) }],
accept: ['ADMIN_HANDOVER_ACCEPT', {}],
resolve: ['ADMIN_HANDOVER_RESOLVE', { resolution: comment }],
close: ['ADMIN_HANDOVER_CLOSE', { note: comment }],
cancel: ['ADMIN_HANDOVER_CANCEL', { reason: comment }],
}[action];
if (!spec) throw new Error(`未知的工单动作:${action}`);
if (action !== 'accept' && action !== 'assign' && comment.length < 2) {
throw new Error('请填写至少 2 个字的说明');
}
await apiClient.post(spec[0], spec[1], { pathParams: { ticketNo } });
showToast('工单状态已更新');
await Promise.all([loadHandovers(), loadAudits()]);
actionDialog.close();
renderMetrics();
return;
}
if (state.action.type === 'advisor') {
const { action, item } = state.action;
const isBook = item.content_type === 'investment_goal_book';
@@ -664,6 +755,8 @@ if (requireAdmin()) {
}));
document.querySelector('[data-identity-form]').addEventListener('submit', queryIdentity);
document.querySelector('[data-reload-audit]').addEventListener('click', loadAudits);
document.querySelector('[data-reload-handover]').addEventListener('click', loadHandovers);
document.querySelector('[data-handover-status]').addEventListener('change', loadHandovers);
document.querySelector('[data-reload-advisor]').addEventListener('click', loadAdvisorReviews);
document.querySelector('[data-reload-knowledge]').addEventListener('click', loadKnowledge);
document.querySelector('[data-knowledge-form]').addEventListener('submit', submitKnowledge);