补齐客服转人工工单流:从"只能看"到"能推进"(基线状态机,不自行发明)
## 问题 `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:
@@ -1,13 +1,21 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Path, Query, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.dependencies.auth import build_request_context
|
||||
from app.api.dependencies.database import get_session
|
||||
from app.api.dependencies.rate_limit import enforce_rate_limit
|
||||
from app.api.schemas.admin import (
|
||||
EmptyPayload,
|
||||
EndpointPayload,
|
||||
HandoverAssignPayload,
|
||||
HandoverCancelPayload,
|
||||
HandoverClosePayload,
|
||||
HandoverResolvePayload,
|
||||
HandoverStatus,
|
||||
IntentPayload,
|
||||
ItemPayload,
|
||||
NegativePayload,
|
||||
@@ -17,12 +25,17 @@ from app.api.schemas.admin import (
|
||||
ReviewPayload,
|
||||
RoutingPayload,
|
||||
)
|
||||
from app.api.views.envelope import envelope as _envelope
|
||||
from app.core.advisor_backtest_contracts import AllocationBacktestQuery
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.profile_governance_contracts import ProfileDriftReviewRequest
|
||||
from app.service.admin_service import AdminService
|
||||
from app.service.allocation_backtest_service import AllocationBacktestService
|
||||
from app.service.api_transaction_service import ApiTransactionService
|
||||
from app.service.customer_profile_candidate_service import CustomerProfileCandidateService
|
||||
from app.service.customer_service_handover_action_service import (
|
||||
CustomerServiceHandoverActionService,
|
||||
)
|
||||
from app.service.customer_service_handover_admin_service import CustomerServiceHandoverAdminService
|
||||
from app.service.profile_governance_service import ProfileGovernanceService
|
||||
|
||||
@@ -218,11 +231,15 @@ async def audit_records(
|
||||
async def list_customer_service_handover_tickets(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
cursor: str | None = Query(default=None),
|
||||
status: HandoverStatus | None = Query(default=None), # noqa: B008
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""只读查看客服待转人工队列;不暴露原始会话或处理动作。"""
|
||||
"""只读查看客服转人工队列;不暴露原始会话。可按状态筛。
|
||||
|
||||
处置动作在下面五个端点里,需要 `handover:write`(本端点只需 `handover:read`)。
|
||||
"""
|
||||
return await CustomerServiceHandoverAdminService().list_tickets(
|
||||
context, limit=limit, cursor=cursor
|
||||
context, limit=limit, cursor=cursor, status=status
|
||||
)
|
||||
|
||||
|
||||
@@ -231,10 +248,142 @@ async def get_customer_service_handover_ticket(
|
||||
ticket_no: str,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
) -> dict[str, Any]:
|
||||
"""只读查看单个工单的脱敏转接摘要。"""
|
||||
"""只读查看单个工单的脱敏转接摘要与流转状态。"""
|
||||
return await CustomerServiceHandoverAdminService().get_ticket(ticket_no, context)
|
||||
|
||||
|
||||
# ---- 转人工工单处置(分配 / 接单 / 解决 / 关闭 / 取消)----
|
||||
#
|
||||
# 状态机与权限口径见 `docs/02` §7.2 与 `app/service/customer_service_handover_action_service.py`:
|
||||
# pending -> assigned -> processing -> resolved -> closed,未解决可 cancelled。
|
||||
# 五个端点都要求 `handover:write` + admin 角色,并且都要求 `Idempotency-Key`
|
||||
# (重复提交直接回放上次结果,不会二次推进状态机)。
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/assignments")
|
||||
async def assign_customer_service_handover_ticket(
|
||||
payload: HandoverAssignPayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""把待处理工单分配给一个坐席(`pending -> assigned`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/assignments",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).assign(
|
||||
ticket_no, payload.assignee_id, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/acceptances")
|
||||
async def accept_customer_service_handover_ticket(
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""坐席接单(`pending` 自助接管 / `assigned -> processing`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/acceptances",
|
||||
{},
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).accept(ticket_no, context),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/resolutions")
|
||||
async def resolve_customer_service_handover_ticket(
|
||||
payload: HandoverResolvePayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""给出解决结论(`processing -> resolved`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/resolutions",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).resolve(
|
||||
ticket_no, payload.resolution, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/closures")
|
||||
async def close_customer_service_handover_ticket(
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
payload: HandoverClosePayload | None = None,
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""归档关闭(`resolved -> closed`);可选补一条说明。"""
|
||||
note = payload.note if payload is not None else ""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/closures",
|
||||
{"note": note},
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).close(
|
||||
ticket_no, note, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customer-service/handover-tickets/{ticket_no}/cancellations")
|
||||
async def cancel_customer_service_handover_ticket(
|
||||
payload: HandoverCancelPayload,
|
||||
ticket_no: str = Path(min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$"),
|
||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||
session: AsyncSession = Depends(get_session), # noqa: B008
|
||||
key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
) -> dict[str, Any]:
|
||||
"""取消未解决的工单(`pending|assigned|processing -> cancelled`)。"""
|
||||
return await _handover_action(
|
||||
session,
|
||||
context,
|
||||
key,
|
||||
f"POST /api/v1/admin/customer-service/handover-tickets/{ticket_no}/cancellations",
|
||||
payload.model_dump(),
|
||||
lambda inner: CustomerServiceHandoverActionService(inner).cancel(
|
||||
ticket_no, payload.reason, context
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _handover_action(
|
||||
session: AsyncSession,
|
||||
context: RequestContext,
|
||||
key: str | None,
|
||||
scope: str,
|
||||
body: dict[str, Any],
|
||||
action: Callable[[AsyncSession], Awaitable[dict[str, Any]]],
|
||||
) -> dict[str, Any]:
|
||||
"""工单处置的统一外壳:幂等 + 统一信封。
|
||||
|
||||
幂等记录与业务写入**同事务**(`ApiTransactionService.execute_in`),
|
||||
重复请求直接回放 `response_json`,不会二次驱动状态机。
|
||||
幂等范围含路径参数 —— 同一个键用在两张不同工单上会被当成两次不同操作
|
||||
(与风控处置同一口径:把路径参数折成模板会让不同资源互相回放)。
|
||||
"""
|
||||
data = await ApiTransactionService().execute_in(
|
||||
session, context, scope, key, body, action
|
||||
)
|
||||
return _envelope(data, context)
|
||||
|
||||
|
||||
@router.get("/customer-profile-candidates")
|
||||
async def list_customer_profile_candidates(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
|
||||
@@ -121,5 +121,42 @@ class ReviewPayload(StrictPayload):
|
||||
comment: str = Field(default="", max_length=1000)
|
||||
|
||||
|
||||
#: `svc_handover_ticket.status` 的取值,**必须逐字对齐数据库 CHECK 约束**
|
||||
#: (`chk_handoff_status`,见 `docs/02-数据库建表设计.md` §7.2)。
|
||||
#: 与 `ReplyScene` 同一个教训:不在列内的值若穿到数据库,会以 500 冒出而不是 422。
|
||||
HandoverStatus = Literal[
|
||||
"pending",
|
||||
"assigned",
|
||||
"processing",
|
||||
"resolved",
|
||||
"closed",
|
||||
"cancelled",
|
||||
]
|
||||
|
||||
|
||||
class HandoverAssignPayload(StrictPayload):
|
||||
"""分配工单给一个坐席(`sys_user.id`)。"""
|
||||
|
||||
assignee_id: int = Field(gt=0)
|
||||
|
||||
|
||||
class HandoverResolvePayload(StrictPayload):
|
||||
"""解决工单必须给结论(`resolution` 落库)。"""
|
||||
|
||||
resolution: str = Field(min_length=2, max_length=2000)
|
||||
|
||||
|
||||
class HandoverClosePayload(StrictPayload):
|
||||
"""关闭工单可选补一条说明,追加在结论之后。"""
|
||||
|
||||
note: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class HandoverCancelPayload(StrictPayload):
|
||||
"""取消工单必须给原因(写进 `resolution`)。"""
|
||||
|
||||
reason: str = Field(min_length=2, max_length=500)
|
||||
|
||||
|
||||
class EmptyPayload(StrictPayload):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""客服转人工工单的**人工处置**服务:分配 / 接单 / 解决 / 关闭 / 取消。
|
||||
|
||||
状态机照基线实现,**不自行发明**(`docs/02-数据库建表设计.md` §7.2、`docs/05` §8.5):
|
||||
|
||||
```text
|
||||
pending -> assigned -> processing -> resolved -> closed
|
||||
| | |
|
||||
`----------+-------------+-> cancelled
|
||||
```
|
||||
|
||||
- `cancelled` 只允许在**未解决**状态进入;每次分配、接单、解决、关闭、取消都写
|
||||
`interaction_audit`(谁、何时、把哪张单子从什么状态推到什么状态);
|
||||
- 只读脱敏查看在 `customer_service_handover_admin_service`,本模块只管状态与字段流转,
|
||||
**不读原始会话、不回吐客户标识**;
|
||||
- 落点是「客服领域 Service」而不是 Controller —— `docs/05` 明确要求
|
||||
"客服工单分配、接单、解决和关闭由客服领域 Service 处理,不允许 Controller 直接写
|
||||
`svc_handover_ticket`"。
|
||||
|
||||
两个刻意的口径:
|
||||
|
||||
1. **`accept` 允许从 `pending` 或 `assigned` 进入 `processing`**:前者是"自助接管"
|
||||
(同时把 `assigned_to` 记成接单人),后者是"由被分配人接单"。这对应
|
||||
`docs/03-平台端到端流程文档.md` 的"只有 `pending/assigned` 状态可以被合法接管"。
|
||||
2. **`cancel` 不写 `closed_at`**:该列语义是"关闭时间",属 `closed` 状态;
|
||||
取消的原因写进 `resolution`("解决结论及理由"),状态置 `cancelled`。
|
||||
这样"关闭"与"取消"在报表上不会混成一类。
|
||||
|
||||
并发口径:所有动作先 `SELECT ... FOR UPDATE` 锁住该工单再判状态,
|
||||
同一张单子被两个人同时点也不会两次推进(后到的那个会看到已经变化的状态而报错)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.contracts import RequestContext
|
||||
from app.core.conversation_privacy import sanitize_customer_service_message
|
||||
from app.core.errors import (
|
||||
GenericResourceNotFoundError,
|
||||
InvalidStateError,
|
||||
ValidationAgentError,
|
||||
)
|
||||
from app.model.audit import InteractionAudit
|
||||
from app.model.platform import HandoverTicket
|
||||
from app.model.risk import RiskUser
|
||||
from app.service.authorization_service import AuthorizationService
|
||||
|
||||
#: 处置动作所需的权限码(`tools/seed_test_rbac.py` 的 9069,只给 admin)。
|
||||
WRITE_PERMISSION = "handover:write"
|
||||
|
||||
#: 允许"取消"的未解决状态(与 `docs/02` §7.2 的图一致)。
|
||||
CANCELLABLE_STATUSES = ("pending", "assigned", "processing")
|
||||
|
||||
#: 允许"接管(接单)"的状态。
|
||||
ACCEPTABLE_STATUSES = ("pending", "assigned")
|
||||
|
||||
|
||||
class HandoverActionError(InvalidStateError):
|
||||
"""工单当前状态不允许执行该动作(409)。
|
||||
|
||||
继承 `InvalidStateError`("非法状态转换")而不是直接用 `ConflictAgentError`:
|
||||
后者的文档写明"只用于继承与 except,禁止直接抛出"。
|
||||
错误码沿用平台唯一的 409 码 `RUN_NOT_CANCELLABLE`(`docs/05` §3.5 只登记了这一个),
|
||||
真正的原因在 `message` 里。
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Action:
|
||||
action_type: str
|
||||
title: str
|
||||
|
||||
|
||||
ACTIONS = {
|
||||
"assign": _Action("handover.assigned", "分配"),
|
||||
"accept": _Action("handover.accepted", "接单"),
|
||||
"resolve": _Action("handover.resolved", "解决"),
|
||||
"close": _Action("handover.closed", "关闭"),
|
||||
"cancel": _Action("handover.cancelled", "取消"),
|
||||
}
|
||||
|
||||
|
||||
class CustomerServiceHandoverActionService:
|
||||
"""面向管理员的转人工工单处置边界。"""
|
||||
|
||||
permission = WRITE_PERMISSION
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# ---------- 五个动作 ----------
|
||||
|
||||
async def assign(
|
||||
self, ticket_no: str, assignee_id: int, context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""把待处理工单分配给一个坐席:`pending -> assigned`。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
ticket = await self._load_for_update(ticket_no)
|
||||
if ticket.status != "pending":
|
||||
raise HandoverActionError(
|
||||
f"当前状态为{ticket.status},只有 pending 的工单可以分配"
|
||||
)
|
||||
assignee = await self.session.scalar(
|
||||
select(RiskUser.id).where(RiskUser.id == assignee_id)
|
||||
)
|
||||
if assignee is None:
|
||||
raise ValidationAgentError(f"坐席 {assignee_id} 不存在")
|
||||
now = _now()
|
||||
ticket.assigned_to = assignee_id
|
||||
ticket.assigned_at = now
|
||||
ticket.status = "assigned"
|
||||
ticket.updated_at = now
|
||||
await self._finish(
|
||||
ticket, context, "assign", {"assignee_id": assignee_id, "to_status": "assigned"}
|
||||
)
|
||||
return self._view(ticket)
|
||||
|
||||
async def accept(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||||
"""坐席接单:`pending -> processing`(自助接管)或 `assigned -> processing`。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
ticket = await self._load_for_update(ticket_no)
|
||||
if ticket.status not in ACCEPTABLE_STATUSES:
|
||||
raise HandoverActionError(
|
||||
f"当前状态为{ticket.status},只有 pending/assigned 的工单可以接单"
|
||||
)
|
||||
now = _now()
|
||||
operator_id = int(context.user_id)
|
||||
detail: dict[str, Any] = {"from_status": ticket.status, "to_status": "processing"}
|
||||
if ticket.status == "pending":
|
||||
# 自助接管:没有分配过就直接接单的人,就是实际受理人。
|
||||
ticket.assigned_to = operator_id
|
||||
ticket.assigned_at = now
|
||||
detail["self_claimed"] = True
|
||||
ticket.accepted_at = now
|
||||
ticket.status = "processing"
|
||||
ticket.updated_at = now
|
||||
await self._finish(ticket, context, "accept", detail)
|
||||
return self._view(ticket)
|
||||
|
||||
async def resolve(
|
||||
self, ticket_no: str, resolution: str, context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""给出解决结论:`processing -> resolved`。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
ticket = await self._load_for_update(ticket_no)
|
||||
if ticket.status != "processing":
|
||||
raise HandoverActionError(
|
||||
f"当前状态为{ticket.status},只有 processing 的工单可以解决"
|
||||
)
|
||||
text = _require_text(resolution, "解决结论")
|
||||
now = _now()
|
||||
ticket.resolution = text
|
||||
ticket.resolved_at = now
|
||||
ticket.status = "resolved"
|
||||
ticket.updated_at = now
|
||||
await self._finish(
|
||||
ticket, context, "resolve", {"to_status": "resolved", "resolution_length": len(text)}
|
||||
)
|
||||
return self._view(ticket)
|
||||
|
||||
async def close(
|
||||
self, ticket_no: str, note: str, context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""归档关闭:`resolved -> closed`。可选补一条说明,追加在结论后面。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
ticket = await self._load_for_update(ticket_no)
|
||||
if ticket.status != "resolved":
|
||||
raise HandoverActionError(
|
||||
f"当前状态为{ticket.status},只有 resolved 的工单可以关闭"
|
||||
)
|
||||
now = _now()
|
||||
extra = note.strip()
|
||||
if extra:
|
||||
ticket.resolution = (
|
||||
f"{ticket.resolution}\n关闭补充:{extra}" if ticket.resolution else extra
|
||||
)
|
||||
ticket.closed_at = now
|
||||
ticket.status = "closed"
|
||||
ticket.updated_at = now
|
||||
await self._finish(
|
||||
ticket, context, "close", {"to_status": "closed", "has_note": bool(extra)}
|
||||
)
|
||||
return self._view(ticket)
|
||||
|
||||
async def cancel(
|
||||
self, ticket_no: str, reason: str, context: RequestContext
|
||||
) -> dict[str, Any]:
|
||||
"""取消未解决的工单:`pending|assigned|processing -> cancelled`。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
ticket = await self._load_for_update(ticket_no)
|
||||
if ticket.status not in CANCELLABLE_STATUSES:
|
||||
raise HandoverActionError(
|
||||
f"当前状态为{ticket.status},只有未解决(pending/assigned/processing)"
|
||||
"的工单可以取消"
|
||||
)
|
||||
text = _require_text(reason, "取消原因")
|
||||
from_status = ticket.status
|
||||
now = _now()
|
||||
ticket.status = "cancelled"
|
||||
ticket.resolution = f"已取消:{text}"
|
||||
ticket.updated_at = now
|
||||
# `closed_at` 不写:它属于 `closed` 状态(见模块 docstring 的口径 2)。
|
||||
await self._finish(
|
||||
ticket,
|
||||
context,
|
||||
"cancel",
|
||||
{"from_status": from_status, "to_status": "cancelled"},
|
||||
)
|
||||
return self._view(ticket)
|
||||
|
||||
# ---------- 内部 ----------
|
||||
|
||||
async def _load_for_update(self, ticket_no: str) -> HandoverTicket:
|
||||
ticket = await self.session.scalar(
|
||||
select(HandoverTicket)
|
||||
.where(HandoverTicket.ticket_no == ticket_no)
|
||||
.with_for_update()
|
||||
)
|
||||
if ticket is None:
|
||||
raise GenericResourceNotFoundError("转人工工单不存在")
|
||||
return ticket
|
||||
|
||||
async def _finish(
|
||||
self,
|
||||
ticket: HandoverTicket,
|
||||
context: RequestContext,
|
||||
action: str,
|
||||
detail: dict[str, Any],
|
||||
) -> None:
|
||||
"""写一条审计并提交;审计与业务写入同事务,失败即整体回滚。"""
|
||||
meta = ACTIONS[action]
|
||||
self.session.add(
|
||||
InteractionAudit(
|
||||
actor_type="user",
|
||||
actor_id=int(context.user_id),
|
||||
target_customer_id=ticket.customer_id,
|
||||
session_id=ticket.session_id,
|
||||
portal="api",
|
||||
action_type=meta.action_type,
|
||||
detail={
|
||||
"ticket_no": ticket.ticket_no,
|
||||
"action": action,
|
||||
**detail,
|
||||
},
|
||||
created_at=_now(),
|
||||
)
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(ticket)
|
||||
|
||||
@staticmethod
|
||||
def _view(ticket: HandoverTicket) -> dict[str, Any]:
|
||||
"""处置结果视图:只回工单自身的流转字段,不含客户标识。"""
|
||||
return {
|
||||
"ticket_no": ticket.ticket_no,
|
||||
"status": ticket.status,
|
||||
"priority": ticket.priority,
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"assigned_at": _iso(ticket.assigned_at),
|
||||
"accepted_at": _iso(ticket.accepted_at),
|
||||
"resolved_at": _iso(ticket.resolved_at),
|
||||
"closed_at": _iso(ticket.closed_at),
|
||||
"resolution": sanitize_customer_service_message(ticket.resolution)
|
||||
if ticket.resolution
|
||||
else None,
|
||||
"updated_at": _iso(ticket.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _require_text(value: str, label: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if len(text) < 2:
|
||||
raise ValidationAgentError(f"{label}至少 2 个字")
|
||||
return text
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat() + "Z" if value is not None and value.tzinfo is None else (
|
||||
value.isoformat() if value is not None else None
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -1,7 +1,10 @@
|
||||
"""管理员查看客服转人工队列的只读服务。
|
||||
|
||||
本模块只暴露工单中已经二次脱敏的最小必要字段;它不读取原始会话、账户、画像或
|
||||
联系方式,也不提供接单、分配、解决或关闭工单的能力。
|
||||
联系方式,也不提供接单、分配、解决或关闭工单的能力 ——
|
||||
**工单的处置(分配/接单/解决/关闭/取消)在
|
||||
`app/service/customer_service_handover_action_service.py`**,那是另一条有写权限与
|
||||
状态机校验的边界,两者刻意分开:读侧可以被更宽的人群使用,写侧必须 `handover:write`。
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@@ -20,24 +23,33 @@ from app.service.authorization_service import AuthorizationService
|
||||
|
||||
|
||||
class CustomerServiceHandoverAdminService:
|
||||
"""面向管理员的待处理客服转人工工单只读边界。"""
|
||||
"""面向管理员的客服转人工工单只读边界。"""
|
||||
|
||||
permission = "handover:read"
|
||||
|
||||
async def list_tickets(
|
||||
self, context: RequestContext, *, limit: int = 20, cursor: str | None = None
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""按工单 ID 倒序返回一页已脱敏的转人工队列。"""
|
||||
"""按工单 ID 倒序返回一页已脱敏的转人工队列;可按状态筛。"""
|
||||
await AuthorizationService.require(context, self.permission, admin=True)
|
||||
before = parse_cursor(cursor)
|
||||
async with SessionFactory() as session:
|
||||
statement = select(HandoverTicket).order_by(HandoverTicket.id.desc()).limit(limit)
|
||||
if before is not None:
|
||||
statement = statement.where(HandoverTicket.id < before)
|
||||
if status:
|
||||
# 状态取值由接口层用 `HandoverStatus` 收口(与数据库 CHECK 逐字对齐),
|
||||
# 这里不再重复枚举,避免两处口径漂移。
|
||||
statement = statement.where(HandoverTicket.status == status)
|
||||
tickets = list(await session.scalars(statement))
|
||||
return {
|
||||
"data": [self._list_item(ticket) for ticket in tickets],
|
||||
"meta": {"trace_id": context.trace_id},
|
||||
"meta": {"trace_id": context.trace_id, "status": status},
|
||||
}
|
||||
|
||||
async def get_ticket(self, ticket_no: str, context: RequestContext) -> dict[str, Any]:
|
||||
@@ -62,6 +74,11 @@ class CustomerServiceHandoverAdminService:
|
||||
"priority": ticket.priority,
|
||||
"reason_code": ticket.reason_code,
|
||||
"status": ticket.status,
|
||||
# 受理人(坐席)与流转时间是队列路由信息,不是客户数据,列表页需要它。
|
||||
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
|
||||
"accepted_at": cls._public_value(ticket.accepted_at),
|
||||
"resolved_at": cls._public_value(ticket.resolved_at),
|
||||
"closed_at": cls._public_value(ticket.closed_at),
|
||||
"created_at": cls._public_value(ticket.created_at),
|
||||
"updated_at": cls._public_value(ticket.updated_at),
|
||||
}
|
||||
@@ -75,6 +92,8 @@ class CustomerServiceHandoverAdminService:
|
||||
"confidence": cls._public_value(ticket.confidence),
|
||||
"reason_detail": cls._safe_text(ticket.reason_detail),
|
||||
"conversation_summary": cls._safe_text(ticket.conversation_summary),
|
||||
"resolution": cls._safe_text(ticket.resolution),
|
||||
"assigned_at": cls._public_value(ticket.assigned_at),
|
||||
"source_references": cls._safe_source_references(ticket.source_references),
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user