接入灰度订单关联物流节点并区分预计与实际轨迹
This commit is contained in:
@@ -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` 访问。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, /<img/);
|
||||
assert.ok(log.querySelector('.msg-actions'));
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', '[]'))
|
||||
Reference in New Issue
Block a user