diff --git a/docs/architecture.md b/docs/architecture.md
index fac1691..eb699af 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -106,8 +106,9 @@ tests/ # 按业务模块组织的回归
- SSE 字段为 `t`、`e`、`card`、`replace`、`suggest`、`d`;客户端兼容 LF/CRLF 和跨字节分段,收到 `d` 才算成功,意外 EOF 保留已收到正文并提供重试。上游缺少结束标记或因长度截断时保存中断状态;压缩、追问、流式 HTTP 响应都要关闭。首段等待 90 秒、整轮 120 秒;会话、历史及人工 JSON 请求 10 秒,上传 15 秒超时可恢复。
- `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。
-- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。子单没有独立货物数据时不继承父单总重量;COMPLETED 只表示头程订单完成,不表示快递签收。页面和模型上下文只显示订单事实,不附加测试副本声明或导入时间;旧历史订单卡片中的相关元数据也不展示。订单追问只在同一 `conv_id` 内复用最近单号,并重新查表取得手工修改后的值;仅明确指向上一票或简短字段追问可复用,新报价或其他话题不会沿用旧订单。
+- `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。
- 订单数据仅为内部验收副本:`ORDER_DATA_TENANT_ID` 可固定服务器端租户范围,空值允许查询内部已导入样本。它不代替将来客户登录授权。没有管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。源库连接凭据和快照文件均在仓库外,业务库只读,不随服务查询访问源库。
+- 节点导出:`scripts/export_order_tracking.py --database-env <显式环境文件> --orders-snapshot <私有订单快照> --output <新私有节点快照>`。仅需离线导出时安装可选 `PyMySQL`;脚本开启源库只读事务,只查询快照订单直接关联的入库完成、已冻结装柜和标准船节点,按客户与源订单 ID 同时匹配。随后运行 `scripts/import_order_test_data.py --snapshot <节点快照> --db <运行数据库> --tracking-only`;只更新已有订单的 `tracking_json`,校验源 ID,不改订单字段,默认保留已有轨迹。`--replace` 才覆盖手工轨迹。快照不随 Git 发布;将来 MCP 接入仍返回同一事实结构,无需改变卡片协议。
- 历史卡片以 `__CARD__{json}` 保存;原 SQLite 表、字段、角色值和历史记录继续可读。历史正文和卡片使用存库时间,卡片恢复复制操作。切换话题或历史时取消旧流和待上传附件;等待建会话期间的新草稿不能被旧发送清空。历史刷新失败保留已有列表;删除或清空成功后先同步本地列表,并使先前的刷新失效,刷新失败或迟到均不恢复已删除记录。删除会话同步移除其人工会话及消息,迟到的模型写入不能生成孤立消息。
- 人工申请使用 `conv_id`,返回 `session_id`,后续用 `sid`。状态为 `waiting/active/closed`,消息方向为 `customer/agent/system`。转接将 active 改为 waiting,列表只允许主动接入。备注保存在 `human_sessions.note`;撤回将原客服消息改为系统提示并追加带 `recalled_id` 的系统事件,轮询按原消息编号更新,重复撤回不追加事件。
- 新建会话保存一条 assistant 欢迎语并返回 `welcome`,前端在首次用户消息之前显示。首次提问判定只统计 user 消息。服务评价用 `ratings` 保存,并汇总到管理统计。Excel 导出依赖 `openpyxl`,原 CSV 可用 `?format=csv` 访问。
diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py
index 2a0c69c..eb15b25 100644
--- a/logistics_chat/order_data.py
+++ b/logistics_chat/order_data.py
@@ -6,12 +6,13 @@ import sqlite3
from datetime import datetime
from .config import get_setting
+from .order_tracking import build_tracking
FIELDS = ('source_order_id', 'tenant_id', 'order_no', 'inbound_code', 'shipment_id',
'status', 'country_code', 'transport_mode', 'warehouse_code', 'order_role',
'parent_order_no', 'package_count', 'gross_weight_kg', 'volume_cbm',
- 'history_json', 'data_note', 'source_updated_at', 'snapshot_at')
+ 'history_json', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at')
STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过',
'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中',
'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'}
@@ -20,7 +21,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'shipment':'Shipment ID', 'country':'目
'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级',
'parent':'父订单号', 'updated':'来源更新时间'}
IDENTIFIER = re.compile(r'(? target[key][:2]:
+ target[key] = candidate
+ if not actual and not estimated:
+ return None
+
+ shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''})
+ multiple = len(shipments) > 1
+ progress = {}
+ for (shipment, code), (_, _, when, fact) in actual.items():
+ sequence = NODE_LABELS[code][0]
+ if shipment not in progress or sequence > progress[shipment][0]:
+ progress[shipment] = (sequence, code, fact)
+
+ def label_shipment(shipment, fact):
+ if not multiple or not shipment:
+ return ''
+ return '货柜 ' + str(fact.get('shipment_no') or (shipments.index(shipment) + 1))
+
+ def node(shipment, code, value, forecast=False):
+ _, _, when, fact = value
+ place = str(fact.get('place') or '')
+ scope = label_shipment(shipment, fact)
+ return {'time': str(fact['estimated_at' if forecast else 'actual_at']),
+ 'text': NODE_LABELS[code][2 if forecast else 1],
+ 'place': ' · '.join(part for part in (scope, place) if part)}
+
+ nodes = [node(shipment, code, value) for (shipment, code), value in
+ sorted(actual.items(), key=lambda item: (item[1][2], NODE_LABELS[item[0][1]][0]), reverse=True)]
+ forecasts = [node(shipment, code, value, True) for (shipment, code), value in
+ sorted(estimated.items(), key=lambda item: item[1][2])
+ if (shipment, code) not in actual and NODE_LABELS[code][0] > progress.get(shipment, (-1,))[0]]
+ # Multiple containers keep independent progress rather than claiming the whole order advanced.
+ current = [(shipment, value) for shipment, value in sorted(progress.items()) if shipment or not shipments]
+ if not current and '' in progress:
+ current = [('', progress[''])]
+ statuses = [':'.join(part for part in (label_shipment(shipment, value[2]), NODE_LABELS[value[1]][1]) if part)
+ for shipment, value in current]
+ return {'nodes': nodes[:50], 'estimated_nodes': forecasts[:20], 'status': ';'.join(statuses)}
diff --git a/scripts/export_order_tracking.py b/scripts/export_order_tracking.py
new file mode 100644
index 0000000..ba867ff
--- /dev/null
+++ b/scripts/export_order_tracking.py
@@ -0,0 +1,113 @@
+"""Read order-linked warehouse and shipping facts into an explicit private snapshot.
+
+Requires the optional PyMySQL package. This command never writes to the source DB.
+"""
+
+import argparse
+import json
+from pathlib import Path
+import sys
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+
+def export_tracking(connection, orders):
+ output = {}
+ for order in orders:
+ key = (str(order.get('tenant_id') or ''), str(order.get('source_order_id') or ''))
+ if not all(value.isdecimal() for value in key) or not order.get('order_no'):
+ raise ValueError('Source snapshot requires numeric tenant_id/source_order_id and order_no')
+ if key in output:
+ raise ValueError('Source snapshot contains duplicate order identities')
+ output[key] = {key_name: order[key_name] for key_name in ('tenant_id', 'source_order_id', 'order_no')}
+ output[key]['tracking_json'] = []
+ ids = sorted({key[1] for key in output})
+ for start in range(0, len(ids), 100):
+ batch = ids[start:start + 100]
+ placeholders = ','.join('%s' for _ in batch)
+ # Every relation uses both source order identity and customer identity.
+ queries = [
+ (f'''SELECT o.tenant_id,o.id AS order_id, i.id AS fact_id,
+ i.inbounded_at AS actual_at,i.updated_at AS observed_at
+ FROM baiyue_order.ord_order o
+ JOIN baiyue_firstleg.fl_inbound_order i ON i.order_id=o.id AND i.tenant_id=o.tenant_id
+ WHERE o.deleted=0 AND i.deleted=0 AND i.inbounded_at IS NOT NULL
+ AND o.id IN ({placeholders}) ORDER BY i.id''', 'inbound'),
+ (f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,i.id AS fact_id,
+ i.frozen_at AS actual_at,i.frozen_at AS observed_at,
+ p.container_booking_id,b.container_no
+ FROM baiyue_order.ord_order o
+ JOIN baiyue_firstleg.fl_loading_plan_item i
+ ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0
+ JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
+ JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
+ WHERE o.deleted=0 AND i.frozen_at IS NOT NULL
+ AND o.id IN ({placeholders}) ORDER BY i.id''', 'loading'),
+ (f'''SELECT DISTINCT o.tenant_id,o.id AS order_id,n.id AS fact_id,n.standard_node,
+ n.actual_at,n.estimated_at,n.observed_at,n.created_at,n.location_name,
+ n.container_booking_id,b.container_no
+ FROM baiyue_order.ord_order o
+ JOIN baiyue_firstleg.fl_loading_plan_item i
+ ON i.order_id=o.id AND i.account_id=o.tenant_id AND i.deleted=0 AND i.frozen_at IS NOT NULL
+ JOIN baiyue_firstleg.fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED'
+ JOIN baiyue_firstleg.fl_container_booking b ON b.id=p.container_booking_id
+ JOIN baiyue_firstleg.fl_carrier_schedule_node_fact n ON n.container_booking_id=b.id
+ WHERE o.deleted=0 AND o.id IN ({placeholders}) ORDER BY n.id''', 'shipping'),
+ ]
+ for sql, source in queries:
+ with connection.cursor() as cursor:
+ cursor.execute(sql, batch)
+ for row in cursor.fetchall():
+ key = (str(row['tenant_id']), str(row['order_id']))
+ if key not in output:
+ continue
+ fact = {'event_id': source + ':' + str(row['fact_id']),
+ 'code': {'inbound': 'DOMESTIC_RECEIVED', 'loading': 'LOADING_COMPLETED'}.get(source)
+ or row['standard_node'],
+ 'shipment_key': str(row.get('container_booking_id') or ''),
+ 'shipment_no': row.get('container_no') or '',
+ 'place': row.get('location_name') or ''}
+ for field in ('actual_at', 'estimated_at', 'observed_at', 'created_at'):
+ value = row.get(field)
+ fact[field] = value.isoformat(sep=' ') if value else None
+ output[key]['tracking_json'].append(fact)
+ return list(output.values())
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--database-env', type=Path, required=True)
+ parser.add_argument('--orders-snapshot', type=Path, required=True)
+ parser.add_argument('--output', type=Path, required=True)
+ args = parser.parse_args()
+ if args.output.exists(): parser.error('Output already exists; choose a new private snapshot path')
+ if not args.output.parent.is_dir(): parser.error('Output parent directory must exist')
+ if not args.orders_snapshot.is_file() or args.orders_snapshot.stat().st_size > 10 * 1024 * 1024:
+ parser.error('Orders snapshot must be an existing JSON file smaller than 10 MB')
+ orders = json.loads(args.orders_snapshot.read_text(encoding='utf-8-sig'))
+ if isinstance(orders, dict): orders = orders.get('orders')
+ if not isinstance(orders, list) or not orders or len(orders) > 3000:
+ parser.error('Orders snapshot must contain 1 to 3000 orders')
+ import pymysql
+ from dotenv import dotenv_values
+ config = dotenv_values(args.database_env, interpolate=False, encoding='utf-8-sig')
+ required = ('CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD')
+ if any(not config.get(key) for key in required): parser.error('Database env is missing connection settings')
+ connection = pymysql.connect(host=config[required[0]], port=int(config[required[1]]),
+ user=config[required[2]], password=config[required[3]], cursorclass=pymysql.cursors.DictCursor,
+ connect_timeout=10, read_timeout=30, write_timeout=10, autocommit=False)
+ try:
+ with connection.cursor() as cursor:
+ cursor.execute('SET SESSION TRANSACTION READ ONLY')
+ cursor.execute('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY')
+ records = export_tracking(connection, orders)
+ finally:
+ connection.rollback()
+ connection.close()
+ args.output.write_text(json.dumps({'orders': records}, ensure_ascii=False, indent=2), encoding='utf-8')
+ print(json.dumps({'orders': len(records), 'orders_with_tracking': sum(bool(row['tracking_json']) for row in records),
+ 'facts': sum(len(row['tracking_json']) for row in records)}))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/import_order_test_data.py b/scripts/import_order_test_data.py
index e7cbdc1..f50f21b 100644
--- a/scripts/import_order_test_data.py
+++ b/scripts/import_order_test_data.py
@@ -7,7 +7,7 @@ import sqlite3
import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-from logistics_chat.order_data import import_records
+from logistics_chat.order_data import import_records, import_tracking_records
def main():
@@ -15,6 +15,7 @@ def main():
parser.add_argument('--snapshot', type=Path, required=True)
parser.add_argument('--db', type=Path, required=True)
parser.add_argument('--replace', action='store_true', help='Replace existing rows, including manual edits')
+ parser.add_argument('--tracking-only', action='store_true', help='Update only tracking for matching local orders')
args = parser.parse_args()
if not args.snapshot.is_file() or args.snapshot.stat().st_size > 10*1024*1024:
parser.error('Snapshot must be an existing JSON file smaller than 10 MB')
@@ -24,7 +25,8 @@ def main():
parser.error('Snapshot must contain a list of at most 3000 orders')
if not args.db.parent.is_dir(): parser.error('Database parent directory must exist')
with sqlite3.connect(args.db) as connection:
- result = import_records(connection, records, replace=args.replace)
+ importer = import_tracking_records if args.tracking_only else import_records
+ result = importer(connection, records, replace=args.replace)
print(json.dumps(result))
diff --git a/static/css/chat.css b/static/css/chat.css
index 505ef24..2305038 100644
--- a/static/css/chat.css
+++ b/static/css/chat.css
@@ -157,6 +157,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
.order-data-fields dd { margin: 0; overflow-wrap: anywhere; }
.order-history-empty { color: var(--muted); font-size: 11px; margin: 10px 0; }
.order-history-title { margin: 18px 0 10px; font-size: 13px; font-weight: 600; }
+.order-tracking-status { margin: 0 0 12px; color: var(--brand-text); font-size: 13px; }
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
.to-bottom.show { display: block; }
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
diff --git a/static/js/chat.js b/static/js/chat.js
index 7ed3dfb..b3f37df 100644
--- a/static/js/chat.js
+++ b/static/js/chat.js
@@ -265,6 +265,9 @@ const I18N = {
orderNo: "订单号",
orderHistory: "订单业务状态历史",
noOrderHistory: "未提供订单状态历史",
+ logisticsHistory: "物流轨迹",
+ noLogisticsHistory: "暂无已发生的物流节点",
+ estimatedTracking: "预计节点",
legacyTrackingNotice: "历史模拟轨迹,仅供演示,不代表真实订单。",
order_inbound: "入仓码", order_shipment: "Shipment ID", order_country: "目的国",
order_warehouse: "目的仓", order_transport: "运输方式", order_packages: "件数",
@@ -357,6 +360,9 @@ const I18N = {
orderNo: "Order number",
orderHistory: "Order workflow history",
noOrderHistory: "No order status history provided",
+ logisticsHistory: "Shipment tracking",
+ noLogisticsHistory: "No actual shipment events available",
+ estimatedTracking: "Estimated milestones",
legacyTrackingNotice: "Historical simulated tracking for demonstration only; this is not a real order record.",
order_inbound: "Inbound code", order_shipment: "Shipment ID", order_country: "Destination country",
order_warehouse: "Warehouse", order_transport: "Transport", order_packages: "Packages",
@@ -841,6 +847,7 @@ const CARD_PREFIX = "__CARD__";
function fillTrackCard(el, card){
el.innerHTML = "";
const isOrder = card.kind === 'order';
+ const isLogistics = isOrder && card.node_kind === 'logistics';
const head = document.createElement("div");
head.className = "track-head";
const no = document.createElement("div");
@@ -876,51 +883,68 @@ function fillTrackCard(el, card){
el.appendChild(fields);
const title = document.createElement('p');
title.className = 'order-history-title';
- title.dataset.i18n = 'orderHistory';
- title.textContent = t('orderHistory');
+ title.dataset.i18n = isLogistics ? 'logisticsHistory' : 'orderHistory';
+ title.textContent = t(title.dataset.i18n);
el.appendChild(title);
+ if(isLogistics && card.tracking_status){
+ const current = document.createElement('p');
+ current.className = 'order-tracking-status';
+ current.textContent = card.tracking_status;
+ el.appendChild(current);
+ }
if(!(card.nodes || []).length){
const empty = document.createElement('p');
empty.className = 'order-history-empty';
- empty.dataset.i18n = 'noOrderHistory';
- empty.textContent = t('noOrderHistory');
+ empty.dataset.i18n = isLogistics ? 'noLogisticsHistory' : 'noOrderHistory';
+ empty.textContent = t(empty.dataset.i18n);
el.appendChild(empty);
}
}
- const timeline = document.createElement("div");
- timeline.className = "track-timeline";
- el.appendChild(timeline);
- const visibleCount = window.matchMedia("(max-width: 520px)").matches ? 2 : 3;
- (card.nodes || []).forEach(function(n, i){
- const node = document.createElement("div");
- node.className = "track-node" + (i === 0 ? " active" : "");
- const dot = document.createElement("span");
- dot.className = "track-dot";
- const text = document.createElement("div");
- text.className = "track-text";
- text.innerHTML = '' + escHtml(n.time) + '' + escHtml(n.text) + "" +
- '' + escHtml(n.place || "") + "";
- node.appendChild(dot);
- node.appendChild(text);
- if(i >= visibleCount){ node.classList.add("track-earlier"); node.hidden = true; }
- timeline.appendChild(node);
- });
- if((card.nodes || []).length > visibleCount){
- const toggle = document.createElement("button");
- toggle.type = "button";
- toggle.className = "track-expand";
- toggle.setAttribute("aria-expanded", "false");
- toggle.dataset.i18n = "moreTracking";
- toggle.textContent = t("moreTracking");
- toggle.onclick = function(){
- const expanded = toggle.getAttribute("aria-expanded") !== "true";
- timeline.querySelectorAll(".track-earlier").forEach(node => { node.hidden = !expanded; });
- toggle.setAttribute("aria-expanded", String(expanded));
- toggle.dataset.i18n = expanded ? "lessTracking" : "moreTracking";
- toggle.textContent = t(toggle.dataset.i18n);
- };
- el.appendChild(toggle);
+ function renderTimeline(nodes, forecast){
+ const timeline = document.createElement("div");
+ timeline.className = "track-timeline";
+ el.appendChild(timeline);
+ const visibleCount = window.matchMedia("(max-width: 520px)").matches ? 2 : 3;
+ nodes.forEach(function(n, i){
+ const node = document.createElement("div");
+ node.className = "track-node" + (i === 0 && !forecast ? " active" : "");
+ const dot = document.createElement("span");
+ dot.className = "track-dot";
+ const text = document.createElement("div");
+ text.className = "track-text";
+ text.innerHTML = '' + escHtml(n.time) + '' + escHtml(n.text) + "" +
+ '' + escHtml(n.place || "") + "";
+ node.appendChild(dot);
+ node.appendChild(text);
+ if(i >= visibleCount){ node.classList.add("track-earlier"); node.hidden = true; }
+ timeline.appendChild(node);
+ });
+ if(nodes.length > visibleCount){
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "track-expand";
+ toggle.setAttribute("aria-expanded", "false");
+ toggle.dataset.i18n = "moreTracking";
+ toggle.textContent = t("moreTracking");
+ toggle.onclick = function(){
+ const expanded = toggle.getAttribute("aria-expanded") !== "true";
+ timeline.querySelectorAll(".track-earlier").forEach(node => { node.hidden = !expanded; });
+ toggle.setAttribute("aria-expanded", String(expanded));
+ toggle.dataset.i18n = expanded ? "lessTracking" : "moreTracking";
+ toggle.textContent = t(toggle.dataset.i18n);
+ };
+ el.appendChild(toggle);
+ }
+ }
+ renderTimeline(card.nodes || [], false);
+ if(isLogistics && (card.estimated_nodes || []).length){
+ const title = document.createElement('p');
+ title.className = 'order-history-title';
+ title.dataset.i18n = 'estimatedTracking';
+ title.textContent = t('estimatedTracking');
+ el.appendChild(title);
+ renderTimeline(card.estimated_nodes, true);
}
}
diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs
index befaca1..4965f0d 100644
--- a/tests/frontend/chat.test.cjs
+++ b/tests/frontend/chat.test.cjs
@@ -539,3 +539,23 @@ test('order cards show facts as text without import metadata, including older sa
assert.doesNotMatch(log.textContent, /旧数据说明|数据导入|2026-10-29 10:30/);
assert.equal(app.context.window.__busy, false);
});
+
+test('shipment cards separate forecasts from actual progress and retain both in history', async () => {
+ const app = await customerPage({human:false, history:[{id:8,title:'订单轨迹',ctime:'2026-10-29'}]});
+ const card = {kind:'order', no:'FL202610290001', status:'履约中', fields:[], node_kind:'logistics',
+ tracking_status:'船舶已离开起运港', nodes:[{time:'2026-10-28 10:00',text:'船舶已离开起运港',place:'起运港'}],
+ estimated_nodes:[{time:'2026-11-02 10:00',text:'预计到达目的港',place:'
'}],
+ summary:'船舶已离开起运港,预计到达目的港2026-11-02'};
+ app.hooks.messages = () => response([{role:'assistant',content:'__CARD__'+JSON.stringify(card),ctime:'2026-10-29 10:00'}]);
+ await app.click(app.element('historyWrap').children[0]);
+ const log = app.element('chatDom');
+ assert.match(log.textContent, /物流轨迹/);
+ assert.match(log.textContent, /预计节点/);
+ assert.match(log.textContent, /预计到达目的港/);
+ const timelines = log.querySelectorAll('.track-timeline');
+ assert.equal(timelines.length, 2);
+ assert.ok(timelines[0].querySelector('.track-node').classList.contains('active'));
+ assert.equal(timelines[1].querySelector('.track-node').classList.contains('active'), false);
+ assert.match(timelines[1].querySelector('.track-text').innerHTML, /<img/);
+ assert.ok(log.querySelector('.msg-actions'));
+});
diff --git a/tests/test_order_data.py b/tests/test_order_data.py
index 4480a37..730873a 100644
--- a/tests/test_order_data.py
+++ b/tests/test_order_data.py
@@ -127,6 +127,24 @@ class OrderDataTests(unittest.TestCase):
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
+ def test_tracking_queries_and_followups_read_linked_facts_without_calling_model(self):
+ self.import_rows([{**self.record, 'tracking_json': [
+ {'code': 'DEPARTED_ORIGIN_PORT', 'actual_at': '2026-10-28 10:00', 'shipment_key': '1'},
+ {'code': 'ARRIVED_DESTINATION_PORT', 'estimated_at': '2026-11-02 10:00', 'shipment_key': '1'},
+ ]}], replace=True)
+ cid = self.client.post('/api/new_chat').json['id']
+ with patch('logistics_chat.ai_client.post_completion') as model:
+ for message in (self.record['order_no']+' 物流轨迹', '到哪里了?', '那到港了吗?', '这票签收了吗?', '轨迹呢?'):
+ card = self.events(message, cid)[0]['card']
+ self.assertEqual(card['node_kind'], 'logistics')
+ self.assertEqual(card['tracking_status'], '船舶已离开起运港')
+ self.assertEqual(card['nodes'][0]['text'], '船舶已离开起运港')
+ self.assertEqual(card['estimated_nodes'][0]['text'], '预计到达目的港')
+ self.assertIn('预计到达目的港', card_to_text(CARD_PREFIX+json.dumps(card)))
+ self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
+ model.assert_not_called()
+ self.assertIsNone(order_data.answer_query('美国30KG报价', cid))
+
def test_followups_refresh_local_data_and_remain_within_conversation(self):
cid = self.client.post('/api/new_chat').json['id']
self.events(self.record['order_no'], cid)
diff --git a/tests/test_order_tracking.py b/tests/test_order_tracking.py
new file mode 100644
index 0000000..9e76ccd
--- /dev/null
+++ b/tests/test_order_tracking.py
@@ -0,0 +1,102 @@
+"""Tracking facts preserve actual/estimated meaning, corrections and order identity."""
+
+import json
+import sqlite3
+import unittest
+from contextlib import closing
+
+from logistics_chat.order_data import import_records, import_tracking_records
+from logistics_chat.order_tracking import build_tracking
+
+
+class OrderTrackingTests(unittest.TestCase):
+ def fact(self, code, actual=None, estimated=None, **extra):
+ return {'code': code, 'actual_at': actual, 'estimated_at': estimated,
+ 'observed_at': '2026-10-29 12:00', 'shipment_key': 'container-1', **extra}
+
+ def test_estimates_do_not_advance_actual_progress_or_fill_missing_milestones(self):
+ result = build_tracking([
+ self.fact('DEPARTED_ORIGIN_PORT', '2026-10-25 10:00'),
+ self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-01 10:00'),
+ ])
+ self.assertEqual(result['status'], '船舶已离开起运港')
+ self.assertEqual([n['text'] for n in result['nodes']], ['船舶已离开起运港'])
+ self.assertEqual(result['estimated_nodes'][0]['text'], '预计到达目的港')
+ self.assertEqual(result['estimated_nodes'][0]['time'], '2026-11-01 10:00')
+
+ def test_latest_observation_can_correct_actual_time_backwards(self):
+ result = build_tracking([
+ self.fact('ARRIVED_DESTINATION_PORT', '2026-10-28 10:00'),
+ self.fact('ARRIVED_DESTINATION_PORT', '2026-10-27 10:00', observed_at='2026-10-30 10:00'),
+ self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-31 10:00', observed_at='2026-10-31 10:00'),
+ ])
+ self.assertEqual(len(result['nodes']), 1)
+ self.assertEqual(result['nodes'][0]['time'], '2026-10-27 10:00')
+ self.assertEqual(result['estimated_nodes'], [])
+
+ def test_delayed_earlier_milestone_and_stale_forecast_do_not_regress_progress(self):
+ result = build_tracking([
+ self.fact('PICKED_UP', '2026-10-28 10:00'),
+ self.fact('AT_ORIGIN_PORT', '2026-10-29 10:00', observed_at='2026-10-30 10:00'),
+ self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-10-29 20:00'),
+ ])
+ self.assertEqual(result['status'], '已提柜')
+ self.assertEqual(len(result['nodes']), 2)
+ self.assertEqual(result['estimated_nodes'], [])
+ self.assertNotIn('签收', json.dumps(result, ensure_ascii=False))
+
+ def test_multiple_containers_keep_independent_progress_and_locations(self):
+ result = build_tracking([
+ self.fact('PICKED_UP', '2026-10-28 10:00', shipment_no='BOX-A', place='码头 A'),
+ self.fact('IN_TRANSIT', '2026-10-27 10:00', shipment_key='container-2', shipment_no='BOX-B'),
+ self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00',
+ shipment_key='container-2', shipment_no='BOX-B'),
+ ])
+ self.assertEqual(result['status'], '货柜 BOX-A:已提柜;货柜 BOX-B:海运途中')
+ self.assertEqual(result['nodes'][0]['place'], '货柜 BOX-A · 码头 A')
+ self.assertEqual(result['estimated_nodes'][0]['place'], '货柜 BOX-B')
+
+ def test_domestic_actual_is_retained_when_ship_has_only_forecasts(self):
+ result = build_tracking([
+ self.fact('DOMESTIC_RECEIVED', '2026-10-28 10:00', shipment_key=''),
+ self.fact('ARRIVED_DESTINATION_PORT', estimated='2026-11-02 10:00'),
+ ])
+ self.assertEqual(result['status'], '已入国内仓')
+ self.assertEqual(len(result['nodes']), 1)
+
+ def test_estimated_only_unknown_codes_and_broken_values_are_safe(self):
+ result = build_tracking([self.fact('PICKED_UP', estimated='2026-11-02 10:00'),
+ {'code': []}, None, self.fact('UNRECOGNIZED', '2026-10-28 10:00'),
+ self.fact('IN_TRANSIT', 'not a time')])
+ self.assertEqual(result['status'], '')
+ self.assertEqual(result['nodes'], [])
+ self.assertEqual(result['estimated_nodes'][0]['text'], '预计提柜')
+ for value in (None, {}, 'broken json', '[null]', []):
+ self.assertIsNone(build_tracking(value))
+
+ def test_tracking_import_preserves_manual_order_fields_and_scopes_by_identity(self):
+ with closing(sqlite3.connect(':memory:')) as connection:
+ orders = [{'order_no': 'ORDER2026001', 'source_order_id': '987654321123456789',
+ 'tenant_id': tenant, 'gross_weight_kg': weight}
+ for tenant, weight in [('10', '42'), ('20', '80')]]
+ import_records(connection, orders)
+ record = {**orders[0], 'tracking_json': [self.fact('IN_TRANSIT', '2026-10-28 10:00')]}
+ self.assertEqual(import_tracking_records(connection, [record]),
+ {'imported': 1, 'preserved': 0, 'missing': 0})
+ self.assertEqual(connection.execute('SELECT gross_weight_kg FROM order_test_data '
+ 'WHERE tenant_id=\'10\'').fetchone()[0], '42')
+ self.assertEqual(connection.execute('SELECT tracking_json FROM order_test_data '
+ 'WHERE tenant_id=\'20\'').fetchone()[0], '[]')
+ self.assertEqual(import_tracking_records(connection, [record])['preserved'], 1)
+ self.assertEqual(import_tracking_records(connection, [record], replace=True)['imported'], 1)
+ with self.assertRaises(ValueError):
+ import_tracking_records(connection, [{**record, 'source_order_id': 'wrong'}], replace=True)
+ self.assertEqual(import_tracking_records(connection, [{**record, 'order_no': 'MISSING2026001'}])['missing'], 1)
+
+ def test_old_table_is_migrated_without_losing_data(self):
+ with closing(sqlite3.connect(':memory:')) as connection:
+ import_records(connection, [{'tenant_id': '10', 'order_no': 'ORDER2026001'}])
+ connection.execute('ALTER TABLE order_test_data DROP COLUMN tracking_json')
+ import_tracking_records(connection, [])
+ self.assertEqual(connection.execute('SELECT order_no,tracking_json FROM order_test_data').fetchone(),
+ ('ORDER2026001', '[]'))