From 311b9bd57dbf831949659b18b2c27b1743ca5b43 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 11:17:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=B8=B8=E9=A9=BB=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E5=B9=B6=E9=80=9A=E8=BF=87=E8=81=8A=E5=A4=A9=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E8=AE=A2=E5=8D=95=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/architecture.md | 2 +- logistics_chat/customer_service.py | 17 +--- logistics_chat/order_data.py | 3 + logistics_chat/order_listing.py | 23 +++++ logistics_chat/routes/customer.py | 2 +- static/css/chat.css | 79 ------------------ static/js/chat.js | 62 -------------- static/js/overview.js | 130 ----------------------------- templates/index.html | 5 -- tests/frontend/chat.test.cjs | 56 ++----------- tests/test_order_statistics.py | 46 +++++----- 12 files changed, 65 insertions(+), 362 deletions(-) delete mode 100644 static/js/overview.js diff --git a/AGENTS.md b/AGENTS.md index 8711274..b300ea8 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 / 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` | +| 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; `order_listing.py`: `read_overview`, `answer_overview` for chat replies | | 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 935f7d5..41d0fb3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -153,7 +153,7 @@ tests/ # 按业务模块组织的回归 `order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。 -登录成功或恢复有效登录时,`overview.js` 自动请求既有 `/api/customer/session?overview=1` 并展开可收起的订单概览。普通会话状态查询保持原响应契约,游客响应不含订单事实。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。错误状态保留重试入口,退出或换账号立即清空概览,迟到响应受请求版本和客户身份约束。概览查询不创建聊天消息,查看计数及入仓码复用现有查询入口。 +用户询问“订单概览”“我的订单情况”时,`answer_overview` 在现有聊天流中回复,沿用订单事实的登录校验与会话保存。`read_overview` 复用 `scoped_orders`,在同一只读快照内计算全部订单、业务状态 PROCESSING 的履约中订单、明确异常数量和履约中订单的实际节点分布。最多5条重点订单优先列异常,其余按更新时间列履约中订单。结果仅出现在聊天消息及对应历史中,登录和恢复登录只更新身份状态。 语音反馈使用输入框上方的绝对定位浮层,不占输入区布局高度,4秒后收起。切换对话清除反馈,同一手势失败后须松开才能重新录音,Pointer、Touch 与键盘入口遵循同一规则。 diff --git a/logistics_chat/customer_service.py b/logistics_chat/customer_service.py index 56f64bf..331a203 100644 --- a/logistics_chat/customer_service.py +++ b/logistics_chat/customer_service.py @@ -3,7 +3,6 @@ import hashlib import json import secrets -import sqlite3 import time from flask import g, has_app_context, jsonify, request @@ -135,20 +134,8 @@ def bind_order_conversation(conv_id): connection.close() -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 session_status(): + return {'ok':True, 'authenticated':bool(g.customer), 'user':g.customer} def order_login_response(): diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index ccdbf71..f5abab3 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -335,6 +335,9 @@ def answer_query(message, conv_id): tenants = customer_tenant_ids() from .order_explanations import general_explanation, detail_question, explain_order if not references: + from .order_listing import answer_overview + overview=answer_overview(message,tenants) + if overview is not None:return overview explanation=general_explanation(message) if explanation:return {'t':explanation} count_intent = parse_count_query(message) if not references else None diff --git a/logistics_chat/order_listing.py b/logistics_chat/order_listing.py index bba5556..c189536 100644 --- a/logistics_chat/order_listing.py +++ b/logistics_chat/order_listing.py @@ -112,6 +112,29 @@ def read_overview(tenants): connection.close() +def answer_overview(message,tenants): + question=re.sub(r'[\s,,。.!!??]','',message) + chinese=re.fullmatch(r'(?:请|帮我|请帮我)?(?:看一下|看看|查看|查询|显示)?(?:一下)?' + r'(?:(?:我|当前账号)的?|当前|现在)?(?:订单|运单)(?:概览|概况|总体情况|整体情况|情况|汇总)(?:呢|吧|吗)?',question) + english=re.fullmatch(r'(?:please\s+)?(?:(?:show|give)\s+(?:me\s+)?)?(?:my\s+)?(?:order|shipment)s?\s+(?:overview|summary)',message.strip(' .!?'),re.I) + if not chinese and not english:return None + if not tenants: + from .customer_service import order_login_response + return order_login_response() + try: + data=read_overview(tenants) + except sqlite3.Error: + return {'t':'订单信息暂时无法读取,请稍后重试。'} + lines=[f'您当前共有 {data["total"]} 票订单,履约中 {data["processing"]} 票,异常订单 {data["exceptions"]} 票。'] + if data['stages']: + lines.append('履约中订单的当前实际节点:') + lines.extend(f'- {STAGE_LABELS[stage["code"]]}:{stage["count"]}票' for stage in data['stages']) + if data['orders']: + lines.append('重点订单(异常优先,最多5票):') + lines.extend(row_text(order) for order in data['orders']) + return {'t':'\n'.join(lines),'_customer_data':True} + + 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') diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py index ddd90db..1bd8546 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(include_overview=request.args.get('overview')=='1') + return customer_service.session_status() @bp.post('/api/customer/login') diff --git a/static/css/chat.css b/static/css/chat.css index 6ed07c7..26b5684 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -305,82 +305,3 @@ 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 e6c985f..b467668 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -43,7 +43,6 @@ let historyRequestVersion = 0; let followLatest = true; let voiceInput = null; let customerLogin = null; -let customerOverview = null; let orderLookup = null; let lookupRequestVersion = 0; let customerRecovery = null; @@ -314,33 +313,6 @@ const I18N = { 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: "附件正在上传,请稍候…", @@ -458,33 +430,6 @@ const I18N = { 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…", @@ -581,7 +526,6 @@ 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")){ @@ -2129,15 +2073,9 @@ 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; diff --git a/static/js/overview.js b/static/js/overview.js deleted file mode 100644 index b3f41c8..0000000 --- a/static/js/overview.js +++ /dev/null @@ -1,130 +0,0 @@ -// 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/templates/index.html b/templates/index.html index 8bcd534..ed2900f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -38,10 +38,6 @@ -
@@ -104,7 +100,6 @@ - diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 367d8c0..7df3fcd 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -116,7 +116,6 @@ 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(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} @@ -124,7 +123,7 @@ async function customerPage(pageOptions = {}){ return {ok:true, json:async () => data}; }, }); - for(const file of ['support.js', 'visitor.js', 'customer.js', 'overview.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){ + for(const file of ['support.js', 'visitor.js', 'customer.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; @@ -221,53 +220,16 @@ test('the homepage and exception shortcut match the US service in both languages } }); -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); +test('login keeps the chat layout and an overview is requested only by a chat question', async () => { 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:[]}})); + assert.equal(app.calls.filter(call=>call.url.includes('overview=1') || call.url==='/stream_chat').length,0); + const template=fs.readFileSync(path.join(__dirname,'../../templates/index.html'),'utf8'); + assert.doesNotMatch(template,/customer-overview|js\/overview\.js/); + await app.context.window.fillInput('我的订单概览'); 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/); + assert.equal(app.calls.filter(call=>call.url==='/stream_chat').length,1); + assert.equal(app.calls.find(call=>call.url==='/stream_chat').body.msg,'我的订单概览'); + assert.match(app.element('chatDom').textContent,/测试 AI 回复/); }); test('voice errors use a floating notice without changing the composer status', async () => { diff --git a/tests/test_order_statistics.py b/tests/test_order_statistics.py index 4161e2d..68fa9d5 100644 --- a/tests/test_order_statistics.py +++ b/tests/test_order_statistics.py @@ -57,25 +57,28 @@ 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): + def test_overview_is_requested_in_chat_and_saved_in_customer_protected_history(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) + for question in ('我的订单概览','查看订单情况','Order overview'): + self.assertTrue(self.ask(question)['login_required']) 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'] + with patch('logistics_chat.order_listing.read_overview') as read: + self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json) + read.assert_not_called() + cid=self.client.post('/api/new_chat').json['id'] + text=self.ask('我的订单概览',cid)['t'] + self.assertIn('共有 16 票订单,履约中 12 票,异常订单 7 票',text) + self.assertIn('船舶已到目的港:10票',text) + self.assertEqual(text.count('- 入仓码:'),5) + self.assertEqual(text.count('异常原因:'),5) + response=self.client.get('/api/get_messages',query_string={'conv_id':cid}) 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.assertTrue(any(row['role']=='assistant' and row['content']==text for row in response.json)) self.client.post('/api/customer/logout') - self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json) + self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[]) + self.login() + self.assertTrue(any(row['content']==text for row in self.client.get('/api/get_messages',query_string={'conv_id':cid}).json)) def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self): self.login() @@ -83,23 +86,24 @@ class OrderStatisticsTests(unittest.TestCase): 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'] + from logistics_chat.order_listing import read_overview + with self.app.app_context():overview=read_overview(('a',)) 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)) + refreshed=self.ask('订单概览')['t'] + self.assertIn('共有 17 票订单,履约中 13 票,异常订单 2 票',refreshed) 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) + result=self.ask('订单概览') + self.assertTrue(self.client.get('/api/customer/session').json['authenticated']) + self.assertIn('暂时无法读取',result['t']) + self.assertNotIn('共有',result['t']) self.assertNotIn('private details',str(result)) def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):