移除常驻概览并通过聊天保存订单汇总
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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 与键盘入口遵循同一规则。
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user