From d5692434ace130f8362f9c49cc8951cbd1cb2782 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 10:52:03 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BE=8E=E5=9B=BD=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=92=8C=E7=99=BB=E5=BD=95=E8=AE=A2=E5=8D=95=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=AF=AD=E9=9F=B3=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/architecture.md | 4 + logistics_chat/customer_service.py | 17 +++- logistics_chat/order_listing.py | 59 ++++++++++--- logistics_chat/order_query.py | 17 ++-- logistics_chat/routes/customer.py | 2 +- static/css/chat.css | 101 +++++++++++++++++++++- static/js/chat.js | 112 +++++++++++++++++++++---- static/js/overview.js | 130 +++++++++++++++++++++++++++++ static/js/voice.js | 32 +++++-- templates/index.html | 12 ++- tests/frontend/chat.test.cjs | 108 ++++++++++++++++++++++-- tests/frontend/voice.test.cjs | 20 +++++ tests/test_order_statistics.py | 53 ++++++++++++ 14 files changed, 611 insertions(+), 58 deletions(-) create mode 100644 static/js/overview.js diff --git a/AGENTS.md b/AGENTS.md index 5c42e6b..8711274 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | AI | `logistics_chat/chat_service.py`: `create_chat_stream`; `logistics_chat/ai_client.py`: `post_completion` | | Voice input | `static/js/voice.js`: `ChatVoice.create`; `logistics_chat/speech_service.py`: `transcribe_recording`, `baidu_transcribe`; `logistics_chat/routes/speech.py` | | Guest identity | `static/js/visitor.js`: bootstrap and cookie recovery; `logistics_chat/visitor_service.py`: owner checks; `routes/visitor.py`: customer human support | -| Customer login | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout | +| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `static/js/overview.js`: automatic scoped overview; `order_listing.py`: `read_overview` | | Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` | | Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery | | Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` | diff --git a/docs/architecture.md b/docs/architecture.md index 6297b62..935f7d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -153,6 +153,10 @@ tests/ # 按业务模块组织的回归 `order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。 +登录成功或恢复有效登录时,`overview.js` 自动请求既有 `/api/customer/session?overview=1` 并展开可收起的订单概览。普通会话状态查询保持原响应契约,游客响应不含订单事实。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。错误状态保留重试入口,退出或换账号立即清空概览,迟到响应受请求版本和客户身份约束。概览查询不创建聊天消息,查看计数及入仓码复用现有查询入口。 + +语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。 + 共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。 已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。 diff --git a/logistics_chat/customer_service.py b/logistics_chat/customer_service.py index 331a203..56f64bf 100644 --- a/logistics_chat/customer_service.py +++ b/logistics_chat/customer_service.py @@ -3,6 +3,7 @@ import hashlib import json import secrets +import sqlite3 import time from flask import g, has_app_context, jsonify, request @@ -134,8 +135,20 @@ def bind_order_conversation(conv_id): connection.close() -def session_status(): - return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer} +def session_status(include_overview=False): + result={'ok':True, 'authenticated':bool(g.customer), 'user':g.customer} + if include_overview and g.customer: + from .visitor_service import customer_tenant_ids + from .order_listing import read_overview + tenants=customer_tenant_ids() + if not tenants: + result['overview_error']='unlinked' + else: + try: + result['overview']=read_overview(tenants) + except sqlite3.Error: + result['overview_error']='unavailable' + return result def order_login_response(): diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py index 6db0f4a..bba5556 100644 --- a/logistics_chat/order_listing.py +++ b/logistics_chat/order_listing.py @@ -66,25 +66,62 @@ def save_state(conv_id,state,message_id): finally:connection.close() -def read_orders(tenants,query,page=1,size=5): - """One scoped snapshot; each order occupies one actual-node bucket.""" +def scoped_orders(connection,tenants,status=None): + """Use one authorized projection and normalization for lists and overviews.""" from .order_data import tenant_filter scope=tenant_filter(tenant_ids=tenants) - if scope is None or not valid_filters(query):raise ValueError('Explicit customer scope and filters required') + if scope is None:raise ValueError('Explicit customer scope required') + where=' WHERE 1=1'+scope[0] + values=scope[1] + if status: + where+=' AND status=?' + values=values+[status] + rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason ' + 'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values) + for row in rows: + item=dict(row) + item['exception_reason']=(item['exception_reason'] or '').strip() + item['stage']=current_stage(item.pop('tracking_json')) + yield item + + +def read_overview(tenants): + """Summarize the customer's current orders in one database snapshot.""" + connection=connect() + try: + connection.row_factory=sqlite3.Row + connection.execute('BEGIN') + total=processing=exceptions=0 + stages=Counter() + abnormal_rows=[] + processing_rows=[] + for row in scoped_orders(connection,tenants): + total+=1 + abnormal=bool(row['exception_reason']) + if abnormal: + exceptions+=1 + if len(abnormal_rows)<5:abnormal_rows.append(row) + if row['status']=='PROCESSING': + processing+=1 + stages[row['stage']]+=1 + if not abnormal and len(processing_rows)<5:processing_rows.append(row) + return {'total':total,'processing':processing,'exceptions':exceptions, + 'stages':[{'code':code,'count':stages[code]} for code in STAGE_LABELS if stages[code]], + 'orders':(abnormal_rows+processing_rows)[:5]} + finally: + connection.close() + + +def read_orders(tenants,query,page=1,size=5): + """One scoped snapshot; each order occupies one actual-node bucket.""" + if not valid_filters(query):raise ValueError('Explicit filters required') connection=connect() try: connection.row_factory=sqlite3.Row - where=' WHERE 1=1'+scope[0];values=scope[1] - if query['status']:where+=' AND status=?';values=values+[query['status']] connection.execute('BEGIN') - rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason ' - 'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values) total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size) - for row in rows: - item=dict(row) - item['exception_reason']=(item['exception_reason'] or '').strip() + for item in scoped_orders(connection,tenants,query['status']): if query['exception'] and not item['exception_reason']:continue - item['stage']=current_stage(item.pop('tracking_json')) if query['stage'] and query['stage']!=item['stage']:continue total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1 tail.append(item) diff --git a/logistics_chat/order_query.py b/logistics_chat/order_query.py index c3b1942..a75fadf 100644 --- a/logistics_chat/order_query.py +++ b/logistics_chat/order_query.py @@ -25,7 +25,8 @@ STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()} STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同') STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'), ('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'), - ('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')) + ('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'), + ('processing','PROCESSING')) NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b' COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b' @@ -42,16 +43,16 @@ def parse_query(message,listing=False): if re.search(pattern,remaining): nodes.append(code);remaining=re.sub(pattern,'',remaining) for phrase,status in STATUSES: - if phrase in remaining: - statuses.append(status);remaining=remaining.replace(phrase,'') - exception=bool(re.search(r'异常',remaining)) - remaining=re.sub(r'物流异常|异常','',remaining) - grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪',remaining)) - remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了', '',remaining) + if re.search(re.escape(phrase),remaining,re.I): + statuses.append(status);remaining=re.sub(re.escape(phrase),'',remaining,flags=re.I) + exception=bool(re.search(r'异常|\b(?:exception|abnormal)\b',remaining,re.I)) + remaining=re.sub(r'物流异常|异常|\b(?:exception|abnormal)\b','',remaining,flags=re.I) + grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪|\btracking (?:stages?|nodes?)\b',remaining,re.I)) + remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了|\btracking (?:stages?|nodes?)\b', '',remaining,flags=re.I) remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I) remaining=re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|' r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|' - r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I) + r'\b(?:list|what|is|of|do|i|have|are|there|my|in|all|the|currently|at|each)\b|[\s,,。.!!??、::]','',remaining,flags=re.I) return {'status':statuses[0] if statuses else None,'stage':nodes[0] if nodes else None, 'exception':exception,'grouped':grouped, 'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)} diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py index 1bd8546..ddd90db 100644 --- a/logistics_chat/routes/customer.py +++ b/logistics_chat/routes/customer.py @@ -9,7 +9,7 @@ bp = Blueprint('customer', __name__) @bp.get('/api/customer/session') def session(): - return customer_service.session_status() + return customer_service.session_status(include_overview=request.args.get('overview')=='1') @bp.post('/api/customer/login') diff --git a/static/css/chat.css b/static/css/chat.css index 966297f..6ed07c7 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -97,7 +97,27 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .recent-item > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .recent-item:hover { color: var(--brand-text); } .recent-date { white-space: nowrap; color: var(--muted); font-size: 10px; } -.composer { flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); } +.composer { position: relative; flex-shrink: 0; align-self: center; width: min(820px, calc(100% - 76px)); padding: 12px 0 15px; background: var(--surface); } +.voice-feedback { + position: absolute; + z-index: 36; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 100%; + padding: 9px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); + text-align: center; + font-size: 13px; + line-height: 1.5; + pointer-events: none; +} +.voice-feedback.is-error { border-color: var(--danger); color: var(--danger); } .composer-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 10px; } .composer-toolbar .hot-bar { flex: 1; min-width: 0; margin-bottom: 0; } .voice-toggle, .voice-hold { display: none; } @@ -285,3 +305,82 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } .msg-action-btn { min-height: 40px; } .to-bottom { right: 15px; } } +.customer-overview { + flex-shrink: 0; + max-height: 36%; + margin: 12px 32px 0; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} +.customer-overview > summary { + display: flex; + align-items: center; + gap: 12px; + position: sticky; + top: 0; + z-index: 1; + min-height: 44px; + padding: 10px 16px; + background: var(--surface); + cursor: pointer; + list-style: none; +} +.customer-overview > summary::-webkit-details-marker { display: none; } +.customer-overview > summary::after { content: '⌄'; color: var(--muted); } +.customer-overview[open] > summary::after { transform: rotate(180deg); } +#customer-overview-title { flex-shrink: 0; font-weight: 650; } +#customer-overview-summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 12px; + color: var(--secondary); +} +.customer-overview-body { padding: 0 16px 12px; } +.overview-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.overview-metric { + display: flex; + flex-direction: column; + min-width: 0; + padding: 8px; + border: 0; + border-radius: 8px; + background: var(--surface-soft); + color: var(--text); + text-align: left; +} +.overview-metric strong { font-size: 21px; line-height: 1.3; } +.overview-metric span { color: var(--secondary); font-size: 12px; } +.overview-metric:last-child strong { color: var(--danger); } +.overview-stages { display: flex; flex-wrap: wrap; gap: 6px 12px; margin: 10px 0; color: var(--secondary); font-size: 12px; } +.overview-orders { display: grid; gap: 6px; } +.overview-order { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px 10px; + min-width: 0; + min-height: 44px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + text-align: left; +} +.overview-reference { overflow-wrap: anywhere; font-size: 13px; } +.overview-stage { max-width: 180px; color: var(--secondary); font-size: 12px; text-align: right; } +.overview-reason { grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--danger); font-size: 12px; } +.overview-order.has-exception { border-left: 3px solid var(--danger); } +.overview-refresh { margin-top: 8px; padding: 4px 0; border: 0; background: transparent; color: var(--brand-text); font-size: 12px; } +.overview-empty { color: var(--secondary); font-size: 13px; } +@media (max-width: 720px) { + .customer-overview { max-height: 32%; margin: 8px 12px 0; } + .customer-overview > summary { padding: 9px 12px; gap: 8px; } + .customer-overview-body { padding: 0 12px 10px; } + .overview-stage { max-width: 120px; } +} diff --git a/static/js/chat.js b/static/js/chat.js index 6353d91..e6c985f 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -6,6 +6,8 @@ const sendBtn = document.getElementById("send-button"); const uploadBtn = document.getElementById("upload-btn"); const uploadInput = document.getElementById("upload-input"); const composerStatus = document.getElementById("composer-status"); +const voiceFeedback = document.getElementById('voice-feedback'); +let voiceFeedbackTimer = null; const drafts = ChatDrafts.create({scope:VisitorSession.localKey(),storage:()=>localStorage,tabStorage:()=>sessionStorage}); let draftTimer = null; let draftSnapshot = null; @@ -41,6 +43,7 @@ let historyRequestVersion = 0; let followLatest = true; let voiceInput = null; let customerLogin = null; +let customerOverview = null; let orderLookup = null; let lookupRequestVersion = 0; let customerRecovery = null; @@ -65,6 +68,21 @@ function setComposerStatus(message, isError){ composerStatus.classList.toggle("is-error", Boolean(isError)); } +function setVoiceStatus(message,isError){ + if(voiceFeedbackTimer!==null) clearTimeout(voiceFeedbackTimer); + voiceFeedbackTimer=null; + if(voiceFeedback.textContent!==message) voiceFeedback.textContent=message || ''; + voiceFeedback.hidden=!message; + voiceFeedback.classList.toggle('is-error',Boolean(isError)); + if(message){ + voiceFeedbackTimer=setTimeout(function(){ + voiceFeedback.hidden=true; + voiceFeedback.textContent=''; + voiceFeedbackTimer=null; + },4000); + } +} + async function requestJson(path, options){ const controller = new AbortController(); const timer = setTimeout(function(){ controller.abort(); }, 10000); @@ -290,11 +308,39 @@ const I18N = { retryAIOnly: "请先返回 AI 问答,再重答这条消息。", clickRetry: "点击重试", welcomeTitle: "物流问题,随时为您解答", - welcomeEyebrow: "中欧 · 英国物流服务", + welcomeEyebrow: "中美物流服务", serviceTag: "物流咨询服务", footerTrack: "查入仓码", - footerQuote: "专线报价", - footerException: "异常处理", + footerQuote: "美国报价", + footerException: "异常订单", + exceptionQuery: "我有多少个异常订单?", + overviewTitle: "订单概览", + overviewTotal: "全部订单", + overviewProcessing: "履约中", + overviewExceptions: "异常订单", + overviewLoading: "正在查询订单…", + overviewUnlinked: "当前账号尚未关联订单", + overviewFailed: "订单概览暂时无法加载", + overviewRefresh: "刷新概览", + overviewEmpty: "当前没有履约中或已标记异常的订单。", + overviewAllQuery: "列出我的全部订单", + overviewProcessingQuery: "列出履约中的订单", + overviewStageLabels: { + DOMESTIC_RECEIVED: "已入国内仓", + LOADING_COMPLETED: "已完成装柜", + DEPARTED_WAREHOUSE: "已离开国内仓库", + AT_ORIGIN_PORT: "已进入起运港", + LOADED_ON_VESSEL: "已装船", + DEPARTED_ORIGIN_PORT: "已开船", + IN_TRANSIT: "海运途中", + ARRIVED_DESTINATION_PORT: "已到目的港", + DISCHARGED_FROM_VESSEL: "已卸船、未清关", + PICKUP_READY: "已清关、可提柜", + PICKED_UP: "已提柜", + EMPTY_RETURNED: "已还柜", + NO_ACTUAL: "暂无实际节点", + MIXED: "多柜进度不同" + }, footerHuman: "联系人工", inputShort: "输入入仓码…", uploading: "附件正在上传,请稍候…", @@ -339,10 +385,10 @@ const I18N = { langTip: "中英文切换(Ctrl+L)", toBottom: "回到底部", quick: [ - "按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服" + "按入仓码查轨迹", "美国海运专线报价", "查询异常订单", "转接人工客服" ], hotList: [ - "如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?" + "如何查询物流轨迹?", "美国海运时效是多久?", "我有多少个异常订单?", "各个轨迹节点分别有多少订单?" ] }, en: { @@ -406,11 +452,39 @@ const I18N = { retryAIOnly: "Return to AI chat before regenerating this answer.", clickRetry: "Retry", welcomeTitle: "Your next shipment, made clearer.", - welcomeEyebrow: "EUROPE & UK LOGISTICS", + welcomeEyebrow: "CHINA–US LOGISTICS", serviceTag: "Logistics support", footerTrack: "Inbound lookup", - footerQuote: "Get a quote", - footerException: "Parcel issue", + footerQuote: "US quote", + footerException: "Exception orders", + exceptionQuery: "How many exception orders do I have?", + overviewTitle: "Order overview", + overviewTotal: "All orders", + overviewProcessing: "In progress", + overviewExceptions: "Exceptions", + overviewLoading: "Loading orders…", + overviewUnlinked: "No orders are linked to this account", + overviewFailed: "Order overview could not load", + overviewRefresh: "Refresh overview", + overviewEmpty: "No orders are in progress or flagged with an exception.", + overviewAllQuery: "List all my orders", + overviewProcessingQuery: "List my processing orders", + overviewStageLabels: { + DOMESTIC_RECEIVED: "Received at origin warehouse", + LOADING_COMPLETED: "Container loaded", + DEPARTED_WAREHOUSE: "Left origin warehouse", + AT_ORIGIN_PORT: "At origin port", + LOADED_ON_VESSEL: "Loaded on vessel", + DEPARTED_ORIGIN_PORT: "Vessel departed", + IN_TRANSIT: "In transit", + ARRIVED_DESTINATION_PORT: "At destination port", + DISCHARGED_FROM_VESSEL: "Discharged, awaiting clearance", + PICKUP_READY: "Cleared, ready for pickup", + PICKED_UP: "Container picked up", + EMPTY_RETURNED: "Empty container returned", + NO_ACTUAL: "No actual tracking events", + MIXED: "Containers at different stages" + }, footerHuman: "Human support", inputShort: "Inbound code…", uploading: "Uploading your attachment…", @@ -457,11 +531,11 @@ const I18N = { langTip: "Switch language (Ctrl+L)", toBottom: "Back to bottom", quick: [ - "Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent" + "Track by inbound code", "US sea freight quote", "View exception orders", "Talk to a human agent" ], hotList: [ - "How do I track my shipment?", "How long does Europe sea freight take?", - "My parcel shows delivered but I didn't get it", "How do I file a claim?" + "How do I track my shipment?", "How long does US sea freight take?", + "How many exception orders do I have?", "How many orders are at each tracking stage?" ] } }; @@ -507,6 +581,7 @@ function applyLanguage(lang){ if(tg) tg.title = tg.getAttribute('aria-expanded') === 'false' ? dict.sideExpand : dict.sideCollapse; updateHumanMode(); if(voiceInput) voiceInput.refresh(); + if(customerOverview) customerOverview.render(); // 欢迎页(若当前显示的是欢迎页则重绘) if(chatDom.querySelector(".ai-welcome-card")){ @@ -633,7 +708,8 @@ function welcomeHTML(){ const h = t("hotList"); const kinds = ["track", "quote", "issue", "human"]; const cards = q.map(function(question, index){ - const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(question)}"`; + const query = index === 2 ? t('exceptionQuery') : question; + const action = index === 3 ? 'data-human-support' : index === 0 ? 'data-order-lookup' : `data-question="${escHtml(query)}"`; return ``; }).join(""); return ` @@ -873,6 +949,7 @@ function ensureConv(){ } function resetConversation(id){ + setVoiceStatus(''); flushDraft(); emojiPicker.close(); if(orderLookup) orderLookup.cancel(); @@ -2036,7 +2113,7 @@ voiceInput = ChatVoice.create({ toggle:document.getElementById('voice-toggle'), hold:document.getElementById('voice-hold'), panel:document.getElementById('voice-panel'), label:document.getElementById('voice-label'), clock:document.getElementById('voice-clock'), inputArea:document.querySelector('.input-area'), input:inputDom, - text:t, status:setComposerStatus, language:() => LANG, + text:t, status:setVoiceStatus, language:() => LANG, context:() => conversationVersion + ':' + currentConvId + ':' + window.humanSid, canStart:function(){ if(uploadPending || sendPreparing || window.__busy || openingConv !== null || humanRequestToken || humanSendingToken || humanClosing) return t('voiceWait'); @@ -2052,9 +2129,15 @@ voiceInput = ChatVoice.create({ if(!await doSend()) voiceInput.setMode(false); } }); +customerOverview = CustomerOverview.create({fetch:fetch,text:t, + onQuery:function(question){window.askNewTopic(question);}, + onLookup:function(order,button){showOrderLookup(order.inbound_code || order.order_no,order.inbound_code ? 'inbound' : 'order',button);}, + onExpired:function(){if(customerLogin) customerLogin.refresh();} +}); customerLogin = CustomerLogin.create({fetch:fetch, text:t, onOpen:function(){ if(voiceInput) voiceInput.cancel(); if(isNarrow()){sideOpen=false;renderSidebar();} syncChatViewport(); }, onChange:function(user,previous){ + customerOverview.setUser(user); if(previous === undefined) return; if(previous && (!user || user.id !== previous.id)){ const from = conversationVersion; @@ -2084,7 +2167,8 @@ document.querySelectorAll('[data-question-index]').forEach(function(button){ button.addEventListener('click', function(){ const index=Number(button.dataset.questionIndex); if(index === 0) return showOrderLookup(undefined,undefined,button); - return window.askNewTopic(t('quick')[index]); + const question = index === 2 ? t('exceptionQuery') : t('quick')[index]; + return window.askNewTopic(question); }); }); chatDom.addEventListener('click', function(event){ diff --git a/static/js/overview.js b/static/js/overview.js new file mode 100644 index 0000000..b3f41c8 --- /dev/null +++ b/static/js/overview.js @@ -0,0 +1,130 @@ +// Display a fresh, customer-scoped order overview after login or session recovery. +var CustomerOverview = { + create(options){ + const panel=document.getElementById('customer-overview'); + const title=document.getElementById('customer-overview-title'); + const summary=document.getElementById('customer-overview-summary'); + const body=document.getElementById('customer-overview-body'); + let user=null; + let overview=null; + let failure=''; + let loading=false; + let requestVersion=0; + let controller=null; + panel.hidden=true; + + function element(tag,className,text){ + const node=document.createElement(tag); + if(className) node.className=className; + if(text !== undefined) node.textContent=text; + return node; + } + + function queryButton(label,question,className){ + const button=element('button',className,label); + button.type='button'; + button.onclick=function(){ + panel.open=false; + options.onQuery(question); + }; + return button; + } + + function render(){ + title.textContent=options.text('overviewTitle'); + body.innerHTML=''; + panel.hidden=!user; + if(!user){summary.textContent='';return;} + if(!overview){ + const key=loading ? 'overviewLoading' : failure==='unlinked' ? 'overviewUnlinked' : 'overviewFailed'; + summary.textContent=options.text(key); + }else{ + summary.textContent=options.text('overviewProcessing')+' '+overview.processing+' · '+options.text('overviewExceptions')+' '+overview.exceptions; + const metrics=element('div','overview-metrics'); + for(const [key,count,question] of [ + ['overviewTotal',overview.total,options.text('overviewAllQuery')], + ['overviewProcessing',overview.processing,options.text('overviewProcessingQuery')], + ['overviewExceptions',overview.exceptions,options.text('exceptionQuery')] + ]){ + const button=queryButton('',question,'overview-metric'); + button.append(element('strong','',String(count)),element('span','',options.text(key))); + metrics.appendChild(button); + } + body.appendChild(metrics); + const stages=element('div','overview-stages'); + const labels=options.text('overviewStageLabels'); + for(const stage of overview.stages){ + stages.appendChild(element('span','',labels[stage.code]+' '+stage.count)); + } + body.appendChild(stages); + const orders=element('div','overview-orders'); + for(const order of overview.orders){ + const button=element('button','overview-order'); + button.type='button'; + const reference=order.inbound_code || order.order_no; + button.appendChild(element('strong','overview-reference',reference)); + button.appendChild(element('span','overview-stage',labels[order.stage])); + if(order.exception_reason){ + button.classList.add('has-exception'); + button.appendChild(element('span','overview-reason',order.exception_reason)); + } + button.onclick=function(){options.onLookup(order,button);}; + orders.appendChild(button); + } + if(!overview.orders.length) orders.appendChild(element('p','overview-empty',options.text('overviewEmpty'))); + body.appendChild(orders); + } + const refresh=element('button','overview-refresh',options.text(loading ? 'overviewLoading' : 'overviewRefresh')); + refresh.type='button'; + refresh.disabled=loading; + refresh.onclick=refreshOverview; + body.appendChild(refresh); + } + + async function refreshOverview(){ + if(!user || loading) return; + const token=++requestVersion; + const customerId=user.id; + controller=new AbortController(); + const activeController=controller; + const timer=setTimeout(()=>activeController.abort(),10000); + loading=true; + failure=''; + render(); + try{ + const response=await options.fetch('/api/customer/session?overview=1',{signal:activeController.signal}); + const result=await response.json(); + if(token!==requestVersion || !user || user.id!==customerId) return; + if(!response.ok || !result.ok) throw new Error('Overview unavailable'); + if(!result.authenticated || !result.user || result.user.id!==customerId){ + setUser(null); + if(options.onExpired) options.onExpired(); + return; + } + overview=result.overview || null; + failure=result.overview_error || (overview ? '' : 'unavailable'); + }catch(error){ + if(token===requestVersion){overview=null;failure='unavailable';} + }finally{ + clearTimeout(timer); + if(token===requestVersion){controller=null;loading=false;render();} + } + } + + function setUser(next){ + if(user && next && user.id===next.id) return; + requestVersion+=1; + if(controller) controller.abort(); + controller=null; + user=next; + overview=null; + failure=''; + loading=false; + panel.open=Boolean(user); + render(); + if(user) refreshOverview(); + } + + return {setUser:setUser,render:render}; + } +}; diff --git a/static/js/voice.js b/static/js/voice.js index 7f195a3..2d29fba 100644 --- a/static/js/voice.js +++ b/static/js/voice.js @@ -4,6 +4,7 @@ var ChatVoice = { const fetch = options.fetch || (typeof window.fetch === 'function' ? window.fetch.bind(window) : globalThis.fetch); const {toggle, hold, panel, label, clock, inputArea, input, text, status} = options; let session = null, mode = false; + let pressedId = null; const active = current => session === current; function stopTracks(current){ if(current.stream) current.stream.getTracks().forEach(track => track.stop()); @@ -39,6 +40,7 @@ var ChatVoice = { } function setMode(on){ cancel(); + pressedId=null; mode = on; render(); if(on && input.blur) input.blur(); @@ -172,24 +174,30 @@ var ChatVoice = { }; if(window.PointerEvent){ hold.addEventListener('pointerdown', function(event){ - if(event.button !== 0) return; + if(event.button !== 0 || pressedId!==null) return; event.preventDefault(); + pressedId=event.pointerId; if(hold.setPointerCapture) try{ hold.setPointerCapture(event.pointerId); }catch(error){} start(event.pointerId, event.clientY); }); hold.addEventListener('pointermove', event => move(event.pointerId, event.clientY)); hold.addEventListener('pointerup', function(event){ event.preventDefault(); + if(pressedId===event.pointerId) pressedId=null; if(session && session.id === event.pointerId) finish(session, false); }); ['pointercancel','lostpointercapture'].forEach(name => hold.addEventListener(name, function(){ + pressedId=null; if(session && session.holding) finish(session, true); })); }else{ hold.addEventListener('touchstart', function(event){ event.preventDefault(); if(event.touches.length !== 1) return; - const touch=event.touches[0]; start(touch.identifier, touch.clientY); + if(pressedId!==null) return; + const touch=event.touches[0]; + pressedId=touch.identifier; + start(touch.identifier, touch.clientY); }, {passive:false}); hold.addEventListener('touchmove', function(event){ event.preventDefault(); @@ -197,23 +205,29 @@ var ChatVoice = { }, {passive:false}); hold.addEventListener('touchend', function(event){ event.preventDefault(); - for(const touch of event.changedTouches) if(session && session.id === touch.identifier) finish(session, false); + for(const touch of event.changedTouches){ + if(pressedId===touch.identifier) pressedId=null; + if(session && session.id === touch.identifier) finish(session, false); + } }, {passive:false}); - hold.addEventListener('touchcancel', function(){ if(session) finish(session, true); }); + hold.addEventListener('touchcancel', function(){ pressedId=null;if(session) finish(session, true); }); } hold.addEventListener('contextmenu', event => event.preventDefault()); hold.addEventListener('keydown', function(event){ - if(![' ','Enter'].includes(event.key) || event.repeat) return; + if(![' ','Enter'].includes(event.key) || event.repeat || pressedId!==null) return; + pressedId='keyboard'; event.preventDefault(); start('keyboard', 0); }); hold.addEventListener('keyup', function(event){ - if([' ','Enter'].includes(event.key) && session && session.id === 'keyboard'){ - event.preventDefault(); finish(session, false); + if([' ','Enter'].includes(event.key)){ + if(pressedId==='keyboard') pressedId=null; + event.preventDefault(); + if(session && session.id === 'keyboard') finish(session, false); } }); document.addEventListener('keydown', event => { if(event.key === 'Escape') cancel(); }); - document.addEventListener('visibilitychange', function(){ if(document.hidden) cancel(); }); - window.addEventListener('pagehide', function(){ cancel(); }); + document.addEventListener('visibilitychange', function(){ if(document.hidden){pressedId=null;cancel();} }); + window.addEventListener('pagehide', function(){ pressedId=null;cancel(); }); render(); return {cancel:cancel,setMode:setMode,refresh:render,busy:() => Boolean(session)}; } diff --git a/templates/index.html b/templates/index.html index e3415e6..8bcd534 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,11 +38,15 @@ +
-
中欧 · 英国物流服务
+
中美物流服务

