Compare commits

...
27 changed files with 1624 additions and 120 deletions
+3 -1
View File
@@ -13,8 +13,10 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` | | Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery | | Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` | | Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `logistics_chat/rates_extra.py` | | 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` | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
| Reply boundaries / list pages | `logistics_chat/customer_faq.py`: supported actions; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions; `quote_material.py`: partial metal chargeable weight; `order_listing.py`: scoped counts and conversation pages |
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` | | 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` | | 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` | | Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
+19 -1
View File
@@ -131,12 +131,30 @@ tests/ # 按业务模块组织的回归
## 验证与维护 ## 验证与维护
美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。 美国报价由 `quote_workbook.py` 导出当前 Excel 本地明细;`quote_data.py` 管理版本与 SQLite;`quote_intent.py` 解析单位、逐箱明细和业务意图;`quote_engine.py` 负责纯匹配/Decimal 计价;`quote_answers.py` 和 `quote_fees.py` 从当前版本说明读取业务收费;`quote_language.py` 翻译常规英文报价;`quote_service.py` 续接当前会话并通过 `answer_quote` 返回既有 `t`/`d` 事件。分流位置在订单权限检查之后、旧报价/模型分流之前。
新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。 新建库不预置价格;私有快照事务导入、显式激活,读取使用同一事务固定版本,后续查询读取更新后的价格。不访问外部工作簿,不按文件日期或旧船期停用。仓码重复分区并列;邮编存文本;上下文仅来自当前访客拥有的同一会话,新的完整问题、其他国家及订单/理赔话题构成边界。回复不包含原始快照、联系人、文件路径或旧船期,不新增公开导入/管理接口。
`quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.py`。 `quote_documents` 保存当前资料说明,复杂业务说明及尚未实现的附加费仍需逐项扩展匹配规则,不代表已全部自动结算。生产镜像必须显式包含 `scripts/import_quote_data.py`。
已确认口径:同件超重/超尺寸只取最高适用档,其他附加费分别计算;洛杉矶普船海卡体积保留两位、四舍五入;海外进口清关超过5个品名的续页按美元/个。原表大尺寸长度条件有误,不猜测下限,只有其他两条明确条件同时满足时自动应用该档,其余疑似大件转人工确认。报价不把提取时效称为签收时效。金额仍读取激活版本,不把私有费率写入源码。
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示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` 负责列表与下一页/上一页/指定页码,每页20票;从当前会话最近的列表回复恢复页码和业务状态,重新以服务端客户范围查询实际总数和该页数据。COUNT与分页读取处于同一只读事务;无有效列表上下文时要求先列订单,不推测剩余数量。页码有界,筛选及翻页回复与订单事实一样保护历史,不增加公开API或管理入口。
已确认海派计费重保留原小数,不向上取整,每箱最低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 联调。 先按 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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
+8
View File
@@ -91,10 +91,18 @@ def create_chat_stream(msg, conv_id):
return (sse(order_login_response()) + sse({'d':1}), {}) return (sse(order_login_response()) + sse({'d':1}), {})
return (sse({'e':'该会话已删除,请新建对话后重试。'}), {}) return (sse({'e':'该会话已删除,请新建对话后重试。'}), {})
order_answer = answer_query(msg, conv_id) 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: if order_answer is None:
order_answer = answer_quote(msg, conv_id) 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.fullmatch(r'\[图片\]/uploads/\S+', msg): if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
order_answer = {'t':'收到您的图片,请描述一下您的问题。'} 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 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 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 re.search(r'\d+(?:\.\d+)?\s*(?:cbm|方|立方)', msg, re.I) or
+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
+40 -5
View File
@@ -219,18 +219,31 @@ def answer_count_query(intent, tenants):
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True} return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
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): 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'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}' 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'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
r'(?![A-Za-z0-9_-])', ' ', message) r'(?![A-Za-z0-9_-])', ' ', message)
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?' 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*' message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*'
r'(?:kg|lbs?|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)' r'(?:kilograms?|kgs?|grams?|g|tons?|lbs?|磅|cbm|cm|mm|公斤|千克|克|吨|立方米|方|m[³3]|箱|件)'
r'(?![A-Za-z0-9_])', ' ', message, flags=re.I) r'(?![A-Za-z0-9_])', ' ', message, flags=re.I)
references = [] references = []
for token in IDENTIFIER.findall(message): for token in IDENTIFIER.findall(message):
if (re.fullmatch(r'[A-Za-z]{2,4}[0-9][A-Za-z0-9]{0,2}',token)
and re.search(r'美国|报价|海派|海卡|海运|\bquote\b',message,re.I)
and not re.search(r'订单|运单|入仓|轨迹|查单|\btrack\b',message,re.I)):
continue
if sum(character.isdigit() for character in token) < 3: if sum(character.isdigit() for character in token) < 3:
continue continue
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I): if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
@@ -298,7 +311,7 @@ def is_tracking_request(message):
r'(?:物流|轨迹|进度)(?:查询|查一下|查下))') r'(?:物流|轨迹|进度)(?:查询|查一下|查下))')
personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)' personal = (r'(?:我的|(?:我)?(?:这|那)(?:票|个|单)|该)(?:货物?|包裹|快递|订单|运单)'
r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|' r'(?:现在|目前)?(?:到哪(?:里)?了?|什么时候(?:能|会)?到|何时能?到|多久能?到|'
r'签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?') r'(?:已)?(?:清关|开船|到港|出库|提柜)(?:了)?(?:吗|没|了吗|没有)?|签收了?(?:吗|没)|状态|进度|轨迹)(?:呢|吗|呀|啊)?')
english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+' english = (r'(?:where\s+(?:is|are)\s+my|track(?:\s+my)?|check(?:\s+my)?)\s+'
r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|' r'(?:parcels?|packages?|shipments?|orders?)(?:\s+(?:status|progress))?|'
r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)') r'what\s+is\s+my\s+(?:parcel|package|shipment|order)\s+(?:status|progress)')
@@ -308,6 +321,9 @@ def is_tracking_request(message):
def is_order_followup(message): def is_order_followup(message):
"""Reuse an order only for an explicit reference or a short detail question.""" """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): if is_lookup_help(message):
return False return False
if is_tracking_request(message): if is_tracking_request(message):
@@ -332,9 +348,17 @@ def is_order_followup(message):
def answer_query(message, conv_id): def answer_query(message, conv_id):
references = extract_reference(message) references = extract_reference(message)
tenants = customer_tenant_ids() 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 count_intent = parse_count_query(message) if not references else None
if count_intent is not None: if count_intent is not None:
return answer_count_query(count_intent,tenants) return answer_count_query(count_intent,tenants)
if not references:
listed=answer_list_query(message,tenants,conv_id)
if listed is not None:return listed
mutation=bool(re.search(r'改址|改.*(?:收货|收件).*地址|修改.*(?:收件|地址|订单)|(?:收货|收件)地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
if references and not tenants: if references and not tenants:
return order_login_response() return order_login_response()
if len(references) > 1: if len(references) > 1:
@@ -364,6 +388,12 @@ def answer_query(message, conv_id):
except (ValueError, KeyError, TypeError): except (ValueError, KeyError, TypeError):
pass pass
if not reference: 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|' if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|' r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|' r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
@@ -381,6 +411,11 @@ def answer_query(message, conv_id):
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'} return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
if len(matches) > 1: if len(matches) > 1:
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'} return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
if mutation:
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
if re.search(r'运费|费用|多少钱|价格', message): if re.search(r'运费|费用|多少钱|价格', message):
return {'t':'该订单未记录实际运费,请联系人工客服核实。'} 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}
+46
View File
@@ -0,0 +1,46 @@
"""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):
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)
+69
View File
@@ -0,0 +1,69 @@
"""Bounded conversational pagination over the server-validated customer scope."""
import re
import sqlite3
from .db import connect
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 answer_list(message,tenants,conv_id=None):
from .order_data import is_lookup_help,parse_count_query,tenant_filter,STATUS_LABELS
from .customer_service import order_login_response
command=page_command(message)
initial=(not is_lookup_help(message) and re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I)
and re.search(r'列出|列表|全部|所有|\blist\b',message,re.I))
if command is None and not initial:return None
if not tenants:return order_login_response()
page=1;status=None
if command is None:
intent=parse_count_query(re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I))
if intent is None or intent['unsupported']:
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
status=intent['status']
scope=tenant_filter(tenant_ids=tenants)
if scope is None:return order_login_response()
connection=None
try:
connection=connect()
if command is not None:
if not conv_id:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'}
prior=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='assistant' ORDER BY id DESC LIMIT 1",(conv_id,)).fetchone()
header=prior[0].split('\n',1)[0] if prior else ''
match=re.fullmatch(r'您当前的订单列表(.*第(\d+)页;业务状态:([^)]+)):',header)
if not match:return {'t':'请先在当前对话中发送“列出我的订单”,再翻页查看。'}
labels={label:code for code,label in STATUS_LABELS.items()}
status=labels.get(match[2])
if match[2]!='全部' and status is None:return {'t':'请重新指定需要查看的订单状态。'}
page=int(command) if isinstance(command,int) else max(1,int(match[1])+(1 if command=='next' else -1))
if not 1<=page<=10000:return {'t':'请提供1至10000之间的有效页码。'}
where=' WHERE 1=1'+scope[0];values=scope[1]
if status:where+=' AND status=?';values=values+[status]
# Both reads see one current snapshot and apply exactly the same scope/filter.
connection.execute('BEGIN')
total=connection.execute('SELECT COUNT(*) FROM order_test_data'+where,values).fetchone()[0]
page=min(page,max(1,(total+19)//20))
rows=connection.execute('SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data'+where+
' ORDER BY id DESC LIMIT 20 OFFSET ?',values+[(page-1)*20]).fetchall()
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
label=STATUS_LABELS[status] if status else '全部'
first='先列出20票;' if page==1 and total>20 else ''
lines=[f'您当前的订单列表({first}第{page}页;业务状态:{label}):',f'共{total}票,当前显示第{(page-1)*20+1}至{(page-1)*20+len(rows)}票。']
for number,inbound,business_status,warehouse in rows:
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(business_status,business_status)};目的仓:{warehouse or "未登记"}')
lines.append('回复“下一页”查看后续订单。' if page*20<total else '当前已是最后一页。')
lines.append('发送入仓码可查询该票订单和物流轨迹。')
return {'t':'\n'.join(lines),'_customer_data':True}
except sqlite3.Error:
return {'t':'订单信息暂时无法读取,请稍后重试。'}
finally:
if connection is not None:connection.close()
+10 -4
View File
@@ -147,6 +147,10 @@ def parse_shipping_intent(text):
dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t) dl = re.search(r"长(\d+(?:\.\d+)?)宽(\d+(?:\.\d+)?)高(\d+(?:\.\d+)?)", t)
if dl: if dl:
intent["dims"] = [float(x) for x in dl.groups()] intent["dims"] = [float(x) for x in dl.groups()]
else:
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 的第三个数)当成重量 # 但不能把尺寸里的数字(如 120*100*100 的第三个数)当成重量
@@ -177,20 +181,22 @@ def parse_shipping_intent(text):
return intent return intent
def detect_dim_unit(text): def detect_dim_unit(text):
"""识别尺寸单位:cm(默认)/ m / mm""" """Recognize explicit dimensions; aggregate CBM does not imply meters."""
low = text.lower() 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): if re.search(r"毫米|\d\s*mm", low):
return "mm" 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): if re.search(r"\d\s*cm|\d\s*厘米", low):
return "cm" 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 "m"
return "cm" return "cm"
def dims_to_cm(dims, unit): def dims_to_cm(dims, unit):
"""统一换算成厘米,体积重公式按 cm 计算""" """统一换算成厘米,体积重公式按 cm 计算"""
if unit == 'in':
return [d * 2.54 for d in dims]
if unit == "m": if unit == "m":
return [d * 100 for d in dims] return [d * 100 for d in dims]
if unit == "mm": 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()]
+125
View File
@@ -0,0 +1,125 @@
"""Answer business questions from the active local book, without old regional FAQs."""
from decimal import Decimal, ROUND_HALF_UP
import re
from .quote_fees import document, amount, material_fee, limit_fees
def answer_business(intent,book):
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':
raw=document(book,('保险费','投保金额'))
percent=amount(raw,r'百分之\s*(\d+(?:\.\d+)?)')
minimum=amount(raw,r'min\s*(\d+(?:\.\d+)?)\s*USD')
multiplier=amount(raw,r'货值乘以\s*(\d+(?:\.\d+)?)')
if percent is None or minimum is None or multiplier is None:
return '当前资料未明确保险的完整计费条件,请提供货值和币种,由人工确认保险费。'
lines=[f'当前资料保险费率为保额的{percent:g}%,最低{minimum:g}USD;按货值投保时,投保金额为货值×{multiplier:g}。']
money=re.search(r'(-?\d+(?:\.\d+)?)\s*(万)?\s*(美元|美金|USD|人民币|元)',text,re.I)
if money and money.group(3).lower() in ('美元','美金','usd'):
value=Decimal(money.group(1))*(10000 if money.group(2) else 1)
if not 0<value<=1000000000:return '请提供大于0的有效货值或保额及币种。'
insured=value if re.search(r'保价|保额',text) and not re.search(r'货值',text) else value*multiplier
total=max(insured*percent/100,minimum).quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
lines.append(f'按保额{number(insured)}USD计算:max({number(insured)}×{percent:g}%,{minimum:g})={number(total)}USD。')
else:
lines.append('请提供货值/保额及币种;人民币货值还需确认适用结算汇率。')
return '\n'.join(lines)
if mode=='fulfillment':
tokens=[t for t in ('20GP','40GP','45HQ','散箱','散托','贴标','仓储','仓租','分拣','SKU') if t.lower() in text.lower()]
if '仓租' in tokens:tokens.append('仓储')
if '卸货' in text and not tokens:tokens=['20GP','40GP','45HQ']
rows=[]
for d in book['documents']:
if d['sheet']!='一件代发收费' or not re.search(r'\$|USD|美金',d['content'],re.I):continue
score=sum(t.lower() in d['content'].lower() for t in tokens) if tokens else 1
if score:rows.append((score,d['content']))
rows=sorted(rows,key=lambda row:(-row[0],-(';' in row[1])))
facts=list(dict.fromkeys(row[1] for row in rows))[:4]
return '美国仓一件代发收费:\n'+'\n'.join('- '+r for r in facts) if facts else '当前资料中未匹配到该一件代发收费项,请补充服务项目、箱数或体积,由人工核实。'
if mode=='customs':
if intent.get('services') and '美森加班' in intent['services'] and document(book,('不可接一般贸易',),'美森加班海卡'):
return '当前资料中美森加班海卡不可接一般贸易,请由人工确认其他渠道,不能直接按该线路加报关费办理。'
if re.search(r'FDA',text,re.I):
raw=document(book,('FDA','申报费'))
fee=amount(raw,r'申报费[::]?\s*RMB\s*(\d+(?:\.\d+)?)')
if fee is not None:return f'当前资料中需做FDA Filing等申报的产品,申报费为{fee:g}人民币/票。请补充具体品名和渠道,确认是否接收及需要的资料。'
return '当前资料未匹配到FDA申报费,请提供具体品名和渠道,由人工确认是否接收及费用。'
raw=document(book,('海外进口清关','续页'))
extra=amount(raw,r'加收USD\s*(\d+(?:\.\d+)?)')
lines=[]
if re.search(r'续页|品名',text) and extra is not None:
lines.append(f'海外进口清关:每票超过5个品名的部分,按{extra:g}美元/个收取。')
count=re.search(r'(\d+)\s*个品名',text)
if count:lines.append(f'{count.group(1)}个品名的该项费用:max({count.group(1)}−5,0)×{extra:g}={max(int(count.group(1))-5,0)*extra:g}美元。')
lines.append('国内出口报关续项与海外进口清关分别收费;国内条款的品名计数方式需按票确认。')
raw_fee=document(book,('一般贸易出口报关费',))
declaration=amount(raw_fee,r'出口报关费[::]\s*RMB\s*(\d+(?:\.\d+)?)')
if declaration is not None:lines.append(f'一般贸易出口报关费为{declaration:g}人民币/票。')
if re.search(r'单独报关',text):
minimum=document(book,('单独报关货物','300KG'))
if minimum:lines.append('单独报关货物最低按300KG计费;还需确认所选渠道是否接受一般贸易。')
return '\n'.join(lines) or '请提供具体申报项目、品名数量及渠道,当前资料无法确定该项费用。'
if mode=='acceptance':
raw=document(book,('禁止运输的货物',))
if raw:
prohibited=next((t for t in ('纯电池','液体','粉末','食品','医疗用品','仿牌') if t in text),None)
if prohibited:return f'当前海派资料将{prohibited}列为不接收/禁止运输的类别,不能按普通货直接报价。若咨询其他渠道,请先由人工核实承运条件。'
if re.search(r'带电|电池',text):return '当前海派资料中内置电池需要提供产品图片、MSDS及海运运输鉴定报告后单独确认;纯电池不接收。请补充电池类型和产品信息,确认后再匹配运价。'
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):
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+)?)')
if fee is not None:lines.append(f'海派明确的普通超重附加费为{fee:g}元/件;同箱同时超重、超尺寸时只收适用的最高一项。')
limits=limit_fees(intent,book)
lines+=limits['items']+limits['notes']
if re.search(r'住宅|私人|商业地址|非亚马逊',text):
lines.append('住宅或商业等非亚马逊地址的派送附加费在原表中需单独询价;请提供完整邮编、地址类型和箱规,不能直接当作FBA仓价格。')
if not lines or re.search(r'包含|包括|还[要需]|什么费用',text):
raw=document(book,('偏远地址加收','超偏远'))
remote=amount(raw,r'(?<!超)偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
extended=amount(raw,r'超偏远地址加收\s*(\d+(?:\.\d+)?)元/kg')
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,只按铁铝货物计费重收取;多项费用分别列明,同箱超限费用只取最高项。')
return '\n'.join(lines)
return None
+59
View File
@@ -0,0 +1,59 @@
"""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'}
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))
+103 -67
View File
@@ -3,64 +3,10 @@
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
import re import re
from .pricing import parse_shipping_intent, detect_dim_unit, dims_to_cm, shipping_region from .pricing import dims_to_cm
US = re.compile(r'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|' from .quote_intent import US, QUOTE_WORDS, parse_intent, is_followup
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)
def parse_intent(message, warehouse_codes):
parsed = parse_shipping_intent(message)
country = 'US' if US.search(message) else ('other' if shipping_region(message) is not None else None)
tokens = re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])', message.upper())
warehouse = next((token for token in tokens if token in warehouse_codes), None)
postal = re.search(r'(?<![0-9])(\d{5})(?:-\d{4})?(?![0-9])', message)
cbm = re.search(r'(\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)', message, re.I)
boxes = re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)', message, re.I)
origin = '华南' if re.search(r'华南|深圳|东莞', message) else ('义乌' if re.search(r'华东|义乌', message) else None)
if re.search(r'华南|深圳|东莞', message) and re.search(r'华东|义乌', message):
origin = None
services = [name for name in ('美森正班','美森加班','EXX','合德','普船') if name.lower() in message.lower()]
if '美森' in message and not any(name.startswith('美森') for name in services):
services += ['美森正班','美森加班']
kinds = []
if re.search(r'海派|快递|UPS|FEDEX', message, re.I):
kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路', message):
kinds.append('truck')
air_only = bool(re.search(r'空运|空派|\bair\b', message, re.I) and not re.search(r'海运|海派|海卡|海铁|\bsea\b', message, re.I))
zone = next((value for value in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if value in message), None)
each = re.search(r'每(?:箱|件)\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)', message, re.I)
weight = str(parsed['weight']) if parsed['weight'] is not None else None
count = int(boxes.group(1)) if boxes else None
if each and count:
weight = str(Decimal(each.group(1))*count)
return {'country':country, 'weight':weight, 'dims':parsed['dims'], 'dim_unit':detect_dim_unit(message),
'cbm':cbm.group(1) if cbm else None, 'boxes':count, 'origin':origin, 'services':services or None,
'kinds':kinds or None, 'transport':parsed['channel'], 'air_only':True if air_only else None, 'warehouse':warehouse,
'postal':postal.group(1) if postal else None, 'zone':zone,
'tax':'excluded' if re.search(r'不包税|自税|不含税', message) else ('included' if '包税' in message else None)}
def is_followup(message, intent):
if re.search(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return False
text = message.strip().strip(',,。.!!?? ')
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样)', text):
return True
if intent['warehouse'] and re.fullmatch(r'(?:目的仓(?:是)?|仓码(?:是)?)?\s*[A-Za-z0-9]+(?:仓)?(?:呢)?', text):
return True
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
if len(text) <= 45 and not (intent['country'] and intent['weight']) and (
intent['services'] or intent['kinds'] or intent['transport'] or intent['air_only'] or intent['weight'] or intent['dims'] or intent['cbm']):
return True
return bool(re.fullmatch(r'(?:价格|运费|费用|时效|多少钱)(?:呢|是多少|怎么样)?', text))
def broad_zone(postal): def broad_zone(postal):
@@ -74,6 +20,16 @@ def broad_zone(postal):
def variants(intent, book): 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)]
if not intent['warehouse'] and len(intent.get('zones') or [])>1:
return [variant for zone in intent['zones'] for variant in
variants({**intent,'zone':zone,'zones':None},book)]
warehouse = intent['warehouse'] warehouse = intent['warehouse']
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else [] found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
zoned = [r for r in found if r['zone']] zoned = [r for r in found if r['zone']]
@@ -101,13 +57,29 @@ def quantity(intent, rate):
if volume is None and intent['dims']: if volume is None and intent['dims']:
cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])] cm = [Decimal(str(value)) for value in dims_to_cm(intent['dims'], intent['dim_unit'])]
volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1) volume = cm[0]*cm[1]*cm[2]/Decimal(1000000)*(intent['boxes'] or 1)
package_charge=Decimal(0)
if intent.get('packages'):
package_volume=Decimal(0)
all_dimensions=True
for package in intent['packages']:
dims=package.get('dims') or intent['dims']
cm=[Decimal(str(value)) for value in dims_to_cm(dims,package.get('dim_unit') or intent['dim_unit'])] if dims else None
box_volume=cm[0]*cm[1]*cm[2]/1000000 if cm else Decimal(0)
all_dimensions=all_dimensions and bool(cm)
package_volume+=box_volume*package['count']
piece=max(Decimal(package['weight']),box_volume*1000000/6000 if rate['kind']=='parcel' else box_volume*167)
package_charge+=max(piece,Decimal(12) if rate['kind']=='parcel' else Decimal(0))*package['count']
if all_dimensions and not intent['cbm']:volume=package_volume
if rate['unit'] == 'cbm': if rate['unit'] == 'cbm':
if volume is None: if volume is None:
return None return None
return max(volume, real/Decimal(363)) result=max(volume, real/Decimal(363))
if rate['service']=='洛杉矶普船海卡':
result=result.quantize(Decimal('.01'),rounding=ROUND_HALF_UP)
return result if result>0 else None
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel' volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
else (volume or Decimal(0))*167) else (volume or Decimal(0))*167)
charge = max(real, volume_weight) charge = max(real, volume_weight,package_charge)
if rate['kind'] == 'parcel': if rate['kind'] == 'parcel':
charge = max(charge, Decimal(12)*(intent['boxes'] or 1)) charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
return charge if real or volume else None return charge if real or volume else None
@@ -144,14 +116,61 @@ 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) 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): def render_rates(intent, book, destinations, remote_fees):
from .quote_fees import charge_details
from .quote_language import translate_quote
lines = [] lines = []
if intent['weight']: if intent.get('mode')=='time':
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:") lines.append('美国线路参考时效:')
elif intent['weight']:
lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:")
else: else:
lines.append('美国线路报价:') lines.append('美国线路报价:')
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): 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(billing,r) is not None]
best={}
for rate in eligible:
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
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(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):
values=[Decimal(v) for v in re.findall(r'\d+(?:\.\d+)?',rate['eta'])]
return tuple(values[:2]) if values else (Decimal('Infinity'),)
earliest=min(eta_key(r) for r in rates)
rates=[r for r in rates if eta_key(r)==earliest]
lines.append('按资料中的参考时效,较快方案如下;提取不等于送达或签收:')
if not rates: if not rates:
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。") lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
continue continue
@@ -162,19 +181,31 @@ def render_rates(intent, book, destinations, remote_fees):
key = (rate['service'], rate['origin'], rate['unit'], rate['tax']) key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
grouped.setdefault(key, []).append(rate) grouped.setdefault(key, []).append(rate)
for (service, origin, unit, tax), rows in grouped.items(): for (service, origin, unit, tax), rows in grouped.items():
if intent.get('mode')=='time':
eta=rows[0]['eta'] or '当前资料未列明时效'
if rows[0]['kind']=='parcel' and '提取' not in eta:eta+='提取'
lines.append(f'- {service} · {origin}:{eta}。提取不等于送达或签收。')
continue
parts = [] parts = []
for rate in rows: for rate in rows:
amount = quantity(intent,rate) amount = quantity(billing,rate)
price = Decimal(rate['price']) price = Decimal(rate['price'])
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else '' rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}" rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
if amount is not None: if amount is not None:
total = amount*price total = amount*price
if fees and fees['fee'] is not None and rate['kind'] == 'parcel': details=charge_details(billing,rate,fees,book)
total += amount*Decimal(fees['fee']) 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)
elif intent.get('cbm') and rate['unit']=='cbm':
lines.append('计费体积四舍五入后不足0.01CBM,最低计费体积需人工确认,不能按零费用出价。')
parts.append(rate_text) parts.append(rate_text)
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip() eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
if rows[0]['kind']=='parcel' and eta and '提取' not in eta:eta+='提取'
elif rows[0]['kind']=='truck' and eta and '船开' not in eta:eta='开船后参考'+eta
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts) lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
+ (f'({eta})' if eta else '')) + (f'({eta})' if eta else ''))
if fees: if fees:
@@ -184,8 +215,13 @@ def render_rates(intent, book, destinations, remote_fees):
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。") lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
if not any(intent.get(key) for key in ('warehouse','postal')): if not any(intent.get(key) for key in ('warehouse','postal')):
lines.append('发我目的仓码或邮编,可继续匹配到仓线路。') lines.append('发我目的仓码或邮编,可继续匹配到仓线路。')
if not intent['weight'] and not intent['cbm'] and not intent['dims']: if intent.get('mode')=='time':
pass
elif not intent['weight'] and not intent['cbm'] and not intent['dims']:
lines.append('货物多少公斤、几箱?') lines.append('货物多少公斤、几箱?')
elif not intent['boxes'] or not intent['dims']: elif not intent['boxes'] or not intent['dims']:
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。') lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
return '\n'.join(lines) if intent.get('mode')=='calculation':
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
text='\n'.join(lines)
return translate_quote(text) if intent.get('english') else text
+122
View File
@@ -0,0 +1,122 @@
"""Amounts come from the active book; explicit customer-approved fee policies live here."""
from decimal import Decimal
import re
from .pricing import dims_to_cm
def document(book,required,service=None):
rows=[d for d in book['documents'] if (not service or d['sheet'] in (service,'海派'))
and all(term in d['content'] for term in required)]
return rows[0]['content'] if rows else ''
def amount(text,pattern):
match=re.search(pattern,text,re.I)
return Decimal(match.group(1)) if match else None
def material_fee(book):
text=document(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'),
'dim_unit':p.get('dim_unit') if p.get('dims') else intent.get('dim_unit','cm')} for p in intent['packages']]
if intent.get('boxes')==1 and intent.get('weight'):
return [{'count':1,'weight':intent['weight'],'dims':intent.get('dims'),'dim_unit':intent.get('dim_unit','cm')}]
return []
def limit_fees(intent,book):
"""Maximum per-piece limit fee, not the sum of overlapping weight/size charges.
The malformed 0<length<274 rule does not create a new threshold. Only two
independently explicit large-piece conditions can establish that tier.
"""
normal=document(book,('正常货物',))
cleaned=re.sub(r'\s+','',normal).replace('<','<').upper()
limits=[amount(cleaned,p) for p in (r'0KG<单件重量<(\d+)KG',r'0CM<最长单边长度<(\d+)CM',
r'0CM<第二边<(\d+)CM',r'最长边<(\d+)CM')]
normal_fee=amount(document(book,('超重收',)),r'超重收\s*[++]?\s*(\d+(?:\.\d+)?)')
size_text=document(book,('普通超尺寸',))
size_fee=amount(size_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
large_text=document(book,('大尺寸货物','任意2个'))
large_fee=amount(large_text,r'加收\s*(\d+(?:\.\d+)?)\s*RMB/件')
huge=document(book,('超大件货物',))
huge_fee=amount(huge,r'加收\s*USD\s*(\d+(?:\.\d+)?)')
result={'cny':Decimal(0),'items':[],'notes':[]}
if not all(limits) or normal_fee is None:return result
max_weight,max_length,max_second,max_girth=limits
for piece in specimens(intent):
weight=Decimal(piece['weight']);count=piece['count']
dims=sorted([Decimal(str(x)) for x in dims_to_cm(piece['dims'],piece['dim_unit'])],reverse=True) if piece['dims'] else []
length,second,girth=(dims[0],dims[1],dims[0]+2*(dims[1]+dims[2])) if dims else (None,None,None)
if weight>68 or (dims and (length>270 or girth>419)):
result['notes'].append('该件可能超出承运尺寸或重量,需人工确认是否接收。'+(f'原表超大件若接收的附加费为{huge_fee:g}USD/件。' if huge_fee else ''))
continue
candidates=[]
if weight>max_weight:candidates.append(normal_fee)
if dims and (length>max_length or second>max_second or girth>max_girth) and size_fee is not None:candidates.append(size_fee)
if large_fee is not None and max_weight<weight<68 and dims and 330<girth<419:
candidates.append(large_fee)
elif dims and (length>=243 or girth>=330):
result['notes'].append('该件接近大尺寸档,原表有一处长度条件不清,适用档位需人工确认。')
if candidates:
fee=max(candidates)*count
result['cny']+=fee
result['items'].append(f'超重/超尺寸按最高项{max(candidates):g}元/件×{count}件={fee:g}元')
return result
def charge_details(intent,rate,remote,book):
from .quote_engine import quantity
result={'cny':Decimal(0),'items':[],'notes':[]}
charged=quantity(intent,rate)
if charged is None:return result
if rate['kind']=='parcel' and remote:
if remote['fee'] is not None:
fee=charged*Decimal(remote['fee']);result['cny']+=fee
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
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']
if intent.get('address_type')=='non_fba':
result['notes'].append('非亚马逊地址还需核对住宅/商业派送附加费,原表此项需单独询价。')
return result
+182
View File
@@ -0,0 +1,182 @@
"""Normalize customer wording into bounded quote fields without model inference."""
from decimal import Decimal
import re
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)
WEIGHT = re.compile(r'(?<![\d.])(-?\d+(?:\.\d+)?)\s*(kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅)(?![A-Za-z])',re.I)
TOPIC_BOUNDARY = re.compile(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收')
def chinese_number(text):
digits={c:i for i,c in enumerate('零一二三四五六七八九')};digits.update({'两':2,'〇':0})
if all(c in digits for c in text):return ''.join(str(digits[c]) for c in text)
if '点' in text:
left,right=text.split('点',1)
return str(chinese_number(left or '零'))+'.'+''.join(str(digits[c]) for c in right)
total=section=number=0
for character in text:
if character in digits:
number=digits[character]
elif character=='万':
total+=(section+number)*10000;section=number=0
else:
section+=(number or 1)*{'十':10,'百':100,'千':1000}[character];number=0
return str(total+section+number)
def normalize(text):
result=re.sub(r'([零〇一二两三四五六七八九十百千万点]+)(?=\s*(?:公斤|千克|克|吨|磅|(?:个)?箱|件|立方米|立方|方|kg|cbm))',
lambda m:chinese_number(m.group(1)),text,flags=re.I)
return re.sub(r'负(?=\d)','-',result)
def kg(value,unit):
value=Decimal(value)
unit=unit.lower()
if unit in ('克','g','gram','grams'):return value/1000
if unit in ('吨','ton','tons'):return value*1000
if unit in ('磅','lb','lbs'):return (value*Decimal('0.4536')).quantize(Decimal('.01'))
return value
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'
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 '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'
if re.search(r'时效|多久|几天',text) and not WEIGHT.search(text):return 'time'
return 'quote'
def parse_intent(message,warehouse_codes):
text=normalize(message)
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=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?)',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(quantity_text))
package_dimensions={}
for index,match in enumerate(matches):
ordinal,n,value,unit=match.groups()
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)})
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:
package_count=sum(p['count'] for p in packages)
aggregate_count=boxes and not any(m.start()<=boxes.start()<m.end() for m in matches)
if count and aggregate_count and count!=package_count:
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.])',''),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 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
if labeled:
if re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?',labeled.group(1)):postal=labeled.group(1)[:5]
else:errors.append('美国邮编请提供5位数字,可附加4位扩展码;当前邮编格式不正确。')
else:
remaining=WEIGHT.sub(' ',text)
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
if match:postal=match.group(1)
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
if '美森' in text and not any(n.startswith('美森') for n in services):services+=['美森正班','美森加班']
kinds=[]
if re.search(r'海派|快递|UPS|FEDEX',text,re.I):kinds.append('parcel')
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路',text):kinds.append('truck')
zones=[z for z in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if z in text]
if not zones and '洛杉矶' in text:zones=['美西']
if not zones and re.search(r'纽约|萨凡纳',text):zones=['美东']
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
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,'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':materials['material'],'metal_scope':materials['metal_scope'],
'metal_chargeable_weight':materials['metal_chargeable_weight'],
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
'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
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
if intent.get('warehouse') or intent.get('unknown_warehouses'):
return not (intent['country'] and intent['weight'])
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','zone','material','metal_scope','metal_chargeable_weight')))
+33
View File
@@ -0,0 +1,33 @@
"""English rendering for deterministic quotes; numerical facts remain unchanged."""
import re
def translate_quote(text):
replacements=(
('美森正班海派','Matson regular sea parcel'),('美森加班海派','Matson extra sea parcel'),
('美森正班海卡','Matson regular sea truck'),('美森加班海卡','Matson extra sea truck'),
('纽约EXX海铁','New York EXX sea rail'),('洛杉矶普船全卡','Los Angeles regular sea truck direct'),
('洛杉矶普船海卡','Los Angeles regular sea truck'),('芝加哥普船海铁','Chicago regular sea rail'),
('休斯顿普船海卡','Houston regular sea truck'),('纽约普船海卡','New York regular sea truck'),
('萨凡纳普船海卡','Savannah regular sea truck'),('EXX海卡','EXX sea truck'),('合德海卡','Hede sea truck'),
('合德海派','Hede sea parcel'),('普船海派','Regular sea parcel'),
('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。','Please provide the carton count, weight and dimensions of each carton to calculate chargeable weight and size surcharges.'),
('发我目的仓码或邮编,可继续匹配到仓线路。','Send the destination warehouse code or ZIP code to match the route.'),
('货物多少公斤、几箱?','What are the total weight and carton count?'),
('当前资料没有美国空运费率,下面列出海运方案。','The current rate sheet has no US air rates. Sea options are listed below.'),
('以下按各目的仓分别比较;实际分票请提供每个仓的重量。','Each destination is compared separately; provide the weight assigned to each shipment.'),
('按相同区域、计费单位及税费方式比较的较低价方案:','Lower-priced options within the same region, billing unit and tax basis:'),
('按资料中的参考时效,较快方案如下;提取不等于送达或签收:','Faster reference options follow. Pickup timing is not a delivery or receipt commitment:'),
('该邮编属于遥远地址,派送附加费需单独确认。','This ZIP code is extremely remote; the delivery surcharge requires confirmation.'),
('海派已计入偏远附加费','Sea parcel totals include the remote surcharge of '),
('当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。','No matching route rate was found. Please provide the destination warehouse or ZIP code.'),
('美国线路参考时效','US reference transit times'),('美国线路报价','US route rates'),('基础运费','base freight'),('美国','US'),('义乌','Yiwu'),('华南','South China'),
('不包税','tax excluded'),('包税','tax included'),('美西','US West'),('美中','US Central'),('美东','US East'),
('开船后参考','Reference timing after sailing: '),('船开后','After sailing: '),('开船后','After sailing: '),
('若干','several '),
('提取',' until pickup'),('天左右',' days approximately'),('天',' days'),('元/KG',' CNY/kg'),('元/CBM',' CNY/CBM'),
('元',' CNY'),(':',': '),(';','; '),(',',', '),('(',' ('),(')',')'))
for source,target in replacements:text=text.replace(source,target)
text=re.sub(r'([东中西])(\d)区',lambda m:{'东':'East','中':'Central','西':'West'}[m[1]]+' zone '+m[2],text)
return text
+30
View File
@@ -0,0 +1,30 @@
"""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*计费')
charged=None;cleaned=text;errors=[]
for pattern in 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'
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}
+35
View File
@@ -0,0 +1,35 @@
"""Keep unanswered US shipping questions outside legacy regional model knowledge."""
import re
from .db import connect
from .quote_intent import US,QUOTE_WORDS,parse_intent,is_followup
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:
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:
previous=clean_conditions(previous)
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收|换个话题',previous):break
if US.search(previous):active=True;break
if shipping_region(previous) is not None:break
parsed=parse_intent(previous,set())
if not (QUOTE_WORDS.search(previous) or is_followup(previous,parsed)):break
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':'请说明您想确认的美国渠道、目的仓码或邮编,以及具体的费用或发货要求;未明确的服务条件需要联系人工核对。'}
+46 -38
View File
@@ -10,49 +10,33 @@ from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants,
def intent_with_context(message, conv_id, book): def intent_with_context(message, conv_id, book):
from .quote_context import merge_intent
codes = {row['code'] for row in book['warehouses']} codes = {row['code'] for row in book['warehouses']}
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations']) codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
intent = parse_intent(message,codes) intent = parse_intent(message,codes)
replaced_destination = ({'postal','zone'} if intent['warehouse'] else earlier_intents=[]
({'warehouse','zone'} if intent['postal'] else
({'warehouse','postal'} if intent['zone'] else set())))
if intent['transport'] or intent['kinds'] or intent['services']:
replaced_destination.add('air_only')
destination_selected = any(intent.get(key) for key in ('warehouse','postal','zone'))
if intent['air_only'] or intent['tax'] == 'excluded':
replaced_destination.add('kinds')
if conv_id and is_followup(message,intent): if conv_id and is_followup(message,intent):
connection = connect() connection=connect()
try: try:
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 8", (conv_id,)).fetchall() recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
finally: finally:
connection.close() connection.close()
for (previous,) in recent: for (previous,) in recent:
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', previous): if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
break earlier=parse_intent(previous,codes)
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 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'))): any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
break earlier_intents.append(earlier)
if earlier['country'] == 'other': if not is_followup(previous,earlier):break
break if earlier_intents:
if not destination_selected: state=earlier_intents[-1]
if earlier['warehouse']: for update in list(reversed(earlier_intents[:-1]))+[intent]:
replaced_destination.update(('postal','zone')) state=merge_intent(update,state)
destination_selected = True intent=state
elif earlier['postal']: if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:
replaced_destination.update(('warehouse','zone')) intent['country']='US'
destination_selected = True
elif earlier['zone']:
replaced_destination.update(('warehouse','postal'))
destination_selected = True
for key, value in earlier.items():
if intent.get(key) is None and key not in replaced_destination:
intent[key] = value
if not is_followup(previous, earlier):
break
if intent['warehouse'] and intent['country'] is None:
intent['country'] = 'US'
return intent return intent
@@ -60,7 +44,8 @@ def answer_quote(message, conv_id):
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message): if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
return None return None
# Avoid a new read for ordinary chat; short followups need their local context. # 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)): re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
return None return None
try: try:
@@ -69,7 +54,13 @@ def answer_quote(message, conv_id):
return None return None
intent = intent_with_context(message, conv_id, book) intent = intent_with_context(message, conv_id, book)
if intent['country'] != 'US': if intent['country'] != 'US':
if intent['country'] is None and intent.get('mode')=='preparation':
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
return None return None
if intent.get('errors'):
return {'t':'\n'.join(intent['errors'])}
if intent.get('unknown_warehouses'):
return {'t':'未找到目的仓码 '+ '、'.join(intent['unknown_warehouses'])+',请核对仓码,或提供5位美国邮编继续查询。'}
if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or if not (QUOTE_WORDS.search(message) or is_followup(message,intent) or
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))): any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
return None return None
@@ -78,10 +69,22 @@ def answer_quote(message, conv_id):
return {'t':'请提供大于 0 的有效重量或体积。'} return {'t':'请提供大于 0 的有效重量或体积。'}
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000: if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
return {'t':'请提供有效的货物箱数。'} return {'t':'请提供有效的货物箱数。'}
if intent['dims'] and any(not 0 < value <= 100000 for value in intent['dims']): dimensions=[intent['dims']] if intent['dims'] else []
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 的有效长宽高。'} return {'t':'请提供大于 0 的有效长宽高。'}
if intent['air_only']: if intent['air_only']:
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'} 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}
destinations = variants(intent,book) destinations = variants(intent,book)
fees = [] fees = []
for destination in destinations: for destination in destinations:
@@ -89,6 +92,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'): 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} 的地址,请提供其他目的仓码或联系人工确认可用线路。'} return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
fees.append(postcode_fee(book['id'],postal) if postal else None) 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): except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
return {'t':'报价数据暂时无法读取,请稍后重试。'} 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 '查验费用和承担方式需按渠道、税费方式及实际费用确认,当前无法给出固定金额。'
+12 -2
View File
@@ -1227,13 +1227,14 @@ window.showHuman = async function(){
if(humanRequestToken !== token || token.version !== conversationVersion) return; if(humanRequestToken !== token || token.version !== conversationVersion) return;
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid}); const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
if(humanRequestToken !== token || token.version !== conversationVersion) return; if(humanRequestToken !== token || token.version !== conversationVersion) return;
if(data.closed){ await leaveOfflineMessage(); return; } if(data.closed) return await leaveOfflineMessage();
if(!data.ok || !data.session_id) throw new Error('Human session failed'); if(!data.ok || !data.session_id) throw new Error('Human session failed');
window.humanSid = data.session_id; window.humanSid = data.session_id;
humanConvId = cid; humanConvId = cid;
humanStatus = 'waiting'; humanStatus = 'waiting';
addMsg(t('humanWaiting'), false); addMsg(t('humanWaiting'), false);
startHumanPoll(); startHumanPoll();
return true;
}catch(error){ }catch(error){
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true); if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
}finally{ }finally{
@@ -1298,7 +1299,7 @@ async function leaveOfflineMessage(){
try{ try{
const data = await humanRequest('/api/ticket', {content:message, phone:''}); const data = await humanRequest('/api/ticket', {content:message, phone:''});
if(!data.ok) throw new Error('Ticket failed'); if(!data.ok) throw new Error('Ticket failed');
if(version === conversationVersion) addMsg(t('offlineSaved'), false); if(version === conversationVersion){ addMsg(t('offlineSaved'), false); return true; }
}catch(error){ }catch(error){
if(version !== conversationVersion) return; if(version !== conversationVersion) return;
if(!inputDom.value){ inputDom.value = message; autoGrow(); } if(!inputDom.value){ inputDom.value = message; autoGrow(); }
@@ -1653,6 +1654,15 @@ async function doSend(retryText, showQuestion){
if(window.humanSid) stopHumanMode(); if(window.humanSid) stopHumanMode();
if(sendPreparing || (window.__busy && !retryText)) return; // 防连点 if(sendPreparing || (window.__busy && !retryText)) return; // 防连点
const humanCommand = sendWord.replace(/[\s,,。.!!??]/g, '');
if(!imgUrl && /^(?:(?:请|麻烦)?(?:帮我)?)?(?:转(?:接|到)?|联系|找)(?:一下|个)?人工(?:客服)?(?:吧|谢谢)?$/.test(humanCommand)){
const version = conversationVersion;
const connected = await window.showHuman();
if(connected && version === conversationVersion && !isRetry && inputDom.value === draft){
inputDom.value = ''; autoGrow(); flushDraft();
}
return !!connected;
}
if(cancelActive){ cancelActive(); cancelActive = null; } if(cancelActive){ cancelActive(); cancelActive = null; }
sendPreparing = true; sendPreparing = true;
+35
View File
@@ -1328,3 +1328,38 @@ test('an unavailable image gets one readable replacement instead of a broken ima
app.element('chatDom').listeners.error({target:image}); app.element('chatDom').listeners.error({target:image});
assert.equal(shown,first); assert.equal(shown,first);
}); });
test('an explicit typed human request opens one real queue without an AI question', async () => {
const app=await customerPage({human:false});
await app.context.window.fillInput('请帮我转人工客服');
assert.equal(app.calls.filter(c=>c.url==='/api/visitor/human/request').length,1);
assert.equal(app.calls.filter(c=>c.url==='/stream_chat').length,0);
assert.equal(app.context.window.humanSid,7);
assert.equal(app.element('input-text').value,'');
});
test('a failed typed transfer keeps the command and an information question uses AI', async () => {
const app=await customerPage({human:false});
app.hooks['/api/visitor/human/request']=()=>response({ok:false},503);
await app.context.window.fillInput('帮我转人工客服');
assert.equal(app.element('input-text').value,'帮我转人工客服');
assert.equal(app.context.window.humanSid,0);
await app.context.window.fillInput('不需要人工客服,人工几点上班?');
assert.equal(app.calls.filter(c=>c.url==='/api/visitor/human/request').length,1);
assert.equal(app.calls.filter(c=>c.url==='/stream_chat').length,1);
});
test('a typed transfer preserves a newer draft while connecting and cancelled offline input', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/visitor/human/request']=()=>new Promise(done=>{resolve=done;});
const sending=app.context.window.fillInput('帮我转人工客服');
await flush();
app.element('input-text').value='新输入的订单问题';
resolve(response({ok:true,session_id:8}));await sending;
assert.equal(app.element('input-text').value,'新输入的订单问题');
const closed=await customerPage({human:false,serviceClosed:true});
await closed.context.window.fillInput('帮我转人工客服');
assert.equal(closed.element('input-text').value,'帮我转人工客服');
assert.equal(closed.calls.filter(c=>c.url==='/api/ticket').length,0);
});
+206
View File
@@ -0,0 +1,206 @@
"""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_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_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,[])
+21
View File
@@ -46,6 +46,27 @@ class OrderCountTests(unittest.TestCase):
self.assertTrue(self.events(question)[0]['login_required']) self.assertTrue(self.events(question)[0]['login_required'])
count.assert_not_called(); model.assert_not_called() count.assert_not_called(); model.assert_not_called()
def test_order_list_is_login_scoped_status_filtered_and_protects_history(self):
self.assertTrue(self.events('列出我的全部订单')[0]['login_required'])
self.login()
cid=self.a.post('/api/new_chat').json['id']
text=self.events('列出我的全部订单',cid)[0]['t']
self.assertIn('COUNT20260001',text);self.assertIn('COUNT20260002',text)
self.assertNotIn('INB20260099',text);self.assertNotIn('COUNT20260003',text)
text=self.events('列出已完成的订单')[0]['t']
self.assertIn('COUNT20260002',text);self.assertNotIn('COUNT20260001',text)
self.assertIn('暂不支持按这个条件',self.events('列出今天的订单')[0]['t'])
self.a.post('/api/customer/logout')
self.assertEqual(self.a.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
def test_address_change_does_not_claim_a_mutation_or_bypass_login(self):
message='帮我把INB20260001收件地址改成新地址'
self.assertTrue(self.events(message)[0]['login_required'])
self.login()
events=self.events(message)
self.assertIn('尚未修改订单',events[0]['t'])
self.assertFalse(any('card' in e for e in events))
def test_logged_in_counts_accept_common_wording_and_refresh_after_data_changes(self): def test_logged_in_counts_accept_common_wording_and_refresh_after_data_changes(self):
self.login() self.login()
cid = self.a.post('/api/new_chat').json['id'] cid = self.a.post('/api/new_chat').json['id']
+3 -2
View File
@@ -100,10 +100,11 @@ class OrderDataTests(unittest.TestCase):
self.assertEqual(events[-1], {'d':1}) self.assertEqual(events[-1], {'d':1})
model.assert_not_called() model.assert_not_called()
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self): def test_bulk_query_lists_only_existing_orders_without_model(self):
with patch('logistics_chat.ai_client.post_completion') as model: with patch('logistics_chat.ai_client.post_completion') as model:
events = self.events('列出所有订单') events = self.events('列出所有订单')
self.assertIn('请提供入仓码', events[0]['t']) self.assertIn(self.record['inbound_code'], events[0]['t'])
self.assertIn(self.record['order_no'], events[0]['t'])
model.assert_not_called() model.assert_not_called()
def test_server_tenant_scope_and_ambiguous_identifiers(self): def test_server_tenant_scope_and_ambiguous_identifiers(self):
+133
View File
@@ -0,0 +1,133 @@
"""Customer-language regressions use synthetic amounts, never private tariffs."""
from decimal import Decimal
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from logistics_chat import create_app, db
from logistics_chat.quote_data import import_snapshot
from logistics_chat.quote_engine import quantity, parse_intent
from test_quotes import fixture_snapshot, frames
class QuoteBusinessTests(unittest.TestCase):
def setUp(self):
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
root=Path(folder.name)
self.app=create_app({'TESTING':True,'DATABASE':root/'test.db','UPLOAD_DIR':root/'uploads',
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'','RATE_LIMIT_PER_MIN':1000})
self.client=self.app.test_client()
self.client.post('/api/visitor/session',json={'key':'1'*64})
book=fixture_snapshot()
documents=[
'铁铝制品另外加附加费¥5/KG',
'正常货物 A.0KG<单件重量<22KG B.0CM<最长单边长度<120CM C.0CM<第二边<75CM D.最长边<260CM',
'单件不能超22KG 超重收+70/件',
'普通超尺寸加收90RMB/件',
'大尺寸货物,任意2个条件,加收400RMB/件:22KG<单件重量<68KG;0<最长边<274CM;330CM<周长+单边长<419CM',
'超大件货物,加收USD900/件',
'保险费:保额的百分之0.25,min 13USD,投保金额是货值乘以1.2。',
'海外进口清关,超过5个品名,续页加收USD7元RMB/个',
'一般贸易出口报关费:RMB60/票',
'偏远地址加收4元/kg;超偏远地址加收6元/kg',
'禁止运输的货物:纯电池、液体、食品',
]
book['documents']=[{'sheet':'海派','cell':f'A{i+1}','text':text} for i,text in enumerate(documents)]
book['documents'].append({'sheet':'美森加班海卡','cell':'A1','text':'不可接一般贸易'})
with self.app.app_context():
c=db.connect();import_snapshot(c,book,activate=True);c.commit();c.close()
def ask(self,text,cid=None):
with patch('logistics_chat.ai_client.post_completion') as model:
events=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
model.assert_not_called()
self.assertEqual(events[-1],{'d':1},events)
return events[0]['t']
def test_different_cartons_use_individual_minimums_and_dimensions(self):
text=self.ask('美国美西,1箱10KG,1箱20KG,义乌普船海派')
self.assertIn('32KG = 64元',text)
text=self.ask('美国美西,2箱,1箱10KG,60x50x40cm,1箱20KG,40x30x20cm,义乌普船海派')
self.assertIn('40KG = 80元',text)
text=self.ask('美国美西,2箱,1箱10KG,60x50x40cm,1箱20KG,义乌普船海派')
self.assertIn('40KG = 80元',text)
self.assertIn('箱数与分箱明细不一致',self.ask('美国3箱,1箱10KG,1箱20KG海派'))
def test_weight_units_chinese_followup_and_invalid_postal(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国美西30KG,义乌海派',cid)
for text in ('五十公斤呢','50000克呢','0.05吨呢'):
self.assertIn('美国 50KG',self.ask(text,cid))
self.assertIn('大于 0',self.ask('美国重量为-30KG海派'))
self.assertIn('邮编格式不正确',self.ask('美国30KG,邮编518000海派'))
self.assertIn('美国 30KG',self.ask('美国30000克海派'))
self.assertIn('未找到目的仓码',self.ask('美国30KG发ABC999海派'))
def test_commodity_survives_weight_followups_and_new_commodity_replaces_it(self):
cid=self.client.post('/api/new_chat').json['id']
text=self.ask('美国美西铁制品30KG,义乌普船海派',cid)
self.assertIn('铁铝附加费5元/KG',text)
self.assertIn('30KG = 210元',text)
self.assertIn('50KG = 350元',self.ask('50KG呢',cid))
self.assertNotIn('铁铝附加费',self.ask('换成普通货50KG呢',cid))
def test_limit_fees_take_maximum_per_carton_and_do_not_guess_bad_threshold(self):
text=self.ask('美国美西1箱30KG,130x50x20cm,义乌普船海派')
self.assertIn('最高项90元/件',text)
self.assertNotIn('160元',text)
text=self.ask('美国美西1箱30KG,180x50x30cm,义乌普船海派')
self.assertIn('最高项400元/件',text)
text=self.ask('美国美西1箱30KG,40x30x20cm,义乌普船海派')
self.assertIn('最高项70元/件',text)
self.assertNotIn('400元/件',text)
self.assertIn('人工确认是否接收',self.ask('美国美西1箱70KG,40x30x20cm,义乌普船海派'))
def test_cbm_uses_two_decimals_half_up_without_creating_free_shipments(self):
rate={'unit':'cbm','kind':'truck','service':'洛杉矶普船海卡'}
intent=parse_intent('美国1KG,1.005CBM',set())
self.assertEqual(quantity(intent,rate),Decimal('1.01'))
intent['cbm']='1.004';self.assertEqual(quantity(intent,rate),Decimal('1.00'))
intent['weight']='0.1';intent['cbm']='0.004'
self.assertIsNone(quantity(intent,rate))
def test_business_topics_use_current_documents_instead_of_generic_quotes(self):
insurance=self.ask('美国保价1万美元,保险费怎么算')
self.assertIn('0.25%',insurance);self.assertIn('=25USD',insurance)
self.assertNotIn('元/KG',insurance)
self.assertIn('大于0',self.ask('美国保价-10美元,保险费怎么算'))
text=self.ask('美国30KG,7个品名清关续页收费')
self.assertIn('7美元/个',text);self.assertIn('=14美元',text)
text=self.ask('美国30KG还需要什么费用')
self.assertIn('偏远地址加4元/KG',text);self.assertIn('超偏远地址加6元/KG',text)
self.assertNotIn('250',text)
self.assertIn('不可接一般贸易',self.ask('美国美森加班海卡一般贸易报关多少钱'))
self.assertIn('不接收',self.ask('美国30KG纯电池可以发吗'))
self.assertIn('不接收',self.ask('美国30KG食品,FDA申报费多少人民币'))
def test_destination_origin_comparison_time_and_english(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国30KG发ZZZ1,义乌海派',cid)
text=self.ask('深圳交仓呢',cid)
self.assertIn('美国 30KG',text);self.assertNotIn('· 义乌',text)
text=self.ask('海运和空运哪个好,美国30KG')
self.assertIn('没有美国空运费率',text)
text=self.ask('美国美西海派多久')
self.assertIn('提取不等于送达',text);self.assertNotIn('元/KG',text)
text=self.ask('Please quote 30kg to US by sea')
self.assertIn('CNY/kg',text)
self.assertFalse(__import__('re').search(r'[\u4e00-\u9fff]',text),text)
def test_human_and_photo_questions_never_use_old_regional_model_facts(self):
self.assertIn('联系人工',self.ask('帮我转人工客服'))
text=self.ask('我可以发张货物照片让你判断报价吗')
self.assertIn('不会自动识别',text);self.assertNotIn('21KG',text)
def test_short_fee_synonyms_keep_current_us_rules(self):
cid=self.client.post('/api/new_chat').json['id']
self.ask('美国美西30KG,义乌普船海派',cid)
for question in ('还加什么费','还有其他费用吗','这个还要加钱吗','有其他额外收费吗'):
text=self.ask(question,cid)
self.assertIn('偏远地址加4元/KG',text)
self.assertNotIn('250',text)