diff --git a/models/tk_tickets.json b/models/tk_tickets.json index 700826b..e5a6013 100644 --- a/models/tk_tickets.json +++ b/models/tk_tickets.json @@ -17,8 +17,8 @@ {"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": "assignee_role", "title": "当前受理角色", "type": "str", "length": 64}, + {"name": "assignee_id", "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"}, @@ -31,8 +31,8 @@ {"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"]} + {"name": "idx_tk_role_status", "idxtype": "index", "idxfields": ["assignee_role", "status"]}, + {"name": "idx_tk_assignee", "idxtype": "index", "idxfields": ["assignee_id", "status"]} ], "codes": [ {"field": "category", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='tk_category'"}, diff --git a/scripts/load_path.py b/scripts/load_path.py index 60076a4..29fee8b 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -9,7 +9,7 @@ ⚠ staff 动作端点(claim/staff_reply/transfer/transfer_candidates/staff_tickets) 只能授 logined 而不能授固定角色——D4 决策:转派目标是 owner 组织类型下的 **任意动态角色**(role 表运行时可增删),RBAC 路径权限无法枚举动态角色, - 业务权限由服务端 _check_staff_access 校验(操作者须持有工单 current_role, + 业务权限由服务端 _check_staff_access 校验(操作者须持有工单 assignee_role, admin/superuser 豁免)。客户侧数据由 I5 服务端自过滤(customer_user_id/org_id)。 - owner.maintainer / owner.admin / owner.superuser:管理页壳 /ticket/manage/**。 菜单「工单管理」入口也按角色 Jinja 分支渲染,客户直接访问 URL 403。 @@ -42,7 +42,7 @@ PATHS_LOGINED = [ 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 动态校验) + # staff 侧 API(服务端 _check_staff_access 按工单 assignee_role 动态校验) f"/{MOD}/api/staff_tickets.dspy", f"/{MOD}/api/ticket_claim.dspy", f"/{MOD}/api/ticket_staff_reply.dspy", diff --git a/ticket/agent.py b/ticket/agent.py index 8fc1573..f1c715a 100644 --- a/ticket/agent.py +++ b/ticket/agent.py @@ -226,8 +226,8 @@ async def process_one_ticket(ticket_id): # 基础设施故障(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, + 'status': core.S_HUMAN_PENDING, 'assignee_role': default_role, + 'assignee_id': None, 'processing_owner': None, 'agent_fail_rounds': fail_rounds + 1}) if moved: await core._add_message( @@ -263,8 +263,8 @@ async def process_one_ticket(ticket_id): # 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}) + 'status': core.S_HUMAN_PENDING, 'assignee_role': default_role, + 'assignee_id': None, 'processing_owner': None, 'agent_fail_rounds': 0}) if moved: await core._add_message( sor, ticket_id, 'agent', core.AGENT_USER, '', diff --git a/ticket/core.py b/ticket/core.py index e57c0b4..9f6d04f 100644 --- a/ticket/core.py +++ b/ticket/core.py @@ -2,10 +2,10 @@ """ticket/core.py — 工单状态机与全部迁移动作。 不变量(设计文档 §4.3): -- I1 处理方唯一:status+current_role+current_assignee 联合表达;迁移全部乐观锁 +- I1 处理方唯一:status+assignee_role+assignee_id 联合表达;迁移全部乐观锁 (UPDATE ... WHERE status=前置态 + SELECT 验证,照 pipeline_service claim 范式) -- I2 human_pending ⟺ current_role 非空 AND current_assignee NULL -- I3 human_processing/staff_replied ⟹ current_assignee 非空 +- 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)不接受任何迁移 @@ -215,8 +215,8 @@ async def create_ticket(user_id, org_id, title, description, category='other', 'status': S_NEW, 'customer_org_id': str(org_id or ''), 'customer_user_id': str(user_id), - 'current_role': None, - 'current_assignee': None, + 'assignee_role': None, + 'assignee_id': None, 'processing_owner': None, 'followup_count': 0, 'agent_fail_rounds': 0, @@ -281,8 +281,8 @@ async def ticket_followup(ticket_id, user_id, org_id, content): 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}) + '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, @@ -317,7 +317,7 @@ async def ticket_confirm(ticket_id, user_id, org_id): 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}) + 'assignee_role': None, 'assignee_id': None}) if not ok: return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') await _add_message(sor, ticket_id, 'customer', user_id, '', @@ -341,7 +341,7 @@ async def ticket_cancel(ticket_id, user_id, org_id): 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}) + 'assignee_role': None, 'assignee_id': None}) if not ok: return False, ('TK_E002', '工单状态刚发生变化,请刷新后重试') await _add_message(sor, ticket_id, 'customer', user_id, '', '客户取消工单。') @@ -351,10 +351,10 @@ async def ticket_cancel(ticket_id, user_id, org_id): # ══════════════════ 运维动作 ══════════════════ async def _check_staff_access(sor, t, user_id, roles): - """运维侧访问:admin/superuser 豁免;否则须持有工单 current_role。""" + """运维侧访问:admin/superuser 豁免;否则须持有工单 assignee_role。""" if _is_admin(roles): return True - cur_role = str(getattr(t, 'current_role', '') or '') + cur_role = str(getattr(t, 'assignee_role', '') or '') return bool(cur_role) and cur_role in roles @@ -368,9 +368,9 @@ async def ticket_claim(ticket_id, user_id): 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 '') + cur_role = str(getattr(t, 'assignee_role', '') or '') ok = await _cas_status(sor, ticket_id, S_HUMAN_PENDING, { - 'status': S_HUMAN_PROCESSING, 'current_assignee': user_id}) + '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, @@ -391,7 +391,7 @@ async def ticket_staff_reply(ticket_id, user_id, content, internal=False): t = await _load_ticket(sor, ticket_id) if not t: return False, E_NOT_FOUND - cur_assignee = str(getattr(t, 'current_assignee', '') or '') + 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', '')) @@ -440,8 +440,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='') 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 '') + cur_role = str(getattr(t, 'assignee_role', '') or '') + cur_assignee = str(getattr(t, 'assignee_id', '') or '') if to_user: # T12 转具体人:校验目标 ∈ '0' 机构用户 @@ -456,8 +456,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='') 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}) + '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', @@ -476,8 +476,8 @@ async def ticket_transfer(ticket_id, user_id, to_role='', to_user='', reason='') 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}) + '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', @@ -527,7 +527,7 @@ async def list_customer_tickets(user_id, org_id, roles=None, status='', limit=10 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, " + "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", {}) @@ -548,10 +548,10 @@ async def list_staff_tickets(user_id, roles=None, queue='all', limit=100): 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) + cond = ("status='human_pending' AND assignee_role IN (%s)" % ph) elif queue == 'mine': cond = ("status IN ('human_processing','staff_replied') " - "AND current_assignee=${u}$") + "AND assignee_id=${u}$") params["u"] = str(user_id or '') else: if not _is_admin(roles) and not any(r.startswith('owner.') for r in roles): @@ -559,11 +559,11 @@ async def list_staff_tickets(user_id, roles=None, queue='all', limit=100): 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.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.current_assignee=u.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 [])] diff --git a/ticket/todos.py b/ticket/todos.py index f61ebdb..1b40c3b 100644 --- a/ticket/todos.py +++ b/ticket/todos.py @@ -3,8 +3,8 @@ 待办由工单状态派生(不建通知表,I7 同一事项只发一次): - 客户:status ∈ {agent_replied, staff_replied} 且工单是我的 → 「工单回复」待确认 -- 运维角色池:status=human_pending 且 current_role ∈ 我的角色 → 「工单认领」 -- 受理人:status=human_processing 且 current_assignee=我 → 「工单处理」 +- 运维角色池:status=human_pending 且 assignee_role ∈ 我的角色 → 「工单认领」 +- 受理人:status=human_processing 且 assignee_id=我 → 「工单处理」 provider 签名:fn(user_id, roles, limit) -> [todo dict] todo dict 字段与 list_my_human_todos 现有两来源对齐 + source='ticket' + detail_url。 @@ -37,15 +37,15 @@ async def list_ticket_todos(user_id, roles=None, limit=100): # ① 客户视角:我的工单已有回复待确认 conds.append("(customer_user_id=${u}$ AND status IN ('agent_replied','staff_replied'))") # ② 受理人视角:我认领的工单待处理 - conds.append("(current_assignee=${u}$ AND status='human_processing')") + conds.append("(assignee_id=${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 " + conds.append("(status='human_pending' AND assignee_role IN (%s))" % ph) + sql = ("SELECT id, ticket_no, title, status, assignee_role, customer_user_id, " + "assignee_id, updated_at, created_at FROM tk_tickets " "WHERE (" + ' OR '.join(conds) + ") " "ORDER BY updated_at DESC LIMIT ${lim}$") recs = await sor.sqlExe(sql, params) @@ -60,13 +60,13 @@ async def list_ticket_todos(user_id, roles=None, limit=100): badge = '工单回复' summary = '%s | 请确认是否解决' % STATUS_LABEL.get(status, status) ttype = 'ticket_customer_confirm' - elif str(d.get('current_assignee', '')) == uid and status == 'human_processing': + elif str(d.get('assignee_id', '')) == uid and status == 'human_processing': badge = '工单处理' summary = '您受理的工单待处理' ttype = 'ticket_staff_process' elif status == 'human_pending': badge = '工单认领' - summary = '受理角色:%s | 待认领' % str(d.get('current_role', '')) + summary = '受理角色:%s | 待认领' % str(d.get('assignee_role', '')) ttype = 'ticket_staff_claim' else: continue diff --git a/wwwroot/api/ticket_detail_popup.dspy b/wwwroot/api/ticket_detail_popup.dspy index cb6174c..c93a6a9 100644 --- a/wwwroot/api/ticket_detail_popup.dspy +++ b/wwwroot/api/ticket_detail_popup.dspy @@ -44,8 +44,8 @@ md_parts.append('**状态**:' + STATUS_LABEL.get(status, status) + ' | **优先级**:' + 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 '-')) + + ' | **受理角色**:' + (str(t.get('assignee_role', '')) or '-') + + ' | **受理人**:' + (str(t.get('assignee_id', '')) or '-')) md_parts.append('') md_parts.append('---') md_parts.append('### 往来记录') diff --git a/wwwroot/manage/index.ui b/wwwroot/manage/index.ui index ba59b85..fc31bb2 100644 --- a/wwwroot/manage/index.ui +++ b/wwwroot/manage/index.ui @@ -38,7 +38,7 @@ }, "row_options": { "browserfields": { - "exclouded": ["id", "customer_org_id", "current_assignee"], + "exclouded": ["id", "customer_org_id", "assignee_id"], "alters": { "category": {"uitype": "code", "data": [ {"value": "consult", "text": "使用咨询"}, {"value": "fault", "text": "故障报修"}, @@ -60,7 +60,7 @@ {"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_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} ]