物流问题,随时为您解答

@@ -51,8 +55,8 @@
- - + +
@@ -64,6 +68,7 @@
+ @@ -99,6 +104,7 @@ + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 64339b2..367d8c0 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -76,6 +76,7 @@ async function customerPage(pageOptions = {}){ }; class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } } let state = {status:'active', messages:[]}; + let customerState=pageOptions.customer || null; const context = vm.createContext({ document, console, Date:TestDate, navigator:{language:'zh-CN',clipboard:pageOptions.clipboard}, AbortController, TextDecoder, confirm:() => true, FormData:class { append(){} }, @@ -93,7 +94,14 @@ async function customerPage(pageOptions = {}){ clearTimeout:id => timers.delete(id), fetch: async (url, options) => { calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)}); - if(hooks[url]) return hooks[url](options); + if(hooks[url]){ + const result=await hooks[url](options); + if(url==='/api/customer/login' || url==='/api/customer/logout'){ + const data=await result.json(); + if(result.ok && data.ok) customerState=url==='/api/customer/login' ? data.user : null; + } + return result; + } if(url === '/api/get_history' && pageOptions.historyFetch) return pageOptions.historyFetch(); if(url.startsWith('/api/visitor/human/poll') && hooks.poll) return hooks.poll(url); if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url); @@ -107,7 +115,8 @@ async function customerPage(pageOptions = {}){ } const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'} : url === '/api/visitor/session' ? {ok:true,key:'1'.repeat(64)} - : url === '/api/customer/session' ? {ok:true,authenticated:Boolean(pageOptions.customer),user:pageOptions.customer || null} + : url === '/api/customer/session' ? {ok:true,authenticated:Boolean(customerState),user:customerState} + : url === '/api/customer/session?overview=1' ? {ok:true,authenticated:Boolean(customerState),user:customerState,overview:pageOptions.overview || {total:0,processing:0,exceptions:0,stages:[],orders:[]}} : url === '/api/visitor/human/request' ? (pageOptions.serviceClosed ? {ok:false,closed:true} : {ok:true, session_id:7}) : url.startsWith('/api/visitor/human/poll') ? state : ['/api/rate', '/api/feedback', '/api/visitor/human/send', '/api/visitor/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true} @@ -115,7 +124,7 @@ async function customerPage(pageOptions = {}){ return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'customer.js', 'overview.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){ vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file}); if(file === 'voice.js'){ const createVoice=context.ChatVoice.create; @@ -193,7 +202,90 @@ test('switching to a quick topic discards the previous topic attachment', async app.context.window.__pendingImg='/uploads/20261029100145_6787.txt'; app.context.window.__pendingTag='[文件]'; await app.click(app.quickButtons[1]); - assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价'); + assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '美国海运专线报价'); +}); + +test('the homepage and exception shortcut match the US service in both languages', async () => { + for(const language of ['zh','en']){ + const app=await customerPage({human:false,storage:{'wuliu-lang':language}}); + await app.click(app.element('.new-chat-btn')); + const html=app.element('chatDom').innerHTML; + assert.doesNotMatch(html,/欧洲|中欧|英国物流|EUROPE|Europe|理赔|file a claim/); + assert.match(html,language==='zh'?/中美物流服务/:/CHINA–US LOGISTICS/); + assert.match(html,language==='zh'?/美国海运专线报价/:/US sea freight quote/); + assert.match(html,language==='zh'?/查询异常订单/:/View exception orders/); + const question=language==='zh'?'我有多少个异常订单?':'How many exception orders do I have?'; + assert.ok(html.includes('data-question="'+question+'"')); + await app.click(app.quickButtons[2]); + assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,question); + } +}); + +test('authenticated entry loads one overview with actionable counts and safe exception text', async () => { + const overview={total:9,processing:3,exceptions:2,stages:[{code:'IN_TRANSIT',count:3}],orders:[ + {order_no:'ORDER20260001',inbound_code:'INB20260001',stage:'IN_TRANSIT',exception_reason:''} + ]}; + const app=await customerPage({human:false,customer:{id:1,name:'admin'},overview}); + const panel=app.element('customer-overview'); + assert.equal(panel.hidden,false);assert.equal(panel.open,true); + assert.match(app.element('customer-overview-summary').textContent,/履约中 3 · 异常订单 2/); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,1); + const body=app.element('customer-overview-body'); + assert.equal(body.querySelector('.overview-reason').innerHTML,''); + assert.equal(body.querySelector('.overview-reason').textContent,''); + await app.click(body.querySelector('.overview-order')); + assert.equal(app.element('order-lookup').hidden,false); + assert.equal(app.element('order-reference').value,'INB20260001'); + await app.click(app.element('order-lookup-cancel')); + await app.click(body.querySelectorAll('.overview-metric')[1]); + assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'列出履约中的订单'); + assert.equal(panel.open,false); +}); + +test('guest entry skips private overview and a late refresh cannot restore it after logout', async () => { + const guest=await customerPage({human:false}); + assert.equal(guest.element('customer-overview').hidden,true); + assert.equal(guest.calls.filter(call=>call.url==='/api/customer/session?overview=1').length,0); + const app=await customerPage({human:false,customer:{id:1,name:'admin'}}); + let resolve; + app.hooks['/api/customer/session?overview=1']=()=>new Promise(done=>{resolve=done;}); + app.element('customer-overview-body').querySelector('.overview-refresh').click(); + app.hooks['/api/customer/logout']=()=>response({ok:true,authenticated:false,user:null}); + await app.click(app.element('customer-account')); + resolve(response({ok:true,authenticated:true,user:{id:1,name:'admin'},overview:{total:99,processing:99,exceptions:99,stages:[],orders:[]}})); + await flush(); + assert.equal(app.element('customer-overview').hidden,true); + assert.equal(app.element('customer-overview-body').textContent,''); + assert.doesNotMatch(app.element('customer-overview-summary').textContent,/99/); +}); + +test('overview failure can retry and account data stays out of its error state', async () => { + const app=await customerPage({human:false,customer:{id:1,name:'admin'}}); + app.hooks['/api/customer/session?overview=1']=()=>response({ok:false},503); + await app.element('customer-overview-body').querySelector('.overview-refresh').click(); + assert.match(app.element('customer-overview-summary').textContent,/暂时无法加载/); + assert.equal(app.element('customer-overview-body').querySelector('.overview-metric'),null); + delete app.hooks['/api/customer/session?overview=1']; + await app.element('customer-overview-body').querySelector('.overview-refresh').click(); + assert.match(app.element('customer-overview-summary').textContent,/履约中 0/); +}); + +test('voice errors use a floating notice without changing the composer status', async () => { + const app=await customerPage({human:false,width:390}); + const status=app.element('composer-status'); + app.voiceOptions.status('语音输入失败,请重试。',true); + const feedback=app.element('voice-feedback'); + assert.equal(feedback.hidden,false); + assert.equal(feedback.textContent,'语音输入失败,请重试。'); + assert.ok(feedback.classList.contains('is-error')); + assert.equal(status.textContent,''); + app.voiceOptions.status('语音输入失败,请重试。',true); + assert.equal(feedback.hidden,false); + await app.runTimers(4000); + assert.equal(feedback.hidden,true); + app.voiceOptions.status('语音输入失败,请重试。',true); + await app.click(app.element('.new-chat-btn')); + assert.equal(feedback.hidden,true); }); test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => { @@ -390,7 +482,7 @@ test('tracking does not interrupt human support while quote and exception shortc const requests = app.calls.filter(call => call.url === '/stream_chat'); assert.equal(requests.length, 2); assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 2); - assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '包裹异常处理']); + assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '我有多少个异常订单?']); assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0); assert.equal(app.context.window.humanSid, 0); assert.equal(app.element('human-session').hidden, true); @@ -398,14 +490,14 @@ test('tracking does not interrupt human support while quote and exception shortc test('welcome and hot question buttons use the same AI routing as the footer', async () => { const app = await customerPage(); - for(const question of ['欧洲海运专线报价', '如何申请理赔?']){ + for(const question of ['美国海运专线报价', '各个轨迹节点分别有多少订单?']){ const button = app.element('question-'+question); button.dataset.question = question; app.element('chatDom').listeners.click({target:button}); await flush(); } const requests = app.calls.filter(call => call.url === '/stream_chat'); - assert.deepEqual(requests.map(call => call.body.msg), ['欧洲海运专线报价', '如何申请理赔?']); + assert.deepEqual(requests.map(call => call.body.msg), ['美国海运专线报价', '各个轨迹节点分别有多少订单?']); assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0); }); @@ -464,7 +556,7 @@ test('a late conversation creation cannot replace the newest quick question', as const requests = app.calls.filter(call => call.url === '/stream_chat'); assert.equal(requests.length, 1); assert.equal(requests[0].body.conv_id, 22); - assert.equal(requests[0].body.msg, '包裹异常处理'); + assert.equal(requests[0].body.msg, '我有多少个异常订单?'); assert.equal(app.context.window.__busy, false); }); diff --git a/tests/frontend/voice.test.cjs b/tests/frontend/voice.test.cjs index cbb2ef8..dc25460 100644 --- a/tests/frontend/voice.test.cjs +++ b/tests/frontend/voice.test.cjs @@ -97,6 +97,26 @@ test('short presses and unavailable service do not upload recordings',async()=>{ assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable'); }); +test('a failed physical hold cannot restart until it has been released',async()=>{ + for(const touch of [false,true]){ + const app=voicePage({enabled:false,touch}); + app.open(); + const point={identifier:7,clientY:300}; + const down=async()=>{ + if(touch) app.elements.hold.listeners.touchstart({touches:[point],preventDefault(){}}); + else app.elements.hold.listeners.pointerdown(app.event()); + await flush(); + }; + await down();await down(); + assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,1); + if(touch) app.elements.hold.listeners.touchend({changedTouches:[point],preventDefault(){}}); + else app.elements.hold.listeners.pointerup(app.event()); + await down(); + assert.equal(app.calls.filter(call=>call.url==='/api/speech/config').length,2); + assert.equal(app.permissionCalls,0); + } +}); + test('late microphone permission after cancellation immediately stops its tracks',async()=>{ let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})}); app.open();await app.down();await app.up();grant(app.stream);await flush(); diff --git a/tests/test_order_statistics.py b/tests/test_order_statistics.py index c570453..4161e2d 100644 --- a/tests/test_order_statistics.py +++ b/tests/test_order_statistics.py @@ -57,6 +57,51 @@ class OrderStatisticsTests(unittest.TestCase): self.assertTrue(self.ask(q)['login_required']) read.assert_not_called() + def test_session_overview_is_opt_in_scoped_prioritized_and_not_cached(self): + with patch('logistics_chat.order_listing.read_overview') as read: + guest=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'}) + self.assertFalse(guest.json['authenticated']) + self.assertNotIn('overview',guest.json) + read.assert_not_called() + self.login() + self.assertNotIn('overview',self.client.get('/api/customer/session').json) + response=self.client.get('/api/customer/session?overview=1&tenant_id=b',headers={'X-Tenant-Id':'b'}) + overview=response.json['overview'] + self.assertIn('no-store',response.headers['Cache-Control']) + self.assertEqual((overview['total'],overview['processing'],overview['exceptions']),(16,12,7)) + self.assertEqual(sum(stage['count'] for stage in overview['stages']),12) + self.assertEqual(len(overview['orders']),5) + self.assertTrue(all(order['exception_reason'] for order in overview['orders'])) + self.assertNotIn('SECRET',response.get_data(as_text=True)) + self.assertNotIn('tenant_id',response.get_data(as_text=True)) + self.client.post('/api/customer/logout') + self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json) + + def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self): + self.login() + with self.app.app_context(): + c=db.connect() + c.execute("UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id='a' AND order_no<>'STAT20260000'") + c.commit();c.close() + overview=self.client.get('/api/customer/session?overview=1').json['overview'] + self.assertEqual(overview['orders'][0]['order_no'],'STAT20260000') + self.assertEqual(len({order['order_no'] for order in overview['orders']}),5) + with self.app.app_context(): + c=db.connect() + order_data.import_records(c,[{'tenant_id':'a','order_no':'NEW20260001','status':'PROCESSING','exception_reason':'资料待核实'}]) + c.commit();c.close() + refreshed=self.client.get('/api/customer/session?overview=1').json['overview'] + self.assertEqual((refreshed['total'],refreshed['processing'],refreshed['exceptions']),(17,13,2)) + + def test_overview_read_failure_keeps_login_available_and_no_facts_leak(self): + self.login() + with patch('logistics_chat.order_listing.read_overview',side_effect=sqlite3.OperationalError('private details')): + result=self.client.get('/api/customer/session?overview=1').json + self.assertTrue(result['authenticated']) + self.assertEqual(result['overview_error'],'unavailable') + self.assertNotIn('overview',result) + self.assertNotIn('private details',str(result)) + def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self): self.login() total=self.ask('我有多少个订单')['t'] @@ -79,6 +124,14 @@ class OrderStatisticsTests(unittest.TestCase): self.assertIn(token,text) self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text) + def test_english_homepage_questions_apply_their_intended_filters(self): + self.assertTrue(self.ask('How many exception orders do I have?')['login_required']) + self.login() + self.assertIn('7 票明确标记异常',self.ask('How many exception orders do I have?')['t']) + text=self.ask('How many orders are at each tracking stage?')['t'] + self.assertIn('船舶已到目的港:10票',text) + self.assertIn('暂无实际节点:3票',text) + def test_expand_and_pages_keep_filters_and_refresh_counts(self): self.login();cid=self.client.post('/api/new_chat').json['id'] self.ask('我有多少异常订单',cid)