diff --git a/AGENTS.md b/AGENTS.md index 2c58f6b..5c42e6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` | | Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries | -| Reply boundaries / list pages | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_listing.py`: scoped counts and conversation pages | +| Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_tracking.py`: `current_stage` | | Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` | | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` | diff --git a/docs/architecture.md b/docs/architecture.md index 8acab80..6297b62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,7 +113,7 @@ tests/ # 按业务模块组织的回归 - `chat_service.create_chat_stream` 的订单查询使用本地 `order_test_data`;忽略附件路径、重量、日期和三维尺寸,保留标识中的后缀,不再调用 `build_track` 生成模拟轨迹。旧模拟函数仅兼容旧导入,不进入聊天查询路径。未知单号、多条匹配和查表失败明确说明,禁止回退模型编造状态。 - `CARD_PREFIX` 新增 `kind=order`:仅包含白名单概要、申报货物汇总和订单业务状态历史,不包含姓名、地址、内部备注或操作人。`order_tracking.py` 从 `order_test_data.tracking_json` 的已关联事实生成物流轨迹;存在有效事实时设置 `node_kind=logistics`,`nodes` 仅存实际节点,`estimated_nodes` 单独展示预计节点,`tracking_status` 只按实际节点序号推进。相同货柜/节点按最新观测修正时间,预计事实不覆盖实际事实;多柜独立展示。缺失的节点、地点和签收事实不得补造,提柜、还柜不等于签收。子单不继承父单重量或轨迹;COMPLETED 只表示头程订单完成。没有物流事实时继续展示业务状态历史。页面和模型上下文不附加测试副本声明或导入时间。订单及物流追问只在同一 `conv_id` 内复用最近单号,并重新查表取得修改后的值;新报价或其他话题不会沿用旧订单。 - 订单表不提供管理页面或对外增删改接口;表数据在运行时 SQLite 内维护,导入脚本默认保留已有记录,只有显式 `--replace` 覆盖。匿名 HTTP 查单在读取订单前返回登录引导;`g.customer_tenant_id` 由可信客户认证适配器设置,不接受访客 key、请求参数或客户自报 Header。客户范围非空时必须传给 `lookup_order`,并与 `ORDER_DATA_TENANT_ID` 的额外限制取交集。源库连接凭据和快照在仓库外,业务库只读,不随服务查询访问源库。 -- 登录后的订单数量查询由 `parse_count_query` 识别,`count_orders` 使用与单号查询相同的 `tenant_filter` 交集规则,必须显式提供非空客户范围;SQL 直接计算订单票数和已登记入仓码的订单票数,按需过滤明确的业务状态。无额外筛选时统计授权范围全部订单,不复用上一票单号;无法识别的附加条件不静默丢弃,也不交给模型猜数。成功统计返回的内部 `_customer_data` 标记由 `chat_service` 消费并移除,使纯文本统计同样在保存/发送前绑定会话客户和范围;不增加公开 SSE 字段或管理接口。 +- 登录后的订单数量由 `parse_count_query` / `order_query.parse_query` 识别,汇总、筛选和列表共用 `order_listing.read_orders` 的服务端 `tenant_filter` 交集与同一只读快照;`count_orders` 保留原聚合调用契约。默认给总数、已登记入仓码数及最多5条按来源更新时间排序的摘要;支持业务状态、明确标记的异常和当前实际节点。未指定节点的轨迹统计按订单分组,每票只计一次,多柜不同进度单列,预计节点不推进进度,业务完成不当作签收。未知条件不默认为全部,也不交给模型猜数。内部 `_customer_data` 和 `_order_list_state` 在发送前移除,事实历史绑定客户与范围;无新增公开API。 - 客户认证使用独立 `customer_users`、`customer_tenant_grants` 与 `customer_sessions` 表,由 `customer_service.attach_customer` 在访客恢复之后验证并设置 `g.customer`、单客户 `g.customer_tenant_id` 或多客户 `g.customer_tenant_ids`;只保存密码哈希与登录 token 哈希。默认账号首次关联当前本地订单的客户范围,之后不自动扩大,普通账号仍按明确关联过滤;`lookup_order` 的单客户参数及多客户白名单都与环境限制取交集。会话的 `customer_scope` 保存范围指纹。登录 Cookie 为 HttpOnly、SameSite=Lax,在 HTTPS 上为 Secure,绑定当前访客并在 8 小时后失效;退出、账号停用、密码或客户范围改变使旧会话失效。默认客户账号不赋予客服后台权限。 - 匿名查单返回 SSE `login_required` 与正文,客户页提供登录按钮;`customer.js` 登录成功后仅在原会话版本未变化时继续原问题,保留期间输入的新草稿。登录过期导致界面清空时,只允许同一客户重新通过历史归属校验后恢复原会话并续查;换账号、访问范围变化、历史加载失败或切换话题均不重放旧问题。侧栏可独立登录/退出,登录状态在窗口恢复焦点时刷新。首次返回订单卡片时,会话绑定客户 ID 和客户范围;历史、人工会话及后续生成同时校验访客、客户和范围。退出立即清除当前可见订单内容和历史标题缓存,重新登录同一身份可恢复;匿名清空历史不删除被登录保护的会话。 - 底部及欢迎卡片的查入仓码入口先确认客户登录,再打开 `OrderLookup`,不自动新建会话或发送缺少号码的问题。当前及历史订单卡片的号码也打开该查询框,保留真实的入仓码/订单号标签,支持单独复制和重新查询。提交使用既有 `/stream_chat` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。 @@ -149,7 +149,11 @@ tests/ # 按业务模块组织的回归 `quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。 -`order_listing.py` 负责列表与下一页/上一页/指定页码,每页20票;从当前会话最近的列表回复恢复页码和业务状态,重新以服务端客户范围查询实际总数和该页数据。COUNT与分页读取处于同一只读事务;无有效列表上下文时要求先列订单,不推测剩余数量。页码有界,筛选及翻页回复与订单事实一样保护历史,不增加公开API或管理入口。 +`order_listing.py` 负责数量摘要、查看全部和翻页。数量摘要最多5票,直接翻页继续每页5票;明确列出订单或“查看全部”按每页20票。`chat_history.order_list_state` 保存筛选、页码、页大小和成功回复的消息ID,绑定客户与范围;只保存仍为最新消息的状态,迟到回复不能覆盖新话题。中间插问同条件总数保留列表页码,改筛选重置,新话题停止沿用。每次重新查询授权数据,不缓存数量;计数及分页来自同一只读快照,无有效上下文先引导查询,页码有界。 + +`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。 + +共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。 已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index 1ee1713..a4d75f5 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -115,6 +115,7 @@ def create_chat_stream(msg, conv_id): order_answer = {'t':'当前报价表未提供该目的地的按方费率,请提供目的仓或邮编、实际重量和尺寸,由人工客服确认报价。'} if order_answer is not None: customer_data = order_answer.pop('_customer_data',False) or 'card' in order_answer + order_list_state=order_answer.pop('_order_list_state',None) def track_stream(): if customer_data: @@ -125,7 +126,10 @@ def create_chat_stream(msg, conv_id): save_message(conv_id, 'user', msg) content = (CARD_PREFIX + json.dumps(order_answer['card'], ensure_ascii=False) if 'card' in order_answer else order_answer['t']) - save_message(conv_id, 'assistant', content) + message_id=save_message(conv_id, 'assistant', content) + if order_list_state is not None: + from .order_listing import save_state + save_state(conv_id,order_list_state,message_id) yield sse(order_answer) yield sse({'d': 1}) return (track_stream(), {'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) diff --git a/logistics_chat/db.py b/logistics_chat/db.py index 58a6364..e397b9a 100644 --- a/logistics_chat/db.py +++ b/logistics_chat/db.py @@ -54,6 +54,8 @@ def init_db(): cur.execute('ALTER TABLE chat_history ADD COLUMN visitor_id TEXT') if 'customer_id' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN customer_id INTEGER') + if 'order_list_state' not in cols: + cur.execute('ALTER TABLE chat_history ADD COLUMN order_list_state TEXT') if 'customer_scope' not in cols: cur.execute('ALTER TABLE chat_history ADD COLUMN customer_scope TEXT') if 'customer_tenant_id' in cols: @@ -232,6 +234,7 @@ def save_message(conv_id, role, content): if not inserted: conn.close() return 'deleted' + message_id=cur.lastrowid if role == 'user': row = cur.execute('SELECT title FROM chat_history WHERE id=?', (conv_id,)).fetchone() if row and (not row[0] or row[0] == '新对话'): @@ -239,6 +242,7 @@ def save_message(conv_id, role, content): cur.execute('UPDATE chat_history SET utime=? WHERE id=?', (now_str(), conv_id)) conn.commit() conn.close() + return message_id def last_user_question(conv_id): diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index 3609ddd..ccdbf71 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -14,7 +14,7 @@ from .customer_service import order_login_response 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', 'tracking_json', 'data_note', 'source_updated_at', 'snapshot_at') + 'history_json', 'tracking_json', 'exception_reason', 'data_note', 'source_updated_at', 'snapshot_at') STATUS_LABELS = {'DRAFT': '草稿', 'SUBMITTED': '已提交', 'APPROVED': '审核通过', 'ACCEPTED': '已接单', 'REJECTED': '审核驳回', 'PROCESSING': '履约中', 'COMPLETED': '头程订单已完成', 'CANCELLED': '已取消'} @@ -23,7 +23,7 @@ FIELD_LABELS = {'inbound':'入仓码', 'number':'订单号', 'shipment':'Shipmen 'weight':'申报毛重', 'volume':'申报体积', 'role':'订单层级', 'parent':'父订单号', 'updated':'来源更新时间'} IDENTIFIER = re.compile(r'(?2000: + raise ValueError('exception_reason must be customer-visible text up to 2000 characters') + row['exception_reason']=row['exception_reason'].strip() or None history = row['history_json'] or [] if isinstance(history, str): history = json.loads(history) if not isinstance(history, list): raise ValueError('history_json must contain a list') @@ -78,7 +84,8 @@ def import_records(connection, records, replace=False): continue columns = ','.join(FIELDS) placeholders = ','.join('?' for _ in FIELDS) - updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no')) + updates = ','.join(key+'=excluded.'+key for key in FIELDS if key not in ('tenant_id', 'order_no') + and (key!='exception_reason' or key in source)) connection.execute(f'INSERT INTO order_test_data ({columns}) VALUES ({placeholders}) ' f'ON CONFLICT(tenant_id,order_no) DO UPDATE SET {updates}', [row[key] for key in FIELDS]) inserted += 1 @@ -179,44 +186,14 @@ def count_orders(tenant_ids, status=None): def parse_count_query(message): - """Recognize bounded total/status questions without ignoring unknown filters.""" - if is_lookup_help(message): - return None - noun = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b' - count = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b' - if not re.search(noun,message,re.I) or not re.search(count,message,re.I): - return None - if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I): - return None - remaining = message - statuses = [] - for phrase, status in (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'), - ('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'), - ('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT')): - if phrase in remaining: - statuses.append(status) - remaining = remaining.replace(phrase,'') - remaining = re.sub(count+'|'+noun,'',remaining,flags=re.I) - remaining = re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|' - r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|' - r'\b(?:what|is|of|do|i|have|are|there|my|in|all|the|currently)\b|[\s,,。.!!??、::]','',remaining,flags=re.I) - return {'status':statuses[0] if statuses else None, 'unsupported':bool(remaining or len(set(statuses)) > 1)} + if is_lookup_help(message):return None + from .order_query import parse_query + return parse_query(message) -def answer_count_query(intent, tenants): - if not tenants: - return order_login_response() - if intent['unsupported']: - return {'t':'暂不支持按这个条件统计。可以查询订单总数,或按草稿、已提交、履约中、已完成、已取消等业务状态统计。'} - try: - counts = count_orders(tenants,intent['status']) - except OrderDataUnavailable: - return {'t':'订单信息暂时无法读取,请稍后重试。'} - if intent['status']: - text = f"您当前有 {counts['total']} 票业务状态为“{STATUS_LABELS[intent['status']]}”的订单" - else: - text = f"您当前共有 {counts['total']} 票订单" - return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True} +def answer_count_query(intent, tenants,conv_id=None): + from .order_listing import answer_summary + return answer_summary(intent,tenants,conv_id) def answer_list_query(message, tenants, conv_id=None): @@ -233,6 +210,14 @@ def extract_reference(message): message = re.sub(r'(?? ORDER BY id", + (conv_id,state['message_id'])): + if page_command(text) is None and parse_query(text) is None and text.strip() not in ('查看全部','查看全部订单'): + return None + return state + except (ValueError,TypeError):return None + finally:connection.close() + + +def save_state(conv_id,state,message_id): + """Called only after the reply and customer binding have been persisted.""" + from .customer_service import current_customer_id,current_scope_key + from .visitor_service import visitor_id + if not conv_id or type(message_id) is not int:return + connection=connect() + try: + state=dict(state) + state['message_id']=message_id + connection.execute('UPDATE chat_history SET order_list_state=? WHERE id=? AND visitor_id=? ' + 'AND customer_id=? AND customer_scope=? ' + 'AND NOT EXISTS(SELECT 1 FROM messages WHERE conv_id=? AND id>?)', + (json.dumps(state,ensure_ascii=False),conv_id,visitor_id(),current_customer_id(),current_scope_key(),conv_id,message_id)) + connection.commit() + finally:connection.close() + + +def read_orders(tenants,query,page=1,size=5): + """One scoped snapshot; each order occupies one actual-node bucket.""" + from .order_data import tenant_filter + scope=tenant_filter(tenant_ids=tenants) + if scope is None or not valid_filters(query):raise ValueError('Explicit customer scope and filters required') + connection=connect() + try: + connection.row_factory=sqlite3.Row + where=' WHERE 1=1'+scope[0];values=scope[1] + if query['status']:where+=' AND status=?';values=values+[query['status']] + connection.execute('BEGIN') + rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code,tracking_json,exception_reason ' + 'FROM order_test_data'+where+' ORDER BY COALESCE(julianday(source_updated_at),julianday(snapshot_at),0) DESC,id DESC',values) + total=with_inbound=0;groups=Counter();selected=[];tail=deque(maxlen=size) + for row in rows: + item=dict(row) + item['exception_reason']=(item['exception_reason'] or '').strip() + if query['exception'] and not item['exception_reason']:continue + item['stage']=current_stage(item.pop('tracking_json')) + if query['stage'] and query['stage']!=item['stage']:continue + total+=1;with_inbound+=bool((item['inbound_code'] or '').strip());groups[item['stage']]+=1 + tail.append(item) + if (page-1)*sizelast: + page=last;selected=list(tail)[-(total-(last-1)*size):] if total else [] + return {'total':total,'with_inbound':with_inbound,'groups':groups,'rows':selected,'page':page} + finally:connection.close() + + +def row_text(row): + from .order_data import STATUS_LABELS + def clean(value):return re.sub(r'[\r\n\t]+',' ',str(value or '未登记')) + text=f'- 入仓码:{clean(row["inbound_code"])};订单号:{clean(row["order_no"])};业务状态:{STATUS_LABELS.get(row["status"],row["status"])};目的仓:{clean(row["warehouse_code"])};当前节点:{STAGE_LABELS[row["stage"]]}' + if row['exception_reason']:text+=';异常原因:'+clean(row['exception_reason']) + return text + + +def unsupported(listing=False): + return {'t':f'暂不支持按这个条件{"列出订单" if listing else "统计"}。可以查询订单总数、业务状态、明确标记的异常,或按当前实际物流节点统计;请一次指定一个节点。'} + + +def answer_summary(intent,tenants,conv_id=None): + from .customer_service import order_login_response + from .order_data import STATUS_LABELS + if not tenants:return order_login_response() + if intent['unsupported']:return unsupported() + query=filters(intent) + try: + data=read_orders(tenants,query) + total=data['total'] + if query['stage']: + text=f'您当前有 {total} 票当前节点为“{STAGE_LABELS[query["stage"]]}”的订单' + elif query['status']: + text=f'您当前有 {total} 票业务状态为“{STATUS_LABELS[query["status"]]}”的订单' + elif query['exception']:text=f'您当前有 {total} 票明确标记异常的订单' + else:text=f'您当前共有 {total} 票订单' + if query['exception'] and (query['stage'] or query['status']):text+='(已明确标记异常)' + if query['stage'] and query['status']:text+=f'(业务状态:{STATUS_LABELS[query["status"]]})' + lines=[text+f',其中 {data["with_inbound"]} 票已登记入仓码。'] + if intent.get('grouped'): + lines.append('按当前实际物流节点分布(每票只计一次):') + lines.extend(f'- {label}:{data["groups"][code]}票' for code,label in STAGE_LABELS.items() if data['groups'][code]) + if data['rows']: + lines.append(f'最近更新的{len(data["rows"])}票:') + lines.extend(row_text(row) for row in data['rows']) + lines.append('回复“查看全部”展开列表,或发送入仓码查询详情。') + state={'query':query,'page':1,'size':5} + previous=load_state(conv_id) + if previous and previous['query']==query:state={key:previous[key] for key in ('query','page','size')} + return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state} + except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'} + + +def answer_list(message,tenants,conv_id=None): + from .order_data import is_lookup_help,STATUS_LABELS + from .customer_service import order_login_response command=page_command(message) + expand=message.strip(' ,,。.!!??') in ('查看全部','查看全部订单') initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I) and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I)) - if command is None and not initial:return None + if command is None and not initial and not expand:return None if not tenants:return order_login_response() - page=1;status=None - if command is None: - intent=parse_count_query(re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)) - if intent is None or intent['unsupported']: - return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'} - status=intent['status'] - scope=tenant_filter(tenant_ids=tenants) - if scope is None:return order_login_response() - connection=None try: - connection=connect() - if command is not None: - if not conv_id:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'} - prior=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='assistant' ORDER BY id DESC LIMIT 1",(conv_id,)).fetchone() - header=prior[0].split('\n',1)[0] if prior else '' - match=re.fullmatch(r'您当前的订单列表(.*第(\d+)页;业务状态:([^)]+)):',header) - if not match:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'} - labels={label:code for code,label in STATUS_LABELS.items()} - status=labels.get(match[2]) - if match[2]!='全部' and status is None:return {'t':'请重新指定需要查看的订单状态。'} - page=int(command) if isinstance(command,int) else max(1,int(match[1])+(1 if command=='next' else -1)) + previous=load_state(conv_id) + if command is not None or expand: + if not previous:return {'t':'请先在当前对话中发送“列出我的订单”或查询订单数量,再翻页查看。'} + query=previous['query'];size=20 if expand else previous['size'] + page=1 if expand else int(command) if isinstance(command,int) else max(1,previous['page']+(1 if command=='next' else -1)) if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'} - where=' WHERE 1=1'+scope[0];values=scope[1] - if status:where+=' AND status=?';values=values+[status] - # Both reads see one current snapshot and apply exactly the same scope/filter. - connection.execute('BEGIN') - total=connection.execute('SELECT COUNT(*) FROM order_test_data'+where,values).fetchone()[0] - page=min(page,max(1,(total+19)//20)) - rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data'+where+ - ' ORDER BY id DESC LIMIT 20 OFFSET ?',values+[(page-1)*20]).fetchall() - if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True} - label=STATUS_LABELS[status] if status else '全部' - first='先列出20票;' if page==1 and total>20 else '' - lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}):',f'共{total}票,当前显示第{(page-1)*20+1}至{(page-1)*20+len(rows)}票。'] - for number,inbound,business_status,warehouse in rows: - lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(business_status,business_status)};目的仓:{warehouse or "未登记"}') - lines.append('回复“下一页”查看后续订单。' if page*20size else '' + extra=(';当前节点:'+STAGE_LABELS[query['stage']] if query['stage'] else '')+(';明确标记异常' if query['exception'] else '') + lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}{extra}):',f'共{total}票,当前显示第{(page-1)*size+1}至{(page-1)*size+len(data["rows"])}票。'] + lines.extend(row_text(row) for row in data['rows']) + lines.append('回复“下一页”查看后续订单。' if page*size1 or len(set(nodes))>1)} + + +def filters(intent): + return {key:intent.get(key) for key in ('status','stage','exception')} diff --git a/logistics_chat/order_tracking.py b/logistics_chat/order_tracking.py index ff7ba59..68a631a 100644 --- a/logistics_chat/order_tracking.py +++ b/logistics_chat/order_tracking.py @@ -33,14 +33,14 @@ def parse_time(value): return None -def build_tracking(raw): - """Latest observation corrects a milestone; forecasts never advance progress.""" +def tracking_facts(raw): + """Share the same corrections and actual/estimated split with aggregation.""" try: facts = json.loads(raw) if isinstance(raw, str) else raw except (ValueError, TypeError): - return None + return {},{} if not isinstance(facts, list): - return None + return {},{} 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: @@ -55,8 +55,27 @@ def build_tracking(raw): 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 + return actual,estimated + + +def current_stage(raw): + actual,estimated=tracking_facts(raw) + progress={} + for (shipment,code) in actual: + if shipment not in progress or NODE_LABELS[code][0]>NODE_LABELS[progress[shipment]][0]: + progress[shipment]=code + shipments={shipment for shipment,_ in actual.keys() | estimated.keys() if shipment} + if shipments: + if not any(shipment in progress for shipment in shipments) and '' in progress:return progress[''] + progress={shipment:progress.get(shipment,'NO_ACTUAL') for shipment in shipments} + codes=set(progress.values()) + return next(iter(codes)) if len(codes)==1 else 'MIXED' if codes else 'NO_ACTUAL' + + +def build_tracking(raw): + """Latest observation corrects a milestone; forecasts never advance progress.""" + actual,estimated=tracking_facts(raw) + if not actual and not estimated:return None shipments = sorted({key[0] for key in actual.keys() | estimated.keys()} - {''}) multiple = len(shipments) > 1 diff --git a/logistics_chat/pricing.py b/logistics_chat/pricing.py index b48cd10..28de772 100644 --- a/logistics_chat/pricing.py +++ b/logistics_chat/pricing.py @@ -94,15 +94,36 @@ RATES = { CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运") -def parse_dims(t): - """解析长x宽x高,支持小数、空格及 x/×/* 分隔。 +DIMENSION_NUMBER = r'(?