Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ec6ed44b | ||
|
|
f777867749 | ||
|
|
9c9c2c9cd5 | ||
|
|
79fb675af5 |
@@ -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
@@ -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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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()]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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')))
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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':'请说明您想确认的美国渠道、目的仓码或邮编,以及具体的费用或发货要求;未明确的服务条件需要联系人工核对。'}
|
||||||
@@ -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':'报价数据暂时无法读取,请稍后重试。'}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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,[])
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user