移除常驻概览并通过聊天保存订单汇总

This commit is contained in:
2026-11-06 11:17:50 +08:00
parent d5692434ac
commit 311b9bd57d
12 changed files with 65 additions and 362 deletions
+1 -1
View File
@@ -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` |
+1 -1
View File
@@ -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 与键盘入口遵循同一规则。
+2 -15
View File
@@ -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():
+3
View File
@@ -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
+23
View File
@@ -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')
+1 -1
View File
@@ -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')
-79
View File
@@ -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; }
}
-62
View File
@@ -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;
-130
View File
@@ -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};
}
};
-5
View File
@@ -38,10 +38,6 @@
<button type="button" id="theme-toggle" class="theme-toggle" title="切换深色/浅色">☾</button>
</div>
</header>
<details id="customer-overview" class="customer-overview" hidden>
<summary><span id="customer-overview-title">订单概览</span><span id="customer-overview-summary"></span></summary>
<div id="customer-overview-body" class="customer-overview-body"></div>
</details>
<div id="chatDom" role="log" aria-live="polite" aria-label="聊天内容">
<section class="ai-welcome-card">
<div class="ai-welcome-top">
@@ -104,7 +100,6 @@
<script src="{{ url_for('static', filename='js/support.js') }}"></script>
<script src="{{ url_for('static', filename='js/visitor.js') }}"></script>
<script src="{{ url_for('static', filename='js/customer.js') }}"></script>
<script src="{{ url_for('static', filename='js/overview.js') }}"></script>
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
+9 -47
View File
@@ -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:'<img src=x onerror=bad()>'}
]};
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,'<img src=x onerror=bad()>');
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 () => {
+25 -21
View File
@@ -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):