Compare commits

..
26 changed files with 1459 additions and 187 deletions
+2
View File
@@ -15,6 +15,8 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| 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 / 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` |
+19 -1
View File
@@ -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` 并显示明确的用户问句,结果追加到当前会话;主输入框草稿及已上传附件不清空、不代发。人工接待、未加载历史或其他请求进行中时不抢占路由。切换话题/身份关闭弹窗,迟到的登录/状态/复制结果不得改动新上下文;弹窗跟随手机可视视口并将焦点还给触发按钮。
@@ -141,6 +141,24 @@ tests/ # 按业务模块组织的回归
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
`quote_allocations.py` 只识别明确分配给各仓的重量、箱数及箱规,逐仓计费及计算每箱最低重量;仅给总重和多个仓码时保留比较语义,不擅自分配重量。`quote_context.py` 在当前会话的有效上下文内按时间顺序应用更正,保留已算出的总重:重量纠正会清除旧分箱明细,单改箱数时统一箱重可沿用,混合箱重需重新确认;“总重不变”优先于旧每箱重量,新尺寸不沿用旧体积。按方报价不把按公斤的材质费直接乘CBM,金额未确定时明确单列待核算项。
`shipping_faq.py` 从激活版本回答交仓、标签、报关资料及查验费用问题;具体船期和计划发货日期需人工核对,不使用旧表日期承诺必达。`order_explanations.py` 只解释授权订单的业务状态和实际节点,不把预计节点当实际位置,不推断逐箱签收或亚马逊上架。文字事实回复与订单卡片同样绑定客户及范围;催单或拦截不声称已提交。本轮不扩展售后理赔规则。
`customer_faq.py` 在报价分流前处理下单、短信查单、开票、导出及附件读取等能力说明,不生成业务操作成功的假回复。美国常见费用/毛净重/议价/总价追问由当前价表直接回答;`quote_scope.py` 将尚未匹配的美国咨询保留为明确的核对回复,不送入旧欧洲/英国知识库。明确切换其他国家时仍走该地区原有分流,理赔规则未扩展。
`quote_edits.py` 先移除被明确否定的仓码或交货仓,再解析替换条件。共享尺寸解析支持英寸转换和口语长宽高,“一个箱子”可形成单箱条件;总计立方米不作为尺寸单位“米”。
`order_listing.py` 负责数量摘要、查看全部和翻页。数量摘要最多5票,直接翻页继续每页5票;明确列出订单或“查看全部”按每页20票。`chat_history.order_list_state` 保存筛选、页码、页大小和成功回复的消息ID,绑定客户与范围;只保存仍为最新消息的状态,迟到回复不能覆盖新话题。中间插问同条件总数保留列表页码,改筛选重置,新话题停止沿用。每次重新查询授权数据,不缓存数量;计数及分页来自同一只读快照,无有效上下文先引导查询,页码有界。
`order_test_data.exception_reason` 是人工明确登记、允许客户查看的物流异常原因;非空表示该票有异常标记,空值表示未标记,不推断货物正常。取消、审核驳回和无轨迹均不自动标为物流异常。可直接维护此字段,不增加管理页面;旧快照未提供该字段时即使显式覆盖也保留已有原因,显式提供空字符串才清除。后续MCP适配返回相同事实即可。
共享尺寸解析将单位与长宽高绑定,支持每边单位和混合单位,不受“12英寸显示器”等商品描述影响。报价沿当前会话回溯到最近话题边界,不按10轮截断;其他国家、新一票及订单话题隔离不变。“铁铝部分还是10KG”等口语仅在先前已明确计费重时更新该基数,既不覆盖整票总重,也不把未确认毛重直接当计费重。
已确认海派计费重保留原小数,不向上取整,每箱最低12KG仍生效。原实重小数原样展示;无限小数的材积结果以“约”展示有限位数,金额计算仍使用原Decimal精度,不把显示近似当作计费进位。
铁铝附加费只按铁铝货物计费重计算。`quote_material.py` 区分整票铁铝、部分铁铝与未明确材质的金属,并将明确的“铁铝部分计费重”从整票毛重解析中分离。部分货物未提供计费重时不按整票加费;提供明确基数后可将该项人民币费用加到公斤或按方运费中。更改整票重量、箱数或箱规后清除旧的部分计费重,防止沿用过期基数。未明确金属是否属于铁铝时先核实材质,不把所有金属都认作铁铝。
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+11 -3
View File
@@ -91,14 +91,18 @@ def create_chat_stream(msg, conv_id):
return (sse(order_login_response()) + sse({'d':1}), {})
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id)
if order_answer is None:
from .customer_faq import answer_capability
order_answer = answer_capability(msg)
if order_answer is None:
order_answer = answer_quote(msg, conv_id)
if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg):
order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'}
if order_answer is None and re.search(r'照片|图片',msg) and re.search(r'报价|价格|判断|识别',msg):
order_answer = {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
if order_answer is None:
from .quote_scope import answer_us_fallback
order_answer = answer_us_fallback(msg,conv_id)
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
if ship and ship['region'] is None and (ship['weight'] is not None or ship['dims'] or
re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
@@ -111,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:
@@ -121,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'})
+21
View File
@@ -0,0 +1,21 @@
"""Describe supported customer actions without inventing operational integrations."""
import re
def answer_capability(message):
if re.search(r'照片|图片',message) and re.search(r'报价|价格|判断|识别|尺寸',message):
return {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
if re.search(r'(?:手机号|手机号码|电话号码).*(?:查|订单|运单)|(?:查|查询).*手机号',message):
return {'t':'当前请使用客户账号登录后,按入仓码查询订单;暂不支持手机号或短信验证码查单。没有入仓码可联系人工核实订单归属。'}
if re.search(r'下单(?!号|时间|日期)|订舱|生成订单|创建订单|预约提货|安排提货|\b(?:place|create)\s+(?:an?\s+)?order\b',message,re.I):
return {'t':'下单、订舱和安排提货需要联系人工办理。当前可查询报价及已关联订单,尚未创建订单或安排提货;请提供目的仓、品名、箱数、重量和尺寸供人工核对。'}
if re.search(r'开(?:发票|票)|发票.*(?:税点|几个点|怎么开|申请)|月结|账期|付款账号|收款账号|银行账号|打款|(?:怎么|如何)付款',message):
return {'t':'开票税点、月结资格及付款方式需要由专属业务员确认。请联系人工核对对应订单和结算信息;当前聊天不提供未经确认的税点或收款账号。'}
if re.search(r'导出.*(?:订单|运单|Excel)|(?:订单|运单).*导出',message,re.I):
return {'t':'当前聊天暂不提供订单Excel导出,可联系人工核对导出需求。可先查看账号内订单列表,或发送入仓码查询单票详情。'}
if re.search(r'Excel|表格',message,re.I) and re.search(r'直接读|自动读|读取|识别|读里面',message):
return {'t':'当前聊天不会自动读取Excel附件里的箱规。请将每箱重量、长宽高及目的仓码粘贴为文字,我可据此核算;批量表格可交人工整理。'}
if re.search(r'换.*手机|换.*设备|跨设备|清除.*(?:浏览器|缓存)',message) and re.search(r'聊天|记录|历史',message):
return {'t':'当前聊天记录与原浏览器的访客身份关联,换手机或清除浏览器数据后不会自动同步恢复。请先在原设备查看;客户账号目前用于订单权限校验,尚未提供跨设备聊天同步。'}
return None
+4
View File
@@ -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):
+49 -80
View File
@@ -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'(?<![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):
@@ -39,13 +39,15 @@ 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 '[]', tracking_json TEXT NOT NULL DEFAULT '[]', data_note TEXT,
history_json TEXT NOT NULL DEFAULT '[]', tracking_json TEXT NOT NULL DEFAULT '[]', exception_reason TEXT, 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 '[]'")
if 'exception_reason' not in columns:
connection.execute('ALTER TABLE order_test_data ADD COLUMN exception_reason TEXT')
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)')
@@ -62,6 +64,10 @@ def import_records(connection, records, replace=False):
row['source_order_id'] = str(row['source_order_id']) if row['source_order_id'] is not None else None
row['order_no'] = str(row['order_no']).strip()
row['status'] = str(row['status'] or 'DRAFT')
if row['exception_reason'] is not None:
if not isinstance(row['exception_reason'],str) or len(row['exception_reason'])>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,92 +186,40 @@ 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):
"""Reuse bounded status parsing; list reads require an explicit customer scope."""
if is_lookup_help(message) or not re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I):
return None
if not re.search(r'列出|列表|全部|所有|\blist\b',message,re.I):
return None
if not tenants:
return order_login_response()
question=re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)
intent=parse_count_query(question)
if intent is None or intent['unsupported']:
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
scope=tenant_filter(tenant_ids=tenants)
if scope is None:
return order_login_response()
from .db import connect
connection=None
try:
connection=connect()
sql='SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data WHERE 1=1'+scope[0]
values=scope[1]
if intent['status']:
sql+=' AND status=?';values=values+[intent['status']]
rows=connection.execute(sql+' ORDER BY id DESC LIMIT 21',values).fetchall()
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
lines=['您当前的订单列表'+('(先列出20票)' if len(rows)>20 else '')+':']
for number,inbound,status,warehouse in rows[:20]:
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(status,status)};目的仓:{warehouse or "未登记"}')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True}
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
finally:
if connection is not None:connection.close()
def answer_list_query(message, tenants, conv_id=None):
from .order_listing import answer_list
return answer_list(message,tenants,conv_id)
def extract_reference(message):
# Explicit postal/monetary fields are not order identifiers, even if invalid.
message = re.sub(r'(?:手机号|手机号码|电话号码|联系电话|电话)\s*[::是为]?\s*(?:\+?86[-\s]?)?1[3-9]\d{9}', ' ', message)
message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*(?:万)?(?:USD|RMB|CNY|美元|美金|人民币|元)(?![A-Za-z0-9_-])',' ',message,flags=re.I)
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message)
from .pricing import DIMENSION_PATTERNS
for pattern in DIMENSION_PATTERNS:
def remove_dimensions(match):
end=match.start()+len(match[0].rstrip())
before=message[match.start()-1:match.start()] if match.start() else ''
after=message[end:end+1]
return match[0] if re.search(r'[A-Za-z0-9_.-]',before+after) else ' '
message=pattern.sub(remove_dimensions,message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
r'(?:\s*(?:inches|inch|in|cm|mm|m|英寸|厘米|毫米|米|["″]))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kilograms?|kgs?|grams?|g|tons?|lbs?|磅|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
@@ -341,7 +296,7 @@ def is_tracking_request(message):
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
r'(?:已)?(?:清关|开船|到港|出库|提柜)(?:了)?(?:吗|没|了吗|没有)?|签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
@@ -351,6 +306,9 @@ def is_tracking_request(message):
def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question."""
from .order_explanations import detail_question
if detail_question(message) and not re.search(r'报价|价格|多少钱|公斤|\bKG\b|海派|海卡',message,re.I):
return True
if is_lookup_help(message):
return False
if is_tracking_request(message):
@@ -375,13 +333,17 @@ def is_order_followup(message):
def answer_query(message, conv_id):
references = extract_reference(message)
tenants = customer_tenant_ids()
from .order_explanations import general_explanation, detail_question, explain_order
if not references:
explanation=general_explanation(message)
if explanation:return {'t':explanation}
count_intent = parse_count_query(message) if not references else None
if count_intent is not None:
return answer_count_query(count_intent,tenants)
return answer_count_query(count_intent,tenants,conv_id)
if not references:
listed=answer_list_query(message,tenants)
listed=answer_list_query(message,tenants,conv_id)
if listed is not None:return listed
mutation=bool(re.search(r'改址|修改.*(?:收件|地址|订单)|收件地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
mutation=bool(re.search(r'改址|改.*(?:收货|收件).*地址|修改.*(?:收件|地址|订单)|(?:收货|收件)地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
if references and not tenants:
return order_login_response()
if len(references) > 1:
@@ -413,6 +375,10 @@ def answer_query(message, conv_id):
if not reference:
if mutation:
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
if detail_question(message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'请提供入仓码,我先核对该票订单的实际物流记录。'}
if re.search(r'哪些.*(?:订单|运单)|(?:订单|运单).*(?:筛选|还没开船|未发货|已发货|未签收)',message) and not is_lookup_help(message):
return order_login_response() if not tenants else {'t':'暂不支持按这个物流条件批量筛选订单。可按业务状态查看列表,或提供入仓码逐票核对实际物流节点。'}
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
@@ -434,4 +400,7 @@ def answer_query(message, conv_id):
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
return {'card': build_card(matches[0])}
card=build_card(matches[0])
explanation=explain_order(message,matches[0],card)
if explanation:return {'t':explanation,'_customer_data':True}
return {'card':card}
+49
View File
@@ -0,0 +1,49 @@
"""Explain recorded order facts without inferring delivery or operational writes."""
import re
def detail_question(message):
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
return False
return bool(re.search(r'异常原因|为什么异常|什么异常|上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|改.*(?:收货|收件).*地址|(?:收货|收件)地址.*改|快递单号|付过款|付款状态|'
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
def general_explanation(message):
if re.search(r'我的|这票|这单|这个订单|该订单',message):return None
if re.search(r'已完成|履约中|上架',message) and re.search(r'是什么意思|是否代表|是不是|等于|有什么区别',message):
return '“履约中”“头程订单已完成”属于业务状态;出库、开船、到港等以实际物流节点为准。业务完成不等于收件人签收,签收也不等于亚马逊已上架;上架需以亚马逊入库记录核实。'
return None
def explain_order(message,row,card):
if not detail_question(message):return None
reference=row.get('inbound_code') or row['order_no']
lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。']
if re.search(r'异常原因|为什么异常|什么异常',message):
reason=(row.get('exception_reason') or '').strip()
return lines[0]+('已记录的异常原因:'+reason if reason else '当前未登记异常原因,请联系人工核实。')
if re.search(r'催单|催一下|拦截',message):
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
if re.search(r'付过款|付款状态',message):
return lines[0]+'当前订单未接入付款状态,不能根据业务状态判断是否付款,请联系人工核对账单。'
if '快递单号' in message:
return lines[0]+'当前未记录可核实的UPS/FedEx尾程快递单号,入仓码和内部订单号不能直接当作快递单号,请联系人工核对。'
lines.append(f'登记的业务状态为“{card["status"]}”。')
actual=card.get('nodes',[]) if card.get('node_kind')=='logistics' else []
if actual:
latest=actual[0]
lines.append('最近一条实际物流记录:'+' '.join(str(latest.get(k) or '') for k in ('time','text','place')).strip()+'。')
else:lines.append('当前没有已记录的实际物流节点,不能根据业务状态推断货物位置。')
if re.search(r'上架|已完成|签收|送达',message):
lines.append('头程业务完成不等于收件人签收;当前节点不能用于确认亚马逊上架。上架结果需核对亚马逊入库记录。')
if re.search(r'没更新|未更新|没有更新|不更新',message):
lines.append('未更新期间的实际位置和原因无法从现有记录确定,可提供入仓码联系人工核查。')
if re.search(r'发货|发走|出库|开船',message):
lines.append('计划发货或开船安排需联系人工核对,不能根据业务状态承诺未来发货日期。')
if re.search(r'少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收',message):
if row.get('package_count') is not None:lines.append(f'登记总件数为{row["package_count"]}件。')
lines.append('当前没有逐箱签收明细,不能把整票业务状态当作每箱均已送达,请联系人工核实各箱情况。')
return '\n'.join(lines)
+174
View File
@@ -0,0 +1,174 @@
"""Scoped summaries, five-row previews and durable conversational list pages."""
from collections import Counter,deque
import json
import re
import sqlite3
from .db import connect
from .order_query import parse_query,filters,STAGE_LABELS
from .order_tracking import current_stage
def page_command(message):
text=re.sub(r'[\s,,。.!!??]','',message)
match=re.fullmatch(r'(?:请)?(?:订单)?第(\d{1,5})页',text)
if match:return int(match.group(1))
if re.fullmatch(r'(?:请|继续)?(?:下一页|下页|继续显示|接着显示(?:剩下的)?订单)',text):return 'next'
if re.fullmatch(r'(?:请)?(?:上一页|上页)',text):return 'previous'
return None
def valid_filters(query):
from .order_data import STATUS_LABELS
return (isinstance(query,dict) and set(query)=={'status','stage','exception'}
and (query['status'] is None or query['status'] in STATUS_LABELS)
and (query['stage'] is None or query['stage'] in STAGE_LABELS)
and isinstance(query['exception'],bool))
def load_state(conv_id):
from .customer_service import current_customer_id,current_scope_key
from .visitor_service import visitor_id
if not conv_id:return None
connection=connect()
try:
row=connection.execute('SELECT order_list_state FROM chat_history WHERE id=? AND visitor_id=? '
'AND customer_id=? AND customer_scope=?',
(conv_id,visitor_id(),current_customer_id(),current_scope_key())).fetchone()
state=json.loads(row[0]) if row and row[0] else None
if not isinstance(state,dict) or not valid_filters(state.get('query')):return None
if type(state.get('page')) is not int or not 1<=state['page']<=10000:return None
if state.get('size') not in (5,20) or type(state.get('message_id')) is not int:return None
for (text,) in connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? 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)*size<total<=page*size:selected.append(item)
last=max(1,(total+size-1)//size)
if page>last:
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 and not expand:return None
if not tenants:return order_login_response()
try:
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之间的有效页码。'}
else:
intent=parse_query(message,listing=True)
if intent is None or intent['unsupported']:return unsupported(True)
query=filters(intent);page=1;size=20
data=read_orders(tenants,query,page,size);page=data['page'];total=data['total']
state={'query':query,'page':page,'size':size}
if not total:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True,'_order_list_state':state}
label=STATUS_LABELS[query['status']] if query['status'] else '全部'
first=f'先列出{size}票;' if page==1 and total>size 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*size<total else '当前已是最后一页。')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True,'_order_list_state':state}
except sqlite3.Error:return {'t':'订单信息暂时无法读取,请稍后重试。'}
+61
View File
@@ -0,0 +1,61 @@
"""Recognize explicit, whitelisted order summary and list filters."""
import re
from .order_tracking import NODE_LABELS
NODE_PHRASES = (
('DOMESTIC_RECEIVED',r'已入国内仓|已入仓'),
('LOADING_COMPLETED',r'已完成装柜|已装柜'),
('DEPARTED_WAREHOUSE',r'已离开国内仓库|已离仓|已出库'),
('AT_ORIGIN_PORT',r'重箱已进入起运港或码头|已到起运港|已进起运港'),
('LOADED_ON_VESSEL',r'已装船'),
('DEPARTED_ORIGIN_PORT',r'船舶已离开起运港|已开船|开船'),
('IN_TRANSIT',r'海运途中|运输中|在途|海上'),
('ARRIVED_DESTINATION_PORT',r'船舶已到目的港|已到目的港|已到港|到港'),
('DISCHARGED_FROM_VESSEL',r'已卸船、未清关|卸船未清关|已卸船|卸船'),
('PICKUP_READY',r'已清关、可提柜|已清关|清关完成|可提柜'),
('PICKED_UP',r'已提柜|提柜'),
('EMPTY_RETURNED',r'已还柜|还柜'),
('NO_ACTUAL',r'没有实际轨迹|暂无实际节点|没有轨迹|无轨迹'),
('MIXED',r'多柜进度不一致|多柜进度不同'),
)
STAGE_LABELS = {code:labels[1] for code,labels in NODE_LABELS.items()}
STAGE_LABELS.update(NO_ACTUAL='暂无实际节点',MIXED='多柜进度不同')
STATUSES = (('头程订单已完成','COMPLETED'),('已完成','COMPLETED'),('履约中','PROCESSING'),
('处理中','PROCESSING'),('已取消','CANCELLED'),('审核通过','APPROVED'),
('审核驳回','REJECTED'),('已接单','ACCEPTED'),('已提交','SUBMITTED'),('草稿','DRAFT'))
NOUN = r'订单|运单|入仓码|入仓号|\borders?\b|\bshipments?\b'
COUNT = r'多少|几(?:个|票|条|张|笔)|总数|数量|总量|统计|数一下|(?:订单|运单)数(?!量)|\bhow\s+many\b|\bcount\b|\bnumber\b|\btotal\b'
def parse_query(message,listing=False):
if not re.search(NOUN,message,re.I):return None
if not listing and not (re.search(COUNT,message,re.I) or re.search(r'分布|(?:都|分别).*到哪',message)):return None
if re.search(r'公斤|千克|\bkg\b|重量|毛重|体积|件数|箱数|运费|费用|价格|多少钱',message,re.I):return None
remaining=message
statuses=[];nodes=[]
# Unknown/negated logistics conditions must never silently become a total.
unsupported=bool(re.search(r'未到港|没到港|还没|未开船|未发货|未签收|已签收|已送达|曾经|曾到|到过|正常订单|无异常',remaining))
for code,pattern in NODE_PHRASES:
if re.search(pattern,remaining):
nodes.append(code);remaining=re.sub(pattern,'',remaining)
for phrase,status in STATUSES:
if phrase in remaining:
statuses.append(status);remaining=remaining.replace(phrase,'')
exception=bool(re.search(r'异常',remaining))
remaining=re.sub(r'物流异常|异常','',remaining)
grouped=not nodes and bool(re.search(r'轨迹|节点|阶段|到哪',remaining))
remaining=re.sub(r'物流轨迹|物流节点|当前节点|轨迹|节点|阶段|到哪里|到哪|各自|各个|每个|分别|分布|各|都|了', '',remaining)
remaining=re.sub(COUNT+'|'+NOUN,'',remaining,flags=re.I)
remaining=re.sub(r'当前账号|当前账户|账号下|账户下|名下|可查询|能查到|可以查到|目前|现在|当前|一共|总共|合计|总计|'
r'请问|麻烦|帮我|想知道|想查|想看|请|查询|查一下|查下|查查|看一下|看看|看下|一下|下|列出|列表|显示|全部|所有|共有|我的|我|能|有|的|个|票|条|张|笔|呢|吗|呀|啊|共|'
r'\b(?:list|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,'stage':nodes[0] if nodes else None,
'exception':exception,'grouped':grouped,
'unsupported':bool(unsupported or remaining or len(set(statuses))>1 or len(set(nodes))>1)}
def filters(intent):
return {key:intent.get(key) for key in ('status','stage','exception')}
+25 -6
View File
@@ -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
+37 -17
View File
@@ -94,15 +94,36 @@ RATES = {
CBM_CHANNELS = ("卡航", "普卡", "铁路", "海运")
def parse_dims(t):
"""解析长x宽x高,支持小数、空格及 x/×/* 分隔。
DIMENSION_NUMBER = r'(?<![\d.+-])(\d+(?:\.\d+)?)(?![\d.])'
DIMENSION_UNIT = r'\s*(英寸|毫米|厘米|公分|米|(?:inches|inch|in|cm|mm|m)(?=$|[^A-Za-z0-9²³]|[xX])|["″])?\s*'
DIMENSION_AXIS = DIMENSION_NUMBER + DIMENSION_UNIT
DIMENSION_PATTERNS = tuple(re.compile(pattern,re.I) for pattern in (
DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS+r'[*xX×]\s*'+DIMENSION_AXIS,
r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高\s*'+DIMENSION_AXIS,
DIMENSION_AXIS+r'长\s*'+DIMENSION_AXIS+r'宽\s*'+DIMENSION_AXIS+r'高',
))
用"最长连续数字"匹配三组,要求每组前后都不与其它数字相连,
这样 "100x80x60 200kg" 里的 60 不会被后面的 200 吞成 6020。
"""
match = re.search(r'(?<![\d.+-])(\d+(?:\.\d+)?)\s*[*xX×]\s*'
r'(\d+(?:\.\d+)?)\s*[*xX×]\s*(\d+(?:\.\d+)?)(?![\d.])', t)
return [float(value) for value in match.groups()] if match else None
def dimension_measurement(text):
"""Keep units attached to the three packaging axes, not product descriptions."""
matches=[match for pattern in DIMENSION_PATTERNS if (match:=pattern.search(text))]
if not matches:return None
match=min(matches,key=lambda item:item.start())
parts=match.groups()
values=[float(parts[index]) for index in (0,2,4)]
aliases={'英寸':'in','inches':'in','inch':'in','"':'in','″':'in',
'毫米':'mm','厘米':'cm','公分':'cm','米':'m'}
units=[aliases.get(value.lower(),value.lower()) if value else None for value in parts[1::2]]
default=next((unit for unit in reversed(units) if unit),'cm')
units=[unit or default for unit in units]
if len(set(units))==1:return values,units[0]
factors={'cm':1,'mm':0.1,'m':100,'in':2.54}
return [value*factors[unit] for value,unit in zip(values,units)],'cm'
def parse_dims(t):
measurement=dimension_measurement(t)
return measurement[0] if measurement else None
SHIPPING_REGION_PATTERNS = tuple((re.compile(pattern, re.I), region) for region, pattern in (
@@ -142,11 +163,6 @@ def parse_shipping_intent(text):
# 尺寸:在原始文本上解析(空格有助于区分尺寸和后面的重量)
intent["dims"] = parse_dims(text)
if not intent["dims"]:
# 支持“长100宽80高60”
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl:
intent["dims"] = [float(x) for x in dl.groups()]
# 重量:带单位的数字。去掉空格后在原始文本上匹配,
# 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
@@ -177,20 +193,24 @@ def parse_shipping_intent(text):
return intent
def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm"""
"""Recognize explicit dimensions; aggregate CBM does not imply meters."""
measurement=dimension_measurement(text)
if measurement:return measurement[1]
low = text.lower()
if re.search(r'英寸|\binch(?:es)?(?![A-Za-z])|\d\s*in(?![A-Za-z0-9])|\d\s*["″]',low):
return 'in'
if re.search(r"毫米|\d\s*mm", low):
return "mm"
if re.search(r"米", low) and not re.search(r"厘米", low):
return "m"
if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm"
if re.search(r"\d\s*m", low): # 注意:cm 里也含 m,所以放在 cm 之后判断
if re.search(r"\d\s*(?:m(?![A-Za-z0-9²³])|米)", low):
return "m"
return "cm"
def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算"""
if unit == 'in':
return [d * 2.54 for d in dims]
if unit == "m":
return [d * 100 for d in dims]
if unit == "mm":
+38
View File
@@ -0,0 +1,38 @@
"""Explicit weight-to-warehouse assignments; unassigned comparisons stay separate."""
from decimal import Decimal
import re
def allocations(text,codes,weight_pattern,to_kg):
if len(codes)<2:return None
code='('+'|'.join(re.escape(code) for code in sorted(codes,key=len,reverse=True))+r')(?![A-Za-z0-9])'
forward=weight_pattern.pattern+r'\s*(?:货物)?\s*(?:发到|发往|送到|发|到|去)?\s*'+code
reverse=code+r'\s*(?:[::]|分配|发|重)?\s*'+weight_pattern.pattern
entries=[];used=[]
for pattern,backwards in ((forward,False),(reverse,True)):
for match in re.finditer(pattern,text,re.I):
if any(match.start()<end and match.end()>start for start,end in used):continue
values=match.groups()
destination,value,unit=values if backwards else (values[2],values[0],values[1])
piece_weight=to_kg(value,unit)
weight_start=match.start(2 if backwards else 1)
carton=re.search(r'(第)?(\d+)\s*(?:箱|件)\s*(每(?:箱|件))?\s*$',text[:weight_start])
boxes=(1 if carton.group(1) else int(carton.group(2))) if carton else None
pieces=([{'count':boxes,'weight':str(piece_weight),'dims':None,'dim_unit':'cm','position':weight_start}]
if carton and (boxes==1 or carton.group(3)) else None)
weight=piece_weight*boxes if pieces else piece_weight
entries.append({'warehouse':destination.upper(),'weight':weight,'boxes':boxes,'packages':pieces})
used.append(match.span())
if len(entries)<2:return None
totals={}
for entry in entries:
code=entry['warehouse']
if code not in totals:
totals[code]=entry
continue
total=totals[code]
total['weight']+=entry['weight']
total['boxes']=total['boxes']+entry['boxes'] if total['boxes'] is not None and entry['boxes'] is not None else None
total['packages']=(total['packages']+entry['packages']) if total['packages'] and entry['packages'] else None
return [{**entry,'weight':str(entry['weight'])} for entry in totals.values()]
+31 -4
View File
@@ -7,9 +7,29 @@ from .quote_fees import document, amount, material_fee, limit_fees
def answer_business(intent,book):
from .quote_engine import number
from .quote_engine import number,exact_number
text=intent['text'];mode=intent.get('mode')
if mode=='weight_basis':
return '运费按计费重量计算,不是只看净重。实重按含包装的毛重,与材积重取较大值;美国海派材积重为长×宽×高(cm)÷6000,每箱不足12KG按12KG计费。海卡包税按对应公斤规则,不包税按方方案以实际体积和毛重÷363取大;请按所选方案核算。'
if mode=='rounding':
weight=(f'若{exact_number(intent["weight"])}KG已是计费重,就按{exact_number(intent["weight"])}KG计算。' if intent.get('weight') else '')
return '美国海派按原小数计费,不向上取整;每箱不足12KG仍按12KG计费。'+weight+'计费重先按各箱实重与材积重取大,再按渠道对应的重量门槛匹配档位。海卡等其他方案的进位方式需另行核对。'
if mode=='mixing':
return '请分别提供各产品的品名、材质、数量、重量,以及是否带电、带磁、液体或粉末,并说明是否放在同一箱。需先核对各品类的承运与申报要求,再确认混装方案,不能仅凭“两种产品”判断可以混装。'
if mode=='metal_scope':
from .quote_fees import material_charge
from .quote_engine import quantity
per_kg=material_fee(book)
if per_kg is None:return '铁铝附加费只按铁铝货物的计费重收取,当前未匹配到费率,请联系人工核对。'
lines=[f'铁铝附加费为{per_kg:g}元/KG,只按铁铝货物的计费重收取;一票只有部分铁铝货物时,不按整票计费重加收。']
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
rate={'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''}
details=material_charge(intent,quantity(intent,rate),unit,book)
lines+=details['items']+details['notes']
if intent.get('metal_scope')!='partial' and details['items'] and not intent.get('dims'):
lines.append('以上按当前提供的重量条件估算,仍需核对各箱尺寸及材积重。')
return '\n'.join(dict.fromkeys(lines))
if mode=='currency':
return '当前海卡、海派运价中的“元/KG”和“元/CBM”按人民币(RMB/CNY)计;一件代发等资料中标注“$”“USD”“美金”的费用按美元计。不同币种分别列明,未提供结算汇率时不自动换算。'
if mode=='insurance':
@@ -74,10 +94,17 @@ def answer_business(intent,book):
return '请提供品名、材质、是否带电及所选渠道,先核对承运条件,再计算对应费用。'
if mode=='fees':
lines=[]
if re.search(r'含税|含派送|全包|包不包',text):
lines.append('报价行标注“包税”或“不包税”的方案按各自税费方式计算;海派的派送还需核对目的仓/邮编,住宅、商业地址及偏远等附加费不能直接当作已全部包含。')
metal=material_fee(book)
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
lines.append(f'当前资料铁铝制品附加费为{metal:g}元/KG。')
if intent.get('weight'):lines.append(f'先按已提供重量{number(intent["weight"])}KG计算该项:{number(intent["weight"])}×{metal:g}={number(Decimal(intent["weight"])*metal)}元;计费重确定后继续核算。')
from .quote_fees import material_charge
from .quote_engine import quantity
unit='cbm' if intent.get('tax')=='excluded' and intent.get('cbm') else 'kg'
charge=quantity(intent,{'kind':'truck' if intent.get('kinds')==['truck'] else 'parcel','unit':unit,'service':''})
details=material_charge(intent,charge,unit,book)
lines.append(f'铁铝附加费为{metal:g}元/KG,只按铁铝货物的计费重收取。')
lines+=details['items']+details['notes']
if re.search(r'超重|超尺寸|超长',text):
raw=document(book,('超重收',))
fee=amount(raw,r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
@@ -93,6 +120,6 @@ def answer_business(intent,book):
if remote is not None and extended is not None:
lines.append(f'当前美国海派资料:偏远地址加{remote:g}元/KG,超偏远地址加{extended:g}元/KG,遥远地址单询;海派每箱不足12KG按12KG计费。')
lines.append('报价中的包税/不包税是不同费率口径;材质、报关申报、偏远及超重超尺寸等按条件单列,不代表所有服务费用都已包含。')
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG;多项费用分别列明,同箱超限费用只取最高项。')
if metal is not None:lines.append(f'铁铝制品附加费{metal:g}元/KG,只按铁铝货物计费重收取;多项费用分别列明,同箱超限费用只取最高项。')
return '\n'.join(lines)
return None
+62
View File
@@ -0,0 +1,62 @@
"""Apply quote edits in chronological order, including derived carton quantities."""
from copy import deepcopy
from decimal import Decimal
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
def merge_intent(current,previous):
result=deepcopy(current)
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total','metal_weight_update'}
if current.get('metal_weight_update') and previous.get('metal_chargeable_weight') is None:
result['metal_chargeable_weight']=None
result['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if current.get('warehouse') or current.get('unknown_warehouses'):
excluded.update(('postal','zone','zones'))
elif current.get('postal'):
excluded.update(('warehouse','warehouses','unknown_warehouses','zone','zones'))
elif current.get('zone'):
excluded.update(('warehouse','warehouses','unknown_warehouses','postal'))
if any(current.get(k) for k in ('warehouse','postal','zone')) and not current.get('unknown_warehouses'):
excluded.add('unknown_warehouses')
if any(current.get(k) for k in ('warehouse','unknown_warehouses','postal','zone')) and not current.get('allocations'):
excluded.add('allocations')
if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm'))
if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested'))
if current['air_only'] or current['tax']=='excluded':excluded.add('kinds')
if current['weight'] is not None:
if not current.get('packages'):excluded.add('packages')
if not current.get('allocations'):excluded.add('allocations')
if current['dims']:excluded.add('cbm')
if current['cbm'] is not None:excluded.add('dims')
if any(current.get(k) is not None for k in ('weight','dims','cbm','boxes','packages','allocations','transport','kinds','services','tax')) and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
if current.get('material')=='ordinary':excluded.update(('metal_scope','metal_chargeable_weight'))
if current.get('metal_scope')=='all' and current.get('metal_chargeable_weight') is None:
excluded.add('metal_chargeable_weight')
for key,value in previous.items():
if result.get(key) is None and key not in excluded:result[key]=deepcopy(value)
if current['dims'] is None and 'dims' not in excluded and previous['dims']:
result['dim_unit']=previous['dim_unit']
pieces=result.get('packages') or []
if current['dims']:
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
elif current['cbm'] is not None:
for piece in pieces:piece.update(dims=None,dim_unit='cm')
if previous.get('quantity_uncertain') and current['weight'] is None and not current.get('preserve_total'):
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
if current.get('preserve_total'):
result['packages']=None
elif len(pieces)==1:
pieces[0]['count']=result['boxes']
result['weight']=str(Decimal(pieces[0]['weight'])*result['boxes'])
result['quantity_note']=f'按每箱{pieces[0]["weight"]}KG不变,更新为{result["boxes"]}箱。'
else:
result['packages']=None
result['quantity_uncertain']=True
result['errors'].append(QUANTITY_QUESTION)
return result
+14
View File
@@ -0,0 +1,14 @@
"""Discard explicitly rejected quote conditions before parsing their replacements."""
import re
def clean_conditions(text):
rejected=r'(?:不是|不要|并非|不从)\s*(?:(?:发|寄|运|送)(?:到|往)?|去|到)?\s*'
value=(r'(?:[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2})(?![A-Za-z0-9])|'
r'深圳|华南|东莞|义乌|华东|美西|美中|美东|美国|洛杉矶|纽约|铁制品|钢制品|铝制品|铁铝制品|金属制品')
return re.sub(rejected+'(?:'+value+')',' ',text,flags=re.I)
def weight_comparison(text):
return bool(re.search(r'会不会按|是不是按|是否按|向上进位|小数公斤|计费.*(?:进位|取整)|公斤.*(?:进位|取整)',text))
+34 -9
View File
@@ -20,6 +20,10 @@ def broad_zone(postal):
def variants(intent, book):
if intent.get('allocations'):
return [{**destination,'allocation':part} for part in intent['allocations']
for destination in variants({**intent,'allocations':None,'warehouse':part['warehouse'],
'warehouses':None,'postal':None},book)]
if len(intent.get('warehouses') or [])>1:
return [variant for warehouse in intent['warehouses'] for variant in
variants({**intent,'warehouse':warehouse,'warehouses':None,'postal':None},book)]
@@ -112,6 +116,17 @@ def number(value, places=2):
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
def exact_number(value):
text=format(Decimal(value),'f')
return text.rstrip('0').rstrip('.') if '.' in text else text
def quantity_number(value,intent):
if Decimal(value)==Decimal(intent.get('weight') or '0') or Decimal(value).as_tuple().exponent>=-6:
return exact_number(value)
return '约'+number(value,6)
def render_rates(intent, book, destinations, remote_fees):
from .quote_fees import charge_details
from .quote_language import translate_quote
@@ -120,24 +135,34 @@ def render_rates(intent, book, destinations, remote_fees):
if intent.get('mode')=='time':
lines.append('美国线路参考时效:')
elif intent['weight']:
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:")
else:
lines.append('美国线路报价:')
if len(intent.get('warehouses') or [])>1:
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
if intent.get('allocations'):
lines.append('按各目的仓分票计算,箱规需逐票核对:')
if intent.get('metal_chargeable_weight') is not None:
lines.append('请分别提供各目的仓的铁铝货物计费重,整票铁铝基数不能重复套用到每个仓。')
elif len(intent.get('warehouses') or [])>1:
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
if intent.get('air_requested') and not intent.get('air_only'):
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
for destination, fees in zip(destinations, remote_fees):
rates = match_rates(intent, book, destination)
part=destination.get('allocation')
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
if part else intent)
if part and intent.get('metal_chargeable_weight') is not None:
billing.update(metal_chargeable_weight=None,metal_scope='partial')
rates = match_rates(billing, book, destination)
if intent.get('mode') in ('cheapest','fastest') and rates:
if intent['mode']=='cheapest':
eligible=[r for r in rates if quantity(intent,r) is not None]
eligible=[r for r in rates if quantity(billing,r) is not None]
best={}
for rate in eligible:
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
value=quantity(intent,rate)*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
best[key]=min(best.get(key,value),value)
rates=[r for r in eligible if quantity(intent,r)*Decimal(r['price'])+charge_details(intent,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
else:
def eta_key(rate):
@@ -163,15 +188,15 @@ def render_rates(intent, book, destinations, remote_fees):
continue
parts = []
for rate in rows:
amount = quantity(intent,rate)
amount = quantity(billing,rate)
price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None:
total = amount*price
details=charge_details(intent,rate,fees,book)
details=charge_details(billing,rate,fees,book)
total+=details['cny']
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
rate_text += f",{quantity_number(amount,billing)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
if details['items']:rate_text+='('+ ';'.join(details['items'])+')'
for note in details['notes']:
if note not in lines:lines.append(note)
+31 -5
View File
@@ -22,6 +22,35 @@ def material_fee(book):
return amount(text,r'铁铝制品[^\n]*?[¥¥]\s*(\d+(?:\.\d+)?)\s*/KG')
def material_charge(intent,charged,unit,book):
result={'cny':Decimal(0),'items':[],'notes':[]}
material=intent.get('material')
if material not in ('metal','unknown_metal'):return result
per_kg=material_fee(book)
if per_kg is None:
result['notes'].append('铁铝附加费费率尚未明确,当前金额未包含该项,请联系人工确认。')
return result
if material=='unknown_metal':
result['notes'].append('请先确认金属是否属于铁铝制品;铁铝附加费只按铁铝货物的计费重收取,当前金额未包含待核实的材质费用。')
return result
basis=intent.get('metal_chargeable_weight')
if basis is None and intent.get('metal_scope')=='partial':
result['notes'].append(f'铁铝附加费{per_kg:g}元/KG只按铁铝货物的计费重收取,不能按整票重量加费;请提供铁铝部分的计费重,当前金额未包含该项。')
return result
if basis is None and unit!='kg':
result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认铁铝货物计费重量后单列,不能把CBM直接乘公斤附加费。')
return result
basis=Decimal(basis) if basis is not None else charged
if basis is None:return result
if unit=='kg' and charged is not None and basis>charged:
result['notes'].append('铁铝货物计费重高于当前整票可计算的计费重,请补充各箱实重和尺寸核对;当前金额未包含该项。')
return result
fee=basis*per_kg
result['cny']=fee
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{basis:g}KG={fee:g}元')
return result
def specimens(intent):
if intent.get('packages'):
return [{**p,'dims':p.get('dims') or intent.get('dims'),
@@ -83,11 +112,8 @@ def charge_details(intent,rate,remote,book):
fee=charged*Decimal(remote['fee']);result['cny']+=fee
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
if rate['unit']=='kg' and intent.get('material')=='metal':
per_kg=material_fee(book)
if per_kg is not None:
fee=charged*per_kg;result['cny']+=fee
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元')
metal=material_charge(intent,charged,rate['unit'],book)
result['cny']+=metal['cny'];result['items']+=metal['items'];result['notes']+=metal['notes']
if rate['kind']=='parcel':
limits=limit_fees(intent,book)
result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes']
+50 -18
View File
@@ -8,7 +8,7 @@ from .pricing import parse_shipping_intent, detect_dim_unit, shipping_region
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
r'(?<![A-Za-z])(?:USA|(?-i:US)|United States|America)(?=$|[^A-Za-z]|FBA(?![A-Za-z]))|'
r'(?:发(?:往|到)?|寄(?:往|到)?|运往|运到|到|往|\bto\s+)\s*us(?=$|[^A-Za-z]|FBA(?![A-Za-z]))',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
QUOTE_WORDS = re.compile(r'报价|价格|运费|费用|收费|多少钱|计费|包税|海运|空运|海派|海卡|海铁|美森|合德|普船|附加|保险|代发|申报|偏远|超重|超尺寸|币种|\bEXX\b|\b(?:quote|price|cost|freight)\b',re.I)
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
@@ -31,7 +31,7 @@ def chinese_number(text):
def normalize(text):
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|箱|件|立方米|立方|方|kg|cbm))',
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|(?:个)?箱|件|立方米|立方|方|kg|cbm))',
lambda m:chinese_number(m.group(1)),text,flags=re.I)
return re.sub(r'负(?=\d)','-',result)
@@ -48,12 +48,22 @@ def kg(value,unit):
def request_mode(text):
# Business topics outrank generic verbs such as “怎么算”.
if re.search(r'保险|保价|投保|insured|insurance',text,re.I):return 'insurance'
from .quote_edits import weight_comparison
if weight_comparison(text):return 'rounding'
if re.search(r'毛重|净重|计费重.*(?:什么|区别)|材积重.*(?:什么|区别)',text):return 'weight_basis'
if re.search(r'混装|混箱|不同产品.*一箱',text):return 'mixing'
if (re.search(r'(?:铁|钢|铝|金属).*(?:附加|加费|加钱|整票)|整票.*(?:铁|钢|铝|金属)',text)
and not re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text)):
return 'metal_scope'
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
from .shipping_faq import preparation_topic
if preparation_topic(text):return 'preparation'
if re.search(r'最便宜|便宜的|便宜一?点|优惠|折扣|打折|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
if re.search(r'最终总价|一共多少钱|总共多少钱|合计多少钱|最终价格|最终费用|到门总价',text):return 'total'
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
if re.search(r'FDA|申报费|报关|续页|品名',text,re.I):return 'customs'
if re.search(r'附加费|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
if re.search(r'附加费|含税|含派送|全包|包不包|还.*(?:费|加钱)|(?:额外|其他|其它).*(?:费用|收费)|包含.*费|包税.*(?:包含|包括)|超重费|超尺寸|住宅|私人|商业地址',text):return 'fees'
if re.search(r'电池|带电|液体|粉末|食品|医疗|能(?:不能|发|走)|可以.*(?:发|走)',text):return 'acceptance'
if re.search(r'人民币|美元|美金|币种|currency|\b(?:RMB|CNY|USD)\b',text,re.I) and not WEIGHT.search(text):return 'currency'
if re.search(r'怎么(?:算|计算)|计算(?:过程|出来)|怎么算出来|calculation',text,re.I):return 'calculation'
@@ -63,27 +73,40 @@ def request_mode(text):
def parse_intent(message,warehouse_codes):
text=normalize(message)
parsed=parse_shipping_intent(text)
from .quote_edits import clean_conditions
text=clean_conditions(text)
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
from .quote_material import material_intent
materials=material_intent(text,WEIGHT,kg)
quantity_text=materials['quantity_text']
parsed=parse_shipping_intent(quantity_text)
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
warehouses=list(dict.fromkeys(t for t in candidates if t in warehouse_codes))
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
and not t.startswith(('USD','RMB','CNY'))]
errors=[]
weights=[m for m in WEIGHT.finditer(text) if not text[:m.start()].rstrip().endswith(('x','X','×','*'))]
errors=materials['errors'][:]
from .quote_allocations import allocations
assigned=allocations(quantity_text,warehouses,WEIGHT,kg)
weights=[m for m in WEIGHT.finditer(quantity_text) if not quantity_text[:m.start()].rstrip().endswith(('x','X','×','*'))]
weight=str(kg(*weights[0].groups())) if weights else None
boxes=re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)',text,re.I)
boxes=re.search(r'(\d+)\s*(?:个)?(?:箱(?:子)?|件|cartons?|boxes?)',quantity_text,re.I)
count=int(boxes.group(1)) if boxes else None
packages=[]
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
matches=list(package_re.finditer(text))
matches=list(package_re.finditer(quantity_text))
package_dimensions={}
for index,match in enumerate(matches):
ordinal,n,value,unit=match.groups()
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
segment=quantity_text[match.end():matches[index+1].start() if index+1<len(matches) else len(quantity_text)]
dims=parse_shipping_intent(segment)['dims']
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
'dims':dims,'dim_unit':detect_dim_unit(segment)})
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
package_dimensions[match.start(3)]={'dims':dims,'dim_unit':detect_dim_unit(segment)}
for part in assigned or []:
for piece in part.get('packages') or []:
piece.update(package_dimensions.get(piece.pop('position'),{}))
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if each and count and not packages:
packages=[{'count':count,'weight':str(kg(*each.groups())),'dims':parsed['dims'],'dim_unit':detect_dim_unit(text)}]
if packages:
@@ -93,12 +116,19 @@ def parse_intent(message,warehouse_codes):
errors.append('箱数与分箱明细不一致,请确认总箱数及每箱重量。')
count=package_count
weight=str(sum(Decimal(p['weight'])*p['count'] for p in packages))
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
total=re.search(r'(?:总重|合计重量|一共重)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
# Per-carton dimensions must not spill onto another carton without dimensions.
if len(packages)>1:parsed['dims']=None
elif len(weights)>1:
elif assigned:
weight=str(sum(Decimal(a['weight']) for a in assigned))
if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。')
total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,quantity_text,re.I)
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。')
elif len(weights)>1 and request_mode(text)!='rounding':
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned):
errors.append('分仓重量与分箱重量不一致,请分别确认每仓的箱数和重量。')
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
postal=None
@@ -125,20 +155,22 @@ def parse_intent(message,warehouse_codes):
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'origin':origin,
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'allocations':assigned,'origin':origin,
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
'air_only':True if air and not sea else None,'air_requested':True if air else None,
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else
('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) else None)),
'material':materials['material'],'metal_scope':materials['metal_scope'],
'metal_chargeable_weight':materials['metal_chargeable_weight'],
'metal_weight_update':materials['metal_weight_update'],
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
'text':text}
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
def is_followup(message,intent):
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
if TOPIC_BOUNDARY.search(message):return False
text=message.strip().strip(',,。.!!?? ')
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
@@ -148,4 +180,4 @@ def is_followup(message,intent):
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin')))
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin','zone','material','metal_scope','metal_chargeable_weight')))
+33
View File
@@ -0,0 +1,33 @@
"""Separate metal chargeable-weight declarations from the shipment's gross weight."""
import re
from decimal import Decimal
def material_intent(text,weight_pattern,to_kg):
declarations=re.sub(r'铁铝(?:附加费|加费|费用)','',text)
metal=bool(re.search(r'铁制|钢制|铝制|铁铝|不锈钢|(?:铁|钢|铝)(?:件|货|扣|罐|盒|板|管|架|材)',declarations))
generic='金属' in text
ordinary=bool(re.search(r'普通货|普货|非铁铝',text))
partial=bool(re.search(r'只有|部分|其中|有些|只是|一部分',text) or
metal and re.search(r'塑料|木制|布料',text))
scope='partial' if partial and (metal or generic) else ('all' if re.search(r'全是|全部是|都是|整票是|货物是|换成|改成',text) and metal else None)
material=('ordinary' if '非铁铝' in text else 'metal' if metal else 'unknown_metal' if generic
else 'ordinary' if ordinary or re.search(r'塑料|木制',text) else None)
name=r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)(?:部分|货物)?(?:的)?'
patterns=(name+r'\s*计费重(?:量)?\s*(?:为|是|[::])?\s*'+weight_pattern.pattern,
name+r'\s*按\s*'+weight_pattern.pattern+r'\s*计费',
r'(?:铁铝|铁制品|钢制品|铝制品|铁件|钢件|铝件|金属)部分(?:的计费重(?:量)?)?\s*'
r'(?:还是|仍然是|仍是|改为|改成|换成)\s*'+weight_pattern.pattern)
charged=None;cleaned=text;errors=[];oral=False
for index,pattern in enumerate(patterns):
matches=list(re.finditer(pattern,text,re.I))
for match in matches:
value=to_kg(*match.groups())
if charged is not None and Decimal(charged)!=value:errors.append('铁铝货物的计费重不一致,请确认。')
charged=str(value);scope='partial'
oral=oral or (index==2 and '计费重' not in match[0])
if not 0<value<=1000000:errors.append('请提供大于0的有效铁铝货物计费重。')
cleaned=cleaned[:match.start()]+' '*(match.end()-match.start())+cleaned[match.end():]
return {'material':material,'metal_scope':scope,'metal_chargeable_weight':charged,
'quantity_text':cleaned,'errors':errors,'metal_weight_update':True if oral else None}
+25
View File
@@ -0,0 +1,25 @@
"""Keep unanswered US shipping questions outside legacy regional model knowledge."""
import re
from .quote_intent import US
from .pricing import shipping_region
def answer_us_fallback(message,conv_id):
if re.search(r'理赔|赔偿|索赔|破损|丢失|退件|投诉',message):return None
if re.search(r'换个话题|另外问',message):return None
from .quote_edits import clean_conditions
current=clean_conditions(message)
active=bool(US.search(current))
if not active and shipping_region(current) is not None:return None
if not active and conv_id:
from .quote_service import previous_quote_intents
active=any(intent['country']=='US' for intent in previous_quote_intents(conv_id,set()))
if not active:return None
from .quote_data import load_book
if not load_book():return {'t':'当前尚未包含可用的美国报价表,请提供目的地仓码或邮编及货物信息,联系人工确认。'}
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}',message))
if english:
return {'t':'Please specify the US route or destination warehouse/ZIP code and the point you want to confirm. Unlisted charges or service conditions require confirmation by customer support.'}
return {'t':'请说明您想确认的美国渠道、目的仓码或邮编,以及具体的费用或发货要求;未明确的服务条件需要联系人工核对。'}
+53 -40
View File
@@ -9,46 +9,44 @@ from .quote_data import load_book, postcode_fee
from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants, render_rates
def previous_quote_intents(conv_id,codes):
"""Read the current quote back to its topic boundary, without a turn cutoff."""
earlier_intents=[]
if conv_id:
connection=connect()
try:
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC",(conv_id,))
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题|另外问',previous):break
earlier=parse_intent(previous,codes)
if earlier['country']=='other':break
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
earlier_intents.append(earlier)
if not is_followup(previous,earlier):break
finally:
connection.close()
return earlier_intents
def intent_with_context(message, conv_id, book):
from copy import deepcopy
from .quote_context import merge_intent
codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes)
def destination_reset(value):
if value.get('warehouse') or value.get('unknown_warehouses'):return {'postal','zone','zones'}
if value.get('postal'):return {'warehouse','warehouses','unknown_warehouses','zone','zones'}
if value.get('zone'):return {'warehouse','warehouses','unknown_warehouses','postal'}
return set()
excluded=destination_reset(intent)
selected_destination=bool(excluded)
if selected_destination and not intent.get('unknown_warehouses'):excluded.add('unknown_warehouses')
if intent['transport'] or intent['kinds'] or intent['services']:excluded.update(('air_only','air_requested'))
if intent['air_only'] or intent['tax']=='excluded':excluded.add('kinds')
if intent.get('weight') is not None and not intent.get('packages'):excluded.add('packages')
explicit_dims=bool(intent['dims'])
if conv_id and is_followup(message,intent):
connection=connect()
try:
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
finally:connection.close()
for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
earlier=parse_intent(previous,codes)
if earlier['country']=='other':break
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
if not selected_destination and destination_reset(earlier):
excluded.update(destination_reset(earlier));selected_destination=True
if intent['dims'] is None and earlier['dims']:
intent['dims']=earlier['dims'];intent['dim_unit']=earlier['dim_unit']
for key,value in earlier.items():
if key in ('mode','text','errors','dim_unit','dims'):continue
if intent.get(key) is None and key not in excluded:intent[key]=deepcopy(value)
if not is_followup(previous,earlier):break
if explicit_dims and intent.get('packages'):
for piece in intent['packages']:
piece['dims']=intent['dims'];piece['dim_unit']=intent['dim_unit']
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:intent['country']='US'
earlier_intents=previous_quote_intents(conv_id,codes) if is_followup(message,intent) else []
if intent.get('metal_weight_update') and not earlier_intents:
intent['metal_chargeable_weight']=None
intent['errors'].append('请确认铁铝部分的计费重,不能把毛重直接当作计费重。')
if earlier_intents:
state=earlier_intents[-1]
if state.get('metal_weight_update'):state['metal_chargeable_weight']=None
for update in list(reversed(earlier_intents[:-1]))+[intent]:
state=merge_intent(update,state)
intent=state
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:
intent['country']='US'
return intent
@@ -56,7 +54,8 @@ def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return None
# Avoid a new read for ordinary chat; short followups need their local context.
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
from .shipping_faq import preparation_topic
if not (US.search(message) or QUOTE_WORDS.search(message) or preparation_topic(message) or conv_id or
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None
try:
@@ -65,6 +64,8 @@ def answer_quote(message, conv_id):
return None
intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US':
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None
if intent.get('errors'):
return {'t':'\n'.join(intent['errors'])}
@@ -82,11 +83,18 @@ def answer_quote(message, conv_id):
dimensions += [piece['dims'] for piece in intent.get('packages') or [] if piece.get('dims')]
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出,空运承运条件也需单独确认。'}
from .quote_answers import answer_business
preparation=None
if intent.get('mode')=='preparation':
from .shipping_faq import answer_preparation
preparation=answer_preparation(intent,book)
if preparation_topic(message)!='schedule' or not re.search(r'\d+\s*(?:KG|公斤|千克|克|吨|方|CBM)',intent['text'],re.I):
return {'t':preparation}
intent['mode']='cheapest' if re.search(r'最便宜|最划算|最低价',message) else 'quote'
business=answer_business(intent,book)
if business is not None:return {'t':business}
if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
destinations = variants(intent,book)
fees = []
for destination in destinations:
@@ -94,6 +102,11 @@ def answer_quote(message, conv_id):
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None)
return {'t':render_rates(intent,book,destinations,fees)}
reply=render_rates(intent,book,destinations,fees)
if intent.get('mode')=='total':
reply+='\n以上为已提供条件下可计算的运费,各方案按所列包税/不包税方式分别核算;目的仓、箱规及未确定附加费需核实后才能确认最终应付总价。'
if intent.get('mode')=='cheapest' and re.search(r'便宜一?点|折扣|优惠|打折',message):
reply+='\n以上按当前价表比较,折扣或合作价格需人工确认,尚未修改单价。'
return {'t':reply+('\n'+preparation if preparation else '')}
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'}
+57
View File
@@ -0,0 +1,57 @@
"""Pre-shipment answers from the active US book, without live operational claims."""
import re
def preparation_topic(text):
if re.search(r'理赔|赔偿|索赔|货损|丢件|破损',text):return None
if re.search(r'截仓|截单|船期|排仓|哪天开船|(?:什么时候|几号).*开船|能.*开船|'
r'(?:今天|明天|这周|下周).*(?:发货|发走|开船)|赶得上|来得及|(?:保证|必须|月底).*到',text):return 'schedule'
if re.search(r'上门.*(?:提货|取货|收货|揽收)|提货服务|交仓地址|仓库地址|仓.*(?:地址|电话|联系人|几点)|送哪个仓|仓库在哪|收货时间|\bpick\s*up\b|\bpickup\b',text,re.I):return 'delivery'
if re.search(r'报关资料|清关资料|报关.*(?:准备|材料)|申报比例|低申报|怎么申报|如何申报|认证资料|熏蒸',text):return 'documents'
if re.search(r'查验.*(?:费|承担)|谁.*承担.*查验',text):return 'inspection'
if re.search(r'(?:东|西|中)[12](?:区)?.*(?:区别|分区|哪区)|分区.*(?:什么意思|怎么分)',text):return 'zones'
if '一件代发' not in text and re.search(r'贴标|FBA标|标签|唛头|产地标|外箱.*贴|贴.*条码|made\s+in\s+china',text,re.I):return 'labels'
if re.search(r'哪些渠道|哪些线路|什么渠道|能发哪些仓|支持.*仓|(?:能发|能走|可以发).*美国',text):return 'coverage'
return None
def answer_preparation(intent,book):
topic=preparation_topic(intent['text'])
if topic is None:return None
from .quote_fees import document
if topic=='coverage':
services=list(dict.fromkeys(rate['service'] for rate in book['rates']))
return '美国可查询的线路:'+ '、'.join(services)+'。请提供目的仓码或邮编、重量及交货仓,我再匹配具体可用线路。'
if topic=='zones':
return '东1、东2等是报价资料中的不同目的地区域,需要按目的仓码或邮编匹配;同一仓码存在多个分区记录时会分别列出。请提供目的仓码或邮编,我按现有记录核对。'
if topic=='schedule':
return '请提供计划交仓日期、交货仓和目的仓码。具体截仓、装柜和开船安排需联系人工核对;当前参考时效不能作为月底或指定日期必达的承诺。'
if topic=='delivery':
raw=document(book,('不提供上门提货服务',))
if intent.get('english'):
return ('The current US route sheet requires delivery to the designated warehouse and does not offer pickup. ' if raw else '')+'Please confirm the route and Yiwu or South China origin with customer support before delivery; warehouse address, contact and receiving hours need confirmation.'
lines=[]
if raw:lines.append('当前美国渠道资料要求客户自行送货至对应仓库,不提供上门提货服务。')
lines.append('请确认所选渠道及义乌或华南交仓,仓库地址、联系人和收货时间请联系人工核对后再送仓。')
return '\n'.join(lines)
if topic=='labels':
label=document(book,('外箱','入仓条码','唛头'))
origin=document(book,('产品上和包装上','产地标识'))
lines=[]
if label:lines.append(re.sub(r'^\s*\d+[、..]\s*','',label.strip())+'\n代贴标签具体费用需按所选服务确认。')
if origin:lines.append('产品和包装还需有“Made in China”产地标识。')
return '\n'.join(lines) or '请提供渠道和目的仓码,由人工核对外箱标签及代贴要求。'
if topic=='documents':
if '熏蒸' in intent['text']:
raw=document(book,('熏蒸','备货阶段'))
return (raw.strip()+'\n' if raw else '')+'请确认是木制成品还是木质包装,并提供具体品名及渠道,核对是否需要熏蒸及证明材料后再安排发货。'
if re.search(r'申报比例|低申报|怎么申报|如何申报',intent['text']):
return '请提供品名、材质、用途、实际交易金额及销售链接,由业务核对申报资料。申报价需结合实际交易信息确认,不能按统一比例直接代算。'
raw=document(book,('单独报关需提供资料',))
if raw:return raw.strip()+'\n请补充具体品名及渠道,涉及商检或认证的资料需逐项核对。'
return '请提供具体品名和渠道,由人工核对装箱单、发票及对应报关、认证材料。'
raw=document(book,('查验','不包税货物','比例均摊'))
if raw:return raw.strip()+'\n具体金额按实际查验费用核算;包税渠道的承担方式需另行确认,不能直接套用不包税条款。'
return '查验费用和承担方式需按渠道、税费方式及实际费用确认,当前无法给出固定金额。'
+254
View File
@@ -0,0 +1,254 @@
"""Oral-language, US-region isolation and scoped-page regressions using synthetic facts."""
from decimal import Decimal
from pathlib import Path
import re
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.pricing import detect_dim_unit,dims_to_cm
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_intent import parse_intent
from logistics_chat.quote_engine import quantity
from test_quotes import fixture_snapshot,frames
class CustomerGapTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A8'})
texts=['偏远地址加收4元/kg;超偏远地址加收6元/kg','铁铝制品另外加附加费¥5/KG',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件','客户自行交货,不提供上门提货服务',
'禁止运输的货物:纯电池、液体','需熏蒸的货物由客户在备货阶段自行完成']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i+1}','text':text} for i,text in enumerate(texts)]
self.order={'tenant_id':'qa-a','order_no':'FOCUS20260001','inbound_code':'QAFOCUS20260001',
'country_code':'US','status':'COMPLETED','package_count':2,
'tracking_json':[{'code':'IN_TRANSIT','actual_at':'2026-10-01 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'OTHER20260099','inbound_code':'QAOTHER20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,q,cid=None,client=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames((client or self.client).post('/stream_chat',json={'msg':q,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,q,cid=None):return self.events(q,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_us_natural_questions_never_enter_old_regional_model_faqs(self):
cases=('你们这个报价是按毛重还是净重?','运费能便宜一点吗?','这个价格含税含派送吗?',
'一共多少钱,给我最终总价','这个物流服务范围具体怎么执行?')
for q in cases:
with self.subTest(q=q):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
text=self.ask(q,cid)
self.assertNotIn('21KG',text);self.assertNotIn('250',text);self.assertNotIn('7元/KG',text)
text=self.ask('我想发点货到美国,怎么收费?')
self.assertIn('元/KG',text);self.assertIn('多少公斤',text)
def test_final_total_respects_tax_labels_and_keeps_unconfirmed_fees_separate(self):
cid=self.conversation();self.ask('美国美西2箱每箱15KG,40x30x20cm,义乌普船海派',cid)
text=self.ask('一共多少钱,给我最终总价',cid)
self.assertIn('30KG = 60元',text);self.assertIn('· 包税',text)
self.assertNotIn('价格不含税',text);self.assertIn('最终应付总价',text)
self.assertIn('折扣或合作价格需人工确认',self.ask('运费能便宜一点吗',cid))
def test_negative_warehouse_and_origin_edits_do_not_keep_rejected_values(self):
cid=self.conversation();self.ask('美国30KG发ZZZ1,深圳普船海派',cid)
text=self.ask('不是发ZZZ1,是YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('YYY2',text)
text=self.ask('不要深圳,还是义乌交仓',cid)
self.assertIn('· 义乌',text);self.assertNotIn('· 华南',text)
text=self.ask('50KG呢',cid)
self.assertIn('YYY2',text);self.assertNotIn('ZZZ1',text);self.assertIn('美国 50KG',text)
def test_inches_convert_before_charge_weight_and_acceptance_limits(self):
intent=parse_intent('美国30KG,60x40x30英寸,1箱普船海派',set())
self.assertEqual(intent['dim_unit'],'in')
self.assertEqual(dims_to_cm([60,40,30],'in'),[152.4,101.6,76.2])
self.assertGreater(quantity(intent,{'kind':'parcel','unit':'kg'}),Decimal('190'))
text=self.ask('美国美西30KG,60x40x30英寸,1箱普船海派,义乌')
self.assertIn('人工确认是否接收',text)
for text in ('60x40x30 inches','60x40x30in','60x40x30"'):
with self.subTest(text=text):
self.assertEqual(detect_dim_unit(text),'in')
self.assertIn('人工确认是否接收',self.ask('美国美西30KG,'+text+',1箱普船海派,义乌'))
self.assertEqual(detect_dim_unit('60x40x30,合计1立方米'),'cm')
self.assertEqual(detect_dim_unit('60x40x30,合计1m3'),'cm')
self.assertEqual(detect_dim_unit('1.2x0.8x0.6m呢'),'m')
def test_spoken_carton_and_dimensions_are_used_without_reasking(self):
intent=parse_intent('美国30KG一个箱子,60长40宽30高,怎么算?',set())
self.assertEqual(intent['boxes'],1);self.assertEqual(intent['dims'],[60,40,30])
text=self.ask('美国美西30KG一个箱子,60长40宽30高,义乌普船海派')
self.assertIn('最高项70元/件',text)
self.assertNotIn('请补充箱数',text)
def test_box_units_are_local_and_each_axis_can_carry_its_own_unit(self):
cases=(('12英寸显示器,包装60x40x30cm',[60,40,30],'cm',Decimal('30')),
('80cm×70cm×60cm',[80,70,60],'cm',Decimal('56')),
('80cmx70cmx60cm',[80,70,60],'cm',Decimal('56')),
('800mm*700mm*600mm',[800,700,600],'mm',Decimal('56')),
('长80厘米宽70厘米高60厘米',[80,70,60],'cm',Decimal('56')),
('0.8m×70cm×600mm',[80,70,60],'cm',Decimal('56')),
('12英寸显示器,包装60x40x30',[60,40,30],'cm',Decimal('30')))
for wording,dims,unit,charge in cases:
with self.subTest(wording=wording):
intent=parse_intent('美国美西30KG,'+wording+',1箱义乌普船海派',set())
self.assertEqual(intent['dims'],dims);self.assertEqual(intent['dim_unit'],unit)
self.assertEqual(quantity(intent,{'kind':'parcel','unit':'kg'}),charge)
text=self.ask('美国美西30KG,'+wording+',1箱义乌普船海派')
self.assertIn(f'{charge}KG =',text)
self.assertNotIn('请补充箱数',text)
def test_oral_partial_weight_edits_never_overwrite_the_whole_shipment(self):
cid=self.conversation()
self.ask('美国美西30KG,部分铁制品,铁铝部分计费重10KG,义乌普船海派',cid)
for q,amount in (('铁铝部分还是10KG',110),('铁铝部分改成8KG',100),('铁铝部分仍是8KG',100)):
text=self.ask(q,cid)
self.assertIn('美国 30KG',text);self.assertIn(f'30KG = {amount}元',text)
self.assertIn('大于0',self.ask('铁铝部分改为-1KG',cid))
fresh=self.conversation()
self.assertIn('请确认铁铝部分的计费重',self.ask('美国美西30KG,铁铝部分还是10KG,义乌普船海派',fresh))
self.assertNotIn('×10KG',self.ask('一共多少钱,给我最终总价',fresh))
def test_long_quote_chain_retains_conditions_but_topic_changes_stop_reuse(self):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
for _ in range(15):self.assertIn('每箱不足12KG',self.ask('这个报价按毛重还是净重?',cid))
self.assertIn('美国 50KG',self.ask('50KG呢',cid))
self.assertIn('美国渠道',self.ask('这个物流服务范围具体怎么执行?',cid))
self.assertIn('请先',self.ask('下一页',cid))
from logistics_chat.quote_service import answer_quote
from logistics_chat.quote_scope import answer_us_fallback
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50KG发德国报价',cid))
db.save_message(cid,'user','换个话题,我要查订单')
self.assertIsNone(answer_us_fallback('50KG呢',cid))
def test_comparing_two_weights_is_not_treated_as_two_cartons(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
text=self.ask('单箱12.5KG会不会按13KG收费?',cid)
self.assertIn('按原小数计费,不向上取整',text)
self.assertIn('12.5KG',text)
self.assertNotIn('请明确总重',text)
def test_confirmed_fractional_weight_and_partial_metal_basis_are_used(self):
self.assertIn('12.5KG = 25元',self.ask('美国美西1箱12.5KG,40x30x20cm,义乌普船海派'))
self.assertIn('12.3456KG = 24.69元',self.ask('美国美西1箱12.3456KG,40x30x20cm,义乌普船海派'))
cid=self.conversation()
text=self.ask('美国美西30KG,部分铁制品,义乌普船海派',cid)
self.assertIn('30KG = 60元',text);self.assertIn('不能按整票重量加费',text)
self.assertNotIn('30KG = 210元',text)
text=self.ask('铁铝部分计费重10KG',cid)
self.assertIn('美国 30KG',text);self.assertIn('30KG = 110元',text)
self.assertIn('×10KG=50元',text)
self.assertIn('30KG = 110元',self.ask('一共多少钱,给我最终总价',cid))
text=self.ask('改成40KG',cid)
self.assertIn('当前金额未包含该项',text);self.assertNotIn('×10KG',text)
text=self.ask('全部换成铁制品40KG',cid)
self.assertIn('40KG = 280元',text)
def test_unknown_metal_and_cbm_quotes_do_not_spread_partial_fee_over_the_whole_load(self):
text=self.ask('美国美西30KG,只是金属扣子,整票都按铁铝加费吗,义乌普船海派')
self.assertIn('只按铁铝货物的计费重',text);self.assertIn('确认金属是否属于铁铝',text)
text=self.ask('美国30KG发ZZZ1,1方不包税海卡,部分铁制品,铁铝部分计费重10KG,义乌')
self.assertIn('1CBM = 150元',text);self.assertIn('×10KG=50元',text)
self.assertIn('大于0',self.ask('美国30KG,部分铁制品,铁铝部分计费重-1KG,义乌海派'))
text=self.ask('美国20KG发ZZZ1,10KG发YYY2,部分铁制品,铁铝部分计费重10KG,义乌普船海派')
self.assertIn('不能重复套用到每个仓',text)
self.assertIn('20KG = 100元',text);self.assertIn('12KG = 24元',text)
self.assertNotIn('铁铝附加费5元/KG×10KG',text)
def test_booking_phone_invoice_and_file_capabilities_do_not_invent_workflows(self):
cases=(('就按刚才方案帮我下单','尚未创建订单'),('手机号能查吗','暂不支持手机号'),
('你们可以开发票吗,开票加几个点','开票税点'),('付款账号发我一下','收款账号'),
('可以月结吗','月结资格'),('帮我导出订单Excel','暂不提供订单Excel导出'),
('Excel能直接读里面箱规报价吗','不会自动读取Excel'),
('我换了个手机,之前聊天记录还在吗','不会自动同步恢复'))
for q,expected in cases:
with self.subTest(q=q):self.assertIn(expected,self.ask(q))
with patch('logistics_chat.order_data.lookup_order') as lookup:
self.assertIn('暂不支持手机号',self.ask('手机号19900000000能查吗'))
lookup.assert_not_called()
with self.app.app_context():
c=db.connect();self.assertEqual(c.execute('SELECT COUNT(*) FROM order_test_data').fetchone()[0],2);c.close()
def test_address_and_pickup_questions_are_not_prices_and_english_stays_english(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('地址、联系人和收货时间',self.ask('义乌仓具体地址是什么?几点可以送?',cid))
text=self.ask('Can you pick up my goods in Shenzhen for US sea freight?')
self.assertIn('does not offer pickup',text);self.assertNotIn('CNY/kg',text)
self.assertFalse(re.search(r'[\u4e00-\u9fff]',text))
def test_air_battery_question_does_not_apply_sea_battery_requirements(self):
text=self.ask('想走美国空运,包税带电的,有吗?')
self.assertIn('空运费率当前资料没有列出',text);self.assertNotIn('MSDS',text)
self.assertIn('备货阶段',self.ask('美国木制品需要熏蒸吗'))
def seed_pages(self):
with self.app.app_context():
c=db.connect();import_records(c,[{'tenant_id':'qa-a','order_no':f'PAGE2026{i:06d}',
'inbound_code':f'QAPAGE2026{i:06d}','status':'PROCESSING'} for i in range(1,26)])
c.commit();c.close()
def test_pages_use_fresh_authorized_count_and_preserve_filter_and_private_history(self):
self.seed_pages()
self.assertTrue(self.events('下一页')[0]['login_required'])
self.login();cid=self.conversation()
text=self.ask('列出我的全部订单',cid)
self.assertIn('共26票',text);self.assertEqual(text.count('- 入仓码:'),20)
self.assertNotIn('QAOTHER',text)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6);self.assertIn('共26票',text)
self.assertIn('最后一页',self.ask('下一页',cid))
self.assertIn('第1页',self.ask('上一页',cid))
self.ask('列出履约中的订单',cid)
text=self.ask('下一页',cid)
self.assertIn('共25票',text);self.assertEqual(text.count('- 入仓码:'),5);self.assertNotIn('QAFOCUS',text)
self.assertIn('第1页',self.ask('第1页',cid))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_page_commands_cannot_reuse_another_topic_or_unbounded_offset(self):
self.login();cid=self.conversation();self.ask('列出我的订单',cid)
self.assertIn('有效页码',self.ask('第0页',cid))
self.ask('列出我的订单',cid)
self.ask('美国30KG海派报价',cid)
self.assertIn('请先',self.ask('下一页',cid))
def test_count_interruptions_do_not_erase_the_active_list_page(self):
self.seed_pages();self.login();cid=self.conversation()
self.ask('列出我的全部订单',cid)
self.ask('我有多少个运单',cid)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6)
def test_clearance_followup_modification_and_unknown_payment_do_not_call_model(self):
self.assertTrue(self.events('我的订单已清关了吗')[0]['login_required'])
self.login();cid=self.conversation();self.events('QAFOCUS20260001',cid)
event=self.events('我的订单已清关了吗?',cid)[0]
self.assertEqual(event['card']['no'],'FOCUS20260001')
self.assertEqual(event['card']['tracking_status'],'海运途中')
self.assertIn('尚未修改订单',self.ask('帮我把收货地址改一下',cid))
self.assertIn('未接入付款状态',self.ask('这票订单付过款了吗',cid))
self.assertIn('不能直接当作快递单号',self.ask('这票对应的UPS快递单号是多少',cid))
self.assertIn('暂不支持按这个物流条件',self.ask('哪些订单还没开船',cid))
+154
View File
@@ -0,0 +1,154 @@
"""Common pre-sale and order questions; all data and monetary values are synthetic."""
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db
from logistics_chat.order_data import import_records
from logistics_chat.quote_data import import_snapshot
from test_quotes import fixture_snapshot,frames
class CustomerQuestionTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
'address':'','source_sheet':'合成分区','source_cell':'A10'})
documents=['铁铝制品另外加附加费¥5/KG','客户自行送货,不提供上门提货服务',
'外箱要贴两张入仓条码与我司唛头标签,代贴收操作费',
'产品上和包装上必须有Made in China产地标识',
'单独报关需提供资料:装箱单、发票、报关委托书、合同',
'如遇查验,不包税货物的查验费用按客户所占货物比例均摊',
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
'超重收+70/件']
book['documents']=[{'sheet':'合成渠道','cell':f'A{i}','text':text} for i,text in enumerate(documents)]
self.order={'tenant_id':'qa-a','order_no':'COMMON20260001','inbound_code':'QACOMMON20260001',
'status':'COMPLETED','package_count':2,'country_code':'US',
'tracking_json':[{'code':'DEPARTED_ORIGIN_PORT','actual_at':'2026-10-01 10:00'},
{'code':'ARRIVED_DESTINATION_PORT','estimated_at':'2026-10-20 10:00'}]}
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True)
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'COMMON20260099','inbound_code':'QACOMMON20260099'}])
c.commit();c.close()
def conversation(self):return self.client.post('/api/new_chat').json['id']
def events(self,text,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
result=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(result[-1],{'d':1},result)
return result
def ask(self,text,cid=None):return self.events(text,cid)[0]['t']
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def test_explicit_weights_are_billed_by_destination_and_not_reused_as_each_total(self):
for text in ('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',
'美国ZZZ1:20KG,YYY2:10KG,义乌普船海派'):
answer=self.ask(text)
self.assertIn('美国 30KG',answer)
self.assertIn('20KG = 100元',answer)
self.assertIn('12KG = 24元',answer)
self.assertNotIn('30KG =',answer)
self.assertIn('分配重量不一致',self.ask('美国总重40KG,20KG发ZZZ1,10KG发YYY2,海派'))
def test_allocation_follows_origin_changes_but_a_single_new_destination_clears_it(self):
cid=self.conversation()
self.ask('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',cid)
text=self.ask('深圳交仓呢',cid)
self.assertIn('20KG = 120元',text);self.assertIn('12KG = 36元',text)
text=self.ask('换成YYY2',cid)
self.assertNotIn('ZZZ1',text);self.assertIn('美国 30KG',text)
def test_allocated_cartons_keep_individual_minimums_and_overweight_fees(self):
text=self.ask('美国2箱每箱5KG发ZZZ1,1箱20KG发YYY2,义乌普船海派')
self.assertIn('美国 30KG',text);self.assertIn('24KG = 120元',text);self.assertIn('20KG = 40元',text)
text=self.ask('美国1箱30KG发ZZZ1,1箱10KG发YYY2,义乌普船海派')
self.assertIn('30KG = 220元',text);self.assertIn('最高项70元/件',text)
text=self.ask('美国1箱10KG发ZZZ1,60x50x40cm,1箱20KG发YYY2,40x30x20cm,义乌普船海派')
self.assertIn('20KG = 100元',text);self.assertIn('20KG = 40元',text)
def test_total_weight_correction_removes_old_per_carton_weight_even_on_next_followup(self):
cid=self.conversation()
self.ask('美国美西2箱每箱30KG,义乌普船海派',cid)
text=self.ask('30公斤是两箱加起来的,不是每箱30公斤',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
text=self.ask('深圳交仓呢',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
self.assertIn('美国 50KG',self.ask('不是30KG,是50KG',cid))
def test_uniform_carton_count_change_preserves_piece_weight_across_followups(self):
cid=self.conversation()
self.ask('美国美西2箱每箱15KG,义乌普船海派',cid)
self.assertIn('美国 60KG',self.ask('改成4箱',cid))
self.assertIn('美国 60KG',self.ask('深圳交仓呢',cid))
self.assertIn('美国 60KG',self.ask('改成6箱,总重不变',cid))
self.assertIn('美国 60KG',self.ask('义乌交仓呢',cid))
def test_mixed_carton_count_requires_new_weights_then_accepts_a_new_total(self):
cid=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',cid)
self.assertIn('请确认新的总重',self.ask('改成4箱',cid))
text=self.ask('总重40KG',cid)
self.assertIn('美国 40KG',text);self.assertIn('48KG = 96元',text)
other=self.conversation()
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',other)
self.ask('改成4箱,总重不变',other)
self.assertIn('美国 30KG',self.ask('深圳交仓呢',other))
def test_new_dimensions_do_not_resurrect_stale_volume(self):
cid=self.conversation()
self.ask('美国363KG发ZZZ1,1箱,1.5方,义乌不包税海卡',cid)
self.assertIn('1CBM = 100元',self.ask('改成每箱50x50x40cm',cid))
self.assertIn('1CBM = 100元',self.ask('深圳交仓呢',cid))
def test_cbm_metal_surcharge_is_separate_instead_of_silently_included(self):
text=self.ask('美国铁制品30KG发ZZZ1,1方,义乌不包税海卡')
self.assertIn('1CBM = 100元',text)
self.assertIn('铁铝附加费为5元/KG',text)
self.assertIn('当前按方运费未包含',text)
self.assertNotIn('= 105元',text)
def test_preparation_questions_use_us_material_without_promising_ship_dates(self):
cases=(('美国可以上门提货吗','不提供上门提货'),('美国FBA外箱怎么贴标签','两张入仓条码'),
('美国单独报关资料需要什么','装箱单'),('美国申报比例怎么算','不能按统一比例'),
('美国不包税查验费用谁承担','比例均摊'),('美国这周哪天开船','联系人工核对'))
for question,expected in cases:
with self.subTest(question=question):self.assertIn(expected,self.ask(question))
cid=self.conversation();self.ask('美国30KG海派报价',cid)
self.assertIn('唛头',self.ask('外箱标签呢',cid))
self.assertIn('目的国家',self.ask('可以上门提货吗'))
self.assertIn('普船海派',self.ask('你们能发美国吗,有哪些渠道'))
text=self.ask('美国美西30KG最便宜的,月底必须到,义乌海派')
self.assertIn('30KG = 60元',text);self.assertIn('不能作为',text)
def test_order_business_status_does_not_imply_signing_shelving_or_piece_delivery(self):
self.assertIn('业务完成不等于',self.ask('头程订单已完成是不是签收了'))
self.assertTrue(self.events('这票上架了吗')[0]['login_required'])
self.login();cid=self.conversation()
self.events(self.order['inbound_code'],cid)
text=self.ask('这票已完成为什么还没上架',cid)
self.assertIn('已离开起运港',text)
self.assertIn('不等于收件人签收',text)
self.assertNotIn('已经到港',text)
text=self.ask('怎么三天都没更新',cid)
self.assertIn('2026-10-01',text);self.assertIn('原因无法',text)
text=self.ask('到了几箱?少了一箱',cid)
self.assertIn('登记总件数为2件',text);self.assertIn('没有逐箱签收明细',text)
text=self.ask('帮我催一下',cid)
self.assertIn('尚未提交',text)
self.assertIn('未找到',self.ask('QACOMMON20260099上架了吗'))
self.client.post('/api/customer/logout')
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
+5 -4
View File
@@ -78,14 +78,15 @@ class OrderCountTests(unittest.TestCase):
'What is the number of my orders?'):
with self.subTest(question=question):
events = self.events(question,cid)
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
self.assertTrue(events[0]['t'].startswith('您当前共有 2 票订单,其中 1 票已登记入仓码。'))
self.assertEqual(events[0]['t'].count('- 入仓码:'),2)
self.assertEqual(events[-1],{'d':1})
model.assert_not_called()
with self.app.app_context():
connection = db.connect()
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
self.login()
@@ -152,7 +153,7 @@ class OrderCountTests(unittest.TestCase):
with self.app.app_context():
with self.assertRaises(order_data.OrderDataUnavailable):
order_data.count_orders(('tenant-a',))
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
with patch('logistics_chat.order_listing.read_orders',side_effect=sqlite3.OperationalError):
events = self.events('我有多少个运单')
self.assertIn('暂时无法读取',events[0]['t'])
@@ -164,7 +165,7 @@ class OrderCountTests(unittest.TestCase):
(' \t\r\n','COUNT20260002','tenant-a'))
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
+166
View File
@@ -0,0 +1,166 @@
"""Statistics use explicit exception reasons and actual, order-level progress."""
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app,db,order_data
from logistics_chat.order_tracking import current_stage
def fact(code,shipment='',forecast=False):
return {'code':code,'shipment_key':shipment,
'estimated_at' if forecast else 'actual_at':'2026-10-01 10:00'}
class OrderStatisticsTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup);root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
rows=[{'tenant_id':'a','order_no':f'STAT2026{i:04}','inbound_code':f'INSTAT2026{i:04}',
'status':'PROCESSING','source_updated_at':f'2026-10-{i+1:02} 10:00',
'tracking_json':[fact('IN_TRANSIT'),fact('ARRIVED_DESTINATION_PORT'),fact('EMPTY_RETURNED',forecast=True)],
'exception_reason':f'待核实资料{i}' if i<6 else None} for i in range(10)]
rows += [{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING',
'tracking_json':[fact('ARRIVED_DESTINATION_PORT'),fact('PICKUP_READY')],'exception_reason':'等待客户资料'},
{'tenant_id':'a','order_no':'CLEAR20260002','status':'PROCESSING','tracking_json':[fact('PICKUP_READY')]},
{'tenant_id':'a','order_no':'MIXED20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B')]},
{'tenant_id':'a','order_no':'FORECAST20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT',forecast=True)]},
{'tenant_id':'a','order_no':'BUSINESS20260001','status':'COMPLETED','tracking_json':'[]'},
{'tenant_id':'a','order_no':'REJECTED20260001','status':'REJECTED','exception_reason':' \t\n'},
{'tenant_id':'b','order_no':'SECRET20260001','status':'PROCESSING','tracking_json':[fact('ARRIVED_DESTINATION_PORT')],'exception_reason':'不能泄露'}]
with self.app.app_context():
c=db.connect();order_data.import_records(c,rows);c.commit();c.close()
def login(self):
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
def ask(self,q,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
response=self.client.post('/stream_chat',json={'msg':q,'conv_id':cid})
events=[json.loads(line[6:]) for line in response.get_data(as_text=True).split('\n\n') if line.startswith('data: ')]
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
self.assertNotIn('_order_list_state',str(events));self.assertNotIn('_customer_data',str(events))
self.assertNotIn('SECRET',str(events));self.assertNotIn('不能泄露',str(events))
return events[0]
def test_guests_never_read_aggregate_or_exception_facts(self):
with patch('logistics_chat.order_listing.read_orders') as read:
for q in ('我有多少异常订单','到港的有多少运单','各个轨迹节点分别有多少订单','查看全部','下一页'):
self.assertTrue(self.ask(q)['login_required'])
read.assert_not_called()
def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):
self.login()
total=self.ask('我有多少个订单')['t']
self.assertIn('共有 16 票',total);self.assertEqual(total.count('- 入仓码:'),5)
abnormal=self.ask('我有多少个异常订单')['t']
self.assertIn('7 票明确标记异常',abnormal);self.assertEqual(abnormal.count('- 入仓码:'),5)
self.assertNotIn('REJECTED20260001',abnormal)
arrived=self.ask('现在到港的有多少运单')['t']
self.assertIn('10 票当前节点为“船舶已到目的港”',arrived)
self.assertEqual(arrived.count('- 入仓码:'),5)
self.assertIn('STAT20260009',arrived);self.assertNotIn('STAT20260000',arrived)
self.assertNotIn('CLEAR20260001',arrived)
text=self.ask('已清关的异常订单有多少')['t']
self.assertIn('1 票当前节点为“已清关、可提柜”',text);self.assertIn('等待客户资料',text)
def test_distribution_counts_each_order_once_and_never_uses_forecasts_or_business_completion(self):
self.login()
text=self.ask('各个轨迹节点分别有多少订单')['t']
for token in ('船舶已到目的港:10票','已清关、可提柜:2票','多柜进度不同:1票','暂无实际节点:3票'):
self.assertIn(token,text)
self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text)
def test_expand_and_pages_keep_filters_and_refresh_counts(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
text=self.ask('下一页',cid)['t']
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),2)
text=self.ask('查看全部',cid)['t']
self.assertIn('共7票',text);self.assertEqual(text.count('- 入仓码:'),7)
self.assertIn('明确标记异常',text)
with self.app.app_context():
c=db.connect();c.execute('UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id=?',('a',));c.commit();c.close()
self.assertIn('没有符合条件',self.ask('下一页',cid)['t'])
def test_pages_do_not_cross_conversations_or_login_boundaries(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('到港的订单有多少',cid)
self.assertIn('第2页',self.ask('下一页',cid)['t'])
self.ask('当前订单总数',cid)
self.assertIn('共16票',self.ask('查看全部',cid)['t'])
self.client.post('/api/customer/logout')
self.assertTrue(self.ask('下一页',cid)['login_required'])
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
self.login();self.assertIn('共16票',self.ask('查看全部',cid)['t'])
other=self.client.post('/api/new_chat').json['id']
self.assertIn('请先',self.ask('下一页',other)['t'])
def test_unknown_or_ambiguous_filters_do_not_degenerate_to_totals(self):
self.login()
for q in ('今天有多少异常订单','未到港的订单有多少','到港和已清关分别有多少订单','已签收的有多少运单'):
with self.subTest(q=q):self.assertIn('暂不支持按这个条件统计',self.ask(q)['t'])
def test_exception_reasons_are_editable_and_old_reimports_preserve_them(self):
self.login()
with self.app.app_context():
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING'}],replace=True);c.commit();c.close()
self.assertIn('等待客户资料',self.ask('CLEAR20260001的异常原因是什么')['t'])
cid=self.client.post('/api/new_chat').json['id'];self.ask('CLEAR20260001',cid)
self.assertIn('等待客户资料',self.ask('为什么异常',cid)['t'])
with self.app.app_context():
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','exception_reason':''}],replace=True);c.commit();c.close()
self.assertIn('未登记异常原因',self.ask('CLEAR20260001的异常原因是什么')['t'])
def test_missing_actuals_and_bad_tracking_do_not_count_as_arrived(self):
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B',True)]),'MIXED')
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT',forecast=True)]),'NO_ACTUAL')
self.assertEqual(current_stage('{broken'),'NO_ACTUAL')
self.assertEqual(current_stage([fact('UNKNOWN')]),'NO_ACTUAL')
self.assertEqual(current_stage([fact('DOMESTIC_RECEIVED'),fact('ARRIVED_DESTINATION_PORT','A',True)]),'DOMESTIC_RECEIVED')
def test_node_names_do_not_collide_with_business_status_or_drop_negations(self):
from logistics_chat.order_query import parse_query
intent=parse_query('已完成装柜的订单有多少')
self.assertFalse(intent['unsupported']);self.assertIsNone(intent['status'])
self.assertEqual(intent['stage'],'LOADING_COMPLETED')
self.assertTrue(parse_query('未清关的订单有多少')['unsupported'])
self.assertTrue(parse_query('没有异常的订单有多少')['unsupported'])
def test_state_survives_app_restart_and_rejects_late_reply_updates(self):
self.login();cid=self.client.post('/api/new_chat').json['id']
self.ask('我有多少异常订单',cid)
restarted=create_app(dict(self.app.config,INITIALIZE_RUNTIME=False))
client=restarted.test_client()
# Copy opaque cookies without exporting them.
for cookie in self.client._cookies.values():client.set_cookie(cookie.key,cookie.value)
response=client.post('/stream_chat',json={'msg':'下一页','conv_id':cid}).get_data(as_text=True)
self.assertIn('第2页',response)
from logistics_chat.order_listing import save_state
cookies='; '.join(cookie.key+'='+cookie.value for cookie in self.client._cookies.values())
with self.app.test_request_context('/',headers={'Cookie':cookies}):
self.app.preprocess_request()
from logistics_chat.customer_service import current_customer_id
self.assertIsNotNone(current_customer_id())
c=db.connect();prior=c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
state=json.loads(prior);c.close()
db.save_message(cid,'user','换个话题,我要报价')
save_state(cid,{'query':state['query'],'page':1,'size':5},state['message_id'])
c=db.connect();self.assertEqual(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0],prior);c.close()
def test_schema_migration_preserves_old_orders(self):
c=sqlite3.connect(':memory:')
try:
c.execute('CREATE TABLE order_test_data (id INTEGER PRIMARY KEY,tenant_id TEXT,order_no TEXT,status TEXT,inbound_code TEXT,shipment_id TEXT)')
c.execute("INSERT INTO order_test_data VALUES(1,'a','OLD20260001','PROCESSING',NULL,NULL)")
order_data.init_order_table(c);order_data.init_order_table(c)
self.assertEqual(c.execute('SELECT order_no,exception_reason,tracking_json FROM order_test_data').fetchone(),('OLD20260001',None,'[]'))
finally:c.close()