接入灰度订单关联物流节点并区分预计与实际轨迹

This commit is contained in:
2026-10-30 10:13:11 +08:00
parent ce445d0fb8
commit 6a428bd468
10 changed files with 464 additions and 47 deletions
+2 -1
View File
@@ -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` 访问。
+50 -7
View File
@@ -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'(?<![A-Za-z0-9_-])[A-Za-z0-9][A-Za-z0-9_-]{5,63}(?![A-Za-z0-9_-])')
ORDER_DETAIL = r'状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
ORDER_DETAIL = r'状态|进度|轨迹|到哪|开船|到港|清关|提柜|还柜|签收|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格|几件|几箱|公斤|千克|多少钱'
class OrderDataUnavailable(Exception):
@@ -36,10 +37,13 @@ def init_order_table(connection):
status TEXT NOT NULL, country_code TEXT, transport_mode TEXT, warehouse_code TEXT,
order_role TEXT, parent_order_no TEXT,
package_count INTEGER, gross_weight_kg TEXT, volume_cbm TEXT,
history_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
source_updated_at TEXT, snapshot_at TEXT,
UNIQUE(tenant_id, order_no)
)''')
columns = {row[1] for row in connection.execute('PRAGMA table_info(order_test_data)')}
if 'tracking_json' not in columns:
connection.execute("ALTER TABLE order_test_data ADD COLUMN tracking_json TEXT NOT NULL DEFAULT '[]'")
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_inbound ON order_test_data(inbound_code)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_order_test_shipment ON order_test_data(shipment_id)')
@@ -60,6 +64,10 @@ def import_records(connection, records, replace=False):
if isinstance(history, str): history = json.loads(history)
if not isinstance(history, list): raise ValueError('history_json must contain a list')
row['history_json'] = json.dumps(history, ensure_ascii=False)
tracking = row['tracking_json'] or []
if isinstance(tracking, str): tracking = json.loads(tracking)
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
row['tracking_json'] = json.dumps(tracking, ensure_ascii=False)
row['snapshot_at'] = row['snapshot_at'] or datetime.now().isoformat(timespec='seconds')
exists = connection.execute('SELECT 1 FROM order_test_data WHERE tenant_id=? AND order_no=?',
(row['tenant_id'], row['order_no'])).fetchone()
@@ -75,6 +83,33 @@ def import_records(connection, records, replace=False):
return {'imported': inserted, 'preserved': skipped}
def import_tracking_records(connection, records, replace=False):
"""Update tracking alone; never replace hand-edited order fields or add unknown orders."""
init_order_table(connection)
imported = preserved = missing = 0
for record in records:
if not isinstance(record, dict) or not record.get('tenant_id') or not record.get('order_no'):
raise ValueError('Tracking record requires tenant_id and order_no')
tracking = record.get('tracking_json')
if isinstance(tracking, str): tracking = json.loads(tracking)
if not isinstance(tracking, list): raise ValueError('tracking_json must contain a list')
row = connection.execute('SELECT id,source_order_id,tracking_json FROM order_test_data '
'WHERE tenant_id=? AND order_no=?',
(str(record['tenant_id']), record['order_no'])).fetchone()
if not row:
missing += 1
continue
if not record.get('source_order_id') or str(record['source_order_id']) != str(row[1]):
raise ValueError('Tracking source_order_id does not match local order')
if not replace and row[2] not in (None, '', '[]'):
preserved += 1
continue
connection.execute('UPDATE order_test_data SET tracking_json=? WHERE id=?',
(json.dumps(tracking, ensure_ascii=False), row[0]))
imported += 1
return {'imported': imported, 'preserved': preserved, 'missing': missing}
def lookup_order(reference):
"""Exact, bounded lookup; scope comes from server configuration, never model SQL."""
from .db import connect
@@ -142,8 +177,16 @@ def build_card(row):
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)
return {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
card = {'kind':'order', 'no':row['order_no'], 'status':status, 'fields':fields,
'nodes':nodes, 'summary':summary}
tracking = build_tracking(row.get('tracking_json'))
if tracking:
card.update(node_kind='logistics', nodes=tracking['nodes'],
estimated_nodes=tracking['estimated_nodes'], tracking_status=tracking['status'])
facts = tracking['nodes'][:10] + tracking['estimated_nodes'][:5]
card['summary'] += '。物流状态:' + (tracking['status'] or '暂无实际节点') + '。'
card['summary'] += ';'.join(f"{node['time']} {node['text']} {node['place']}" for node in facts)
return card
def is_order_followup(message):
@@ -155,9 +198,9 @@ def is_order_followup(message):
question = re.sub(r'[\s,,。.!!??、::]', '', message)
return bool(re.fullmatch(
r'(?:那|那么|还有|请问|请|查一下|看一下)?(?:它的|订单的)?'
r'(?:(?:状态|进度|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:(?:(?:物流|运输)?(?:状态|进度|轨迹)|重量|毛重|体积|件数|箱数|目的地|目的国|目的仓|仓库|父订单|运费|费用|价格)'
r'(?:(?:是|有|为)?(?:多少|什么|怎么样|怎样|如何|哪个))?|(?:有)?(?:几件|几箱|多少公斤|多少千克|多少钱))'
r'(?:呢|吗|呀|啊)?', question))
r'(?:呢|吗|呀|啊)?|(?:到哪(?:里)?|开船|到港|清关|提柜|还柜|签收)(?:了)?(?:呢|吗|没|了吗|没有)?)', question))
def answer_query(message, conv_id):
@@ -189,7 +232,7 @@ def answer_query(message, conv_id):
except (ValueError, KeyError, TypeError):
pass
if not reference:
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
if re.search(r'查.*(?:订单|运单|物流轨迹)|订单状态|物流轨迹|shipment\s*id|(?:列出|搜索|所有|全部|多少).*(?:订单|入仓)|订单.*(?:多少|列表)', message, re.I):
return {'t':'请提供订单号、入仓码或 Shipment ID。'}
return None
try:
+93
View File
@@ -0,0 +1,93 @@
"""Customer tracking derived from explicit order-linked facts, without inference."""
import json
from datetime import datetime, timezone
# Maritime codes follow the firstleg standard; warehouse facts remain distinct.
NODE_LABELS = {
'DOMESTIC_RECEIVED': (0, '已入国内仓', '预计入国内仓'),
'LOADING_COMPLETED': (5, '已完成装柜', '预计完成装柜'),
'DEPARTED_WAREHOUSE': (10, '已离开国内仓库', '预计离开国内仓库'),
'AT_ORIGIN_PORT': (20, '重箱已进入起运港或码头', '预计进入起运港或码头'),
'LOADED_ON_VESSEL': (30, '已装船', '预计装船'),
'DEPARTED_ORIGIN_PORT': (40, '船舶已离开起运港', '预计离开起运港'),
'IN_TRANSIT': (50, '海运途中', '预计进入海运阶段'),
'ARRIVED_DESTINATION_PORT': (60, '船舶已到目的港', '预计到达目的港'),
'DISCHARGED_FROM_VESSEL': (70, '已卸船、未清关', '预计卸船'),
'PICKUP_READY': (80, '已清关、可提柜', '预计可提柜'),
'PICKED_UP': (90, '已提柜', '预计提柜'),
'EMPTY_RETURNED': (100, '已还柜', '预计还柜'),
}
def parse_time(value):
if not isinstance(value, str) or not value.strip():
return None
try:
result = datetime.fromisoformat(value.strip().replace('Z', '+00:00'))
if result.tzinfo:
result = result.astimezone(timezone.utc).replace(tzinfo=None)
return result
except (ValueError, OverflowError):
return None
def build_tracking(raw):
"""Latest observation corrects a milestone; forecasts never advance progress."""
try:
facts = json.loads(raw) if isinstance(raw, str) else raw
except (ValueError, TypeError):
return None
if not isinstance(facts, list):
return None
actual, estimated = {}, {}
for index, fact in enumerate(facts):
if not isinstance(fact, dict) or not isinstance(fact.get('code'), str) or fact['code'] not in NODE_LABELS:
continue
shipment = str(fact.get('shipment_key') or '')
key = (shipment, fact['code'])
observation = parse_time(fact.get('observed_at')) or parse_time(fact.get('created_at')) or datetime.min
for field, target in (('actual_at', actual), ('estimated_at', estimated)):
when = parse_time(fact.get(field))
if when is None:
continue
candidate = (observation, index, when, fact)
if key not in target or candidate[:2] > 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)}
+113
View File
@@ -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()
+4 -2
View File
@@ -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))
+1
View File
@@ -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); }
+61 -37
View File
@@ -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 = '<span class="track-time">' + escHtml(n.time) + '</span><strong class="track-event">' + escHtml(n.text) + "</strong>" +
'<span class="track-place">' + escHtml(n.place || "") + "</span>";
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 = '<span class="track-time">' + escHtml(n.time) + '</span><strong class="track-event">' + escHtml(n.text) + "</strong>" +
'<span class="track-place">' + escHtml(n.place || "") + "</span>";
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);
}
}
+20
View File
@@ -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:'<img src=x onerror=bad()>'}],
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, /&lt;img/);
assert.ok(log.querySelector('.msg-actions'));
});
+18
View File
@@ -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)
+102
View File
@@ -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', '[]'))