ticket/ticket/core.py
yumoqing 9c36ab38d2 fix: 工单管理查不到+待办清不掉 四个叠加缺陷根治(2026-09-10用户报障)
报障现象: owner端工单管理查不到任何工单, 但有个工单待办; 待办弹窗无处理按钮致待办数不变。

根因链(全部有DB/代码证据):
1. 弹窗缺客户动作按钮: 双身份用户(admin既是TK0004客户又是staff), staff视角在
   staff_replied态一个按钮都没有, 而此刻待办要求的动作是客户确认——待办永远清不掉。
   修: staff视角下当前用户=工单客户且状态在已回复态时追加客户动作按钮。
2. 管理页默认队列=待认领, 当时无待认领单+前端首次查询点击渲染时序中间态→看似查不到。
   修: 默认队列改全部工单。
3. T12转派校验不全: 只查目标用户属于0机构不查owner角色, 测试时把TK0003转给无角色的hyz1
   产生孤儿工单(角色池无人可认领+hyz1打开管理页被权限分支挡)。
   修: 目标用户必须持owner角色否则TK_E004拒绝; 候选端点只列持owner角色的用户;
   孤儿工单TK0003数据修复退回角色池。
4. staff_tickets的all分支权限过窄: 无owner角色的账号直接返回空, 受理人看不到自己手上的工单。
   修: 无owner角色账号至少能看到受理人是自己的工单。
2026-09-10 17:38:31 +08:00

622 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""ticket/core.py — 工单状态机与全部迁移动作。
不变量(设计文档 §4.3
- I1 处理方唯一status+assignee_role+assignee_id 联合表达;迁移全部乐观锁
UPDATE ... WHERE status=前置态 + SELECT 验证,照 pipeline_service claim 范式)
- I2 human_pending ⟺ assignee_role 非空 AND assignee_id NULL
- I3 human_processing/staff_replied ⟹ assignee_id 非空
- 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),
'assignee_role': None,
'assignee_id': 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_processingassignee 不变)
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': '已通知受理人'}
# T6agent 追问。超限→转人工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, 'assignee_role': default_role,
'assignee_id': 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',
'assignee_role': None, 'assignee_id': 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',
'assignee_role': None, 'assignee_id': 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 豁免;否则须持有工单 assignee_role。"""
if _is_admin(roles):
return True
cur_role = str(getattr(t, 'assignee_role', '') or '')
return bool(cur_role) and cur_role in roles
async def ticket_claim(ticket_id, user_id):
"""T7 认领human_pending → human_processingassignee=自己。乐观锁防抢单。"""
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, 'assignee_role', '') or '')
ok = await _cas_status(sor, ticket_id, S_HUMAN_PENDING, {
'status': S_HUMAN_PROCESSING, 'assignee_id': 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, 'assignee_id', '') 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
候选校验D4to_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, 'assignee_role', '') or '')
cur_assignee = str(getattr(t, 'assignee_id', '') 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.')]
# 2026-09-10 修复:目标用户必须持有 owner 角色。原逻辑无角色时静默回退
# cur_role产生孤儿工单角色池无人可认领目标不持该角色
# 目标自己打开管理页也被权限分支挡住——待办在却查无此单。
if not owner_roles:
return False, ('TK_E004', '目标用户未持有 owner 组织类型角色,无法受理工单,请改派角色或先为其分配角色')
new_role = owner_roles[0]
ok = await _cas_status(sor, ticket_id, S_HUMAN_PROCESSING, {
'status': S_HUMAN_PROCESSING, 'assignee_role': new_role,
'assignee_id': 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, 'assignee_role': full_role,
'assignee_id': 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 DISTINCT u.id, u.username, u.nick_name FROM users u "
"JOIN userrole ur ON ur.userid=u.id JOIN role r ON r.id=ur.roleid "
"WHERE u.orgid='0' AND u.user_status='0' AND r.orgtypeid='owner' "
"AND r.name NOT IN ('*','customer') ORDER BY u.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, assignee_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 assignee_role IN (%s)" % ph)
elif queue == 'mine':
cond = ("status IN ('human_processing','staff_replied') "
"AND assignee_id=${u}$")
params["u"] = str(user_id or '')
else:
# all: admin/owner 角色看全量;其他账号至少能看到自己受理的工单
# 2026-09-10 修复:受理人若无 owner 角色,原逻辑直接返回[]
# 受理人看不到自己手上的工单——待办点进来却查无此单)
if _is_admin(roles) or any(r.startswith('owner.') for r in roles):
cond = ("status NOT IN ('closed','cancelled') "
"OR closed_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)")
else:
cond = "assignee_id=${u}$"
params["u"] = str(user_id or '')
recs = await sor.sqlExe(
"SELECT t.id, t.ticket_no, t.title, t.category, t.priority, t.status, "
"t.assignee_role, t.assignee_id, 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.assignee_id=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