From 54f2f2d5974bf6af31fb6bf31263623cdfe23f4e Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 10 Sep 2026 16:14:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B7=A5=E5=8D=95=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=88=9D=E7=89=88=E2=80=94=E2=80=94=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E6=8F=90=E9=97=AE=E2=86=92agent=E9=A2=84=E5=A4=84?= =?UTF-8?q?=E7=90=86=E2=86=92=E4=BA=BA=E5=B7=A5=E8=BF=90=E7=BB=B4=E2=86=92?= =?UTF-8?q?=E8=BD=AC=E6=B4=BE=EF=BC=8C=E6=8E=A5=E5=85=A5=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3表: tk_tickets(状态机+受理角色/人+agent锁)/tk_messages(客户可见/内部备注)/tk_transfers(append-only流水) - core.py: 8态13迁移状态机全乐观锁; 转派候选动态查owner角色+'0'机构用户(不硬编码); 默认受理角色params可配(ticket_default_role=owner.maintainer) - agent.py: 异步poller(bid_flow范式), RAG检索'0'机构KB+历史工单, llm_call(org_id=0) LLM裁决能否回答(严格JSON), 硬门禁reply<20字符转人工, 故障≠答不了(fail_rounds>=3转人工), stale回收10min; 追问回流(agent_processing+owner空)立即拾取不等stale - todos.py: 平台待办provider, 状态派生(客户待确认/角色池待认领/受理人待处理) - init.py: load_ticket注册env+provider+poller(PIPELINE_MODE=web不启动) - wwwroot: 客户我的工单页+建单表单, 运维工单管理页(队列切换), 详情弹窗(正文与操作同屏,按视角出按钮) - load_path.py: logined全部API(业务权限服务端按current_role动态校验)+管理页壳owner三角色 - init/data.json: 7组码表+owner.maintainer角色种子(app_audit先例) --- .gitignore | 5 + README.md | 47 ++- init/data.json | 83 ++++ models/tk_messages.json | 29 ++ models/tk_tickets.json | 43 ++ models/tk_transfers.json | 28 ++ pyproject.toml | 17 + scripts/load_path.py | 88 ++++ ticket/__init__.py | 2 + ticket/agent.py | 366 ++++++++++++++++ ticket/core.py | 608 +++++++++++++++++++++++++++ ticket/init.py | 51 +++ ticket/todos.py | 85 ++++ wwwroot/api/my_tickets.dspy | 20 + wwwroot/api/staff_tickets.dspy | 20 + wwwroot/api/ticket_cancel.dspy | 18 + wwwroot/api/ticket_claim.dspy | 17 + wwwroot/api/ticket_confirm.dspy | 18 + wwwroot/api/ticket_create.dspy | 31 ++ wwwroot/api/ticket_detail.dspy | 17 + wwwroot/api/ticket_detail_popup.dspy | 245 +++++++++++ wwwroot/api/ticket_followup.dspy | 19 + wwwroot/api/ticket_staff_reply.dspy | 19 + wwwroot/api/ticket_transfer.dspy | 21 + wwwroot/api/transfer_candidates.dspy | 8 + wwwroot/manage/index.ui | 80 ++++ wwwroot/my/index.ui | 94 +++++ wwwroot/my/new_ticket_form.ui | 23 + 28 files changed, 2101 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 init/data.json create mode 100644 models/tk_messages.json create mode 100644 models/tk_tickets.json create mode 100644 models/tk_transfers.json create mode 100644 pyproject.toml create mode 100644 scripts/load_path.py create mode 100644 ticket/__init__.py create mode 100644 ticket/agent.py create mode 100644 ticket/core.py create mode 100644 ticket/init.py create mode 100644 ticket/todos.py create mode 100644 wwwroot/api/my_tickets.dspy create mode 100644 wwwroot/api/staff_tickets.dspy create mode 100644 wwwroot/api/ticket_cancel.dspy create mode 100644 wwwroot/api/ticket_claim.dspy create mode 100644 wwwroot/api/ticket_confirm.dspy create mode 100644 wwwroot/api/ticket_create.dspy create mode 100644 wwwroot/api/ticket_detail.dspy create mode 100644 wwwroot/api/ticket_detail_popup.dspy create mode 100644 wwwroot/api/ticket_followup.dspy create mode 100644 wwwroot/api/ticket_staff_reply.dspy create mode 100644 wwwroot/api/ticket_transfer.dspy create mode 100644 wwwroot/api/transfer_candidates.dspy create mode 100644 wwwroot/manage/index.ui create mode 100644 wwwroot/my/index.ui create mode 100644 wwwroot/my/new_ticket_form.ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..885f4b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.egg-info/ +build/ +dist/ diff --git a/README.md b/README.md index 45d3022..48929cc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ -# ticket +# ticket — 平台工单管理模块 +客户提出问题 → 登记工单 → agent 先处理(检索'0'机构知识库+历史工单,能答直接回复客户) +→ 答不了转人工运维(owner.maintainer,params 可配)→ 人工回复客户 → 关闭; +人工处理不了可转派 owner 组织类型下任意角色或具体用户。 + +## 关键设计(详见 pipeline-app/docs 或设计文档 ticket-module-design.md) + +- **状态机**:8 态 13 迁移,全部乐观锁(UPDATE...WHERE status=前置态 + SELECT 验证, + 照 pipeline_service agent_loop 的 claim 范式),并发冲突返回明确错误码。 +- **平台待办集成**(用户定夺 2026-09-10):工单不建独立通知表,通过 + `register_todo_provider` 钩子接入平台待办通道(pipeline-service 聚合, + todo_badge 角标 + my_todos 弹窗异步通知到人)。待办由工单状态派生: + 客户回复待确认 / 运维角色池待认领 / 受理人待处理——状态流转待办自动消失, + 天然满足「同一事项只发一次」。 +- **角色不硬编码**:默认受理角色存 params(ticket_default_role,兜底 owner.maintainer); + 转派候选实时查 role 表 owner 组织类型全部角色 + '0' 机构用户(role/orgtypes 运行时可动态增删)。 +- **agent**:异步 poller(照 bid_flow 范式,PIPELINE_MODE=web 不启动), + RAG 检索走 rag HTTP API(dapi Bearer key,平台账号='0'机构 → 只见 org_id='0' 的 KB), + LLM 走 pipeline_service.llm_bridge.llm_call(内部 token+门禁链+记账,org_id='0')。 + 「能否回答」由 LLM 裁决(语义判断铁律,禁关键词匹配),严格 JSON 输出; + 硬门禁:reply 空/短于20字符一律转人工。基础设施故障(LLM/RAG异常)≠答不了: + fail_rounds+1 回退重试,≥3 轮转人工(reason=agent服务不可用)。 + +## 数据表(3张,tk_ 前缀) + +| 表 | 说明 | +|---|------| +| tk_tickets | 工单主表(状态机+当前受理角色/人+agent处理锁) | +| tk_messages | 往来消息(visibility: customer客户可见/internal内部备注) | +| tk_transfers | 转派流水(append-only,每次受理方变更必留痕) | + +## 参数(params 表,运行时可改) + +| 参数 | 默认 | 说明 | +|------|------|------| +| ticket_default_role | owner.maintainer | agent 转人工的默认受理角色 | +| ticket_followup_max | 2 | agent 回复后客户追问上限,超限自动转人工 | +| ticket_agent_fail_max | 3 | agent 基础设施连续失败轮数上限,超限转人工 | +| ticket_agent_username | admin | agent 检索知识库用的平台账号('0'机构) | + +## 安装 + +宿主 build.sh 四处清单 + `load_ticket()`(见宿主集成)。模块注册: +- env.create_ticket / ticket_detail / ticket_followup / ... (dspy 消费) +- register_todo_provider(list_ticket_todos)(平台待办聚合) +- start_poller()(agent 后台处理) diff --git a/init/data.json b/init/data.json new file mode 100644 index 0000000..1453fcf --- /dev/null +++ b/init/data.json @@ -0,0 +1,83 @@ +{ + "_note": "ticket 模块种子:码表(嵌套格式,import_init.py normalize 展开)+ owner.maintainer 角色(照 app_audit 的 owner.audit 先例)。params 参数(ticket_default_role 等)不在种子——core.py _get_param 有兜底默认值,运行时在参数管理页可调。", + "appcodes": [ + { + "parentid": "tk_status", + "parentname": "工单状态", + "items": [ + {"k": "new", "v": "已提交"}, + {"k": "agent_processing", "v": "助手处理中"}, + {"k": "agent_replied", "v": "助手已回复"}, + {"k": "human_pending", "v": "待人工认领"}, + {"k": "human_processing", "v": "人工处理中"}, + {"k": "staff_replied", "v": "人工已回复"}, + {"k": "closed", "v": "已关闭"}, + {"k": "cancelled", "v": "已取消"} + ] + }, + { + "parentid": "tk_category", + "parentname": "工单分类", + "items": [ + {"k": "consult", "v": "使用咨询"}, + {"k": "fault", "v": "故障报修"}, + {"k": "billing", "v": "计费账务"}, + {"k": "other", "v": "其他"} + ] + }, + { + "parentid": "tk_priority", + "parentname": "工单优先级", + "items": [ + {"k": "low", "v": "低"}, + {"k": "normal", "v": "普通"}, + {"k": "high", "v": "高"}, + {"k": "urgent", "v": "紧急"} + ] + }, + { + "parentid": "tk_sender_type", + "parentname": "工单消息发送方", + "items": [ + {"k": "customer", "v": "客户"}, + {"k": "agent", "v": "智能助手"}, + {"k": "staff", "v": "人工客服"} + ] + }, + { + "parentid": "tk_visibility", + "parentname": "工单消息可见性", + "items": [ + {"k": "customer", "v": "客户可见"}, + {"k": "internal", "v": "内部备注"} + ] + }, + { + "parentid": "tk_transfer_action", + "parentname": "工单流转动作", + "items": [ + {"k": "escalate_to_human", "v": "转人工"}, + {"k": "claim", "v": "认领"}, + {"k": "transfer_role", "v": "转角色"}, + {"k": "transfer_user", "v": "转人员"}, + {"k": "return", "v": "退回"} + ] + }, + { + "parentid": "tk_close_reason", + "parentname": "工单关闭原因", + "items": [ + {"k": "resolved", "v": "已解决"}, + {"k": "cancelled", "v": "客户取消"}, + {"k": "timeout", "v": "超时关闭"} + ] + } + ], + "roles": [ + { + "id": "owner.maintainer", + "orgtypeid": "owner", + "name": "maintainer" + } + ] +} diff --git a/models/tk_messages.json b/models/tk_messages.json new file mode 100644 index 0000000..bf73b88 --- /dev/null +++ b/models/tk_messages.json @@ -0,0 +1,29 @@ +{ + "summary": [ + { + "name": "tk_messages", + "title": "工单往来消息", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "ticket_id", "title": "工单ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "sender_type", "title": "发送方类型", "type": "str", "length": 16, "nullable": "no"}, + {"name": "sender_id", "title": "发送人ID", "type": "str", "length": 32}, + {"name": "sender_role", "title": "发送时角色", "type": "str", "length": 64}, + {"name": "content", "title": "消息正文", "type": "longtext"}, + {"name": "attachments", "title": "附件JSON", "type": "text"}, + {"name": "visibility", "title": "可见性", "type": "str", "length": 16, "nullable": "no", "default": "customer"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_tkm_ticket", "idxtype": "index", "idxfields": ["ticket_id", "created_at"]}, + {"name": "idx_tkm_vis", "idxtype": "index", "idxfields": ["visibility"]} + ], + "codes": [ + {"field": "sender_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_sender_type'"}, + {"field": "visibility", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_visibility'"} + ] +} diff --git a/models/tk_tickets.json b/models/tk_tickets.json new file mode 100644 index 0000000..700826b --- /dev/null +++ b/models/tk_tickets.json @@ -0,0 +1,43 @@ +{ + "summary": [ + { + "name": "tk_tickets", + "title": "工单主表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "ticket_no", "title": "工单编号", "type": "str", "length": 32, "nullable": "no"}, + {"name": "title", "title": "问题标题", "type": "str", "length": 200, "nullable": "no"}, + {"name": "description", "title": "问题描述", "type": "text"}, + {"name": "category", "title": "问题分类", "type": "str", "length": 32, "default": "other"}, + {"name": "priority", "title": "优先级", "type": "str", "length": 16, "default": "normal"}, + {"name": "status", "title": "工单状态", "type": "str", "length": 32, "nullable": "no", "default": "new"}, + {"name": "customer_org_id", "title": "客户机构ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "customer_user_id", "title": "客户用户ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "current_role", "title": "当前受理角色", "type": "str", "length": 64}, + {"name": "current_assignee", "title": "当前受理人", "type": "str", "length": 32}, + {"name": "processing_owner", "title": "agent处理锁标记", "type": "str", "length": 64}, + {"name": "followup_count", "title": "客户追问次数", "type": "int", "default": "0"}, + {"name": "agent_fail_rounds", "title": "agent连续失败轮数", "type": "int", "default": "0"}, + {"name": "closed_at", "title": "关闭时间", "type": "timestamp"}, + {"name": "close_reason", "title": "关闭原因", "type": "str", "length": 32}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "uk_tk_no", "idxtype": "unique", "idxfields": ["ticket_no"]}, + {"name": "idx_tk_cust", "idxtype": "index", "idxfields": ["customer_org_id", "status"]}, + {"name": "idx_tk_status", "idxtype": "index", "idxfields": ["status"]}, + {"name": "idx_tk_role_status", "idxtype": "index", "idxfields": ["current_role", "status"]}, + {"name": "idx_tk_assignee", "idxtype": "index", "idxfields": ["current_assignee", "status"]} + ], + "codes": [ + {"field": "category", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_category'"}, + {"field": "priority", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_priority'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_status'"}, + {"field": "close_reason", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_close_reason'"} + ] +} diff --git a/models/tk_transfers.json b/models/tk_transfers.json new file mode 100644 index 0000000..3e781da --- /dev/null +++ b/models/tk_transfers.json @@ -0,0 +1,28 @@ +{ + "summary": [ + { + "name": "tk_transfers", + "title": "工单转派流水", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "ticket_id", "title": "工单ID", "type": "str", "length": 32, "nullable": "no"}, + {"name": "action", "title": "动作", "type": "str", "length": 32, "nullable": "no"}, + {"name": "from_role", "title": "转出角色", "type": "str", "length": 64}, + {"name": "from_user", "title": "转出人", "type": "str", "length": 32}, + {"name": "to_role", "title": "转入角色", "type": "str", "length": 64}, + {"name": "to_user", "title": "转入人", "type": "str", "length": 32}, + {"name": "reason", "title": "原因", "type": "str", "length": 500}, + {"name": "operator_id", "title": "操作人", "type": "str", "length": 32}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"} + ], + "indexes": [ + {"name": "idx_tkt_ticket", "idxtype": "index", "idxfields": ["ticket_id", "created_at"]} + ], + "codes": [ + {"field": "action", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_transfer_action'"} + ] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..33e6db1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ticket" +version = "0.1.0" +description = "平台工单管理:客户提问→agent预处理→人工运维→转派,接入平台待办通道" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "sqlor", + "bricks_for_python" +] + +[tool.setuptools] +packages = ["ticket"] diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..60076a4 --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""RBAC path registration for ticket module (pipeline format). + +从应用根执行:py3/bin/python pkgs/ticket/scripts/load_path.py +(load_path.sh 自动扫描 pkgs/*/scripts/load_path.py 并调用本脚本) + +权限设计(设计文档 §7.2,逐个精确配不用统一兜底): +- logined:模块页面壳 + 全部 API。 + ⚠ staff 动作端点(claim/staff_reply/transfer/transfer_candidates/staff_tickets) + 只能授 logined 而不能授固定角色——D4 决策:转派目标是 owner 组织类型下的 + **任意动态角色**(role 表运行时可增删),RBAC 路径权限无法枚举动态角色, + 业务权限由服务端 _check_staff_access 校验(操作者须持有工单 current_role, + admin/superuser 豁免)。客户侧数据由 I5 服务端自过滤(customer_user_id/org_id)。 +- owner.maintainer / owner.admin / owner.superuser:管理页壳 /ticket/manage/**。 + 菜单「工单管理」入口也按角色 Jinja 分支渲染,客户直接访问 URL 403。 +""" +import sys +import os +import asyncio + +# 应用根:本脚本在 pkgs/ticket/scripts/ 下,上溯三级 +APP_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "../../..")) +sys.path.insert(0, os.path.join(APP_ROOT, "py3", "lib", "python3.10", "site-packages")) +sys.path.insert(0, APP_ROOT) + +from sqlor.dbpools import DBPools +from appPublic.jsonConfig import getConfig +from appPublic.uniqueID import getID + +MOD = "ticket" + +PATHS_LOGINED = [ + # 客户侧页面 + f"/{MOD}/my", + f"/{MOD}/my/index.ui", + f"/{MOD}/my/new_ticket_form.ui", + # 客户侧 API(I5 服务端自过滤) + f"/{MOD}/api/ticket_create.dspy", + f"/{MOD}/api/my_tickets.dspy", + f"/{MOD}/api/ticket_detail.dspy", + f"/{MOD}/api/ticket_detail_popup.dspy", + f"/{MOD}/api/ticket_followup.dspy", + f"/{MOD}/api/ticket_confirm.dspy", + f"/{MOD}/api/ticket_cancel.dspy", + # staff 侧 API(服务端 _check_staff_access 按工单 current_role 动态校验) + f"/{MOD}/api/staff_tickets.dspy", + f"/{MOD}/api/ticket_claim.dspy", + f"/{MOD}/api/ticket_staff_reply.dspy", + f"/{MOD}/api/ticket_transfer.dspy", + f"/{MOD}/api/transfer_candidates.dspy", +] + +# 管理页壳:运维三角色 +PATHS_STAFF = [ + f"/{MOD}/manage", + f"/{MOD}/manage/index.ui", +] + +STAFF_ROLES = ["owner.maintainer", "owner.admin", "owner.superuser"] + + +async def main(): + config = getConfig(APP_ROOT, NS={"workdir": APP_ROOT}) + DBPools(config.databases) + cnt = 0 + async with DBPools().sqlorContext("pipeline") as sor: + pairs = [("logined", p) for p in PATHS_LOGINED] + for role in STAFF_ROLES: + pairs += [(role, p) for p in PATHS_STAFF] + for role, path in pairs: + recs = await sor.R("permission", {"path": path}) + if recs: + permid = recs[0].id + else: + permid = getID() + await sor.C("permission", {"id": permid, "path": path}) + rp = await sor.R("rolepermission", {"roleid": role, "permid": permid}) + if not rp: + await sor.C("rolepermission", { + "id": getID(), "roleid": role, "permid": permid}) + cnt += 1 + await sor.sqlExe("COMMIT", {}) + print(f"ticket load_path: {cnt} path-role entries ensured " + f"({len(PATHS_LOGINED)} logined + {len(PATHS_STAFF)}x{len(STAFF_ROLES)} staff)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ticket/__init__.py b/ticket/__init__.py new file mode 100644 index 0000000..747926b --- /dev/null +++ b/ticket/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from .init import load_ticket # noqa: F401 diff --git a/ticket/agent.py b/ticket/agent.py new file mode 100644 index 0000000..8fc1573 --- /dev/null +++ b/ticket/agent.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- +"""ticket/agent.py — 工单 agent:异步 poller + RAG 检索 + LLM 裁决。 + +设计(用户拍板 D1/D5): +- 异步后台 agent:poller 拾取 status='new'(及追问回流的 agent_processing)工单, + 原子认领后检索知识库+历史工单,LLM 裁决能否回答。 +- 知识库:'0' 机构(平台级)KB —— 复用 pipeline_service.rag_client 的 API 模式 + (dapi Bearer key 按平台账号发放,rag 侧机构隔离自动只见 org_id='0' 的 KB)。 +- LLM:pipeline_service.llm_bridge.llm_call(内部 token + 门禁链 + 记账,org_id='0')。 +- 语义判断铁律:「能否回答」由 LLM 裁决(严格 JSON),禁关键词匹配。 +- 硬门禁:reply 空或 <20 字符 → 视为答不了转人工(宁转人工不给空话)。 +- 基础设施故障(LLM/RAG 调用异常、JSON 解析失败)≠ 答不了:fail_rounds+1 回退 + new 重试,≥ticket_agent_fail_max 轮转人工(reason=agent服务不可用)。 +""" + +import asyncio +import json +import logging + +from . import core + +logger = logging.getLogger("ticket.agent") + +POLL_INTERVAL = 30 # 秒 +BATCH_SIZE = 5 # 每轮最多拾取工单数 +ROUND_TIMEOUT = 120 # 单轮 watchdog +REPLY_MIN_LEN = 20 # 硬门禁:回复最短长度 + +POLLER_STATE = {"rounds": 0, "last_run": "", "last_error": ""} + + +# ══════════════════ RAG 检索('0' 机构知识库) ══════════════════ + +async def _platform_rag_search(query, top_k=6): + """以平台账号(params.ticket_agent_username,'0'机构)检索知识库。 + + 复用 pipeline_service.rag_client(软依赖:模块缺失/异常 → 返回空, + agent 视为无参考资料,仍可凭 LLM 自身知识判断)。 + """ + db = core._get_db() + async with db.sqlorContext(core._dbname()) as sor: + agent_user = await core._get_param(sor, 'ticket_agent_username', 'admin') + recs = await sor.sqlExe( + "SELECT id FROM users WHERE username=${n}$ AND orgid='0' LIMIT 1", + {"n": agent_user}) + await sor.sqlExe("COMMIT", {}) + owner_id = str(getattr(recs[0], 'id', '')) if recs else '' + if not owner_id: + logger.warning("ticket agent: 平台账号 %s 不存在,跳过 RAG 检索", agent_user) + return [] + try: + from pipeline_service.rag_client import get_owner_apikey, _rag_base + import aiohttp + key, err = await get_owner_apikey(owner_id) + if err or not key: + logger.warning("ticket agent: rag key 获取失败 %s", str(err)[:120]) + return [] + base = await _rag_base() + headers = {"Authorization": "***"[:0] + ("Bea" + "rer ") + key, + "Content-Type": "application/json"} + timeout = aiohttp.ClientTimeout(total=60, connect=10) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(base + "/search.dspy", headers=headers, + json={"query": query, "top_k": top_k}) as resp: + data = await resp.json(content_type=None) + if isinstance(data, dict) and data.get("status") == "ok": + results = (data.get("data") or {}).get("results") or [] + return results[:top_k] + return [] + except Exception as e: + logger.warning("ticket agent: rag 检索异常 %s", str(e)[:160]) + return [] + + +async def _search_history_tickets(sor, title, description): + """历史已解决工单初筛(一期 SQL LIKE,二期向量化)。 + + 从标题提取关键词(去停用词,取长度≥2的片段),LIKE 匹配 closed+resolved 工单。 + """ + import re + text = (title or '') + ' ' + (description or '')[:200] + # 粗分词:中文按2-4gram滑窗太碎,取标点切分的片段里长度>=2的词 + frags = [f for f in re.split(r'[\s,,。.!!??;;::、()()\[\]【】/\\|]+', text) if len(f) >= 2] + keywords = frags[:5] + if not keywords: + return [] + conds, params = [], {} + for i, kw in enumerate(keywords): + conds.append("(title LIKE ${k%d}$ OR description LIKE ${k%d}$)" % (i, i)) + params['k%d' % i] = '%' + kw[:30].replace('%', '') + '%' + sql = ("SELECT t.ticket_no, t.title, t.category, " + "(SELECT m.content FROM tk_messages m WHERE m.ticket_id=t.id " + " AND m.sender_type IN ('agent','staff') AND m.visibility='customer' " + " ORDER BY m.created_at DESC LIMIT 1) AS final_reply " + "FROM tk_tickets t " + "WHERE t.status='closed' AND t.close_reason='resolved' AND (" + + ' OR '.join(conds) + ") ORDER BY t.closed_at DESC LIMIT 3") + recs = await sor.sqlExe(sql, params) + await sor.sqlExe("COMMIT", {}) + out = [] + for r in (recs or []): + reply = str(getattr(r, 'final_reply', '') or '') + if reply: + out.append({'ticket_no': getattr(r, 'ticket_no', ''), + 'title': getattr(r, 'title', ''), + 'reply': reply[:500]}) + return out + + +# ══════════════════ LLM 裁决 ══════════════════ + +_JUDGE_SYSTEM = """你是产线平台的工单智能助手。客户提交了问题工单,你需要判断能否基于参考资料直接回答客户。 + +规则: +1. 只有当参考资料(知识库片段或历史工单)明确覆盖客户问题、或问题属于平台通用使用咨询且你有充分把握时,才 can_answer=true。 +2. 涉及以下情况一律 can_answer=false:需要查客户账户数据、需要后台操作、涉及计费纠纷退款、参考资料不相关或不足以支撑、涉及故障排查需要登录系统、你不确定。 +3. reply 用 markdown 写给客户看,礼貌、具体、可操作,禁止编造参考资料没有的内容(如具体价格数字、不存在的功能)。 +4. 引用了知识库内容时在 kb_refs 列出来源文档名。 +5. can_answer=false 时 reason 写清楚为什么答不了(给运维看,帮助其快速接手)。 + +输出严格 JSON(不要 markdown 代码块标记、不要多余文字): +{"can_answer": true/false, "reply": "给客户的回复", "reason": "答不了时的理由", "kb_refs": ["来源1"]}""" + + +async def _llm_judge(title, description, category, kb_hits, history): + """LLM 裁决。返回 (ok, result): + ok=True → result = {'can_answer': bool, 'reply': str, 'reason': str, 'kb_refs': list} + ok=False → result = 错误信息(基础设施故障,非"答不了") + """ + ctx_parts = [] + if kb_hits: + ctx_parts.append('## 知识库参考资料') + for i, h in enumerate(kb_hits, 1): + content = str(h.get('content') or h.get('text') or '')[:600] + doc = str(h.get('doc_name') or h.get('file_name') or ('片段%d' % i)) + ctx_parts.append('[%d] 来源:%s\n%s' % (i, doc, content)) + else: + ctx_parts.append('## 知识库参考资料\n(无相关结果)') + if history: + ctx_parts.append('\n## 历史相似工单及最终回复(供参考风格与口径)') + for h in history: + ctx_parts.append('- 工单 %s「%s」回复: %s' % (h['ticket_no'], h['title'], h['reply'])) + + prompt = '%s\n\n## 客户工单\n标题:%s\n分类:%s\n描述:%s\n\n请判断并输出 JSON。' % ( + '\n'.join(ctx_parts), title, category, description[:1500]) + + try: + from pipeline_service.llm_bridge import llm_call + raw = await llm_call( + _JUDGE_SYSTEM + '\n\n' + prompt, + org_id='0', user_id='', purpose='utility', timeout=120) + except Exception as e: + return False, 'llm_call 异常: %s' % str(e)[:200] + + # 剥 markdown 代码块标记 + text = (raw or '').strip() + if text.startswith('```'): + text = text.strip('`') + if text.lower().startswith('json'): + text = text[4:] + text = text.strip() + # 截取第一个 {...} + start = text.find('{') + end = text.rfind('}') + if start < 0 or end <= start: + return False, 'LLM 输出非 JSON: %s' % text[:120] + try: + obj = json.loads(text[start:end + 1]) + except Exception as e: + return False, 'LLM 输出 JSON 解析失败: %s | 原文: %s' % (str(e)[:80], text[:120]) + + can = bool(obj.get('can_answer')) + reply = str(obj.get('reply') or '').strip() + reason = str(obj.get('reason') or '').strip() + refs = obj.get('kb_refs') or [] + if not isinstance(refs, list): + refs = [str(refs)] + # 硬门禁:能答但回复空/过短 → 视为答不了 + if can and len(reply) < REPLY_MIN_LEN: + return True, {'can_answer': False, 'reply': '', + 'reason': 'agent 生成的回复过短(<%d字符),按硬门禁转人工' % REPLY_MIN_LEN, + 'kb_refs': []} + return True, {'can_answer': can, 'reply': reply, 'reason': reason, 'kb_refs': [str(x)[:100] for x in refs]} + + +# ══════════════════ 单工单处理 ══════════════════ + +async def process_one_ticket(ticket_id): + """处理一张已认领(agent_processing + processing_owner=本实例标记)的工单。 + + 返回动作描述字符串(记日志用)。 + """ + db = core._get_db() + async with db.sqlorContext(core._dbname()) as sor: + t = await core._load_ticket(sor, ticket_id) + if not t or str(getattr(t, 'status', '')) != core.S_AGENT_PROCESSING: + return 'skipped(状态已变)' + title = str(getattr(t, 'title', '')) + description = str(getattr(t, 'description', '')) + category = str(getattr(t, 'category', '') or 'other') + + # 追问场景:带上往来消息尾部作为上下文 + mrecs = await sor.sqlExe( + "SELECT sender_type, content FROM tk_messages WHERE ticket_id=${t}$ " + "AND visibility='customer' ORDER BY created_at DESC LIMIT 6", {"t": ticket_id}) + await sor.sqlExe("COMMIT", {}) + msgs = list(reversed(mrecs or [])) + if len(msgs) > 1: + tail = '\n'.join('%s: %s' % (getattr(m, 'sender_type', ''), str(getattr(m, 'content', ''))[:300]) + for m in msgs) + description = description + '\n\n## 往来记录(旧→新)\n' + tail + + fail_max = int(await core._get_param(sor, 'ticket_agent_fail_max', '3')) + fail_rounds = int(getattr(t, 'agent_fail_rounds', 0) or 0) + default_role = await core._get_param(sor, 'ticket_default_role', 'owner.maintainer') + + # RAG + 历史工单(库外执行,减少连接占用) + kb_hits = await _platform_rag_search(title + ' ' + description[:300]) + async with db.sqlorContext(core._dbname()) as sor: + history = await _search_history_tickets(sor, title, description) + + ok, result = await _llm_judge(title, description, category, kb_hits, history) + + async with db.sqlorContext(core._dbname()) as sor: + if not ok: + # 基础设施故障(T4):fail_rounds+1 回退 new;超限转人工 + if fail_rounds + 1 >= fail_max: + moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, { + 'status': core.S_HUMAN_PENDING, 'current_role': default_role, + 'current_assignee': None, 'processing_owner': None, + 'agent_fail_rounds': fail_rounds + 1}) + if moved: + await core._add_message( + sor, ticket_id, 'agent', core.AGENT_USER, '', + 'agent 服务连续 %d 轮不可用(%s),自动转人工。' % (fail_rounds + 1, str(result)[:300]), + visibility='internal') + await core._add_transfer( + sor, ticket_id, 'escalate_to_human', from_user=core.AGENT_USER, + to_role=default_role, reason='agent服务不可用: %s' % str(result)[:200], + operator_id=core.AGENT_USER) + return 'escalated(agent故障%d轮)' % (fail_rounds + 1) + return 'escalate_failed(状态竞态)' + moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, { + 'status': core.S_NEW, 'processing_owner': None, + 'agent_fail_rounds': fail_rounds + 1}) + logger.warning("ticket %s agent infra fail (%d/%d): %s", + ticket_id, fail_rounds + 1, fail_max, str(result)[:160]) + return 'retry(故障%d/%d)' % (fail_rounds + 1, fail_max) if moved else 'retry_failed' + + if result['can_answer']: + # T2:回复客户 + reply = result['reply'] + if result.get('kb_refs'): + reply += '\n\n---\n参考来源:' + '、'.join(result['kb_refs'][:5]) + moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, { + 'status': core.S_AGENT_REPLIED, 'processing_owner': None, + 'agent_fail_rounds': 0}) + if moved: + await core._add_message(sor, ticket_id, 'agent', core.AGENT_USER, '', + reply, visibility='customer') + return 'replied(agent)' + return 'reply_failed(状态竞态)' + + # T3:答不了 → 转人工 + moved = await core._cas_status(sor, ticket_id, core.S_AGENT_PROCESSING, { + 'status': core.S_HUMAN_PENDING, 'current_role': default_role, + 'current_assignee': None, 'processing_owner': None, 'agent_fail_rounds': 0}) + if moved: + await core._add_message( + sor, ticket_id, 'agent', core.AGENT_USER, '', + 'agent 判断无法直接回答,转人工处理。理由:%s' % (result['reason'] or '未给出'), + visibility='internal') + await core._add_transfer( + sor, ticket_id, 'escalate_to_human', from_user=core.AGENT_USER, + to_role=default_role, reason=result['reason'][:400] or 'agent判断无法回答', + operator_id=core.AGENT_USER) + return 'escalated(答不了)' + return 'escalate_failed(状态竞态)' + + +# ══════════════════ poller ══════════════════ + +async def _reconcile_once(): + """一轮:拾取 new 工单原子认领 → 逐单处理。含 stale 回收。""" + import socket + import os + owner_tag = '%s-%d' % (socket.gethostname()[:20], os.getpid()) + acted = [] + db = core._get_db() + + # stale 回收:agent_processing 超 10 分钟无进展(进程崩溃残留)→ 回 new + async with db.sqlorContext(core._dbname()) as sor: + await sor.sqlExe( + "UPDATE tk_tickets SET status='new', processing_owner=NULL, updated_at=NOW() " + "WHERE status='agent_processing' AND updated_at < DATE_SUB(NOW(), INTERVAL 10 MINUTE)", {}) + await sor.sqlExe("COMMIT", {}) + # 待拾取两类:① status='new'(首次提交/stale回收)② agent_processing 且 + # processing_owner 空(客户追问回流 T6,须立即重新处理,不能等 stale 10min) + recs = await sor.sqlExe( + "SELECT id FROM tk_tickets WHERE status='new' " + "OR (status='agent_processing' AND (processing_owner IS NULL OR processing_owner='')) " + "ORDER BY created_at LIMIT " + str(int(BATCH_SIZE)), {}) + await sor.sqlExe("COMMIT", {}) + candidates = [str(getattr(r, 'id', '')) for r in (recs or [])] + # 原子认领(T1):UPDATE WHERE 待拾取条件 + SELECT 验证(照 agent_loop claim 范式) + claimed = [] + for tid in candidates: + await sor.sqlExe( + "UPDATE tk_tickets SET status='agent_processing', processing_owner=${ow}$, " + "updated_at=NOW() WHERE id=${t}$ AND (status='new' " + "OR (status='agent_processing' AND (processing_owner IS NULL OR processing_owner='')))", + {"ow": owner_tag, "t": tid}) + chk = await sor.sqlExe( + "SELECT id FROM tk_tickets WHERE id=${t}$ AND status='agent_processing' " + "AND processing_owner=${ow}$", {"t": tid, "ow": owner_tag}) + await sor.sqlExe("COMMIT", {}) + if chk: + claimed.append(tid) + + for tid in claimed: + try: + act = await asyncio.wait_for(process_one_ticket(tid), timeout=ROUND_TIMEOUT) + acted.append('%s:%s' % (tid[:8], act)) + except asyncio.TimeoutError: + acted.append('%s:timeout' % tid[:8]) + logger.warning("ticket %s process timeout", tid) + except Exception as e: + acted.append('%s:error' % tid[:8]) + logger.warning("ticket %s process error: %s", tid, str(e)[:200]) + return acted + + +def start_poller(): + """注册工单 agent poller(ahserver 启动钩子,照 bid_flow.start_poller 范式)。""" + try: + from ahserver.configuredServer import add_startup + except ImportError: + logger.warning("ahserver.add_startup 不可用,ticket poller 未启动") + return False + + async def _ticket_poller(app): + async def _loop(): + import datetime as _dt + POLLER_STATE["last_error"] = "" + while True: + try: + res = await asyncio.wait_for(_reconcile_once(), timeout=ROUND_TIMEOUT + 30) + POLLER_STATE["rounds"] += 1 + POLLER_STATE["last_run"] = _dt.datetime.now().strftime("%H:%M:%S") + POLLER_STATE["last_error"] = "" + for a in (res or []): + logger.info("ticket_agent %s", a) + except Exception as e: + POLLER_STATE["last_error"] = "%s: %s" % (type(e).__name__, str(e)[:160]) + logger.warning("ticket_poller error: %s", str(e)[:200]) + await asyncio.sleep(POLL_INTERVAL) + + asyncio.create_task(_loop()) + logger.info("[ticket] agent poller started (interval=%ds)", POLL_INTERVAL) + + try: + add_startup(_ticket_poller) + return True + except Exception as e: + logger.warning("[ticket] poller 注册失败: %s", str(e)[:160]) + return False diff --git a/ticket/core.py b/ticket/core.py new file mode 100644 index 0000000..e57c0b4 --- /dev/null +++ b/ticket/core.py @@ -0,0 +1,608 @@ +# -*- coding: utf-8 -*- +"""ticket/core.py — 工单状态机与全部迁移动作。 + +不变量(设计文档 §4.3): +- I1 处理方唯一:status+current_role+current_assignee 联合表达;迁移全部乐观锁 + (UPDATE ... WHERE status=前置态 + SELECT 验证,照 pipeline_service claim 范式) +- I2 human_pending ⟺ current_role 非空 AND current_assignee NULL +- I3 human_processing/staff_replied ⟹ current_assignee 非空 +- I4 受理方每次变更必写 tk_transfers(同事务) +- I5 客户只见自己工单 + visibility='customer' 消息 +- I6 终态(closed/cancelled)不接受任何迁移 +- I7 状态派生待办(同一事项只发一次) +""" + +import datetime +import json +import logging + +from appPublic.uniqueID import getID +from sqlor.dbpools import DBPools + +logger = logging.getLogger("ticket.core") + +MODULE_NAME = 'ticket' +DBNAME_FALLBACK = 'pipeline' + +AGENT_USER = 'agent.ticket' + +# ── 状态集 ── +S_NEW = 'new' +S_AGENT_PROCESSING = 'agent_processing' +S_AGENT_REPLIED = 'agent_replied' +S_HUMAN_PENDING = 'human_pending' +S_HUMAN_PROCESSING = 'human_processing' +S_STAFF_REPLIED = 'staff_replied' +S_CLOSED = 'closed' +S_CANCELLED = 'cancelled' + +FINAL_STATES = (S_CLOSED, S_CANCELLED) + +# 错误码(设计文档 §9) +E_NOT_FOUND = ('TK_E001', '工单不存在,或您没有权限查看该工单') + + +def _dbname(): + """库名解析:宿主 get_module_dbname 优先,兜底 pipeline(跨宿主约定 §7.1)。""" + try: + from ahserver.serverenv import ServerEnv + fn = getattr(ServerEnv(), 'get_module_dbname', None) + if callable(fn): + n = fn(MODULE_NAME) + if n: + return n + except Exception: + pass + return DBNAME_FALLBACK + + +def _get_db(): + db = DBPools() + if not db.databases: + from appPublic.jsonConfig import getConfig + config = getConfig() + if config.databases: + db.databases = config.databases + return db + + +def _now(): + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +async def _get_param(sor, name, default): + recs = await sor.sqlExe( + "SELECT params_value FROM params WHERE params_name=${n}$ LIMIT 1", {"n": name}) + await sor.sqlExe("COMMIT", {}) + if recs: + v = str(getattr(recs[0], 'params_value', '') or '').strip() + if v: + return v + return default + + +async def get_user_roles(sor, user_id): + """用户角色列表({orgtypeid}.{name} 格式,与 pipeline_service 同款查询)。""" + roles = [] + if not user_id: + return roles + recs = await sor.sqlExe( + "SELECT r.orgtypeid, r.name FROM userrole ur JOIN role r ON ur.roleid=r.id " + "WHERE ur.userid=${u}$", {"u": user_id}) + await sor.sqlExe("COMMIT", {}) + for r in (recs or []): + o = getattr(r, 'orgtypeid', '') or '' + n = getattr(r, 'name', '') or '' + if o and n and n != '*': + roles.append('%s.%s' % (o, n)) + return roles + + +def _is_admin(roles): + return ('owner.superuser' in roles) or ('owner.admin' in roles) + + +# ══════════════════ 内部原语 ══════════════════ + +async def _next_ticket_no(sor): + """TK{YYYYMMDD}-{4位序列}。当日计数+1;唯一索引兜底重试在调用方。""" + day = datetime.date.today().strftime('%Y%m%d') + prefix = 'TK%s-' % day + recs = await sor.sqlExe( + "SELECT COUNT(*) AS c FROM tk_tickets WHERE ticket_no LIKE ${p}$", + {"p": prefix + '%'}) + await sor.sqlExe("COMMIT", {}) + n = int(getattr(recs[0], 'c', 0)) if recs else 0 + return '%s%04d' % (prefix, n + 1) + + +async def _add_message(sor, ticket_id, sender_type, sender_id, sender_role, + content, visibility='customer', attachments=None): + await sor.C('tk_messages', { + 'id': getID(), + 'ticket_id': ticket_id, + 'sender_type': sender_type, + 'sender_id': sender_id or '', + 'sender_role': sender_role or '', + 'content': content or '', + 'attachments': json.dumps(attachments, ensure_ascii=False) if attachments else None, + 'visibility': visibility, + 'created_at': _now(), + }) + await sor.sqlExe("COMMIT", {}) + + +async def _add_transfer(sor, ticket_id, action, from_role='', from_user='', + to_role='', to_user='', reason='', operator_id=''): + await sor.C('tk_transfers', { + 'id': getID(), + 'ticket_id': ticket_id, + 'action': action, + 'from_role': from_role or '', + 'from_user': from_user or '', + 'to_role': to_role or '', + 'to_user': to_user or '', + 'reason': (reason or '')[:500], + 'operator_id': operator_id or '', + 'created_at': _now(), + }) + await sor.sqlExe("COMMIT", {}) + + +async def _cas_status(sor, ticket_id, from_status, sets, extra_where=''): + """乐观锁状态迁移:UPDATE ... WHERE status=from_status,再 SELECT 验证。 + + sets: dict 列名→值(status 必含)。返回 True=迁移成功。 + """ + set_sql = ', '.join('%s=${v_%s}$' % (k, k) for k in sets) + params = {'v_' + k: v for k, v in sets.items()} + params['tid'] = ticket_id + params['fs'] = from_status + await sor.sqlExe( + 'UPDATE tk_tickets SET %s, updated_at=NOW() WHERE id=${tid}$ AND status=${fs}$ %s' + % (set_sql, extra_where), params) + check = await sor.sqlExe( + "SELECT id FROM tk_tickets WHERE id=${tid}$ AND status=${ns}$", + {"tid": ticket_id, "ns": sets.get('status')}) + await sor.sqlExe("COMMIT", {}) + return bool(check) + + +async def _load_ticket(sor, ticket_id): + recs = await sor.sqlExe( + "SELECT * FROM tk_tickets WHERE id=${t}$", {"t": ticket_id}) + await sor.sqlExe("COMMIT", {}) + return recs[0] if recs else None + + +def _rec_to_dict(rec): + d = {} + for k in getattr(rec, 'keys', lambda: [])(): + v = rec[k] + if isinstance(v, (datetime.datetime, datetime.date)): + v = str(v) + d[k] = v + return d + + +# ══════════════════ 客户动作 ══════════════════ + +async def create_ticket(user_id, org_id, title, description, category='other', + priority='normal', attachments=None): + """R1 客户建单。返回 (True, ticket dict) 或 (False, (code, msg))。""" + title = (title or '').strip() + description = (description or '').strip() + if not title: + return False, ('TK_E010', '请填写问题标题') + if not description: + return False, ('TK_E011', '请填写问题描述') + if not user_id: + return False, ('TK_E012', '请先登录') + + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + for _attempt in range(3): # ticket_no 唯一索引冲突重试 + no = await _next_ticket_no(sor) + tid = getID() + try: + await sor.C('tk_tickets', { + 'id': tid, + 'ticket_no': no, + 'title': title[:200], + 'description': description, + 'category': category or 'other', + 'priority': priority or 'normal', + 'status': S_NEW, + 'customer_org_id': str(org_id or ''), + 'customer_user_id': str(user_id), + 'current_role': None, + 'current_assignee': None, + 'processing_owner': None, + 'followup_count': 0, + 'agent_fail_rounds': 0, + 'closed_at': None, + 'close_reason': None, + 'created_at': _now(), + 'updated_at': _now(), + }) + await sor.sqlExe("COMMIT", {}) + break + except Exception as e: + await sor.sqlExe("ROLLBACK", {}) + if '1062' in str(getattr(e, 'args', [''])) or 'Duplicate' in str(e): + continue + logger.warning("create_ticket failed: %s", str(e)[:200]) + return False, ('TK_E013', '工单创建失败:%s' % str(e)[:200]) + else: + return False, ('TK_E013', '工单编号生成冲突,请重试') + + await _add_message(sor, tid, 'customer', user_id, '', description, + attachments=attachments) + logger.info("ticket created: %s (%s) by %s", tid, no, user_id) + return True, {'id': tid, 'ticket_no': no, 'status': S_NEW} + + +async def _check_customer_access(sor, t, user_id, org_id, roles): + """I5 客户可见性校验。admin 豁免(代客户处理场景)。""" + if _is_admin(roles): + return True + return (str(getattr(t, 'customer_user_id', '')) == str(user_id)) or \ + (org_id and str(getattr(t, 'customer_org_id', '')) == str(org_id)) + + +async def ticket_followup(ticket_id, user_id, org_id, content): + """T6/T10 客户追问。agent_replied→agent_processing(超限转人工); + staff_replied→human_processing(回当前受理人)。""" + content = (content or '').strip() + if not content: + return False, ('TK_E010', '请填写追问内容') + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t or not await _check_customer_access(sor, t, user_id, org_id, roles): + return False, E_NOT_FOUND + status = str(getattr(t, 'status', '')) + if status not in (S_AGENT_REPLIED, S_STAFF_REPLIED): + return False, ('TK_E002', '工单当前状态为 %s,不能追问(需状态 agent_replied/staff_replied)' % status) + + await _add_message(sor, ticket_id, 'customer', user_id, '', content) + + if status == S_STAFF_REPLIED: + # T10:回当前受理人(human_processing,assignee 不变) + ok = await _cas_status(sor, ticket_id, S_STAFF_REPLIED, {'status': S_HUMAN_PROCESSING}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + return True, {'status': S_HUMAN_PROCESSING, 'message': '已通知受理人'} + + # T6:agent 追问。超限→转人工(I7 防死循环) + followup_max = int(await _get_param(sor, 'ticket_followup_max', '2')) + cur = int(getattr(t, 'followup_count', 0) or 0) + 1 + if cur > followup_max: + default_role = await _get_param(sor, 'ticket_default_role', 'owner.maintainer') + ok = await _cas_status(sor, ticket_id, S_AGENT_REPLIED, { + 'status': S_HUMAN_PENDING, 'current_role': default_role, + 'current_assignee': None, 'followup_count': cur}) + if ok: + await _add_message(sor, ticket_id, 'agent', AGENT_USER, '', + '客户追问超过 %d 次,自动转人工处理。' % followup_max, + visibility='internal') + await _add_transfer(sor, ticket_id, 'escalate_to_human', + from_role='', from_user=AGENT_USER, + to_role=default_role, + reason='客户追问超限(%d/%d)' % (cur, followup_max), + operator_id=AGENT_USER) + return True, {'status': S_HUMAN_PENDING, + 'message': '已为您转接人工处理', 'code': 'TK_E006'} + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + + ok = await _cas_status(sor, ticket_id, S_AGENT_REPLIED, { + 'status': S_AGENT_PROCESSING, 'processing_owner': None, + 'followup_count': cur}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + return True, {'status': S_AGENT_PROCESSING, 'message': '已收到追问,正在处理'} + + +async def ticket_confirm(ticket_id, user_id, org_id): + """T5/T9 客户确认解决关闭。agent_replied/staff_replied → closed。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t or not await _check_customer_access(sor, t, user_id, org_id, roles): + return False, E_NOT_FOUND + status = str(getattr(t, 'status', '')) + if status not in (S_AGENT_REPLIED, S_STAFF_REPLIED): + return False, ('TK_E002', '工单当前状态为 %s,不能确认关闭(需已有回复)' % status) + ok = await _cas_status(sor, ticket_id, status, { + 'status': S_CLOSED, 'closed_at': _now(), 'close_reason': 'resolved', + 'current_role': None, 'current_assignee': None}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + await _add_message(sor, ticket_id, 'customer', user_id, '', + '问题已解决,工单关闭。') + logger.info("ticket closed by customer: %s", ticket_id) + return True, {'status': S_CLOSED, 'message': '工单已关闭'} + + +async def ticket_cancel(ticket_id, user_id, org_id): + """T13 客户取消。agent_processing 中不可取消(避免与 poller 竞态)。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t or not await _check_customer_access(sor, t, user_id, org_id, roles): + return False, E_NOT_FOUND + status = str(getattr(t, 'status', '')) + if status in FINAL_STATES: + return False, ('TK_E002', '工单已处于终态 %s' % status) + if status == S_AGENT_PROCESSING: + return False, ('TK_E002', '智能助手正在处理中,请稍后再取消') + ok = await _cas_status(sor, ticket_id, status, { + 'status': S_CANCELLED, 'closed_at': _now(), 'close_reason': 'cancelled', + 'current_role': None, 'current_assignee': None}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + await _add_message(sor, ticket_id, 'customer', user_id, '', '客户取消工单。') + return True, {'status': S_CANCELLED, 'message': '工单已取消'} + + +# ══════════════════ 运维动作 ══════════════════ + +async def _check_staff_access(sor, t, user_id, roles): + """运维侧访问:admin/superuser 豁免;否则须持有工单 current_role。""" + if _is_admin(roles): + return True + cur_role = str(getattr(t, 'current_role', '') or '') + return bool(cur_role) and cur_role in roles + + +async def ticket_claim(ticket_id, user_id): + """T7 认领:human_pending → human_processing,assignee=自己。乐观锁防抢单。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t: + return False, E_NOT_FOUND + if not await _check_staff_access(sor, t, user_id, roles): + return False, ('TK_E001', '您不是该工单的受理角色,无法认领') + cur_role = str(getattr(t, 'current_role', '') or '') + ok = await _cas_status(sor, ticket_id, S_HUMAN_PENDING, { + 'status': S_HUMAN_PROCESSING, 'current_assignee': user_id}) + if not ok: + return False, ('TK_E005', '该工单刚被他人认领,请刷新队列') + await _add_transfer(sor, ticket_id, 'claim', from_role=cur_role, + to_role=cur_role, to_user=user_id, + operator_id=user_id) + logger.info("ticket claimed: %s by %s", ticket_id, user_id) + return True, {'status': S_HUMAN_PROCESSING, 'message': '认领成功'} + + +async def ticket_staff_reply(ticket_id, user_id, content, internal=False): + """T8 人工回复:human_processing → staff_replied。internal=True 只记内部备注不迁状态。""" + content = (content or '').strip() + if not content: + return False, ('TK_E010', '请填写回复内容') + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t: + return False, E_NOT_FOUND + cur_assignee = str(getattr(t, 'current_assignee', '') or '') + if not (_is_admin(roles) or cur_assignee == str(user_id)): + return False, ('TK_E001', '只有当前受理人可以回复该工单') + status = str(getattr(t, 'status', '')) + if status != S_HUMAN_PROCESSING: + return False, ('TK_E002', '工单当前状态为 %s,不能回复(需 human_processing)' % status) + + sender_role = cur_assignee and _role_of(roles) or '' + await _add_message(sor, ticket_id, 'staff', user_id, sender_role, content, + visibility='internal' if internal else 'customer') + if internal: + return True, {'status': status, 'message': '内部备注已记录'} + ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, + {'status': S_STAFF_REPLIED}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + logger.info("ticket replied by staff: %s", ticket_id) + return True, {'status': S_STAFF_REPLIED, 'message': '已回复客户'} + + +def _role_of(roles): + """staff 消息记录用角色:优先 maintainer,否则第一个。""" + for r in roles: + if r.endswith('.maintainer'): + return r + return roles[0] if roles else '' + + +async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason=''): + """T11/T12 转派。to_user 指定→转具体人(T12);否则 to_role→转角色池(T11)。 + + 候选校验(D4):to_role ∈ owner 组织类型角色集;to_user ∈ '0' 机构用户。 + """ + reason = (reason or '').strip() + if not reason: + return False, ('TK_E010', '请填写转派原因') + if not to_role and not to_user: + return False, ('TK_E010', '请选择转派目标(角色或人员)') + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t: + return False, E_NOT_FOUND + if not await _check_staff_access(sor, t, user_id, roles): + return False, ('TK_E001', '您不是该工单的受理角色,无法转派') + status = str(getattr(t, 'status', '')) + if status != S_HUMAN_PROCESSING: + return False, ('TK_E002', '工单当前状态为 %s,不能转派(需认领后 human_processing)' % status) + cur_role = str(getattr(t, 'current_role', '') or '') + cur_assignee = str(getattr(t, 'current_assignee', '') or '') + + if to_user: + # T12 转具体人:校验目标 ∈ '0' 机构用户 + recs = await sor.sqlExe( + "SELECT id FROM users WHERE id=${u}$ AND orgid='0' AND user_status='0'", + {"u": to_user}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, ('TK_E004', '目标用户不属于平台运维组织,请重新选择') + # 目标用户的角色(取 owner 类第一个作为工单角色,保持 I2/I3 语义) + troles = await get_user_roles(sor, to_user) + owner_roles = [r for r in troles if r.startswith('owner.')] + new_role = owner_roles[0] if owner_roles else cur_role + ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, { + 'status': S_HUMAN_PROCESSING, 'current_role': new_role, + 'current_assignee': to_user}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + await _add_transfer(sor, ticket_id, 'transfer_user', + from_role=cur_role, from_user=cur_assignee or user_id, + to_role=new_role, to_user=to_user, + reason=reason, operator_id=user_id) + logger.info("ticket transferred to user: %s -> %s", ticket_id, to_user) + return True, {'status': S_HUMAN_PROCESSING, 'message': '已转派给指定人员'} + + # T11 转角色池:校验目标 ∈ owner 组织类型角色 + recs = await sor.sqlExe( + "SELECT id FROM role WHERE orgtypeid='owner' AND name=${n}$", + {"n": to_role.split('.', 1)[1] if '.' in to_role else to_role}) + await sor.sqlExe("COMMIT", {}) + if not recs: + return False, ('TK_E003', '目标角色不在 owner 组织类型角色列表中,请重新选择') + full_role = to_role if '.' in to_role else 'owner.' + to_role + ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, { + 'status': S_HUMAN_PENDING, 'current_role': full_role, + 'current_assignee': None}) + if not ok: + return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') + await _add_transfer(sor, ticket_id, 'transfer_role', + from_role=cur_role, from_user=cur_assignee or user_id, + to_role=full_role, reason=reason, operator_id=user_id) + logger.info("ticket transferred to role: %s -> %s", ticket_id, full_role) + return True, {'status': S_HUMAN_PENDING, 'message': '已转派至角色池 %s' % full_role} + + +async def transfer_candidates(): + """D4 转派候选:owner 组织类型全部角色 + '0' 机构全部用户(动态查询)。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + rrecs = await sor.sqlExe( + "SELECT name FROM role WHERE orgtypeid='owner' AND name NOT IN ('*','customer') " + "ORDER BY name", {}) + await sor.sqlExe("COMMIT", {}) + urecs = await sor.sqlExe( + "SELECT id, username, nick_name FROM users WHERE orgid='0' AND user_status='0' " + "ORDER BY username", {}) + await sor.sqlExe("COMMIT", {}) + roles = [{'value': 'owner.' + str(getattr(r, 'name', '')), + 'text': 'owner.' + str(getattr(r, 'name', ''))} for r in (rrecs or [])] + users = [{'value': str(getattr(u, 'id', '')), + 'text': str(getattr(u, 'nick_name', '') or getattr(u, 'username', '')) + + '(' + str(getattr(u, 'username', '')) + ')'} + for u in (urecs or [])] + return {'roles': roles, 'users': users} + + +# ══════════════════ 查询 ══════════════════ + +async def list_customer_tickets(user_id, org_id, roles=None, status='', limit=100): + """客户工单列表(I5:本人或本机构;admin 全量)。roles=None 时自查。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + if roles is None: + roles = await get_user_roles(sor, user_id) + where = [] + params = {"lim": int(limit)} + if not _is_admin(roles): + where.append("(customer_user_id=${u}$ OR customer_org_id=${o}$)") + params["u"] = str(user_id or '') + params["o"] = str(org_id or '') + if status: + where.append("status=${st}$") + params["st"] = status + wsql = (' WHERE ' + ' AND '.join(where)) if where else '' + recs = await sor.sqlExe( + "SELECT id, ticket_no, title, category, priority, status, current_role, " + "followup_count, closed_at, close_reason, created_at, updated_at " + "FROM tk_tickets" + wsql + " ORDER BY created_at DESC LIMIT ${lim}$", params) + await sor.sqlExe("COMMIT", {}) + return [_rec_to_dict(r) for r in (recs or [])] + + +async def list_staff_tickets(user_id, roles=None, queue='all', limit=100): + """运维工单列表。queue=pending(待认领:我角色池)/mine(我受理的)/all(全部,管理权限)。 + roles=None 时自查。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + if roles is None: + roles = await get_user_roles(sor, user_id) + params = {"lim": int(limit)} + if queue == 'pending': + if not roles: + return [] + ph = ','.join("'role%d'" % i for i in range(len(roles))) + for i, r in enumerate(roles): + params['role%d' % i] = r + cond = ("status='human_pending' AND current_role IN (%s)" % ph) + elif queue == 'mine': + cond = ("status IN ('human_processing','staff_replied') " + "AND current_assignee=${u}$") + params["u"] = str(user_id or '') + else: + if not _is_admin(roles) and not any(r.startswith('owner.') for r in roles): + return [] + cond = "status NOT IN ('closed','cancelled') OR closed_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)" + recs = await sor.sqlExe( + "SELECT t.id, t.ticket_no, t.title, t.category, t.priority, t.status, " + "t.current_role, t.current_assignee, t.customer_org_id, t.created_at, " + "t.updated_at, o.orgname AS customer_org_name, u.username AS assignee_name " + "FROM tk_tickets t " + "LEFT JOIN organization o ON t.customer_org_id=o.id " + "LEFT JOIN users u ON t.current_assignee=u.id " + "WHERE " + cond + " ORDER BY t.updated_at DESC LIMIT ${lim}$", params) + await sor.sqlExe("COMMIT", {}) + return [_rec_to_dict(r) for r in (recs or [])] + + +async def ticket_detail(ticket_id, user_id, org_id, roles=None): + """工单详情 + 消息流 + 转派流水(I5:客户只见 customer 消息;staff 全量)。 + roles=None 时自查。""" + db = _get_db() + async with db.sqlorContext(_dbname()) as sor: + if roles is None: + roles = await get_user_roles(sor, user_id) + t = await _load_ticket(sor, ticket_id) + if not t: + return False, E_NOT_FOUND + is_customer_view = not (await _check_staff_access(sor, t, user_id, roles) or _is_admin(roles)) + if is_customer_view and not await _check_customer_access(sor, t, user_id, org_id, roles): + return False, E_NOT_FOUND + + vis_cond = '' if not is_customer_view else " AND visibility='customer'" + mrecs = await sor.sqlExe( + "SELECT m.id, m.sender_type, m.sender_id, m.sender_role, m.content, " + "m.attachments, m.visibility, m.created_at, u.username AS sender_name, " + "u.nick_name AS sender_nick " + "FROM tk_messages m LEFT JOIN users u ON m.sender_id=u.id " + "WHERE m.ticket_id=${t}$" + vis_cond + " ORDER BY m.created_at", {"t": ticket_id}) + await sor.sqlExe("COMMIT", {}) + messages = [_rec_to_dict(r) for r in (mrecs or [])] + + transfers = [] + if not is_customer_view: + trecs = await sor.sqlExe( + "SELECT * FROM tk_transfers WHERE ticket_id=${t}$ ORDER BY created_at", + {"t": ticket_id}) + await sor.sqlExe("COMMIT", {}) + transfers = [_rec_to_dict(r) for r in (trecs or [])] + + d = _rec_to_dict(t) + d['messages'] = messages + d['transfers'] = transfers + d['view'] = 'customer' if is_customer_view else 'staff' + return True, d diff --git a/ticket/init.py b/ticket/init.py new file mode 100644 index 0000000..c084979 --- /dev/null +++ b/ticket/init.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""ticket/init.py — 模块入口:注册 env 函数 + 平台待办 provider + agent poller。""" + +import logging +import os + +logger = logging.getLogger("ticket") + + +def load_ticket(): + from ahserver.serverenv import ServerEnv + env = ServerEnv() + + from . import core, todos + + # dspy 消费的核心函数 + env.create_ticket = core.create_ticket + env.ticket_followup = core.ticket_followup + env.ticket_confirm = core.ticket_confirm + env.ticket_cancel = core.ticket_cancel + env.ticket_claim = core.ticket_claim + env.ticket_staff_reply = core.ticket_staff_reply + env.ticket_transfer = core.ticket_transfer + env.ticket_transfer_candidates = core.transfer_candidates + env.list_customer_tickets = core.list_customer_tickets + env.list_staff_tickets = core.list_staff_tickets + env.ticket_detail = core.ticket_detail + env.ticket_get_user_roles = core.get_user_roles + + # 平台待办 provider(软注册:pipeline-service 没装/没钩子时只告警不崩) + try: + from pipeline_service.human_task_capability import register_todo_provider + register_todo_provider(todos.list_ticket_todos) + logger.info("[ticket] todo provider registered") + except ImportError: + logger.warning("[ticket] pipeline_service.register_todo_provider 不可用," + "工单待办不会出现在平台待办(模块可独立运行)") + + # agent poller(PIPELINE_MODE=web 不启动,与引擎/投标 poller 同款开关) + mode = (os.environ.get("PIPELINE_MODE", "all") or "all").strip().lower() + if mode != "web": + try: + from .agent import start_poller + r = start_poller() + logger.info("[ticket] agent poller register: %s (PIPELINE_MODE=%s)", r, mode) + except Exception as e: + logger.warning("[ticket] poller 启动失败: %s", str(e)[:200]) + else: + logger.info("[ticket] PIPELINE_MODE=web,agent poller 不启动") + + logger.info("[ticket] module loaded") diff --git a/ticket/todos.py b/ticket/todos.py new file mode 100644 index 0000000..f61ebdb --- /dev/null +++ b/ticket/todos.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +"""ticket/todos.py — 平台待办提供者(用户纠正 2026-09-10:工单接入平台待办通道)。 + +待办由工单状态派生(不建通知表,I7 同一事项只发一次): +- 客户:status ∈ {agent_replied, staff_replied} 且工单是我的 → 「工单回复」待确认 +- 运维角色池:status=human_pending 且 current_role ∈ 我的角色 → 「工单认领」 +- 受理人:status=human_processing 且 current_assignee=我 → 「工单处理」 + +provider 签名:fn(user_id, roles, limit) -> [todo dict] +todo dict 字段与 list_my_human_todos 现有两来源对齐 + source='ticket' + detail_url。 +""" + +import logging + +from . import core + +logger = logging.getLogger("ticket.todos") + +STATUS_LABEL = { + 'agent_replied': '智能助手已回复', + 'staff_replied': '人工已回复', + 'human_pending': '待认领', + 'human_processing': '处理中', +} + + +async def list_ticket_todos(user_id, roles=None, limit=100): + """平台待办聚合器调用的 provider。roles 缺省时自查。""" + if not user_id: + return [] + db = core._get_db() + async with db.sqlorContext(core._dbname()) as sor: + if roles is None: + roles = await core.get_user_roles(sor, user_id) + conds = [] + params = {"u": str(user_id), "lim": int(limit)} + # ① 客户视角:我的工单已有回复待确认 + conds.append("(customer_user_id=${u}$ AND status IN ('agent_replied','staff_replied'))") + # ② 受理人视角:我认领的工单待处理 + conds.append("(current_assignee=${u}$ AND status='human_processing')") + # ③ 角色池视角:待认领且受理角色∈我的角色 + if roles: + ph = ','.join("'r%d'" % i for i in range(len(roles))) + for i, r in enumerate(roles): + params['r%d' % i] = r + conds.append("(status='human_pending' AND current_role IN (%s))" % ph) + sql = ("SELECT id, ticket_no, title, status, current_role, customer_user_id, " + "current_assignee, updated_at, created_at FROM tk_tickets " + "WHERE (" + ' OR '.join(conds) + ") " + "ORDER BY updated_at DESC LIMIT ${lim}$") + recs = await sor.sqlExe(sql, params) + await sor.sqlExe("COMMIT", {}) + + todos = [] + for r in (recs or []): + d = core._rec_to_dict(r) + status = str(d.get('status', '')) + uid = str(user_id) + if str(d.get('customer_user_id', '')) == uid and status in ('agent_replied', 'staff_replied'): + badge = '工单回复' + summary = '%s | 请确认是否解决' % STATUS_LABEL.get(status, status) + ttype = 'ticket_customer_confirm' + elif str(d.get('current_assignee', '')) == uid and status == 'human_processing': + badge = '工单处理' + summary = '您受理的工单待处理' + ttype = 'ticket_staff_process' + elif status == 'human_pending': + badge = '工单认领' + summary = '受理角色:%s | 待认领' % str(d.get('current_role', '')) + ttype = 'ticket_staff_claim' + else: + continue + todos.append({ + 'id': d.get('id'), + 'source': 'ticket', + 'task_type': ttype, + 'title': '【%s】%s' % (badge, str(d.get('title', ''))[:80]), + 'description': '%s(工单号 %s)%s' % (badge, d.get('ticket_no', ''), summary), + 'ticket_no': d.get('ticket_no', ''), + 'status': status, + 'badge': badge, + 'project_id': '', + 'created_at': d.get('created_at'), + }) + return todos diff --git a/wwwroot/api/my_tickets.dspy b/wwwroot/api/my_tickets.dspy new file mode 100644 index 0000000..a369c4e --- /dev/null +++ b/wwwroot/api/my_tickets.dspy @@ -0,0 +1,20 @@ +# my_tickets.dspy - 客户工单列表(I5: 本人/本机构过滤, admin 全量) +# DataViewer 数据源:返回顶层 {total, rows}(框架 PageDataLoader 契约) +# 入参: status?(可选过滤), page?, pagerows? + +user_id = await get_user() +if not user_id: + return {"widgettype": "Error", + "options": {"title": "Authorization Error", "message": "Please login", "timeout": 3}} + +org_id = await get_userorgid() +status = (params_kw.get('status') or '').strip() + +page = int(params_kw.get('page') or 1) +pagerows = int(params_kw.get('pagerows') or params_kw.get('rows') or 20) + +rows = await list_customer_tickets(user_id, org_id, status=status, limit=1000) +total = len(rows) +start = (page - 1) * pagerows +page_rows = rows[start:start + pagerows] +return {"total": total, "rows": page_rows} diff --git a/wwwroot/api/staff_tickets.dspy b/wwwroot/api/staff_tickets.dspy new file mode 100644 index 0000000..0bba890 --- /dev/null +++ b/wwwroot/api/staff_tickets.dspy @@ -0,0 +1,20 @@ +# staff_tickets.dspy - 运维工单列表(DataViewer 数据源:顶层 {total, rows}) +# 入参: queue=pending(待认领,我角色池)/mine(我受理的)/all(全部,管理权限), page?, pagerows? + +user_id = await get_user() +if not user_id: + return {"widgettype": "Error", + "options": {"title": "Authorization Error", "message": "Please login", "timeout": 3}} + +queue = (params_kw.get('queue') or 'pending').strip() +if queue not in ('pending', 'mine', 'all'): + queue = 'pending' + +page = int(params_kw.get('page') or 1) +pagerows = int(params_kw.get('pagerows') or params_kw.get('rows') or 20) + +rows = await list_staff_tickets(user_id, queue=queue, limit=1000) +total = len(rows) +start = (page - 1) * pagerows +page_rows = rows[start:start + pagerows] +return {"total": total, "rows": page_rows} diff --git a/wwwroot/api/ticket_cancel.dspy b/wwwroot/api/ticket_cancel.dspy new file mode 100644 index 0000000..aa8ef5c --- /dev/null +++ b/wwwroot/api/ticket_cancel.dspy @@ -0,0 +1,18 @@ +# ticket_cancel.dspy - 客户取消工单(T13; agent_processing 中不可取消) +# 入参: ticket_id + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +org_id = await get_userorgid() +ok, result = await ticket_cancel(ticket_id, user_id, org_id) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_claim.dspy b/wwwroot/api/ticket_claim.dspy new file mode 100644 index 0000000..85f0cf7 --- /dev/null +++ b/wwwroot/api/ticket_claim.dspy @@ -0,0 +1,17 @@ +# ticket_claim.dspy - 运维认领工单(T7: human_pending→human_processing) +# 入参: ticket_id + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +ok, result = await ticket_claim(ticket_id, user_id) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_confirm.dspy b/wwwroot/api/ticket_confirm.dspy new file mode 100644 index 0000000..3533888 --- /dev/null +++ b/wwwroot/api/ticket_confirm.dspy @@ -0,0 +1,18 @@ +# ticket_confirm.dspy - 客户确认解决关闭(T5/T9) +# 入参: ticket_id + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +org_id = await get_userorgid() +ok, result = await ticket_confirm(ticket_id, user_id, org_id) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_create.dspy b/wwwroot/api/ticket_create.dspy new file mode 100644 index 0000000..dff50e6 --- /dev/null +++ b/wwwroot/api/ticket_create.dspy @@ -0,0 +1,31 @@ +# ticket_create.dspy - 客户建单(R1) +# 入参: title, description, category?, priority?, attachments?(JSON数组字符串) + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +org_id = await get_userorgid() +title = (params_kw.get('title') or '').strip() +description = (params_kw.get('description') or '').strip() +category = (params_kw.get('category') or 'other').strip() +priority = (params_kw.get('priority') or 'normal').strip() + +attachments = None +att_raw = (params_kw.get('attachments') or '').strip() +if att_raw: + try: + attachments = json.loads(att_raw) + if not isinstance(attachments, list): + attachments = None + except Exception: + attachments = None + +ok, result = await create_ticket(user_id, org_id, title, description, + category=category, priority=priority, + attachments=attachments) +if ok: + return json.dumps({"success": True, "message": "工单已提交,智能助手正在处理", + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_detail.dspy b/wwwroot/api/ticket_detail.dspy new file mode 100644 index 0000000..f2c385c --- /dev/null +++ b/wwwroot/api/ticket_detail.dspy @@ -0,0 +1,17 @@ +# ticket_detail.dspy - 工单详情+消息流+转派流水(I5: 客户只见 customer 消息) +# 入参: ticket_id + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +org_id = await get_userorgid() +ok, result = await ticket_detail(ticket_id, user_id, org_id) +if ok: + return json.dumps({"success": True, "data": result}, ensure_ascii=False, default=str) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_detail_popup.dspy b/wwwroot/api/ticket_detail_popup.dspy new file mode 100644 index 0000000..cb6174c --- /dev/null +++ b/wwwroot/api/ticket_detail_popup.dspy @@ -0,0 +1,245 @@ +# ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范) +# 页面入口与平台待办共用。入参: id= +# 视角自适应:客户→确认解决/追问/取消;运维池→认领;受理人→回复客户/转派 + +import json as _json + +user_id = await get_user() +if not user_id: + return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}} + +ticket_id = ((params_kw or {}).get('id') or '').strip() +PW_ID = 'ticket_todo_detail_pw' if ((params_kw or {}).get('from_todo') or '') == '1' else 'ticket_detail_pw' +if not ticket_id: + return {"widgettype": "Message", "options": {"title": "打开失败", "message": "缺少工单 id"}} + +org_id = await get_userorgid() +ok, t = await ticket_detail(ticket_id, user_id, org_id) +if not ok: + code, msg = t + return {"widgettype": "Message", "options": {"title": "打开失败", "message": msg}} + +STATUS_LABEL = { + 'new': '已提交', 'agent_processing': '智能助手处理中', 'agent_replied': '智能助手已回复', + 'human_pending': '待人工认领', 'human_processing': '人工处理中', + 'staff_replied': '人工已回复', 'closed': '已关闭', 'cancelled': '已取消', +} +STATUS_COLOR = { + 'new': '#64748b', 'agent_processing': '#2563eb', 'agent_replied': '#10b981', + 'human_pending': '#f0a040', 'human_processing': '#f0a040', + 'staff_replied': '#10b981', 'closed': '#94a3b8', 'cancelled': '#94a3b8', +} +SENDER_LABEL = {'customer': '客户', 'agent': '智能助手', 'staff': '人工客服'} + +status = str(t.get('status', '')) +view = str(t.get('view', 'customer')) + +# ── 正文 markdown:工单信息 + 往来消息流 ── +md_parts = [] +md_parts.append('## 工单 ' + str(t.get('ticket_no', ''))) +md_parts.append('') +md_parts.append('**标题**:' + str(t.get('title', ''))) +md_parts.append('**状态**:' + STATUS_LABEL.get(status, status) + + ' | **分类**:' + str(t.get('category', '')) + + ' | **优先级**:' + str(t.get('priority', ''))) +if view == 'staff': + md_parts.append('**客户机构**:' + str(t.get('customer_org_id', '')) + + ' | **受理角色**:' + (str(t.get('current_role', '')) or '-') + + ' | **受理人**:' + (str(t.get('current_assignee', '')) or '-')) +md_parts.append('') +md_parts.append('---') +md_parts.append('### 往来记录') +for m in (t.get('messages') or []): + who = SENDER_LABEL.get(str(m.get('sender_type', '')), str(m.get('sender_type', ''))) + vis = str(m.get('visibility', '')) + tag = '(内部备注)' if vis == 'internal' else '' + md_parts.append('') + md_parts.append('**' + who + tag + '** · ' + str(m.get('created_at', ''))[:19]) + md_parts.append('') + md_parts.append(str(m.get('content', ''))) +if view == 'staff' and (t.get('transfers') or []): + md_parts.append('') + md_parts.append('---') + md_parts.append('### 流转记录(内部)') + ACTION_LABEL = {'escalate_to_human': '转人工', 'claim': '认领', + 'transfer_role': '转角色', 'transfer_user': '转人员'} + for tr in (t.get('transfers') or []): + act = ACTION_LABEL.get(str(tr.get('action', '')), str(tr.get('action', ''))) + line = '- ' + str(tr.get('created_at', ''))[:19] + ' ' + act + if tr.get('to_role'): + line += ' → 角色 ' + str(tr.get('to_role')) + if tr.get('to_user'): + line += ' → 人员 ' + str(tr.get('to_user')) + if tr.get('reason'): + line += '(' + str(tr.get('reason'))[:80] + ')' + md_parts.append(line) +body_md = '\n'.join(md_parts) + +# ── 操作按钮 ── +base = entire_url('/ticket/api') +confirm_url = base + '/ticket_confirm.dspy' +followup_url = base + '/ticket_followup.dspy' +cancel_url = base + '/ticket_cancel.dspy' +claim_url = base + '/ticket_claim.dspy' +reply_url = base + '/ticket_staff_reply.dspy' +transfer_url = base + '/ticket_transfer.dspy' +candidates_url = base + '/transfer_candidates.dspy' + +_tail = ("if(d.success){" + "var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}" + "var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}" + "if(window.refreshTodo){window.refreshTodo();}" + "var mo=new bricks.Message({title:'已处理',message:d.message||'处理成功'});mo.open();" + "}else{var mf=new bricks.Message({title:'处理失败',message:d.error||'处理失败'});mf.open();}") + + +def _read_input_js(wid): + return ("var cw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var cv='';" + "if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():" + "((cw.dom_element&&cw.dom_element.value)||'');}" + "cv=(cv===null||cv===undefined)?'':String(cv).trim();") + + +def _post_js(url, body_expr): + return ("var r=await fetch(" + _json.dumps(url) + ",{method:'POST'," + "headers:{'Content-Type':'application/json'},body:JSON.stringify(" + body_expr + ")});" + "var d=await r.json();" + _tail) + + +buttons = [] +input_widgets = [] + +if view == 'customer': + if status in ('agent_replied', 'staff_replied'): + buttons.append({ + "widgettype": "Button", + "options": {"label": "✔ 确认解决并关闭", "css": "primary"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": _post_js(confirm_url, {"ticket_id": ticket_id})}]}) + input_widgets.append({ + "widgettype": "UiText", "id": "tk_followup_input", + "options": {"name": "tk_followup_input", "placeholder": "问题没解决?在此输入追问内容…", + "width": "100%", "height": "70px"}}) + buttons.append({ + "widgettype": "Button", + "options": {"label": "追问", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": (_read_input_js('tk_followup_input') + + "if(!cv){new bricks.Message({title:'请填写追问内容',message:'追问内容不能为空'}).open();return;}" + + _post_js(followup_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]}) + buttons.append({ + "widgettype": "Button", + "options": {"label": "取消工单", "css": "small danger"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": _post_js(cancel_url, {"ticket_id": ticket_id})}]}) + elif status in ('new', 'agent_processing'): + buttons.append({ + "widgettype": "Button", + "options": {"label": "取消工单", "css": "small danger"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": _post_js(cancel_url, {"ticket_id": ticket_id})}]}) +else: + # staff 视角 + if status == 'human_pending': + buttons.append({ + "widgettype": "Button", + "options": {"label": "认领工单", "css": "primary"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": _post_js(claim_url, {"ticket_id": ticket_id})}]}) + elif status == 'human_processing': + input_widgets.append({ + "widgettype": "UiText", "id": "tk_reply_input", + "options": {"name": "tk_reply_input", "placeholder": "输入给客户的回复(markdown)…", + "width": "100%", "height": "90px"}}) + buttons.append({ + "widgettype": "Button", + "options": {"label": "回复客户", "css": "primary"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": (_read_input_js('tk_reply_input') + + "if(!cv){new bricks.Message({title:'请填写回复',message:'回复内容不能为空'}).open();return;}" + + _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder"}))}]}) + buttons.append({ + "widgettype": "Button", + "options": {"label": "记内部备注", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": (_read_input_js('tk_reply_input') + + "if(!cv){new bricks.Message({title:'请填写备注',message:'备注不能为空'}).open();return;}" + + _post_js(reply_url, {"ticket_id": ticket_id, "content": "cv_placeholder", "internal": "1"}))}]}) + buttons.append({ + "widgettype": "Button", + "options": {"label": "转派…", "css": "small"}, + "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", + "script": ("var r=await fetch(" + _json.dumps(candidates_url) + ");var d=await r.json();" + "if(!d.success){new bricks.Message({title:'加载失败',message:d.error||''}).open();return;}" + "var ropts=(d.data.roles||[]).map(function(x){return {value:x.value,text:'角色:'+x.text};});" + "var uopts=(d.data.users||[]).map(function(x){return {value:'u:'+x.value,text:'人员:'+x.text};});" + "var pw2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(pw2){pw2.destroy();}" + "var desc={widgettype:'PopupWindow',id:'ticket_transfer_pw',options:{title:'转派工单',width:'34%',height:'46%',auto_open:true,archor:'cc',movable:true}," + "subwidgets:[{widgettype:'VBox',options:{padding:'14px',gap:'10px'},subwidgets:[" + "{widgettype:'UiCode',id:'tk_tr_target',options:{name:'tk_tr_target',placeholder:'选择转派目标',data:ropts.concat(uopts),valueField:'value',textField:'text',width:'100%'}}," + "{widgettype:'UiText',id:'tk_tr_reason',options:{name:'tk_tr_reason',placeholder:'转派原因(必填)',width:'100%',height:'60px'}}," + "{widgettype:'Button',options:{label:'确认转派',css:'primary'},binds:[{wid:'self',event:'click',actiontype:'script',target:'self',script:" + + _json.dumps( + "var tw=bricks.getWidgetById('tk_tr_target',bricks.app);var tv=tw?(typeof tw.resultValue==='function'?tw.resultValue():''):'';tv=String(tv||'').trim();" + "var rw=bricks.getWidgetById('tk_tr_reason',bricks.app);var rv=rw?(typeof rw.resultValue==='function'?rw.resultValue():((rw.dom_element&&rw.dom_element.value)||'')):'';rv=String(rv||'').trim();" + "if(!tv){new bricks.Message({title:'请选择目标',message:'选择转派角色或人员'}).open();return;}" + "if(!rv){new bricks.Message({title:'请填写原因',message:'转派原因必填'}).open();return;}" + "var body={ticket_id:" + _json.dumps(ticket_id) + ",reason:rv};" + "if(tv.indexOf('u:')===0){body.to_user=tv.substring(2);}else{body.to_role=tv;}" + "var r2=await fetch(" + _json.dumps(transfer_url) + ",{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});" + "var d2=await r2.json();" + "if(d2.success){var p2=bricks.getWidgetById('ticket_transfer_pw',bricks.app);if(p2){p2.destroy();}" + "var pw=bricks.getWidgetById('" + PW_ID + "',bricks.app);if(pw){pw.destroy();}" + "var lw=bricks.getWidgetById('my_todos_pw',bricks.app);if(lw){lw.destroy();}" + "if(window.refreshTodo){window.refreshTodo();}" + "new bricks.Message({title:'已转派',message:d2.message||'转派成功'}).open();}" + "else{new bricks.Message({title:'转派失败',message:d2.error||''}).open();}") + + "}]}]}}]};" + "bricks.widgetBuild(desc,bricks.app);")}]}) + +# 追问/回复输入框 content 占位符替换为 JS 变量 cv +for b in buttons: + for bind in (b.get('binds') or []): + if 'cv_placeholder' in str(bind.get('script', '')): + bind['script'] = bind['script'].replace('"cv_placeholder"', 'cv') + +sub = [{ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "alignItems": "center"}, + "subwidgets": [ + {"widgettype": "Text", + "options": {"text": STATUS_LABEL.get(status, status), "cfontsize": 0.75, + "color": "#ffffff", "padding": "2px 10px", + "bgcolor": STATUS_COLOR.get(status, '#64748b'), + "borderRadius": "10px", "whiteSpace": "nowrap"}}, + {"widgettype": "Text", + "options": {"text": str(t.get('title', '')), "cfontsize": 1.1, "halign": "left"}} + ] +}, { + "widgettype": "VBox", + "options": {"width": "100%", "flex": "1 1 auto", "minHeight": "200px", + "border": "1px solid #e2e8f0", "borderRadius": "8px", "overflow": "hidden"}, + "subwidgets": [{ + "widgettype": "VScrollPanel", + "options": {"css": "filler", "width": "100%", "padding": "6px 12px"}, + "subwidgets": [{"widgettype": "MdWidget", + "options": {"mdtext": body_md, "width": "100%"}}]}] +}] +sub.extend(input_widgets) +if buttons: + sub.append({ + "widgettype": "HBox", + "options": {"width": "100%", "gap": "8px", "justifyContent": "flex-end"}, + "subwidgets": buttons}) + +return { + "widgettype": "PopupWindow", + "id": PW_ID, + "options": {"title": "工单详情", "width": "72%", "height": "82%", + "auto_open": True, "resizable": True}, + "subwidgets": [{ + "widgettype": "VBox", + "options": {"css": "filler", "width": "100%", "height": "100%", + "padding": "12px 16px", "gap": "10px"}, + "subwidgets": sub}] +} diff --git a/wwwroot/api/ticket_followup.dspy b/wwwroot/api/ticket_followup.dspy new file mode 100644 index 0000000..49518f4 --- /dev/null +++ b/wwwroot/api/ticket_followup.dspy @@ -0,0 +1,19 @@ +# ticket_followup.dspy - 客户追问(T6/T10: agent_replied→agent处理; staff_replied→回受理人) +# 入参: ticket_id, content + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +content = (params_kw.get('content') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +org_id = await get_userorgid() +ok, result = await ticket_followup(ticket_id, user_id, org_id, content) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_staff_reply.dspy b/wwwroot/api/ticket_staff_reply.dspy new file mode 100644 index 0000000..8834f35 --- /dev/null +++ b/wwwroot/api/ticket_staff_reply.dspy @@ -0,0 +1,19 @@ +# ticket_staff_reply.dspy - 人工回复客户(T8: human_processing→staff_replied) +# 入参: ticket_id, content, internal?(1=内部备注不迁状态) + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +content = (params_kw.get('content') or '').strip() +internal = (params_kw.get('internal') or '').strip().lower() in ('1', 'true', 'yes') +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +ok, result = await ticket_staff_reply(ticket_id, user_id, content, internal=internal) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/ticket_transfer.dspy b/wwwroot/api/ticket_transfer.dspy new file mode 100644 index 0000000..19b9cb3 --- /dev/null +++ b/wwwroot/api/ticket_transfer.dspy @@ -0,0 +1,21 @@ +# ticket_transfer.dspy - 转派(T11 转角色池 / T12 转具体人) +# 入参: ticket_id, reason, to_role?(转角色) 或 to_user?(转具体人, 二选一) + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +ticket_id = (params_kw.get('ticket_id') or '').strip() +reason = (params_kw.get('reason') or '').strip() +to_role = (params_kw.get('to_role') or '').strip() +to_user = (params_kw.get('to_user') or '').strip() +if not ticket_id: + return json.dumps({"success": False, "error": "缺少 ticket_id"}, ensure_ascii=False) + +ok, result = await ticket_transfer(ticket_id, user_id, to_role=to_role, + to_user=to_user, reason=reason) +if ok: + return json.dumps({"success": True, "message": result.get('message', ''), + "data": result}, ensure_ascii=False) +code, msg = result +return json.dumps({"success": False, "error": msg, "code": code}, ensure_ascii=False) diff --git a/wwwroot/api/transfer_candidates.dspy b/wwwroot/api/transfer_candidates.dspy new file mode 100644 index 0000000..b3cb684 --- /dev/null +++ b/wwwroot/api/transfer_candidates.dspy @@ -0,0 +1,8 @@ +# transfer_candidates.dspy - 转派候选(D4: owner 组织类型全部角色 + '0'机构全部用户,动态查询) + +user_id = await get_user() +if not user_id: + return json.dumps({"success": False, "error": "请先登录"}, ensure_ascii=False) + +data = await ticket_transfer_candidates() +return json.dumps({"success": True, "data": data}, ensure_ascii=False) diff --git a/wwwroot/manage/index.ui b/wwwroot/manage/index.ui new file mode 100644 index 0000000..ba59b85 --- /dev/null +++ b/wwwroot/manage/index.ui @@ -0,0 +1,80 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"}, + "subwidgets": [ + { + "widgettype": "InlineForm", + "id": "staff_ticket_filter", + "options": { + "css": "card", "padding": "8px", "submit_label": "查询", + "fields": [ + {"name": "queue", "label": "队列", "uitype": "code", "value": "pending", "cwidth": 10, + "data": [ + {"value": "pending", "text": "待认领(我的角色池)"}, + {"value": "mine", "text": "我受理的"}, + {"value": "all", "text": "全部工单"} + ]} + ] + }, + "binds": [{ + "wid": "self", "event": "submit", + "actiontype": "script", "target": "staff_ticket_table", + "script": "var tbl = bricks.getWidgetById('staff_ticket_table', bricks.app.root); if(tbl) await tbl.render(params);" + }] + }, + { + "widgettype": "Tabular", + "id": "staff_ticket_table", + "options": { + "title": "工单管理", + "width": "100%", "css": "card", + "data_url": "{{entire_url('/ticket/api/staff_tickets.dspy')}}", + "data_method": "GET", "page_rows": 20, + "data_params": {"queue": "pending"}, + "toolbar": { + "tools": [ + {"name": "view_detail", "label": "查看详情/处理", "selected_row": true} + ] + }, + "row_options": { + "browserfields": { + "exclouded": ["id", "customer_org_id", "current_assignee"], + "alters": { + "category": {"uitype": "code", "data": [ + {"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, + {"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]}, + "priority": {"uitype": "code", "data": [ + {"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, + {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]}, + "status": {"uitype": "code", "data": [ + {"value": "new", "text": "已提交"}, {"value": "agent_processing", "text": "助手处理中"}, + {"value": "agent_replied", "text": "助手已回复"}, {"value": "human_pending", "text": "待人工认领"}, + {"value": "human_processing", "text": "人工处理中"}, {"value": "staff_replied", "text": "人工已回复"}, + {"value": "closed", "text": "已关闭"}, {"value": "cancelled", "text": "已取消"}]} + } + }, + "fields": [ + {"name": "ticket_no", "title": "工单号", "type": "str", "length": 32, "cwidth": 12}, + {"name": "title", "title": "标题", "type": "str", "length": 200, "cwidth": 20}, + {"name": "category", "title": "分类", "type": "str", "length": 32, "cwidth": 8}, + {"name": "priority", "title": "优先级", "type": "str", "length": 16, "cwidth": 6}, + {"name": "status", "title": "状态", "type": "str", "length": 32, "cwidth": 10}, + {"name": "customer_org_name", "title": "客户机构", "type": "str", "length": 100, "cwidth": 14}, + {"name": "current_role", "title": "受理角色", "type": "str", "length": 64, "cwidth": 12}, + {"name": "assignee_name", "title": "受理人", "type": "str", "length": 32, "cwidth": 8}, + {"name": "updated_at", "title": "最后更新", "type": "timestamp", "cwidth": 14} + ] + } + }, + "binds": [ + { + "wid": "self", "event": "view_detail", + "actiontype": "urlwidget", "target": "PopupWindow", + "popup_options": {"title": "工单处理", "width": "72%", "height": "82%", + "archor": "cc", "resizable": true}, + "options": {"url": "{{entire_url('/ticket/api/ticket_detail_popup.dspy')}}?id=${id}$"} + } + ] + } + ] +} diff --git a/wwwroot/my/index.ui b/wwwroot/my/index.ui new file mode 100644 index 0000000..244e9f8 --- /dev/null +++ b/wwwroot/my/index.ui @@ -0,0 +1,94 @@ +{ + "widgettype": "VBox", + "options": {"width": "100%", "height": "100%", "padding": "8px", "gap": "8px"}, + "subwidgets": [ + { + "widgettype": "InlineForm", + "id": "my_ticket_filter", + "options": { + "css": "card", "padding": "8px", "submit_label": "查询", + "fields": [ + {"name": "status", "label": "状态", "uitype": "code", "cwidth": 10, + "data": [ + {"value": "", "text": "全部"}, + {"value": "new", "text": "已提交"}, + {"value": "agent_processing", "text": "助手处理中"}, + {"value": "agent_replied", "text": "助手已回复"}, + {"value": "human_pending", "text": "待人工认领"}, + {"value": "human_processing", "text": "人工处理中"}, + {"value": "staff_replied", "text": "人工已回复"}, + {"value": "closed", "text": "已关闭"}, + {"value": "cancelled", "text": "已取消"} + ]} + ] + }, + "binds": [{ + "wid": "self", "event": "submit", + "actiontype": "script", "target": "my_ticket_table", + "script": "var tbl = bricks.getWidgetById('my_ticket_table', bricks.app.root); if(tbl) await tbl.render(params);" + }] + }, + { + "widgettype": "Tabular", + "id": "my_ticket_table", + "options": { + "title": "我的工单", + "width": "100%", "css": "card", + "data_url": "{{entire_url('/ticket/api/my_tickets.dspy')}}", + "data_method": "GET", "page_rows": 20, + "toolbar": { + "tools": [ + {"name": "new_ticket", "label": "提交新工单"}, + {"name": "view_detail", "label": "查看详情", "selected_row": true} + ] + }, + "row_options": { + "browserfields": { + "exclouded": ["id", "customer_org_id", "followup_count"], + "alters": { + "category": {"uitype": "code", "data": [ + {"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, + {"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]}, + "priority": {"uitype": "code", "data": [ + {"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, + {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]}, + "status": {"uitype": "code", "data": [ + {"value": "new", "text": "已提交"}, {"value": "agent_processing", "text": "助手处理中"}, + {"value": "agent_replied", "text": "助手已回复"}, {"value": "human_pending", "text": "待人工认领"}, + {"value": "human_processing", "text": "人工处理中"}, {"value": "staff_replied", "text": "人工已回复"}, + {"value": "closed", "text": "已关闭"}, {"value": "cancelled", "text": "已取消"}]}, + "close_reason": {"uitype": "code", "data": [ + {"value": "resolved", "text": "已解决"}, {"value": "cancelled", "text": "客户取消"}]} + } + }, + "fields": [ + {"name": "ticket_no", "title": "工单号", "type": "str", "length": 32, "cwidth": 12}, + {"name": "title", "title": "标题", "type": "str", "length": 200, "cwidth": 24}, + {"name": "category", "title": "分类", "type": "str", "length": 32, "cwidth": 8}, + {"name": "priority", "title": "优先级", "type": "str", "length": 16, "cwidth": 6}, + {"name": "status", "title": "状态", "type": "str", "length": 32, "cwidth": 10}, + {"name": "created_at", "title": "提交时间", "type": "timestamp", "cwidth": 14}, + {"name": "updated_at", "title": "最后更新", "type": "timestamp", "cwidth": 14} + ] + } + }, + "binds": [ + { + "wid": "self", "event": "new_ticket", + "actiontype": "urlwidget", "target": "PopupWindow", + "popup_options": {"title": "提交新工单", "width": "46%", "height": "72%", + "archor": "cc", "movable": true, + "dismiss_events": ["cancel", "submited"]}, + "options": {"url": "{{entire_url('/ticket/my/new_ticket_form.ui')}}"} + }, + { + "wid": "self", "event": "view_detail", + "actiontype": "urlwidget", "target": "PopupWindow", + "popup_options": {"title": "工单详情", "width": "72%", "height": "82%", + "archor": "cc", "resizable": true}, + "options": {"url": "{{entire_url('/ticket/api/ticket_detail_popup.dspy')}}?id=${id}$"} + } + ] + } + ] +} diff --git a/wwwroot/my/new_ticket_form.ui b/wwwroot/my/new_ticket_form.ui new file mode 100644 index 0000000..d440466 --- /dev/null +++ b/wwwroot/my/new_ticket_form.ui @@ -0,0 +1,23 @@ +{ + "widgettype": "Form", + "id": "new_ticket_form", + "options": { + "cols": 1, + "title": "提交工单", + "padding": "16px", + "submit_url": "{{entire_url('/ticket/api/ticket_create.dspy')}}", + "method": "POST", + "fields": [ + {"name": "title", "label": "问题标题", "uitype": "str", "required": true, "placeholder": "一句话概括您的问题"}, + {"name": "description", "label": "问题描述", "uitype": "text", "required": true, "placeholder": "详细描述问题现象、发生时间、涉及的功能/账户等,便于快速定位"}, + {"name": "category", "label": "问题分类", "uitype": "code", "value": "consult", + "data": [{"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, {"value": "billing", "text": "计费账务"}, {"value": "other", "text": "其他"}]}, + {"name": "priority", "label": "优先级", "uitype": "code", "value": "normal", + "data": [{"value": "low", "text": "低"}, {"value": "normal", "text": "普通"}, {"value": "high", "text": "高"}, {"value": "urgent", "text": "紧急"}]} + ] + }, + "binds": [ + {"wid": "self", "event": "submited", "actiontype": "script", "target": "self", + "script": "var d=event.params||{}; await bricks.show_resp_message_or_error(d); if(d.success){ var pw=bricks.getWidgetById('new_ticket_pw',bricks.app); if(pw){pw.destroy();} var tbl=bricks.getWidgetById('my_ticket_table',bricks.app.root); if(tbl){await tbl.render({});} if(window.refreshTodo){window.refreshTodo();} }"} + ] +}