Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c9c2c9cd5 | ||
|
|
79fb675af5 |
@@ -13,7 +13,7 @@ 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -131,12 +131,16 @@ 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`。
|
||||
|
||||
已确认口径:同件超重/超尺寸只取最高适用档,其他附加费分别计算;洛杉矶普船海卡体积保留两位、四舍五入;海外进口清关超过5个品名的续页按美元/个。原表大尺寸长度条件有误,不猜测下限,只有其他两条明确条件同时满足时自动应用该档,其余疑似大件转人工确认。报价不把提取时效称为签收时效。金额仍读取激活版本,不把私有费率写入源码。
|
||||
|
||||
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
|
||||
|
||||
先按 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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||
|
||||
@@ -93,6 +93,10 @@ def create_chat_stream(msg, conv_id):
|
||||
order_answer = answer_query(msg, conv_id)
|
||||
if order_answer is None:
|
||||
order_answer = answer_quote(msg, conv_id)
|
||||
if order_answer is None and re.search(r'(?:转|联系|接入|找).*人工|人工客服',msg):
|
||||
order_answer = {'t':'点击“联系人工”可发起人工客服申请;非工作时间可留言。'}
|
||||
if order_answer is None and re.search(r'照片|图片',msg) and re.search(r'报价|价格|判断|识别',msg):
|
||||
order_answer = {'t':'可以通过输入框旁的“+”上传照片供人工查看。当前聊天不会自动识别图片,请同时用文字提供品名、重量、箱数、尺寸和目的仓码或邮编,再核算报价。'}
|
||||
if order_answer is None and re.fullmatch(r'\[图片\]/uploads/\S+', msg):
|
||||
order_answer = {'t':'收到您的图片,请描述一下您的问题。'}
|
||||
ship = shipping_intent_with_context(msg, conv_id) if order_answer is None else None
|
||||
|
||||
@@ -219,7 +219,46 @@ def answer_count_query(intent, tenants):
|
||||
return {'t':text + f",其中 {counts['with_inbound']} 票已登记入仓码。", '_customer_data':True}
|
||||
|
||||
|
||||
def answer_list_query(message, tenants):
|
||||
"""Reuse bounded status parsing; list reads require an explicit customer scope."""
|
||||
if is_lookup_help(message) or not re.search(r'订单|运单|\borders?\b|\bshipments?\b',message,re.I):
|
||||
return None
|
||||
if not re.search(r'列出|列表|全部|所有|\blist\b',message,re.I):
|
||||
return None
|
||||
if not tenants:
|
||||
return order_login_response()
|
||||
question=re.sub(r'列出|列表|全部|所有|\blist\b','多少',message,flags=re.I)
|
||||
intent=parse_count_query(question)
|
||||
if intent is None or intent['unsupported']:
|
||||
return {'t':'暂不支持按这个条件列出订单。可查看全部订单或按履约中、已完成等业务状态查询。'}
|
||||
scope=tenant_filter(tenant_ids=tenants)
|
||||
if scope is None:
|
||||
return order_login_response()
|
||||
from .db import connect
|
||||
connection=None
|
||||
try:
|
||||
connection=connect()
|
||||
sql='SELECT order_no,inbound_code,status,warehouse_code FROM order_test_data WHERE 1=1'+scope[0]
|
||||
values=scope[1]
|
||||
if intent['status']:
|
||||
sql+=' AND status=?';values=values+[intent['status']]
|
||||
rows=connection.execute(sql+' ORDER BY id DESC LIMIT 21',values).fetchall()
|
||||
if not rows:return {'t':'当前账号下没有符合条件的订单。','_customer_data':True}
|
||||
lines=['您当前的订单列表'+('(先列出20票)' if len(rows)>20 else '')+':']
|
||||
for number,inbound,status,warehouse in rows[:20]:
|
||||
lines.append(f'- 入仓码:{inbound or "未登记"};订单号:{number};业务状态:{STATUS_LABELS.get(status,status)};目的仓:{warehouse or "未登记"}')
|
||||
lines.append('发送入仓码可查询该票订单和物流轨迹。')
|
||||
return {'t':'\n'.join(lines),'_customer_data':True}
|
||||
except sqlite3.Error:
|
||||
return {'t':'订单信息暂时无法读取,请稍后重试。'}
|
||||
finally:
|
||||
if connection is not None:connection.close()
|
||||
|
||||
|
||||
def extract_reference(message):
|
||||
# Explicit postal/monetary fields are not order identifiers, even if invalid.
|
||||
message = re.sub(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*[0-9-]+', ' ', message, flags=re.I)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d+(?:\.\d+)?\s*(?:万)?(?:USD|RMB|CNY|美元|美金|人民币|元)(?![A-Za-z0-9_-])',' ',message,flags=re.I)
|
||||
message = re.sub(r'\[(?:图片|文件)\]/uploads/\S+|https?://\S+', '', message)
|
||||
message = re.sub(r'(?<![A-Za-z0-9_-])\d{4}-\d{1,2}-\d{1,2}'
|
||||
r'(?:[T ]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?'
|
||||
@@ -227,10 +266,14 @@ def extract_reference(message):
|
||||
message = re.sub(r'(?<![A-Za-z0-9_.-])\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?'
|
||||
r'(?:\s*(?:cm|mm|m|厘米|毫米|米))?(?![A-Za-z0-9_.-])', ' ', message, flags=re.I)
|
||||
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)
|
||||
references = []
|
||||
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:
|
||||
continue
|
||||
if re.fullmatch(r'\d{4}[-_]\d{2}[-_]\d{2}|\d+(?:KG|LBS?|CBM|CM|MM)', token, re.I):
|
||||
@@ -335,6 +378,10 @@ def answer_query(message, conv_id):
|
||||
count_intent = parse_count_query(message) if not references else None
|
||||
if count_intent is not None:
|
||||
return answer_count_query(count_intent,tenants)
|
||||
if not references:
|
||||
listed=answer_list_query(message,tenants)
|
||||
if listed is not None:return listed
|
||||
mutation=bool(re.search(r'改址|修改.*(?:收件|地址|订单)|收件地址.*改|(?:取消|删除).*(?:订单|运单)|(?:订单|运单).*取消',message))
|
||||
if references and not tenants:
|
||||
return order_login_response()
|
||||
if len(references) > 1:
|
||||
@@ -364,6 +411,8 @@ def answer_query(message, conv_id):
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
if not reference:
|
||||
if mutation:
|
||||
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
|
||||
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
@@ -381,6 +430,8 @@ def answer_query(message, conv_id):
|
||||
return {'t':f'未找到“{reference}”对应的订单。请核对入仓码,或联系人工客服。'}
|
||||
if len(matches) > 1:
|
||||
return {'t':'该标识匹配到多个订单,请提供完整入仓码或联系人工客服确认。'}
|
||||
if mutation:
|
||||
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。'}
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
|
||||
return {'card': build_card(matches[0])}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""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
|
||||
|
||||
text=intent['text'];mode=intent.get('mode')
|
||||
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=[]
|
||||
metal=material_fee(book)
|
||||
if metal is not None and re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text):
|
||||
lines.append(f'当前资料铁铝制品附加费为{metal:g}元/KG。')
|
||||
if intent.get('weight'):lines.append(f'先按已提供重量{number(intent["weight"])}KG计算该项:{number(intent["weight"])}×{metal:g}={number(Decimal(intent["weight"])*metal)}元;计费重确定后继续核算。')
|
||||
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
|
||||
@@ -3,64 +3,10 @@
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
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'美国|美西|美中|美东|洛杉矶|纽约|芝加哥|休斯顿|萨凡纳|'
|
||||
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))
|
||||
from .quote_intent import US, QUOTE_WORDS, parse_intent, is_followup
|
||||
|
||||
|
||||
def broad_zone(postal):
|
||||
@@ -74,6 +20,12 @@ def broad_zone(postal):
|
||||
|
||||
|
||||
def variants(intent, 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']
|
||||
found = [r for r in book['warehouses'] if r['code'] == warehouse] if warehouse else []
|
||||
zoned = [r for r in found if r['zone']]
|
||||
@@ -101,13 +53,29 @@ def quantity(intent, rate):
|
||||
if volume is None and intent['dims']:
|
||||
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)
|
||||
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 volume is 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'
|
||||
else (volume or Decimal(0))*167)
|
||||
charge = max(real, volume_weight)
|
||||
charge = max(real, volume_weight,package_charge)
|
||||
if rate['kind'] == 'parcel':
|
||||
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
|
||||
return charge if real or volume else None
|
||||
@@ -145,13 +113,39 @@ def number(value, places=2):
|
||||
|
||||
|
||||
def render_rates(intent, book, destinations, remote_fees):
|
||||
from .quote_fees import charge_details
|
||||
from .quote_language import translate_quote
|
||||
|
||||
lines = []
|
||||
if intent['weight']:
|
||||
if intent.get('mode')=='time':
|
||||
lines.append('美国线路参考时效:')
|
||||
elif intent['weight']:
|
||||
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
|
||||
else:
|
||||
lines.append('美国线路报价:')
|
||||
if len(intent.get('warehouses') or [])>1:
|
||||
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
|
||||
if intent.get('air_requested') and not intent.get('air_only'):
|
||||
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
|
||||
for destination, fees in zip(destinations, remote_fees):
|
||||
rates = match_rates(intent, book, destination)
|
||||
if intent.get('mode') in ('cheapest','fastest') and rates:
|
||||
if intent['mode']=='cheapest':
|
||||
eligible=[r for r in rates if quantity(intent,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(intent,rate)*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(intent,r)*Decimal(r['price'])+charge_details(intent,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
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:
|
||||
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||||
continue
|
||||
@@ -162,6 +156,11 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
|
||||
grouped.setdefault(key, []).append(rate)
|
||||
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 = []
|
||||
for rate in rows:
|
||||
amount = quantity(intent,rate)
|
||||
@@ -170,11 +169,18 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
|
||||
if amount is not None:
|
||||
total = amount*price
|
||||
if fees and fees['fee'] is not None and rate['kind'] == 'parcel':
|
||||
total += amount*Decimal(fees['fee'])
|
||||
details=charge_details(intent,rate,fees,book)
|
||||
total+=details['cny']
|
||||
rate_text += f",{number(amount,3)}{'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)
|
||||
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)
|
||||
+ (f'({eta})' if eta else ''))
|
||||
if fees:
|
||||
@@ -184,8 +190,13 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
lines.append(f"海派已计入偏远附加费 {number(fees['fee'])}元/KG。")
|
||||
if not any(intent.get(key) for key in ('warehouse','postal')):
|
||||
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('货物多少公斤、几箱?')
|
||||
elif not intent['boxes'] or not intent['dims']:
|
||||
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,96 @@
|
||||
"""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 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('遥远地址的附加费需单独确认,当前金额未包含该项。')
|
||||
if rate['unit']=='kg' and intent.get('material')=='metal':
|
||||
per_kg=material_fee(book)
|
||||
if per_kg is not None:
|
||||
fee=charged*per_kg;result['cny']+=fee
|
||||
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元')
|
||||
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,151 @@
|
||||
"""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'
|
||||
if re.search(r'纯电池|带电|内置电池|液体|粉末|食品|医疗用品|仿牌',text):return 'acceptance'
|
||||
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||
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)
|
||||
parsed=parse_shipping_intent(text)
|
||||
country='US' if US.search(text) else ('other' if shipping_region(text) is not None else None)
|
||||
candidates=re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])',text.upper())
|
||||
warehouses=list(dict.fromkeys(t for t in candidates if t in warehouse_codes))
|
||||
unknown=[t for t in candidates if t not in warehouse_codes and re.fullmatch(r'[A-Z]{2,4}[0-9][A-Z0-9]{0,2}',t)
|
||||
and not t.startswith(('USD','RMB','CNY'))]
|
||||
errors=[]
|
||||
weights=[m for m in WEIGHT.finditer(text) if not text[:m.start()].rstrip().endswith(('x','X','×','*'))]
|
||||
weight=str(kg(*weights[0].groups())) if weights else None
|
||||
boxes=re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)',text,re.I)
|
||||
count=int(boxes.group(1)) if boxes else None
|
||||
packages=[]
|
||||
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
|
||||
matches=list(package_re.finditer(text))
|
||||
for index,match in enumerate(matches):
|
||||
ordinal,n,value,unit=match.groups()
|
||||
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
|
||||
dims=parse_shipping_intent(segment)['dims']
|
||||
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
|
||||
'dims':dims,'dim_unit':detect_dim_unit(segment)})
|
||||
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),text,re.I)
|
||||
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.])',''),text,re.I)
|
||||
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
|
||||
# Per-carton dimensions must not spill onto another carton without dimensions.
|
||||
if len(packages)>1:parsed['dims']=None
|
||||
elif len(weights)>1:
|
||||
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,'origin':origin,
|
||||
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
|
||||
'air_only':True if air and not sea else None,'air_requested':True if air else None,
|
||||
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
|
||||
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
|
||||
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
|
||||
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||||
'material':('ordinary' if re.search(r'普通货|普货|塑料|木制|非铁铝',text) else
|
||||
('metal' if re.search(r'铁制|钢制|铝制|铁铝|不锈钢',text) else None)),
|
||||
'address_type':('non_fba' if re.search(r'私人|住宅|商业地址|非亚马逊',text) else ('fba' if warehouses else None)),
|
||||
'text':text}
|
||||
|
||||
|
||||
def is_followup(message,intent):
|
||||
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')))
|
||||
@@ -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
|
||||
@@ -10,49 +10,45 @@ from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants,
|
||||
|
||||
|
||||
def intent_with_context(message, conv_id, book):
|
||||
from copy import deepcopy
|
||||
codes = {row['code'] for row in book['warehouses']}
|
||||
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
|
||||
intent = parse_intent(message,codes)
|
||||
replaced_destination = ({'postal','zone'} if intent['warehouse'] else
|
||||
({'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')
|
||||
def destination_reset(value):
|
||||
if value.get('warehouse') or value.get('unknown_warehouses'):return {'postal','zone','zones'}
|
||||
if value.get('postal'):return {'warehouse','warehouses','unknown_warehouses','zone','zones'}
|
||||
if value.get('zone'):return {'warehouse','warehouses','unknown_warehouses','postal'}
|
||||
return set()
|
||||
excluded=destination_reset(intent)
|
||||
selected_destination=bool(excluded)
|
||||
if selected_destination and not intent.get('unknown_warehouses'):excluded.add('unknown_warehouses')
|
||||
if intent['transport'] or intent['kinds'] or intent['services']:excluded.update(('air_only','air_requested'))
|
||||
if intent['air_only'] or intent['tax']=='excluded':excluded.add('kinds')
|
||||
if intent.get('weight') is not None and not intent.get('packages'):excluded.add('packages')
|
||||
explicit_dims=bool(intent['dims'])
|
||||
if conv_id and is_followup(message,intent):
|
||||
connection = connect()
|
||||
connection=connect()
|
||||
try:
|
||||
recent = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 8", (conv_id,)).fetchall()
|
||||
finally:
|
||||
connection.close()
|
||||
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
|
||||
finally:connection.close()
|
||||
for (previous,) in recent:
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', previous):
|
||||
break
|
||||
earlier = parse_intent(previous,codes)
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
|
||||
earlier=parse_intent(previous,codes)
|
||||
if earlier['country']=='other':break
|
||||
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
|
||||
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
|
||||
break
|
||||
if earlier['country'] == 'other':
|
||||
break
|
||||
if not destination_selected:
|
||||
if earlier['warehouse']:
|
||||
replaced_destination.update(('postal','zone'))
|
||||
destination_selected = True
|
||||
elif earlier['postal']:
|
||||
replaced_destination.update(('warehouse','zone'))
|
||||
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'
|
||||
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
|
||||
if not selected_destination and destination_reset(earlier):
|
||||
excluded.update(destination_reset(earlier));selected_destination=True
|
||||
if intent['dims'] is None and earlier['dims']:
|
||||
intent['dims']=earlier['dims'];intent['dim_unit']=earlier['dim_unit']
|
||||
for key,value in earlier.items():
|
||||
if key in ('mode','text','errors','dim_unit','dims'):continue
|
||||
if intent.get(key) is None and key not in excluded:intent[key]=deepcopy(value)
|
||||
if not is_followup(previous,earlier):break
|
||||
if explicit_dims and intent.get('packages'):
|
||||
for piece in intent['packages']:
|
||||
piece['dims']=intent['dims'];piece['dim_unit']=intent['dim_unit']
|
||||
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:intent['country']='US'
|
||||
return intent
|
||||
|
||||
|
||||
@@ -70,6 +66,10 @@ def answer_quote(message, conv_id):
|
||||
intent = intent_with_context(message, conv_id, book)
|
||||
if intent['country'] != 'US':
|
||||
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
|
||||
any(intent.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):
|
||||
return None
|
||||
@@ -78,8 +78,13 @@ def answer_quote(message, conv_id):
|
||||
return {'t':'请提供大于 0 的有效重量或体积。'}
|
||||
if intent['boxes'] is not None and not 0 < intent['boxes'] <= 10000:
|
||||
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 的有效长宽高。'}
|
||||
from .quote_answers import answer_business
|
||||
business=answer_business(intent,book)
|
||||
if business is not None:return {'t':business}
|
||||
if intent['air_only']:
|
||||
return {'t':'当前美国报价资料包含美森、EXX、合德及普船的海卡、海铁和海派。发我目的仓码或邮编、重量,可以列出这些线路的价格;空运费率当前资料没有列出。'}
|
||||
destinations = variants(intent,book)
|
||||
|
||||
+12
-2
@@ -1227,13 +1227,14 @@ window.showHuman = async function(){
|
||||
if(humanRequestToken !== token || token.version !== conversationVersion) return;
|
||||
const data = await humanRequest('/api/visitor/human/request', {conv_id:cid});
|
||||
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');
|
||||
window.humanSid = data.session_id;
|
||||
humanConvId = cid;
|
||||
humanStatus = 'waiting';
|
||||
addMsg(t('humanWaiting'), false);
|
||||
startHumanPoll();
|
||||
return true;
|
||||
}catch(error){
|
||||
if(humanRequestToken === token && token.version === conversationVersion) setComposerStatus(t('humanConnectFailed'), true);
|
||||
}finally{
|
||||
@@ -1298,7 +1299,7 @@ async function leaveOfflineMessage(){
|
||||
try{
|
||||
const data = await humanRequest('/api/ticket', {content:message, phone:''});
|
||||
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){
|
||||
if(version !== conversationVersion) return;
|
||||
if(!inputDom.value){ inputDom.value = message; autoGrow(); }
|
||||
@@ -1653,6 +1654,15 @@ async function doSend(retryText, showQuestion){
|
||||
if(window.humanSid) stopHumanMode();
|
||||
|
||||
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; }
|
||||
|
||||
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});
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -46,6 +46,27 @@ class OrderCountTests(unittest.TestCase):
|
||||
self.assertTrue(self.events(question)[0]['login_required'])
|
||||
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):
|
||||
self.login()
|
||||
cid = self.a.post('/api/new_chat').json['id']
|
||||
|
||||
@@ -100,10 +100,11 @@ class OrderDataTests(unittest.TestCase):
|
||||
self.assertEqual(events[-1], {'d':1})
|
||||
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:
|
||||
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()
|
||||
|
||||
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