新增订单概况并优化自动刷新和错误提示
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。退出/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/<id>` 返回精确订单的现有轨迹卡片。两者只使用服务端客户授权租户与配置范围的交集,不接受客户端租户声明;列表仅返回白名单摘要,详情不含内部备注。页面重新可见或窗口重新获得焦点时,先验证客户登录,再自动刷新当前状态筛选的第一页与全量概况;同次唤醒事件合并,已有订单请求进行中时不重复刷新。自动刷新保持面板展开状态、轨迹详情及聊天草稿,失败保留原列表并提供重试;退出/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 页面,破图在聊天中显示可读提示。
|
||||
|
||||
@@ -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<? ' if before is not None else '')+'ORDER BY id DESC LIMIT ?',
|
||||
((before,) if before is not None else ())+(PAGE_SIZE+1,))
|
||||
return {'ok': True, 'orders': rows[:PAGE_SIZE],
|
||||
def scoped_rows(connection, columns, suffix='', values=()):
|
||||
tenants = effective_tenants(customer_tenant_ids())
|
||||
if not tenants:
|
||||
return []
|
||||
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))]
|
||||
|
||||
|
||||
def list_orders(before=None, status='all'):
|
||||
if status not in FILTERS:
|
||||
raise ValueError('Invalid order status filter')
|
||||
summary = dict.fromkeys(('total', *STATUS_GROUPS, 'other'), 0)
|
||||
suffix, values = '', ()
|
||||
if status in STATUS_GROUPS or status == 'other':
|
||||
codes = STATUS_GROUPS.get(status, tuple(code for codes in STATUS_GROUPS.values() for code in codes))
|
||||
suffix = 'AND status '+('NOT IN' if status == 'other' else 'IN')+' ('+','.join('?' for _ in codes)+') '
|
||||
values = codes
|
||||
if before is not None:
|
||||
suffix += 'AND id<? '
|
||||
values += (before,)
|
||||
with order_connection() as connection:
|
||||
for row in scoped_rows(connection, 'status,COUNT(*) AS count', 'GROUP BY status'):
|
||||
group = next((key for key, codes in STATUS_GROUPS.items() if row['status'] in codes), 'other')
|
||||
summary[group] += row['count']
|
||||
summary['total'] += row['count']
|
||||
rows = scoped_rows(connection, ','.join(SUMMARY_FIELDS), suffix+'ORDER BY id DESC LIMIT ?', values+(PAGE_SIZE+1,))
|
||||
return {'ok': True, 'orders': rows[:PAGE_SIZE], 'summary': summary,
|
||||
'next_before': rows[PAGE_SIZE-1]['id'] if len(rows) > 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+24
-3
@@ -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;
|
||||
|
||||
+14
-7
@@ -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;
|
||||
|
||||
@@ -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();}};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,10 +51,12 @@
|
||||
<button id="customer-orders-refresh" type="button">刷新</button>
|
||||
</div>
|
||||
<div id="customer-orders-body" class="customer-orders-body" hidden>
|
||||
<div id="customer-orders-summary" class="customer-orders-summary" role="group" aria-label="订单概况" hidden></div>
|
||||
<button id="customer-orders-back" type="button" hidden>返回订单列表</button>
|
||||
<p id="customer-orders-message" role="status" hidden></p>
|
||||
<div id="customer-orders-list" class="customer-orders-list"></div>
|
||||
<div id="customer-orders-detail" class="track-card" aria-live="polite" hidden></div>
|
||||
<p id="customer-orders-progress" class="customer-orders-progress" role="status" hidden></p>
|
||||
<button id="customer-orders-retry" type="button" hidden>重试</button>
|
||||
<button id="customer-orders-more" type="button" hidden>加载更多订单</button>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user