From 79fb675af577f88f4d3886e1090439c5cbe52eff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 5 Nov 2026 13:06:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=8A=A5=E4=BB=B7=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=97=AE=E7=AD=94=E4=B8=8E=E8=BF=9E=E7=BB=AD=E8=BF=BD?= =?UTF-8?q?=E9=97=AE=EF=BC=8C=E8=A1=A5=E9=BD=90=E8=AE=A2=E5=8D=95=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=92=8C=E4=BA=BA=E5=B7=A5=E8=BD=AC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- docs/architecture.md | 6 +- logistics_chat/chat_service.py | 4 + logistics_chat/order_data.py | 55 ++++++++++- logistics_chat/quote_answers.py | 98 ++++++++++++++++++++ logistics_chat/quote_engine.py | 137 +++++++++++++++------------- logistics_chat/quote_fees.py | 96 ++++++++++++++++++++ logistics_chat/quote_intent.py | 151 +++++++++++++++++++++++++++++++ logistics_chat/quote_language.py | 33 +++++++ logistics_chat/quote_service.py | 79 ++++++++-------- static/js/chat.js | 14 ++- tests/frontend/chat.test.cjs | 35 +++++++ tests/test_order_counts.py | 21 +++++ tests/test_order_data.py | 5 +- tests/test_quote_business.py | 125 +++++++++++++++++++++++++ 15 files changed, 753 insertions(+), 108 deletions(-) create mode 100644 logistics_chat/quote_answers.py create mode 100644 logistics_chat/quote_fees.py create mode 100644 logistics_chat/quote_intent.py create mode 100644 logistics_chat/quote_language.py create mode 100644 tests/test_quote_business.py diff --git a/AGENTS.md b/AGENTS.md index e90cd3f..4753b64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/docs/architecture.md b/docs/architecture.md index 54d7f3c..ccf84c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。 diff --git a/logistics_chat/chat_service.py b/logistics_chat/chat_service.py index 40be67d..51951e3 100644 --- a/logistics_chat/chat_service.py +++ b/logistics_chat/chat_service.py @@ -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 diff --git a/logistics_chat/order_data.py b/logistics_chat/order_data.py index e46f917..c9f78d3 100644 --- a/logistics_chat/order_data.py +++ b/logistics_chat/order_data.py @@ -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'(? 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])} diff --git a/logistics_chat/quote_answers.py b/logistics_chat/quote_answers.py new file mode 100644 index 0000000..87ea193 --- /dev/null +++ b/logistics_chat/quote_answers.py @@ -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 01: + 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 diff --git a/logistics_chat/quote_fees.py b/logistics_chat/quote_fees.py new file mode 100644 index 0000000..651fef0 --- /dev/null +++ b/logistics_chat/quote_fees.py @@ -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 068 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=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 diff --git a/logistics_chat/quote_intent.py b/logistics_chat/quote_intent.py new file mode 100644 index 0000000..b2d5719 --- /dev/null +++ b/logistics_chat/quote_intent.py @@ -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'(?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'))) diff --git a/logistics_chat/quote_language.py b/logistics_chat/quote_language.py new file mode 100644 index 0000000..1d52af1 --- /dev/null +++ b/logistics_chat/quote_language.py @@ -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 diff --git a/logistics_chat/quote_service.py b/logistics_chat/quote_service.py index adda426..5e26eeb 100644 --- a/logistics_chat/quote_service.py +++ b/logistics_chat/quote_service.py @@ -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) diff --git a/static/js/chat.js b/static/js/chat.js index 3f42b3d..6353d91 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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; diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 1cba531..64339b2 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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); +}); diff --git a/tests/test_order_counts.py b/tests/test_order_counts.py index 1c5ac69..3fbe3f7 100644 --- a/tests/test_order_counts.py +++ b/tests/test_order_counts.py @@ -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'] diff --git a/tests/test_order_data.py b/tests/test_order_data.py index 5482fad..b1005dd 100644 --- a/tests/test_order_data.py +++ b/tests/test_order_data.py @@ -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): diff --git a/tests/test_quote_business.py b/tests/test_quote_business.py new file mode 100644 index 0000000..fd44f3d --- /dev/null +++ b/tests/test_quote_business.py @@ -0,0 +1,125 @@ +"""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)