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', '[]'))