diff --git a/AGENTS.md b/AGENTS.md index 39296fa..cfd1fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | 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 | -| My orders | `logistics_chat/customer_order_service.py`: scoped read-only list and exact detail; `routes/customer.py`: `/api/customer/orders`; `static/js/customer_orders.js`: automatic login panel, pagination, tracking, logout cleanup | +| My orders | `logistics_chat/customer_order_service.py`: scoped overview counts, status filters, read-only list and exact detail; `routes/customer.py`: `/api/customer/orders`; `static/js/customer_orders.js`: automatic login panel, pagination, tracking, logout cleanup | | Staff login | `logistics_chat/staff_service.py`: opaque 24-hour sessions, default-deny endpoint allowlist, CSRF, login/logout; `routes/admin.py`; `static/js/admin.js` | | 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 4254936..59d647b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -92,7 +92,8 @@ tests/ # 按业务模块组织的回归 - 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。 - 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。 - 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。 -- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;`GET /api/customer/orders/` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。退出/401 清空面板,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。 +- 登录后由 `customer_orders.js` 自动展开只读“我的订单”面板,不切换聊天或改写草稿。`GET /api/customer/orders` 按 ID 倒序每页 20 条,以 `before` 游标继续;同时返回同一数据库快照下的全量授权范围 `summary`,不受分页或 `status` 筛选影响。状态分组为 `pending`(草稿、已提交、审核通过、已接单)、`processing`(履约中)、`completed`(头程订单已完成)、`cancelled`(已取消)与 `other`(驳回及未识别状态),`all` 查看全部;履约状态不推断实际运输状态。页面标题显示真实总数,列表另标已加载数量,点击概况重置游标并在服务端筛选。`GET /api/customer/orders/` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。页面重新可见或窗口重新获得焦点时,先验证客户登录,再自动刷新当前状态筛选的第一页与全量概况;同次唤醒事件合并,已有订单请求进行中时不重复刷新。自动刷新保持面板展开状态、轨迹详情及聊天草稿,失败保留原列表并提供重试;退出/401 清空面板及概况,异步结果受请求版本约束;Nginx 与 Flask 的公开路由白名单仅开放这两个客户鉴权入口,旧 `/api/my_orders` 继续受后台保护。 +- 客户订单错误按网络失败、10 秒本地超时/408/504、429 限流、5xx 服务异常及成功响应格式异常区分中英文提示。400/403/详情 404 不提供原请求重试,分别引导刷新、联系人工、返回订单列表;401 沿用清空数据并复核登录的流程。HTTP 状态优先于错误响应体,不直接显示服务端内部诊断;取消和迟到请求不得覆盖当前视图。 - 客户附件预览由 `clearAttachment` 管理,轨迹展开由 `fillTrackCard` 管理。客服 `drafts` 按 `sid` 保存草稿,发送成功后只清理已确认发送的内容;常用回复仅填入草稿。`renderList` 只在展示数据变化时重绘。 - 客户文字草稿由 `drafts.js` 的 `ChatDrafts.create` 管理,脚本在 `visitor.js` 之后、`chat.js` 之前加载;localStorage 按访客 key 和会话分条存储,sessionStorage 仅记录当前标签页的活动会话。切换前同步保存,输入防抖及页面隐藏时保存;无持久存储时使用页面内存并提示。恢复历史的异步结果受会话版本约束,加载失败禁止继续发送,删除会话同步清除其草稿。草稿不含未发送附件,不跨设备同步。 - 人工申请由服务端以 UTC+8 判断每日 08:00–22:00 营业状态,客户手机时区不参与判断。附件清理保留 `messages` 和 `human_messages` 引用的文件,仅删除超过 7 天的未引用临时文件;读取引用失败时停止清理。附件不存在或不属于当前游客均返回相同的私有、不可缓存 404 页面,破图在聊天中显示可读提示。 diff --git a/logistics_chat/customer_order_service.py b/logistics_chat/customer_order_service.py index 6e6eced..2f3a525 100644 --- a/logistics_chat/customer_order_service.py +++ b/logistics_chat/customer_order_service.py @@ -1,6 +1,7 @@ """Read-only customer order browsing; scope comes exclusively from verified sessions.""" import sqlite3 +from contextlib import contextmanager from .customer_service import effective_tenants from .db import connect @@ -10,20 +11,22 @@ from .visitor_service import customer_tenant_ids PAGE_SIZE = 20 SUMMARY_FIELDS = ('id', 'order_no', 'inbound_code', 'status', 'country_code', 'warehouse_code', 'transport_mode') +STATUS_GROUPS = { + 'pending': ('DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED'), + 'processing': ('PROCESSING',), 'completed': ('COMPLETED',), 'cancelled': ('CANCELLED',), +} +FILTERS = ('all', *STATUS_GROUPS, 'other') -def scoped_rows(columns, suffix='', values=()): - tenants = effective_tenants(customer_tenant_ids()) - if not tenants: - return [] +@contextmanager +def order_connection(): connection = None try: connection = connect() connection.row_factory = sqlite3.Row - placeholders = ','.join('?' for _ in tenants) - return [dict(row) for row in connection.execute( - f'SELECT {columns} FROM order_test_data WHERE tenant_id IN ({placeholders}) '+suffix, - (*tenants, *values))] + # Summary and list must describe the same snapshot, including during imports. + connection.execute('BEGIN') + yield connection except sqlite3.Error: raise OrderDataUnavailable from None finally: @@ -31,16 +34,41 @@ def scoped_rows(columns, suffix='', values=()): connection.close() -def list_orders(before=None): - rows = scoped_rows(','.join(SUMMARY_FIELDS), - ('AND id PAGE_SIZE else None} def order_detail(order_id): if not 0 < order_id <= 9223372036854775807: return None - rows = scoped_rows('*', 'AND id=? LIMIT 1', (order_id,)) + with order_connection() as connection: + rows = scoped_rows(connection, '*', 'AND id=? LIMIT 1', (order_id,)) return build_card(rows[0]) if rows else None diff --git a/logistics_chat/routes/customer.py b/logistics_chat/routes/customer.py index 1e218f4..e8f3362 100644 --- a/logistics_chat/routes/customer.py +++ b/logistics_chat/routes/customer.py @@ -46,12 +46,15 @@ def order_request(action): @bp.get('/api/customer/orders') def orders(): def browse(): + status = request.args.get('status', 'all') + if status not in customer_order_service.FILTERS: + return {'ok': False, 'error': '订单状态筛选无效。'}, 400 before = request.args.get('before') if before is not None: if not before.isascii() or not before.isdigit() or len(before) > 19 or not 0 < int(before) <= 9223372036854775807: return {'ok': False, 'error': '分页参数无效。'}, 400 before = int(before) - return customer_order_service.list_orders(before) + return customer_order_service.list_orders(before, status) return order_request(browse) diff --git a/static/css/chat.css b/static/css/chat.css index 057842a..679528d 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -54,6 +54,13 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; } #customer-orders-toggle[aria-expanded="true"]::after { transform: rotate(180deg); } .customer-orders-body { min-height: 0; overflow: auto; padding: 8px 26px 12px; overscroll-behavior: contain; } .customer-orders-list { display: flex; flex-direction: column; } +.customer-orders-summary { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); gap: 6px; padding: 4px 0 10px; } +.customer-orders .customer-order-filter { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 6px; color: var(--secondary); border-color: var(--border); } +.customer-order-filter strong { color: var(--text); font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; } +.customer-orders .customer-order-filter[aria-pressed="true"] { color: var(--brand-text); background: var(--brand-soft); border-color: var(--brand); } +.customer-order-filter[aria-pressed="true"] strong { color: var(--brand-text); } +.customer-orders-progress { margin: 10px 4px 0; color: var(--secondary); text-align: center; font-size: 13px; } +@media (max-width: 1100px) { .customer-orders-summary { grid-template-columns: repeat(3,minmax(0,1fr)); } } .customer-orders .customer-order-row { display: grid; grid-template-columns: minmax(130px,1.2fr) minmax(60px,1fr) auto auto; gap: 16px; align-items: center; text-align: left; width: 100%; border-bottom: 1px solid var(--border); border-radius: 0; padding: 12px 6px; color: var(--text); } .customer-order-identity { min-width: 0; display: flex; flex-direction: column; gap: 4px; } .customer-order-identity strong { overflow-wrap: anywhere; font-size: 15px; font-weight: 600; } diff --git a/static/js/chat.js b/static/js/chat.js index de69d1d..b7a7149 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -256,7 +256,16 @@ const I18N = { myOrders: "我的订单", ordersRefresh: "刷新", ordersBack: "← 返回订单列表", ordersMore: "加载更多订单", ordersRetry: "重试", ordersTrack: "查看轨迹", ordersLoading: "正在加载订单…", ordersEmpty: "当前账号暂无可查看的订单。", - ordersFailed: "订单加载失败,请检查网络后重试。", + ordersFailed: "订单加载失败,请稍后重试。", + ordersNetworkError: "网络连接失败,请检查网络后重试。", ordersTimeout: "订单请求超时,请重试。", + ordersUnavailable: "订单不存在或当前账号无权查看,请返回订单列表并刷新。", + ordersForbidden: "当前账号无权查看,请联系人工客服核对订单权限。", + ordersRateLimited: "操作过于频繁,请稍后再试。", ordersServerError: "订单服务暂时不可用,请稍后重试。", + ordersInvalidRequest: "订单请求无效,请点击订单区的“刷新”重新加载。", + ordersInvalidResponse: "订单数据暂时无法读取,请重试;若仍失败,请联系人工客服。", + ordersOverview: "订单概况", ordersGroup_all: "全部", ordersGroup_processing: "履约中", + ordersGroup_completed: "已完成", ordersGroup_pending: "待履约", ordersGroup_cancelled: "已取消", ordersGroup_other: "其他", + ordersProgress: "已加载 {loaded} / {total} 笔", ordersFilterEmpty: "当前状态下暂无订单。", orderStatus_DRAFT: "草稿", orderStatus_SUBMITTED: "已提交", orderStatus_APPROVED: "审核通过", orderStatus_ACCEPTED: "已接单", orderStatus_REJECTED: "审核驳回", orderStatus_PROCESSING: "履约中", orderStatus_COMPLETED: "头程订单已完成", orderStatus_CANCELLED: "已取消", @@ -389,7 +398,16 @@ const I18N = { myOrders: "My orders", ordersRefresh: "Refresh", ordersBack: "← Back to orders", ordersMore: "Load more orders", ordersRetry: "Retry", ordersTrack: "View tracking", ordersLoading: "Loading orders…", ordersEmpty: "No orders are available for this account.", - ordersFailed: "Could not load orders. Check your connection and retry.", + ordersFailed: "Could not load orders. Please try again later.", + ordersNetworkError: "Connection failed. Check your network and retry.", ordersTimeout: "The order request timed out. Please retry.", + ordersUnavailable: "This order does not exist or you cannot access it. Return to the order list and refresh.", + ordersForbidden: "You cannot access these orders. Contact support to check your permissions.", + ordersRateLimited: "Too many requests. Please wait and try again.", ordersServerError: "The order service is temporarily unavailable. Please try again later.", + ordersInvalidRequest: "The order request is invalid. Use Refresh in the orders panel to reload.", + ordersInvalidResponse: "Could not read the order data. Retry, or contact support if the problem continues.", + ordersOverview: "Order overview", ordersGroup_all: "All", ordersGroup_processing: "In progress", + ordersGroup_completed: "Completed", ordersGroup_pending: "Pending", ordersGroup_cancelled: "Cancelled", ordersGroup_other: "Other", + ordersProgress: "Loaded {loaded} of {total} orders", ordersFilterEmpty: "No orders with this status.", orderStatus_DRAFT: "Draft", orderStatus_SUBMITTED: "Submitted", orderStatus_APPROVED: "Approved", orderStatus_ACCEPTED: "Accepted", orderStatus_REJECTED: "Rejected", orderStatus_PROCESSING: "In progress", orderStatus_COMPLETED: "First leg completed", orderStatus_CANCELLED: "Cancelled", @@ -2120,7 +2138,10 @@ customerOrders = CustomerOrders.create({fetch:fetch, text:t, renderCard:fillTrac onUnauthorized:function(){ if(customerLogin) customerLogin.refresh(); } }); customerLogin = CustomerLogin.create({fetch:fetch, text:t, - onSession:function(user){ customerOrders.setUser(user); }, + onSession:function(user,refreshOrders){ + customerOrders.setUser(user); + if(refreshOrders) customerOrders.refresh(); + }, onOpen:function(){ if(voiceInput) voiceInput.cancel(); sideOpen=false; renderSidebar(); syncChatViewport(); }, onChange:function(user,previous){ if(previous === undefined) return; diff --git a/static/js/customer.js b/static/js/customer.js index 1230afd..acb9a09 100644 --- a/static/js/customer.js +++ b/static/js/customer.js @@ -9,7 +9,7 @@ var CustomerLogin = { const cancel=document.getElementById('customer-cancel'); const error=document.getElementById('customer-error'); const accounts=['customer-account','customer-account-mobile'].map(id=>document.getElementById(id)).filter(Boolean); - let user, pending=null, opener=null, busy=false, version=0; + let user, pending=null, opener=null, busy=false, version=0, resumePending=false; overlay.hidden=true; function status(message){ error.textContent=message || ''; error.hidden=!message; } function render(){ @@ -26,14 +26,14 @@ var CustomerLogin = { username.placeholder=options.text('customerUsernamePlaceholder'); password.placeholder=options.text('customerPasswordPlaceholder'); } - function update(next){ + function update(next, refreshOrders){ const previous=user; user=next; render(); if(options.onChange && (previous === undefined || (previous && previous.id) !== (user && user.id))){ options.onChange(user,previous); } - if(options.onSession) options.onSession(user); + if(options.onSession) options.onSession(user,Boolean(refreshOrders)); } async function request(path, data){ const controller=new AbortController(); @@ -49,12 +49,12 @@ var CustomerLogin = { return result; }finally{ clearTimeout(timer); } } - async function refresh(){ + async function refresh(refreshOrders){ if(busy) return; const token=++version; try{ const result=await request('/api/customer/session'); - if(token === version) update(result.authenticated && result.user ? result.user : null); + if(token === version) update(result.authenticated && result.user ? result.user : null,refreshOrders); }catch(error){} // Query endpoints still enforce login if status cannot refresh. } function restoreFocus(){ @@ -121,8 +121,15 @@ var CustomerLogin = { finally{ if(token === version) setBusy(false); } } accounts.forEach(account=>{account.onclick=activateAccount;}); - window.addEventListener('focus',refresh); - document.addEventListener('visibilitychange',function(){ if(!document.hidden) refresh(); }); + async function resume(){ + // Browsers often emit both events for one return to the page. + if(document.hidden || resumePending) return; + resumePending=true; + try{ await refresh(true); } + finally{ resumePending=false; } + } + window.addEventListener('focus',resume); + document.addEventListener('visibilitychange',resume); render(); return {open:open,refresh:refresh,render:render,require:async function(resume){ if(busy) return; diff --git a/static/js/customer_orders.js b/static/js/customer_orders.js index c629230..349829a 100644 --- a/static/js/customer_orders.js +++ b/static/js/customer_orders.js @@ -5,6 +5,19 @@ var CustomerOrders = { const root=node(''), toggle=node('toggle'), body=node('body'), list=node('list'); const detail=node('detail'), message=node('message'), more=node('more'); const refresh=node('refresh'), back=node('back'), retry=node('retry'); + const overview=node('summary'), progress=node('progress'); + const groups=['all','processing','completed','pending','cancelled','other']; + const filters=groups.map(function(group){ + const button=document.createElement('button'); + button.type='button';button.className='customer-order-filter';button.dataset.filter=group; + button.onclick=function(){ + if(activeFilter === group && selected === null && !errorKey) return; + activeFilter=group;loadList(false); + }; + overview.appendChild(button); + return button; + }); + let summary=null, activeFilter='all'; let user=null, rows=[], cursor=null, card=null, selected=null; let expanded=false, loading=false, errorKey='', retryAction=null, version=0, controller=null, listScroll=0; function cancel(){ @@ -15,7 +28,7 @@ var CustomerOrders = { } function render(){ root.hidden=!user; - toggle.textContent=options.text('myOrders')+(rows.length ? ' · '+rows.length : ''); + toggle.textContent=options.text('myOrders')+(summary ? ' · '+summary.total : ''); toggle.setAttribute('aria-expanded',String(expanded)); body.hidden=!expanded; refresh.hidden=!expanded; @@ -24,13 +37,27 @@ var CustomerOrders = { back.hidden=selected === null; list.hidden=selected !== null; detail.hidden=selected === null; + overview.hidden=!summary || selected !== null; + overview.setAttribute('aria-label',options.text('ordersOverview')); + filters.forEach(function(button){ + const group=button.dataset.filter; + button.textContent=''; + button.setAttribute('aria-pressed',String(activeFilter === group)); + if(!summary) return; + const label=document.createElement('span');label.textContent=options.text('ordersGroup_'+group); + const count=document.createElement('strong');count.textContent=String(summary[group === 'all' ? 'total' : group]); + button.append(label,count); + }); + progress.hidden=!summary || selected !== null || !rows.length || loading; + progress.textContent=summary ? options.text('ordersProgress').replace('{loaded}',rows.length) + .replace('{total}',summary[activeFilter === 'all' ? 'total' : activeFilter]) : ''; more.hidden=selected !== null || !cursor; more.disabled=loading; more.textContent=options.text('ordersMore'); - retry.hidden=!errorKey; + retry.hidden=!errorKey || !retryAction; retry.textContent=options.text('ordersRetry'); message.textContent=errorKey ? options.text(errorKey) : loading ? options.text('ordersLoading') - : !rows.length ? options.text('ordersEmpty') : ''; + : !rows.length ? options.text(activeFilter === 'all' ? 'ordersEmpty' : 'ordersFilterEmpty') : ''; message.hidden=!message.textContent; body.setAttribute('aria-busy',String(loading)); } @@ -66,7 +93,8 @@ var CustomerOrders = { const token=version; const active=new AbortController();controller=active; loading=true;errorKey='';retryAction=retryRequest;render(); - const timer=setTimeout(()=>active.abort(),10000); + let timedOut=false, receivedResponse=false; + const timer=setTimeout(()=>{timedOut=true;active.abort();},10000); try{ const response=await options.fetch(url,{signal:active.signal,cache:'no-store'}); if(token !== version) return; @@ -75,27 +103,41 @@ var CustomerOrders = { if(options.onUnauthorized) options.onUnauthorized(); return; } - if(!response.ok) throw new Error('orders unavailable'); + receivedResponse=true; + if(!response.ok){ + const errors={400:'ordersInvalidRequest',403:'ordersForbidden',408:'ordersTimeout', + 429:'ordersRateLimited',504:'ordersTimeout'}; + errorKey=response.status === 404 && /\/orders\/\d+$/.test(url) ? 'ordersUnavailable' + : errors[response.status] || (response.status >= 500 ? 'ordersServerError' : 'ordersFailed'); + if([400,403,404].includes(response.status)) retryAction=null; + return; + } const result=await response.json(); if(token !== version) return; apply(result); }catch(error){ - if(token === version) errorKey='ordersFailed'; + if(token === version) errorKey=timedOut ? 'ordersTimeout' + : receivedResponse ? 'ordersInvalidResponse' : 'ordersNetworkError'; }finally{ clearTimeout(timer); if(token === version){ controller=null;loading=false;render(); } } } - function loadList(append){ + function loadList(append, preserveView){ if(!user || (append && (loading || !cursor))) return; - if(!append){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); } - const suffix=append ? '?before='+cursor : ''; + if(!append && !preserveView){ selected=null;card=null;rows=[];cursor=null;body.scrollTop=0;renderCard();renderRows(); } + const params=[]; + if(activeFilter !== 'all') params.push('status='+activeFilter); + if(append) params.push('before='+cursor); + const suffix=params.length ? '?'+params.join('&') : ''; return request('/api/customer/orders'+suffix,function(result){ if(!Array.isArray(result.orders)) throw new Error('invalid order list'); + // Older responses may lack an overview; never infer totals from one page. + summary=result.summary || null; rows=append ? rows.concat(result.orders) : result.orders; cursor=result.next_before || null; renderRows(); - },()=>loadList(append)); + },()=>loadList(append,preserveView)); } function loadDetail(id){ if(!user) return; @@ -112,6 +154,7 @@ var CustomerOrders = { function setUser(next){ if(user && next && user.id === next.id) return; cancel();user=next;rows=[];cursor=null;card=null;selected=null;errorKey='';retryAction=null; + summary=null;activeFilter='all'; expanded=Boolean(user);renderRows();renderCard();render(); if(user) loadList(false); } @@ -127,6 +170,8 @@ var CustomerOrders = { if(button) button.focus({preventScroll:true}); }; render(); - return {setUser:setUser,render:function(){renderRows();renderCard();render();}}; + return {setUser:setUser,refresh:function(){ + if(user && !loading) return loadList(false,true); + },render:function(){renderRows();renderCard();render();}}; } }; diff --git a/templates/index.html b/templates/index.html index 5b524bb..2b896fc 100644 --- a/templates/index.html +++ b/templates/index.html @@ -51,10 +51,12 @@ diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 08a8855..e5e99e2 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -152,7 +152,7 @@ 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' && pageOptions.customer ? {ok:true,authenticated:true,user:pageOptions.customer} - : url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null} + : url === '/api/customer/orders' ? {ok:true,orders:pageOptions.orders || [],next_before:null,summary:pageOptions.orderSummary} : 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} @@ -175,6 +175,7 @@ async function customerPage(pageOptions = {}){ await flush(); return {context, element, calls, hooks, dialogs, quickButtons, voiceOptions,storage:storageValues,tabStorage:tabValues, documentEvent:(name,event)=>{ if(listeners[name]) listeners[name](event); }, + windowEvent:(name,event)=>{ (windowListeners[name] || []).forEach(callback=>callback(event)); }, resize:async height => { context.window.innerHeight=height; if(context.window.visualViewport) context.window.visualViewport.height=height; @@ -222,20 +223,79 @@ function streamResponse(chunks){ : {done:true}})}}; } +test('returning to the page refreshes orders after validating the same customer and coalesces foreground events', async()=>{ + const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[ + {id:41,order_no:'ORDER41',status:'PROCESSING'}],orderSummary:{total:1,processing:1}}); + const before=app.calls.filter(call=>call.url==='/api/customer/orders').length; + app.element('input-text').value='正在输入的草稿'; + app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42',status:'COMPLETED'}], + next_before:null,summary:{total:2,processing:1,completed:1,pending:0,cancelled:0,other:0}}); + app.context.document.hidden=true; + app.documentEvent('visibilitychange');app.windowEvent('focus');await flush(); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before); + app.context.document.hidden=false; + app.documentEvent('visibilitychange');app.windowEvent('focus');await flush(); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1); + assert.match(app.element('customer-orders-toggle').textContent,/2/); + assert.match(app.element('customer-orders-list').textContent,/ORDER42/); + assert.equal(app.element('input-text').value,'正在输入的草稿'); +}); + +test('returning with an expired login clears orders instead of refreshing protected data',async()=>{ + const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]}); + const before=app.calls.filter(call=>call.url==='/api/customer/orders').length; + app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:false,user:null}); + app.windowEvent('focus');await flush(); + assert.equal(app.element('customer-orders').hidden,true); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before); +}); + +test('a failed session check on return retains orders and a later return retries normally',async()=>{ + const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[{id:41,order_no:'ORDER41'}]}); + const before=app.calls.filter(call=>call.url==='/api/customer/orders').length; + app.hooks['/api/customer/session']=()=>Promise.reject(new Error('offline')); + app.windowEvent('focus');await flush(); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before); + assert.match(app.element('customer-orders-list').textContent,/ORDER41/); + app.hooks['/api/customer/session']=()=>response({ok:true,authenticated:true,user:{id:22,name:'客户'}}); + app.hooks['/api/customer/orders']=()=>response({orders:[{id:42,order_no:'ORDER42'}],next_before:null}); + app.windowEvent('focus');await flush(); + assert.match(app.element('customer-orders-list').textContent,/ORDER42/); + assert.equal(app.calls.filter(call=>call.url==='/api/customer/orders').length,before+1); +}); + test('an existing customer session automatically shows scoped orders and translates the panel', async () => { const app=await customerPage({human:false,customer:{id:22,name:'客户'},orders:[ - {id:41,order_no:'ORDER20260041',inbound_code:'IN20260041',status:'PROCESSING',transport_mode:'SEA'}]}); + {id:41,order_no:'ORDER20260041',inbound_code:'IN20260041',status:'PROCESSING',transport_mode:'SEA'}], + orderSummary:{total:26,processing:20,completed:1,pending:4,cancelled:0,other:1}}); assert.equal(app.element('customer-orders').hidden,false); assert.match(app.element('customer-orders-list').textContent,/IN20260041/); + assert.match(app.element('customer-orders-toggle').textContent,/26/); + assert.match(app.element('customer-orders-summary').textContent,/履约中20/); await app.click(app.element('lang-toggle')); assert.match(app.element('customer-orders-toggle').textContent,/My orders/); assert.match(app.element('customer-orders-list').textContent,/In progress/); + assert.match(app.element('customer-orders-summary').textContent,/Completed1/); + assert.match(app.element('customer-orders-progress').textContent,/Loaded 1 of 26/); app.hooks['/api/customer/logout']=()=>response({ok:true}); await app.click(app.element('customer-account')); assert.equal(app.element('customer-orders').hidden,true); assert.equal(app.element('customer-orders-list').textContent,''); }); +test('order errors explain the actual failure and translate without displaying server diagnostics',async()=>{ + const app=await customerPage({human:false,customer:{id:22,name:'客户'}}); + app.hooks['/api/customer/orders']=()=>response({error:'private server traceback'},503); + await app.click(app.element('customer-orders-refresh')); + assert.match(app.element('customer-orders-message').textContent,/订单服务暂时不可用/); + assert.doesNotMatch(app.element('customer-orders-message').textContent,/网络|private|traceback/); + await app.click(app.element('lang-toggle')); + assert.match(app.element('customer-orders-message').textContent,/temporarily unavailable/); + app.hooks['/api/customer/orders']=()=>Promise.reject(new TypeError('Failed to fetch')); + await app.click(app.element('customer-orders-retry')); + assert.match(app.element('customer-orders-message').textContent,/Check your network/); +}); + test('AI send preserves a newer draft typed while the conversation is being created', async () => { const app=await customerPage({human:false}); let resolve; diff --git a/tests/frontend/customer_orders.test.cjs b/tests/frontend/customer_orders.test.cjs index fcb9813..8e5cc17 100644 --- a/tests/frontend/customer_orders.test.cjs +++ b/tests/frontend/customer_orders.test.cjs @@ -15,19 +15,116 @@ function page(){ setAttribute(key,value){ this.attributes[key]=String(value); } focus(){ this.focused=true; } } - const ids={}, requests=[]; + const ids={}, requests=[], timers=new Map(); + let timerId=0; const el=id=>ids[id] ||= new Element(); - const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController, setTimeout, clearTimeout}); + const context=vm.createContext({document:{getElementById:el,createElement:()=>new Element()}, AbortController, + setTimeout:callback=>{const id=++timerId;timers.set(id,callback);return id;}, + clearTimeout:id=>timers.delete(id)}); vm.runInContext(fs.readFileSync('static/js/customer_orders.js','utf8'),context); - const orders=context.CustomerOrders.create({text:key=>key, + const orders=context.CustomerOrders.create({text:key=>key === 'ordersProgress' ? 'Loaded {loaded} of {total}' : key, fetch:(url,options)=>new Promise((resolve,reject)=>requests.push({url,options,resolve,reject})), renderCard:(element,card)=>{element.textContent=card.no;}, onUnauthorized:()=>{page.expired=true;}}); - return {el,orders,requests,reply:async (i,data,status=200)=>{ + return {el,orders,requests,timeout:()=>{for(const callback of Array.from(timers.values())) callback();},reply:async (i,data,status=200)=>{ requests[i].resolve({ok:status===200,status,json:async()=>data}); await flush(); }}; } const row=(id=1)=>({id,order_no:'ORDER'+id,inbound_code:'INBOUND'+id,status:'PROCESSING'}); +const counts={total:26,pending:4,processing:20,completed:1,cancelled:0,other:1}; +const filter=(p,name)=>p.el('customer-orders-summary').children.find(button=>button.dataset.filter===name); + +test('overview uses full server totals; a status click filters from the first page',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:1,summary:counts}); + assert.match(p.el('customer-orders-toggle').textContent,/26/); + assert.match(filter(p,'processing').textContent,/20/); + filter(p,'completed').onclick(); + assert.equal(p.requests[1].url,'/api/customer/orders?status=completed'); + assert.equal(p.el('customer-orders-list').textContent,''); + await p.reply(1,{orders:[row(9)],next_before:null,summary:counts}); + assert.equal(filter(p,'completed').attributes['aria-pressed'],'true'); + assert.equal(filter(p,'all').attributes['aria-pressed'],'false'); + assert.match(p.el('customer-orders-progress').textContent,/1/); + filter(p,'processing').onclick(); + await p.reply(2,{orders:[row()],next_before:1,summary:counts}); + p.el('customer-orders-more').onclick(); + assert.equal(p.requests[3].url,'/api/customer/orders?status=processing&before=1'); + await p.reply(3,{orders:[row(2)],next_before:null,summary:counts}); + assert.match(p.el('customer-orders-list').textContent,/INBOUND1.*INBOUND2/); +}); + +test('network failure and timeout show different recoverable messages',async()=>{ + const p=page();p.orders.setUser({id:1}); + p.requests[0].reject(new TypeError('Failed to fetch'));await flush(); + assert.equal(p.el('customer-orders-message').textContent,'ordersNetworkError'); + assert.equal(p.el('customer-orders-retry').hidden,false); + p.el('customer-orders-retry').onclick();p.timeout(); + assert.equal(p.requests[1].options.signal.aborted,true); + p.requests[1].reject(Object.assign(new Error('abort'),{name:'AbortError'}));await flush(); + assert.equal(p.el('customer-orders-message').textContent,'ordersTimeout'); + assert.equal(p.el('customer-orders-retry').hidden,false); + p.el('customer-orders-retry').onclick(); + await p.reply(2,{orders:[row()],next_before:null}); + assert.equal(p.el('customer-orders-message').hidden,true); +}); + +test('HTTP errors are classified without showing raw server output',async()=>{ + for(const [status,key,retryable] of [[400,'ordersInvalidRequest',false],[403,'ordersForbidden',false], + [429,'ordersRateLimited',true],[500,'ordersServerError',true],[502,'ordersServerError',true], + [503,'ordersServerError',true],[408,'ordersTimeout',true],[504,'ordersTimeout',true],[418,'ordersFailed',true]]){ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{error:'private traceback with secret'},status); + assert.equal(p.el('customer-orders-message').textContent,key,'status '+status); + assert.equal(p.el('customer-orders-retry').hidden,!retryable,'status '+status); + assert.doesNotMatch(p.el('customer-orders-message').textContent,/private|secret/); + } +}); + +test('missing order directs the customer back to the list instead of retrying a missing record',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:null,summary:counts}); + p.el('customer-orders-list').children[0].onclick(); + await p.reply(1,{error:'not found or forbidden'},404); + assert.equal(p.el('customer-orders-message').textContent,'ordersUnavailable'); + assert.equal(p.el('customer-orders-back').hidden,false); + assert.equal(p.el('customer-orders-retry').hidden,true); + p.el('customer-orders-back').onclick(); + assert.equal(p.el('customer-orders-message').hidden,true); + assert.equal(p.el('customer-orders-list').hidden,false); +}); + +test('malformed successful responses are not reported as a network outage',async()=>{ + const p=page();p.orders.setUser({id:1}); + p.requests[0].resolve({ok:true,status:200,json:async()=>{throw new SyntaxError('Unexpected <');}});await flush(); + assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse'); + p.el('customer-orders-retry').onclick(); + await p.reply(1,{orders:'invalid'}); + assert.equal(p.el('customer-orders-message').textContent,'ordersInvalidResponse'); +}); + +test('an error page from a proxy uses HTTP status even when the body is not JSON',async()=>{ + const p=page();p.orders.setUser({id:1}); + let parsed=false; + p.requests[0].resolve({ok:false,status:502,json:async()=>{parsed=true;throw new SyntaxError('html');}}); + await flush(); + assert.equal(parsed,false); + assert.equal(p.el('customer-orders-message').textContent,'ordersServerError'); +}); + +test('switching filters ignores late results; logout erases counts and selection',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:null,summary:counts}); + filter(p,'completed').onclick();filter(p,'pending').onclick(); + await p.reply(2,{orders:[],next_before:null,summary:counts}); + await p.reply(1,{orders:[row(9)],next_before:null,summary:{...counts,total:999}}); + assert.doesNotMatch(p.el('customer-orders-toggle').textContent,/999/); + assert.equal(p.el('customer-orders-list').textContent,''); + assert.equal(p.el('customer-orders-message').textContent,'ordersFilterEmpty'); + p.orders.setUser(null); + assert.equal(p.el('customer-orders-summary').hidden,true); + assert.doesNotMatch(p.el('customer-orders-summary').textContent,/26|20/); +}); test('login automatically opens list; collapse/reopen and paging keep drafts outside panel untouched',async()=>{ const p=page();p.orders.setUser({id:1}); @@ -94,3 +191,51 @@ test('refresh cancels pagination; stale errors cannot replace the refreshed list assert.doesNotMatch(p.el('customer-orders-list').textContent,/INBOUND1/); assert.match(p.el('customer-orders-list').textContent,/INBOUND3/); }); + +test('automatic refresh keeps the filter, old rows, scroll and collapsed state until success',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:null,summary:counts}); + filter(p,'processing').onclick(); + await p.reply(1,{orders:[row(2)],next_before:2,summary:counts}); + p.el('customer-orders-body').scrollTop=85; + p.el('customer-orders-toggle').onclick(); + p.orders.refresh();p.orders.refresh(); + assert.equal(p.requests.length,3); + assert.equal(p.requests[2].url,'/api/customer/orders?status=processing'); + assert.match(p.el('customer-orders-list').textContent,/INBOUND2/); + await p.reply(2,{orders:[row(3)],next_before:null,summary:{...counts,total:27,processing:21}}); + assert.match(p.el('customer-orders-toggle').textContent,/27/); + assert.match(p.el('customer-orders-list').textContent,/INBOUND3/); + assert.equal(p.el('customer-orders-body').scrollTop,85); + assert.equal(p.el('customer-orders-body').hidden,true); + assert.equal(filter(p,'processing').attributes['aria-pressed'],'true'); +}); + +test('failed automatic refresh retains previous data and retry can update it',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:1,summary:counts}); + p.orders.refresh();p.requests[1].reject(new Error('offline'));await flush(); + assert.match(p.el('customer-orders-list').textContent,/INBOUND1/); + assert.match(p.el('customer-orders-toggle').textContent,/26/); + assert.equal(p.el('customer-orders-retry').hidden,false); + p.el('customer-orders-retry').onclick(); + await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}}); + assert.match(p.el('customer-orders-list').textContent,/INBOUND2/); +}); + +test('automatic refresh keeps the open tracking detail and logout invalidates late responses',async()=>{ + const p=page();p.orders.setUser({id:1}); + await p.reply(0,{orders:[row()],next_before:null,summary:counts}); + p.el('customer-orders-list').children[0].onclick(); + await p.reply(1,{card:{no:'ORDER1'}}); + p.orders.refresh(); + await p.reply(2,{orders:[row(2)],next_before:null,summary:{...counts,total:27}}); + assert.equal(p.el('customer-orders-detail').textContent,'ORDER1'); + assert.equal(p.el('customer-orders-list').hidden,true); + p.el('customer-orders-back').onclick(); + assert.match(p.el('customer-orders-list').textContent,/INBOUND2/); + p.orders.refresh();p.orders.setUser(null); + await p.reply(3,{orders:[row(99)],summary:counts}); + assert.equal(p.el('customer-orders-list').textContent,''); + assert.equal(p.el('customer-orders').hidden,true); +}); diff --git a/tests/test_customer_orders.py b/tests/test_customer_orders.py index d166114..d10244e 100644 --- a/tests/test_customer_orders.py +++ b/tests/test_customer_orders.py @@ -67,6 +67,9 @@ class CustomerOrderTests(unittest.TestCase): second = self.client.get('/api/customer/orders', query_string={'before': first['next_before']}).json self.assertEqual(len(second['orders']), 5) self.assertIsNone(second['next_before']) + self.assertEqual(first['summary'], {'total': 25, 'pending': 0, 'processing': 25, + 'completed': 0, 'cancelled': 0, 'other': 0}) + self.assertEqual(second['summary'], first['summary']) self.assertFalse({x['id'] for x in first['orders']} & {x['id'] for x in second['orders']}) for value in ['-1', '0', 'NaN', '9223372036854775808']: self.assertEqual(self.client.get('/api/customer/orders', query_string={'before': value}).status_code, 400) @@ -84,10 +87,12 @@ class CustomerOrderTests(unittest.TestCase): def test_empty_scope_and_configured_tenant_restriction(self): self.login('empty') self.assertEqual(self.client.get('/api/customer/orders').json['orders'], []) + self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0) self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404) self.app.config['ORDER_DATA_TENANT_ID'] = 'b' self.login() self.assertEqual(self.client.get('/api/customer/orders').json['orders'], []) + self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 0) self.assertEqual(self.client.get('/api/customer/orders/1').status_code, 404) def test_multiple_grants_and_revocation(self): @@ -100,6 +105,7 @@ class CustomerOrderTests(unittest.TestCase): con.close() self.login() self.assertEqual(self.client.get('/api/customer/orders').json['orders'][0]['id'], self.foreign_id) + self.assertEqual(self.client.get('/api/customer/orders').json['summary']['total'], 26) with self.app.app_context(): con = db.connect() con.execute("DELETE FROM customer_tenant_grants WHERE tenant_id='b'") @@ -113,3 +119,41 @@ class CustomerOrderTests(unittest.TestCase): response = self.client.get('/api/customer/orders') self.assertEqual(response.status_code, 503) self.assertNotIn('sqlite', response.get_data(as_text=True)) + + def test_overview_counts_full_scope_and_filters_before_paging(self): + statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'COMPLETED', + 'CANCELLED', 'REJECTED', 'UNKNOWN_FUTURE_STATUS'] + with self.app.app_context(): + con = db.connect() + for index, status in enumerate(statuses): + con.execute('UPDATE order_test_data SET status=? WHERE tenant_id=? AND order_no=?', + (status, 'a', f'A2026{index:04}')) + con.commit() + con.close() + self.login() + expected = {'total': 25, 'pending': 4, 'processing': 17, 'completed': 1, 'cancelled': 1, 'other': 2} + all_data = self.client.get('/api/customer/orders?tenant_id=b').json + self.assertEqual(all_data['summary'], expected) + for status, count in [('pending', 4), ('processing', 17), ('completed', 1), ('cancelled', 1), ('other', 2)]: + with self.subTest(status=status): + response = self.client.get('/api/customer/orders', query_string={'status': status, 'tenant_id': 'b'}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json['orders']), count) + self.assertEqual(response.json['summary'], expected) + self.assertIsNone(response.json['next_before']) + self.assertNotIn(self.foreign_id, [item['id'] for item in response.json['orders']]) + self.assertEqual({x['status'] for x in self.client.get('/api/customer/orders?status=other').json['orders']}, + {'REJECTED', 'UNKNOWN_FUTURE_STATUS'}) + for status in ['bad', 'PROCESSING', "pending' OR 1=1", '']: + self.assertEqual(self.client.get('/api/customer/orders', query_string={'status': status}).status_code, 400) + + def test_filtered_cursor_keeps_summary_and_does_not_include_other_statuses(self): + self.login() + first = self.client.get('/api/customer/orders?status=processing').json + second = self.client.get('/api/customer/orders', query_string={'status': 'processing', 'before': first['next_before']}).json + self.assertEqual(len(first['orders']), 20) + self.assertEqual(len(second['orders']), 5) + self.assertEqual(second['summary']['processing'], 25) + empty = self.client.get('/api/customer/orders?status=completed').json + self.assertEqual(empty['orders'], []) + self.assertEqual(empty['summary']['total'], 25)