Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04f94ad665 |
@@ -8,7 +8,7 @@
|
||||
- **知识库优先**:优先使用本地知识库回答,减少模型编造
|
||||
- **追问兜底**:当前问题没命中关键词时,用上一句提问去检索
|
||||
- **运费试算**:识别重量/尺寸/渠道,按报价表阶梯单价计算并展示完整明细
|
||||
- **订单查询**:按订单号、入仓码、Shipment ID 查询本地订单测试表;展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **订单查询**:直接输入入仓码查询订单与已有轨迹,卡片主显示入仓码;兼容订单号与 Shipment ID,展示概要、申报重量/体积和业务状态历史,不生成模拟运输轨迹
|
||||
- **历史会话**:会话保存到 SQLite,支持打开、搜索、删除
|
||||
- **交互**:停止生成、复制(纯文本)、重答、多行输入、回到底部
|
||||
- **中英文切换**:右上角「中/EN」按钮或快捷键 `Ctrl + L`
|
||||
|
||||
@@ -88,6 +88,7 @@ tests/ # 按业务模块组织的回归
|
||||
- 单文件围绕一个职责;超过约 300–400 行时检查职责,而非按行数机械拆分。报价数据保持单一来源;兼容转发不复制字典。页面脚本按页面保留既有执行顺序和全局函数,不擅自改为 ES Modules。
|
||||
- 配色、字体基线和通用控件放在 `theme.css`,页面布局放在对应 CSS。客户页通过 `body.dark` 覆盖主题令牌;新增可翻译文案加入 `chat.js` 的 `I18N`,静态文案用 `data-i18n`。调整样式后检查桌面、390px 窄屏、侧栏、聊天卡片和客服空/活动状态。
|
||||
- 客户界面不附加测试/演示声明、底部免责声明、键盘说明、快捷入口副标题或附件准备说明;草稿恢复和发送成功静默处理。加载、录音、人工排队状态及操作失败反馈保留,权限校验继续由服务端执行。
|
||||
- 客户查货以入仓码为主要入口,输入占位、快捷入口与查询追问均优先使用入仓码;订单卡片从 `fields.inbound` 取得主显示号码,内部订单号放在展开详情。`card.no` 仍保存内部订单号以兼容历史协议和上下文关联;未提供入仓码时保留准确的订单号标签,不生成替代入仓码。
|
||||
- 客户附件预览由 `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 页面,破图在聊天中显示可读提示。
|
||||
|
||||
@@ -18,7 +18,7 @@ FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_
|
||||
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
|
||||
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
|
||||
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipment ID', 'country':'目的国',
|
||||
'warehouse':'目的仓', 'transport':'运输方式', 'packages':'件数',
|
||||
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
|
||||
'parent':'父订单号', 'updated':'来源更新时间'}
|
||||
@@ -176,6 +176,8 @@ def build_card(row):
|
||||
def field(key, value):
|
||||
if value is not None and str(value).strip(): fields.append({'key': key, 'value': str(value)})
|
||||
field('inbound', row['inbound_code'])
|
||||
if row['inbound_code']:
|
||||
field('number', row['order_no'])
|
||||
field('shipment', row['shipment_id'])
|
||||
field('country', row['country_code'])
|
||||
field('warehouse', row['warehouse_code'])
|
||||
@@ -195,8 +197,9 @@ def build_card(row):
|
||||
for event in history[-50:] if isinstance(event, dict)] if isinstance(history, list) else []
|
||||
nodes.reverse()
|
||||
status = STATUS_LABELS.get(row['status'], row['status'])
|
||||
summary = f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields)
|
||||
inbound = row['inbound_code']
|
||||
summary = f"入仓码 {inbound},业务状态:{status}。" if inbound else f"订单号 {row['order_no']},业务状态:{status}。"
|
||||
summary += ';'.join(FIELD_LABELS[item['key']]+':'+item['value'] for item in fields if item['key'] != 'inbound')
|
||||
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
|
||||
'nodes':nodes, 'summary':summary}
|
||||
tracking = build_tracking(row.get('tracking_json'))
|
||||
@@ -234,7 +237,7 @@ def answer_query(message, conv_id):
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
return {'t':'请一次提供一个订单号、入仓码或 Shipment ID,避免查错订单。'}
|
||||
return {'t':'请一次提供一个入仓码,避免查错订单。'}
|
||||
reference = references[0] if references else None
|
||||
if not reference and conv_id and is_order_followup(message):
|
||||
from .db import connect
|
||||
@@ -260,11 +263,12 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码)|订单状态|物流轨迹|shipment\s*id|'
|
||||
if re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b', message, re.I):
|
||||
return order_login_response() if not tenants else {'t':'请提供订单号、入仓码或 Shipment ID。'}
|
||||
r'\b(?:my|find|lookup|track)\s+(?:orders?|shipments?)\b|\b(?:order|shipment)\s+(?:status|tracking|lookup)\b|'
|
||||
r'\b(?:track|lookup|find|search)\b.*\binbound\b|\binbound\s+(?:code|number)\s+(?:lookup|tracking|status)\b', message, re.I):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码查询订单和物流轨迹。'}
|
||||
return None
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
@@ -273,9 +277,9 @@ def answer_query(message, conv_id):
|
||||
except OrderDataUnavailable:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
if not matches:
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对订单号、入仓码或 Shipment ID,或联系人工客服。'}
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||
if len(matches) > 1:
|
||||
return {'t':'该标识匹配到多个订单,请提供该订单唯一的订单号或入仓码。'}
|
||||
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||
return {'card': build_card(matches[0])}
|
||||
|
||||
+13
-12
@@ -270,7 +270,7 @@ const I18N = {
|
||||
networkFail: "网络连接中断,请检查网络后重试",
|
||||
stopped: "已停止生成",
|
||||
stoppedMark: "(已停止生成)",
|
||||
inputPh: "输入问题、订单号或入仓码…",
|
||||
inputPh: "输入入仓码或问题…",
|
||||
send: "发送",
|
||||
stop: "停止",
|
||||
copy: "复制",
|
||||
@@ -284,11 +284,11 @@ const I18N = {
|
||||
welcomeTitle: "物流问题,随时为您解答",
|
||||
welcomeEyebrow: "中欧 · 英国物流服务",
|
||||
serviceTag: "物流咨询服务",
|
||||
footerTrack: "查订单",
|
||||
footerTrack: "查入仓码",
|
||||
footerQuote: "专线报价",
|
||||
footerException: "异常处理",
|
||||
footerHuman: "联系人工",
|
||||
inputShort: "输入问题…",
|
||||
inputShort: "输入入仓码…",
|
||||
uploading: "附件正在上传,请稍候…",
|
||||
uploadFailed: "附件上传失败,请重新选择文件",
|
||||
serviceUnavailable: "服务暂时不可用,请稍后重试,或联系人工客服。",
|
||||
@@ -317,7 +317,7 @@ const I18N = {
|
||||
logisticsHistory: "物流轨迹",
|
||||
noLogisticsHistory: "暂无已发生的物流节点",
|
||||
estimatedTracking: "预计节点",
|
||||
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_inbound: "入仓码", order_number: "订单号", order_shipment: "Shipment ID", order_country: "目的国",
|
||||
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
|
||||
order_weight: "申报毛重", order_volume: "申报体积", order_role: "订单层级",
|
||||
order_parent: "父订单号", order_updated: "来源更新时间",
|
||||
@@ -331,7 +331,7 @@ const I18N = {
|
||||
langTip: "中英文切换(Ctrl+L)",
|
||||
toBottom: "回到底部",
|
||||
quick: [
|
||||
"查询订单状态", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
"按入仓码查轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
|
||||
],
|
||||
hotList: [
|
||||
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
|
||||
@@ -381,7 +381,7 @@ const I18N = {
|
||||
networkFail: "Connection lost, please check your network and retry",
|
||||
stopped: "Stopped",
|
||||
stoppedMark: "(Generation stopped)",
|
||||
inputPh: "Ask a question or enter an order number…",
|
||||
inputPh: "Enter an inbound code or question…",
|
||||
send: "Send",
|
||||
stop: "Stop",
|
||||
copy: "Copy",
|
||||
@@ -395,11 +395,11 @@ const I18N = {
|
||||
welcomeTitle: "Your next shipment, made clearer.",
|
||||
welcomeEyebrow: "EUROPE & UK LOGISTICS",
|
||||
serviceTag: "Logistics support",
|
||||
footerTrack: "Order lookup",
|
||||
footerTrack: "Inbound lookup",
|
||||
footerQuote: "Get a quote",
|
||||
footerException: "Parcel issue",
|
||||
footerHuman: "Human support",
|
||||
inputShort: "Message…",
|
||||
inputShort: "Inbound code…",
|
||||
uploading: "Uploading your attachment…",
|
||||
uploadFailed: "Upload failed. Please select the file again.",
|
||||
serviceUnavailable: "Service is temporarily unavailable. Please try again later or contact support.",
|
||||
@@ -430,7 +430,7 @@ const I18N = {
|
||||
logisticsHistory: "Shipment tracking",
|
||||
noLogisticsHistory: "No actual shipment events available",
|
||||
estimatedTracking: "Estimated milestones",
|
||||
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_inbound: "Inbound code", order_number: "Order number", order_shipment: "Shipment ID", order_country: "Destination country",
|
||||
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
|
||||
order_weight: "Declared gross weight", order_volume: "Declared volume", order_role: "Order level",
|
||||
order_parent: "Parent order", order_updated: "Source updated",
|
||||
@@ -444,7 +444,7 @@ const I18N = {
|
||||
langTip: "Switch language (Ctrl+L)",
|
||||
toBottom: "Back to bottom",
|
||||
quick: [
|
||||
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
"Track by inbound code", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
|
||||
],
|
||||
hotList: [
|
||||
"How do I track my shipment?", "How long does Europe sea freight take?",
|
||||
@@ -935,8 +935,9 @@ function fillTrackCard(el, card){
|
||||
head.className = "track-head";
|
||||
const no = document.createElement("div");
|
||||
no.className = "track-no";
|
||||
const numberLabel = isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(card.no);
|
||||
const inbound = isOrder && (card.fields || []).find(field=>field && field.key === 'inbound' && field.value);
|
||||
const numberLabel = inbound ? 'order_inbound' : isOrder ? 'orderNo' : 'trackingNo';
|
||||
no.innerHTML = '<span data-i18n="'+numberLabel+'">' + t(numberLabel) + "</span>" + escHtml(inbound ? inbound.value : card.no);
|
||||
const st = document.createElement("div");
|
||||
st.className = "track-status";
|
||||
st.textContent = card.status;
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<footer class="composer">
|
||||
<div id="human-session" class="human-session" role="status" hidden><span id="human-session-label"></span><button id="return-ai" type="button">返回 AI 问答</button></div>
|
||||
<div class="composer-toolbar"><div class="hot-bar">
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查运单</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="0" data-i18n="footerTrack">查入仓码</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="1" data-i18n="footerQuote">专线报价</button>
|
||||
<button type="button" class="hot-capsule" data-question-index="2" data-i18n="footerException">异常处理</button>
|
||||
<button type="button" class="hot-capsule" onclick="showHuman()" data-i18n="footerHuman">联系人工</button>
|
||||
@@ -59,7 +59,7 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或入仓码" placeholder="输入入仓码或问题…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
|
||||
@@ -385,7 +385,7 @@ test('all three footer questions leave the human queue and send to separate AI c
|
||||
const requests = app.calls.filter(call => call.url === '/stream_chat');
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(new Set(requests.map(call => call.body.conv_id)).size, 3);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['查询订单状态', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.deepEqual(requests.map(call => call.body.msg), ['按入仓码查轨迹', '欧洲海运专线报价', '包裹异常处理']);
|
||||
assert.equal(app.calls.filter(call => call.url === '/api/visitor/human/send').length, 0);
|
||||
assert.equal(app.context.window.humanSid, 0);
|
||||
assert.equal(app.element('human-session').hidden, true);
|
||||
@@ -611,6 +611,22 @@ test('shipment cards separate forecasts from actual progress and retain both in
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
test('inbound codes are the primary card identity for live replies and restored history', async () => {
|
||||
const app=await customerPage({human:false,history:[{id:8,title:'入仓轨迹',ctime:'now'}]});
|
||||
const card={kind:'order',no:'FL202610290001',status:'履约中',
|
||||
fields:[{key:'inbound',value:'INB20261029001-01'},{key:'number',value:'FL202610290001'}],
|
||||
nodes:[],summary:'入仓码 INB20261029001-01'};
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('INB20261029001-01');await flush();
|
||||
let heading=app.element('chatDom').querySelector('.track-no');
|
||||
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
|
||||
assert.match(app.element('chatDom').querySelector('.order-data-fields').textContent,/订单号.*FL202610290001/);
|
||||
app.hooks.messages=()=>response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'now'}]);
|
||||
await app.click(app.element('historyWrap').children[0]);
|
||||
heading=app.element('chatDom').querySelector('.track-no');
|
||||
assert.match(heading.innerHTML,/入仓码.*INB20261029001-01/);assert.doesNotMatch(heading.innerHTML,/FL202610290001/);
|
||||
});
|
||||
|
||||
test('order details can expand without hiding the shipment timeline', async () => {
|
||||
const app = await customerPage({human:false,width:390});
|
||||
const card = {kind:'order',no:'ORDER2026001',status:'履约中',node_kind:'logistics',
|
||||
|
||||
@@ -61,9 +61,26 @@ class OrderDataTests(unittest.TestCase):
|
||||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||||
self.assertEqual(order_data.extract_reference(text), [])
|
||||
events = self.events('帮我查询物流轨迹')
|
||||
self.assertIn('订单号', events[0]['t'])
|
||||
self.assertIn('入仓码', events[0]['t'])
|
||||
self.assertEqual(events[-1], {'d':1})
|
||||
|
||||
def test_direct_inbound_lookup_and_followup_keep_the_inbound_identity(self):
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
card=self.events(self.record['inbound_code'],cid)[0]['card']
|
||||
self.assertEqual(card['no'],self.record['order_no'])
|
||||
self.assertTrue(card['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in card['fields'] if item['key']=='number'),self.record['order_no'])
|
||||
self.import_rows([{**self.record,'gross_weight_kg':'42.000'}],replace=True)
|
||||
followup=self.events('现在到哪了',cid)[0]['card']
|
||||
self.assertTrue(followup['summary'].startswith('入仓码 '+self.record['inbound_code']))
|
||||
self.assertEqual(next(item['value'] for item in followup['fields'] if item['key']=='weight'),'42.000 kg')
|
||||
|
||||
def test_missing_inbound_code_is_not_replaced_by_the_internal_order_number(self):
|
||||
self.import_rows([{**self.record,'inbound_code':None}],replace=True)
|
||||
card=self.events(self.record['order_no'])[0]['card']
|
||||
self.assertFalse(any(item['key']=='inbound' for item in card['fields']))
|
||||
self.assertTrue(card['summary'].startswith('订单号 '+self.record['order_no']))
|
||||
|
||||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||||
@@ -86,7 +103,7 @@ class OrderDataTests(unittest.TestCase):
|
||||
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
events = self.events('列出所有订单')
|
||||
self.assertIn('请提供订单号', events[0]['t'])
|
||||
self.assertIn('请提供入仓码', events[0]['t'])
|
||||
model.assert_not_called()
|
||||
|
||||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||||
|
||||
@@ -119,7 +119,8 @@ class VisitorTests(unittest.TestCase):
|
||||
self.assertEqual(staff.data,b'A_PRIVATE')
|
||||
|
||||
def test_guest_keys_and_client_claims_cannot_unlock_orders(self):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status'):
|
||||
for message in ('查询15252125','查询订单状态','查询 UNKNOWN20260001','我的订单呢','帮我看下订单','订单查询','查询','my orders','order status',
|
||||
'按入仓码查轨迹','入仓码查询','入仓号查轨迹','Track by inbound code'):
|
||||
with patch('logistics_chat.order_data.lookup_order') as lookup, patch('logistics_chat.ai_client.post_completion') as model:
|
||||
response=self.a.post('/stream_chat',json={'msg':message},headers={'X-Customer-Id':'tenant-a','X-Tenant-Id':'tenant-a'})
|
||||
self.assertIn('客户账号登录并关联',response.get_data(as_text=True))
|
||||
|
||||
Reference in New Issue
Block a user