feat: 工单管理模块初版——客户提问→agent预处理→人工运维→转派,接入平台待办
- 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先例)
This commit is contained in:
parent
f1aec291e9
commit
54f2f2d597
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
47
README.md
47
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 后台处理)
|
||||
|
||||
83
init/data.json
Normal file
83
init/data.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
models/tk_messages.json
Normal file
29
models/tk_messages.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
43
models/tk_tickets.json
Normal file
43
models/tk_tickets.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
28
models/tk_transfers.json
Normal file
28
models/tk_transfers.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@ -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"]
|
||||
88
scripts/load_path.py
Normal file
88
scripts/load_path.py
Normal file
@ -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())
|
||||
2
ticket/__init__.py
Normal file
2
ticket/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .init import load_ticket # noqa: F401
|
||||
366
ticket/agent.py
Normal file
366
ticket/agent.py
Normal file
@ -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
|
||||
608
ticket/core.py
Normal file
608
ticket/core.py
Normal file
@ -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
|
||||
51
ticket/init.py
Normal file
51
ticket/init.py
Normal file
@ -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")
|
||||
85
ticket/todos.py
Normal file
85
ticket/todos.py
Normal file
@ -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
|
||||
20
wwwroot/api/my_tickets.dspy
Normal file
20
wwwroot/api/my_tickets.dspy
Normal file
@ -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}
|
||||
20
wwwroot/api/staff_tickets.dspy
Normal file
20
wwwroot/api/staff_tickets.dspy
Normal file
@ -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}
|
||||
18
wwwroot/api/ticket_cancel.dspy
Normal file
18
wwwroot/api/ticket_cancel.dspy
Normal file
@ -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)
|
||||
17
wwwroot/api/ticket_claim.dspy
Normal file
17
wwwroot/api/ticket_claim.dspy
Normal file
@ -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)
|
||||
18
wwwroot/api/ticket_confirm.dspy
Normal file
18
wwwroot/api/ticket_confirm.dspy
Normal file
@ -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)
|
||||
31
wwwroot/api/ticket_create.dspy
Normal file
31
wwwroot/api/ticket_create.dspy
Normal file
@ -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)
|
||||
17
wwwroot/api/ticket_detail.dspy
Normal file
17
wwwroot/api/ticket_detail.dspy
Normal file
@ -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)
|
||||
245
wwwroot/api/ticket_detail_popup.dspy
Normal file
245
wwwroot/api/ticket_detail_popup.dspy
Normal file
@ -0,0 +1,245 @@
|
||||
# ticket_detail_popup.dspy - 工单详情弹窗:正文与操作同屏(复核类弹窗规范)
|
||||
# 页面入口与平台待办共用。入参: id=<ticket_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}]
|
||||
}
|
||||
19
wwwroot/api/ticket_followup.dspy
Normal file
19
wwwroot/api/ticket_followup.dspy
Normal file
@ -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)
|
||||
19
wwwroot/api/ticket_staff_reply.dspy
Normal file
19
wwwroot/api/ticket_staff_reply.dspy
Normal file
@ -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)
|
||||
21
wwwroot/api/ticket_transfer.dspy
Normal file
21
wwwroot/api/ticket_transfer.dspy
Normal file
@ -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)
|
||||
8
wwwroot/api/transfer_candidates.dspy
Normal file
8
wwwroot/api/transfer_candidates.dspy
Normal file
@ -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)
|
||||
80
wwwroot/manage/index.ui
Normal file
80
wwwroot/manage/index.ui
Normal file
@ -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}$"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
94
wwwroot/my/index.ui
Normal file
94
wwwroot/my/index.ui
Normal file
@ -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}$"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
23
wwwroot/my/new_ticket_form.ui
Normal file
23
wwwroot/my/new_ticket_form.ui
Normal file
@ -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();} }"}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user