优化分仓报价和发货咨询,完善订单状态解释
This commit is contained in:
@@ -15,6 +15,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees |
|
||||
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
|
||||
| Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries |
|
||||
| Tracking | `logistics_chat/tracking.py`: `extract_tracking_no`, `build_track`, `CARD_PREFIX` |
|
||||
| Order test data | `logistics_chat/order_data.py`: `lookup_order`, `count_orders`, `answer_query`, `import_records`; `scripts/import_order_test_data.py` |
|
||||
| Persistence | `logistics_chat/db.py`: `init_db`, `save_message`; `logistics_chat/history_service.py` |
|
||||
|
||||
@@ -141,6 +141,10 @@ tests/ # 按业务模块组织的回归
|
||||
|
||||
订单列表使用与逐单查询相同的服务端租户交集,每次最多展示20票并保护其会话历史;未知筛选条件不默认为全部订单。改址/取消尚无业务写入接口,只明确引导人工,不声称修改成功。输入明确的转人工指令复用现有申请接口,失败保留草稿,不增加SSE事件类型。
|
||||
|
||||
`quote_allocations.py` 只识别明确分配给各仓的重量、箱数及箱规,逐仓计费及计算每箱最低重量;仅给总重和多个仓码时保留比较语义,不擅自分配重量。`quote_context.py` 在当前会话的有效上下文内按时间顺序应用更正,保留已算出的总重:重量纠正会清除旧分箱明细,单改箱数时统一箱重可沿用,混合箱重需重新确认;“总重不变”优先于旧每箱重量,新尺寸不沿用旧体积。按方报价不把按公斤的材质费直接乘CBM,金额未确定时明确单列待核算项。
|
||||
|
||||
`shipping_faq.py` 从激活版本回答交仓、标签、报关资料及查验费用问题;具体船期和计划发货日期需人工核对,不使用旧表日期承诺必达。`order_explanations.py` 只解释授权订单的业务状态和实际节点,不把预计节点当实际位置,不推断逐箱签收或亚马逊上架。文字事实回复与订单卡片同样绑定客户及范围;催单或拦截不声称已提交。本轮不扩展售后理赔规则。
|
||||
|
||||
先按 README 安装项目依赖。Windows 使用 `.\.venv\Scripts\python.exe -m pytest -v` 或同解释器的 `-m unittest discover -s tests -v`;Linux/macOS 使用 `.venv/bin/python`。业务测试依赖 Flask,不能用未安装依赖的全局 Python 运行整套测试。模型请求全部模拟,不代表真实 DeepSeek 联调。
|
||||
|
||||
路径、函数或资产移动后同步更新根 `AGENTS.md`、本文、知识资料和 README 目录树。仅改导航时检查路径、符号、命令;改业务时补充相关成功和失败分支,前端改动检查实际页面。
|
||||
|
||||
@@ -351,6 +351,9 @@ def is_tracking_request(message):
|
||||
|
||||
def is_order_followup(message):
|
||||
"""Reuse an order only for an explicit reference or a short detail question."""
|
||||
from .order_explanations import detail_question
|
||||
if detail_question(message) and not re.search(r'报价|价格|多少钱|公斤|\bKG\b|海派|海卡',message,re.I):
|
||||
return True
|
||||
if is_lookup_help(message):
|
||||
return False
|
||||
if is_tracking_request(message):
|
||||
@@ -375,6 +378,10 @@ def is_order_followup(message):
|
||||
def answer_query(message, conv_id):
|
||||
references = extract_reference(message)
|
||||
tenants = customer_tenant_ids()
|
||||
from .order_explanations import general_explanation, detail_question, explain_order
|
||||
if not references:
|
||||
explanation=general_explanation(message)
|
||||
if explanation:return {'t':explanation}
|
||||
count_intent = parse_count_query(message) if not references else None
|
||||
if count_intent is not None:
|
||||
return answer_count_query(count_intent,tenants)
|
||||
@@ -413,6 +420,8 @@ def answer_query(message, conv_id):
|
||||
if not reference:
|
||||
if mutation:
|
||||
return order_login_response() if not tenants else {'t':'当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。请提供入仓码。'}
|
||||
if detail_question(message) and not is_lookup_help(message):
|
||||
return order_login_response() if not tenants else {'t':'请提供入仓码,我先核对该票订单的实际物流记录。'}
|
||||
if is_tracking_request(message) or (not is_lookup_help(message) and re.search(r'(?:查|搜|看|找).*(?:订单|运单|物流轨迹|入仓码|入仓号)|(?:入仓码|入仓号).*(?:查|轨迹|状态)|订单状态|物流轨迹|shipment\s*id|'
|
||||
r'(?:列出|搜索|所有|全部|多少|我的).*(?:订单|运单|入仓)|(?:订单|运单).*(?:多少|列表|查询|在哪|到哪|进度)|'
|
||||
r'^\s*(?:查单|查询|查一下|帮我查一下)[。?!?!\s]*$|'
|
||||
@@ -434,4 +443,7 @@ def answer_query(message, conv_id):
|
||||
return {'t':f'已查到订单“{reference}”。当前支持查询订单与物流轨迹;改址、取消等操作请联系人工客服办理,尚未修改订单。','_customer_data':True}
|
||||
if re.search(r'运费|费用|多少钱|价格', message):
|
||||
return {'t':'该订单未记录实际运费,请联系人工客服核实。','_customer_data':True}
|
||||
return {'card': build_card(matches[0])}
|
||||
card=build_card(matches[0])
|
||||
explanation=explain_order(message,matches[0],card)
|
||||
if explanation:return {'t':explanation,'_customer_data':True}
|
||||
return {'card':card}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Explain recorded order facts without inferring delivery or operational writes."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def detail_question(message):
|
||||
if re.search(r'报价|运费|海派|海卡|\d+\s*(?:KG|公斤)',message,re.I) and not re.search(r'这票|该订单|我的订单',message):
|
||||
return False
|
||||
return bool(re.search(r'上架|没更新|未更新|没有更新|不更新|少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收|'
|
||||
r'已完成.*(?:签收|送达)|(?:签收|送达).*已完成|催单|催一下|拦截|'
|
||||
r'(?:这票|该订单|我的货).*(?:发货|发走|出库|开船)',message))
|
||||
|
||||
|
||||
def general_explanation(message):
|
||||
if re.search(r'我的|这票|这单|这个订单|该订单',message):return None
|
||||
if re.search(r'已完成|履约中|上架',message) and re.search(r'是什么意思|是否代表|是不是|等于|有什么区别',message):
|
||||
return '“履约中”“头程订单已完成”属于业务状态;出库、开船、到港等以实际物流节点为准。业务完成不等于收件人签收,签收也不等于亚马逊已上架;上架需以亚马逊入库记录核实。'
|
||||
return None
|
||||
|
||||
|
||||
def explain_order(message,row,card):
|
||||
if not detail_question(message):return None
|
||||
reference=row.get('inbound_code') or row['order_no']
|
||||
lines=[f'入仓码 {reference}。' if row.get('inbound_code') else f'订单号 {reference}。']
|
||||
if re.search(r'催单|催一下|拦截',message):
|
||||
return lines[0]+'催单或拦截需联系人工客服办理;当前仅查询订单,尚未提交催单或拦截申请。'
|
||||
lines.append(f'登记的业务状态为“{card["status"]}”。')
|
||||
actual=card.get('nodes',[]) if card.get('node_kind')=='logistics' else []
|
||||
if actual:
|
||||
latest=actual[0]
|
||||
lines.append('最近一条实际物流记录:'+' '.join(str(latest.get(k) or '') for k in ('time','text','place')).strip()+'。')
|
||||
else:lines.append('当前没有已记录的实际物流节点,不能根据业务状态推断货物位置。')
|
||||
if re.search(r'上架|已完成|签收|送达',message):
|
||||
lines.append('头程业务完成不等于收件人签收;当前节点不能用于确认亚马逊上架。上架结果需核对亚马逊入库记录。')
|
||||
if re.search(r'没更新|未更新|没有更新|不更新',message):
|
||||
lines.append('未更新期间的实际位置和原因无法从现有记录确定,可提供入仓码联系人工核查。')
|
||||
if re.search(r'发货|发走|出库|开船',message):
|
||||
lines.append('计划发货或开船安排需联系人工核对,不能根据业务状态承诺未来发货日期。')
|
||||
if re.search(r'少了?.*(?:箱|件)|到了?几箱|逐箱|每箱.*签收',message):
|
||||
if row.get('package_count') is not None:lines.append(f'登记总件数为{row["package_count"]}件。')
|
||||
lines.append('当前没有逐箱签收明细,不能把整票业务状态当作每箱均已送达,请联系人工核实各箱情况。')
|
||||
return '\n'.join(lines)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Explicit weight-to-warehouse assignments; unassigned comparisons stay separate."""
|
||||
|
||||
from decimal import Decimal
|
||||
import re
|
||||
|
||||
|
||||
def allocations(text,codes,weight_pattern,to_kg):
|
||||
if len(codes)<2:return None
|
||||
code='('+'|'.join(re.escape(code) for code in sorted(codes,key=len,reverse=True))+r')(?![A-Za-z0-9])'
|
||||
forward=weight_pattern.pattern+r'\s*(?:货物)?\s*(?:发到|发往|送到|发|到|去)?\s*'+code
|
||||
reverse=code+r'\s*(?:[::]|分配|发|重)?\s*'+weight_pattern.pattern
|
||||
entries=[];used=[]
|
||||
for pattern,backwards in ((forward,False),(reverse,True)):
|
||||
for match in re.finditer(pattern,text,re.I):
|
||||
if any(match.start()<end and match.end()>start for start,end in used):continue
|
||||
values=match.groups()
|
||||
destination,value,unit=values if backwards else (values[2],values[0],values[1])
|
||||
piece_weight=to_kg(value,unit)
|
||||
weight_start=match.start(2 if backwards else 1)
|
||||
carton=re.search(r'(第)?(\d+)\s*(?:箱|件)\s*(每(?:箱|件))?\s*$',text[:weight_start])
|
||||
boxes=(1 if carton.group(1) else int(carton.group(2))) if carton else None
|
||||
pieces=([{'count':boxes,'weight':str(piece_weight),'dims':None,'dim_unit':'cm','position':weight_start}]
|
||||
if carton and (boxes==1 or carton.group(3)) else None)
|
||||
weight=piece_weight*boxes if pieces else piece_weight
|
||||
entries.append({'warehouse':destination.upper(),'weight':weight,'boxes':boxes,'packages':pieces})
|
||||
used.append(match.span())
|
||||
if len(entries)<2:return None
|
||||
totals={}
|
||||
for entry in entries:
|
||||
code=entry['warehouse']
|
||||
if code not in totals:
|
||||
totals[code]=entry
|
||||
continue
|
||||
total=totals[code]
|
||||
total['weight']+=entry['weight']
|
||||
total['boxes']=total['boxes']+entry['boxes'] if total['boxes'] is not None and entry['boxes'] is not None else None
|
||||
total['packages']=(total['packages']+entry['packages']) if total['packages'] and entry['packages'] else None
|
||||
return [{**entry,'weight':str(entry['weight'])} for entry in totals.values()]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Apply quote edits in chronological order, including derived carton quantities."""
|
||||
|
||||
from copy import deepcopy
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
QUANTITY_QUESTION='箱数已更改,请确认新的总重或各箱重量,原分箱明细不能直接套用。'
|
||||
|
||||
|
||||
def merge_intent(current,previous):
|
||||
result=deepcopy(current)
|
||||
excluded={'mode','text','errors','dim_unit','quantity_note','quantity_uncertain','preserve_total'}
|
||||
if current.get('warehouse') or current.get('unknown_warehouses'):
|
||||
excluded.update(('postal','zone','zones'))
|
||||
elif current.get('postal'):
|
||||
excluded.update(('warehouse','warehouses','unknown_warehouses','zone','zones'))
|
||||
elif current.get('zone'):
|
||||
excluded.update(('warehouse','warehouses','unknown_warehouses','postal'))
|
||||
if any(current.get(k) for k in ('warehouse','postal','zone')) and not current.get('unknown_warehouses'):
|
||||
excluded.add('unknown_warehouses')
|
||||
if any(current.get(k) for k in ('warehouse','unknown_warehouses','postal','zone')) and not current.get('allocations'):
|
||||
excluded.add('allocations')
|
||||
if current.get('allocations'):excluded.update(('packages','boxes','dims','cbm'))
|
||||
if current['transport'] or current['kinds'] or current['services']:excluded.update(('air_only','air_requested'))
|
||||
if current['air_only'] or current['tax']=='excluded':excluded.add('kinds')
|
||||
if current['weight'] is not None:
|
||||
if not current.get('packages'):excluded.add('packages')
|
||||
if not current.get('allocations'):excluded.add('allocations')
|
||||
if current['dims']:excluded.add('cbm')
|
||||
if current['cbm'] is not None:excluded.add('dims')
|
||||
for key,value in previous.items():
|
||||
if result.get(key) is None and key not in excluded:result[key]=deepcopy(value)
|
||||
if current['dims'] is None and 'dims' not in excluded and previous['dims']:
|
||||
result['dim_unit']=previous['dim_unit']
|
||||
pieces=result.get('packages') or []
|
||||
if current['dims']:
|
||||
for piece in pieces:piece.update(dims=current['dims'],dim_unit=current['dim_unit'])
|
||||
elif current['cbm'] is not None:
|
||||
for piece in pieces:piece.update(dims=None,dim_unit='cm')
|
||||
if previous.get('quantity_uncertain') and current['weight'] is None and not current.get('preserve_total'):
|
||||
result['quantity_uncertain']=True
|
||||
result['errors'].append(QUANTITY_QUESTION)
|
||||
if pieces and result['boxes']!=sum(p['count'] for p in pieces):
|
||||
if current.get('preserve_total'):
|
||||
result['packages']=None
|
||||
elif len(pieces)==1:
|
||||
pieces[0]['count']=result['boxes']
|
||||
result['weight']=str(Decimal(pieces[0]['weight'])*result['boxes'])
|
||||
result['quantity_note']=f'按每箱{pieces[0]["weight"]}KG不变,更新为{result["boxes"]}箱。'
|
||||
else:
|
||||
result['packages']=None
|
||||
result['quantity_uncertain']=True
|
||||
result['errors'].append(QUANTITY_QUESTION)
|
||||
return result
|
||||
@@ -20,6 +20,10 @@ def broad_zone(postal):
|
||||
|
||||
|
||||
def variants(intent, book):
|
||||
if intent.get('allocations'):
|
||||
return [{**destination,'allocation':part} for part in intent['allocations']
|
||||
for destination in variants({**intent,'allocations':None,'warehouse':part['warehouse'],
|
||||
'warehouses':None,'postal':None},book)]
|
||||
if len(intent.get('warehouses') or [])>1:
|
||||
return [variant for warehouse in intent['warehouses'] for variant in
|
||||
variants({**intent,'warehouse':warehouse,'warehouses':None,'postal':None},book)]
|
||||
@@ -123,21 +127,27 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
|
||||
else:
|
||||
lines.append('美国线路报价:')
|
||||
if len(intent.get('warehouses') or [])>1:
|
||||
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
|
||||
if intent.get('allocations'):
|
||||
lines.append('按各目的仓分票计算,箱规需逐票核对:')
|
||||
elif len(intent.get('warehouses') or [])>1:
|
||||
lines.append('以下按各目的仓分别比较;实际分票请提供每个仓的重量。')
|
||||
if intent.get('air_requested') and not intent.get('air_only'):
|
||||
lines.append('当前资料没有美国空运费率,下面列出海运方案。')
|
||||
for destination, fees in zip(destinations, remote_fees):
|
||||
rates = match_rates(intent, book, destination)
|
||||
part=destination.get('allocation')
|
||||
billing=({**intent,'weight':part['weight'],'packages':part.get('packages'),'dims':None,'cbm':None,'boxes':part.get('boxes')}
|
||||
if part else intent)
|
||||
rates = match_rates(billing, book, destination)
|
||||
if intent.get('mode') in ('cheapest','fastest') and rates:
|
||||
if intent['mode']=='cheapest':
|
||||
eligible=[r for r in rates if quantity(intent,r) is not None]
|
||||
eligible=[r for r in rates if quantity(billing,r) is not None]
|
||||
best={}
|
||||
for rate in eligible:
|
||||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||||
value=quantity(intent,rate)*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
|
||||
value=quantity(billing,rate)*Decimal(rate['price'])+charge_details(billing,rate,fees,book)['cny']
|
||||
best[key]=min(best.get(key,value),value)
|
||||
rates=[r for r in eligible if quantity(intent,r)*Decimal(r['price'])+charge_details(intent,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
rates=[r for r in eligible if quantity(billing,r)*Decimal(r['price'])+charge_details(billing,r,fees,book)['cny']==best[(destination['zone'] or r['zone'],r['unit'],r['tax'])]] or rates
|
||||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||||
else:
|
||||
def eta_key(rate):
|
||||
@@ -163,13 +173,13 @@ def render_rates(intent, book, destinations, remote_fees):
|
||||
continue
|
||||
parts = []
|
||||
for rate in rows:
|
||||
amount = quantity(intent,rate)
|
||||
amount = quantity(billing,rate)
|
||||
price = Decimal(rate['price'])
|
||||
rate_text = f"{rate['zone']} " if rate['zone'] and not destination['zone'] else ''
|
||||
rate_text += f"{number(price)}元/{'KG' if unit == 'kg' else 'CBM'}"
|
||||
if amount is not None:
|
||||
total = amount*price
|
||||
details=charge_details(intent,rate,fees,book)
|
||||
details=charge_details(billing,rate,fees,book)
|
||||
total+=details['cny']
|
||||
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
|
||||
if details['items']:rate_text+='('+ ';'.join(details['items'])+')'
|
||||
|
||||
@@ -83,11 +83,13 @@ def charge_details(intent,rate,remote,book):
|
||||
fee=charged*Decimal(remote['fee']);result['cny']+=fee
|
||||
result['items'].append(f'偏远费{remote["fee"]}元/KG×{charged:g}KG={fee:g}元')
|
||||
else:result['notes'].append('遥远地址的附加费需单独确认,当前金额未包含该项。')
|
||||
if rate['unit']=='kg' and intent.get('material')=='metal':
|
||||
if intent.get('material')=='metal':
|
||||
per_kg=material_fee(book)
|
||||
if per_kg is not None:
|
||||
if per_kg is not None and rate['unit']=='kg':
|
||||
fee=charged*per_kg;result['cny']+=fee
|
||||
result['items'].append(f'铁铝附加费{per_kg:g}元/KG×{charged:g}KG={fee:g}元')
|
||||
elif per_kg is not None:
|
||||
result['notes'].append(f'铁铝附加费为{per_kg:g}元/KG,当前按方运费未包含该项;需确认适用计费重量后单列,不能把CBM直接乘公斤附加费。')
|
||||
if rate['kind']=='parcel':
|
||||
limits=limit_fees(intent,book)
|
||||
result['cny']+=limits['cny'];result['items']+=limits['items'];result['notes']+=limits['notes']
|
||||
|
||||
@@ -49,6 +49,8 @@ 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'
|
||||
from .shipping_faq import preparation_topic
|
||||
if preparation_topic(text):return 'preparation'
|
||||
if re.search(r'最便宜|便宜的|最省钱|最划算|最低价|cheapest|least expensive',text,re.I):return 'cheapest'
|
||||
if re.search(r'最快|快的|fastest',text,re.I):return 'fastest'
|
||||
if re.search(r'一件代发|散箱入库|整柜卸货|卸货费|仓租|仓储|贴标费',text):return 'fulfillment'
|
||||
@@ -63,6 +65,7 @@ def request_mode(text):
|
||||
|
||||
def parse_intent(message,warehouse_codes):
|
||||
text=normalize(message)
|
||||
text=re.sub(r'(?:而?不是|并非|不要按|不是按)\s*(?:每[箱件]|单[箱件])?\s*'+WEIGHT.pattern,' ',text,flags=re.I)
|
||||
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())
|
||||
@@ -70,6 +73,8 @@ def parse_intent(message,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=[]
|
||||
from .quote_allocations import allocations
|
||||
assigned=allocations(text,warehouses,WEIGHT,kg)
|
||||
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)
|
||||
@@ -77,12 +82,17 @@ def parse_intent(message,warehouse_codes):
|
||||
packages=[]
|
||||
package_re=re.compile(r'(第)?(\d+)\s*(?:箱|件)\s*(?:[,,]?\s*每(?:箱|件))?\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),re.I)
|
||||
matches=list(package_re.finditer(text))
|
||||
package_dimensions={}
|
||||
for index,match in enumerate(matches):
|
||||
ordinal,n,value,unit=match.groups()
|
||||
segment=text[match.end():matches[index+1].start() if index+1<len(matches) else len(text)]
|
||||
dims=parse_shipping_intent(segment)['dims']
|
||||
packages.append({'count':1 if ordinal else int(n),'weight':str(kg(value,unit)),
|
||||
'dims':dims,'dim_unit':detect_dim_unit(segment)})
|
||||
package_dimensions[match.start(3)]={'dims':dims,'dim_unit':detect_dim_unit(segment)}
|
||||
for part in assigned or []:
|
||||
for piece in part.get('packages') or []:
|
||||
piece.update(package_dimensions.get(piece.pop('position'),{}))
|
||||
each=re.search(r'每(?:箱|件)\s*'+WEIGHT.pattern.replace(r'(?<![\d.])',''),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)}]
|
||||
@@ -97,8 +107,15 @@ def parse_intent(message,warehouse_codes):
|
||||
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与分箱重量合计不一致,请确认。')
|
||||
# Per-carton dimensions must not spill onto another carton without dimensions.
|
||||
if len(packages)>1:parsed['dims']=None
|
||||
elif assigned:
|
||||
weight=str(sum(Decimal(a['weight']) for a in assigned))
|
||||
if set(a['warehouse'] for a in assigned)!=set(warehouses):errors.append('请分别提供每个目的仓的重量,避免遗漏分票。')
|
||||
total=re.search(r'(?:总重|合计重量)\s*'+WEIGHT.pattern,text,re.I)
|
||||
if total and kg(*total.groups())!=Decimal(weight):errors.append('总重与各目的仓分配重量不一致,请确认。')
|
||||
elif len(weights)>1:
|
||||
errors.append('请明确总重,或分别提供每箱重量,避免把不同重量混在一起。')
|
||||
if assigned and packages and Decimal(weight)!=sum(Decimal(a['weight']) for a in assigned):
|
||||
errors.append('分仓重量与分箱重量不一致,请分别确认每仓的箱数和重量。')
|
||||
if any(kg(*w.groups())<=0 for w in weights):errors.append('重量必须大于 0,请确认有效重量。')
|
||||
labeled=re.search(r'(?:邮编|zip(?:\s*code)?|post\s*code)\s*[::为是]?\s*([0-9-]+)',text,re.I)
|
||||
postal=None
|
||||
@@ -125,7 +142,7 @@ def parse_intent(message,warehouse_codes):
|
||||
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
|
||||
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
|
||||
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
|
||||
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'origin':origin,
|
||||
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'allocations':assigned,'origin':origin,
|
||||
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
|
||||
'air_only':True if air and not sea else None,'air_requested':True if air else None,
|
||||
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
|
||||
@@ -135,10 +152,11 @@ def parse_intent(message,warehouse_codes):
|
||||
'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}
|
||||
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
|
||||
|
||||
|
||||
def is_followup(message,intent):
|
||||
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
|
||||
if TOPIC_BOUNDARY.search(message):return False
|
||||
text=message.strip().strip(',,。.!!?? ')
|
||||
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
|
||||
|
||||
@@ -10,45 +10,33 @@ from .quote_engine import US, QUOTE_WORDS, parse_intent, is_followup, variants,
|
||||
|
||||
|
||||
def intent_with_context(message, conv_id, book):
|
||||
from copy import deepcopy
|
||||
from .quote_context import merge_intent
|
||||
|
||||
codes = {row['code'] for row in book['warehouses']}
|
||||
codes.update(value.upper() for rate in book['rates'] for value in rate['destinations'])
|
||||
intent = parse_intent(message,codes)
|
||||
def destination_reset(value):
|
||||
if value.get('warehouse') or value.get('unknown_warehouses'):return {'postal','zone','zones'}
|
||||
if value.get('postal'):return {'warehouse','warehouses','unknown_warehouses','zone','zones'}
|
||||
if value.get('zone'):return {'warehouse','warehouses','unknown_warehouses','postal'}
|
||||
return set()
|
||||
excluded=destination_reset(intent)
|
||||
selected_destination=bool(excluded)
|
||||
if selected_destination and not intent.get('unknown_warehouses'):excluded.add('unknown_warehouses')
|
||||
if intent['transport'] or intent['kinds'] or intent['services']:excluded.update(('air_only','air_requested'))
|
||||
if intent['air_only'] or intent['tax']=='excluded':excluded.add('kinds')
|
||||
if intent.get('weight') is not None and not intent.get('packages'):excluded.add('packages')
|
||||
explicit_dims=bool(intent['dims'])
|
||||
earlier_intents=[]
|
||||
if conv_id and is_followup(message,intent):
|
||||
connection=connect()
|
||||
try:
|
||||
recent=connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' ORDER BY id DESC LIMIT 10",(conv_id,)).fetchall()
|
||||
finally:connection.close()
|
||||
finally:
|
||||
connection.close()
|
||||
for (previous,) in recent:
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收',previous):break
|
||||
earlier=parse_intent(previous,codes)
|
||||
if earlier['country']=='other':break
|
||||
if not (QUOTE_WORDS.search(previous) or US.search(previous) or is_followup(previous,earlier) or
|
||||
any(earlier.get(key) for key in ('weight','dims','cbm','warehouse','postal'))):break
|
||||
if not selected_destination and destination_reset(earlier):
|
||||
excluded.update(destination_reset(earlier));selected_destination=True
|
||||
if intent['dims'] is None and earlier['dims']:
|
||||
intent['dims']=earlier['dims'];intent['dim_unit']=earlier['dim_unit']
|
||||
for key,value in earlier.items():
|
||||
if key in ('mode','text','errors','dim_unit','dims'):continue
|
||||
if intent.get(key) is None and key not in excluded:intent[key]=deepcopy(value)
|
||||
earlier_intents.append(earlier)
|
||||
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'
|
||||
if earlier_intents:
|
||||
state=earlier_intents[-1]
|
||||
for update in list(reversed(earlier_intents[:-1]))+[intent]:
|
||||
state=merge_intent(update,state)
|
||||
intent=state
|
||||
if (intent['warehouse'] or intent.get('unknown_warehouses')) and intent['country'] is None:
|
||||
intent['country']='US'
|
||||
return intent
|
||||
|
||||
|
||||
@@ -56,7 +44,8 @@ def answer_quote(message, conv_id):
|
||||
if re.search(r'理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
|
||||
return None
|
||||
# Avoid a new read for ordinary chat; short followups need their local context.
|
||||
if not (US.search(message) or QUOTE_WORDS.search(message) or conv_id or
|
||||
from .shipping_faq import preparation_topic
|
||||
if not (US.search(message) or QUOTE_WORDS.search(message) or preparation_topic(message) or conv_id or
|
||||
re.search(r'(?<![A-Za-z0-9])[A-Za-z]{2,4}[0-9](?![A-Za-z0-9])', message)):
|
||||
return None
|
||||
try:
|
||||
@@ -65,6 +54,8 @@ def answer_quote(message, conv_id):
|
||||
return None
|
||||
intent = intent_with_context(message, conv_id, book)
|
||||
if intent['country'] != 'US':
|
||||
if intent['country'] is None and intent.get('mode')=='preparation':
|
||||
return {'t':'请先确认目的国家和所选渠道,我再为您核对交仓、标签或发货资料要求。'}
|
||||
return None
|
||||
if intent.get('errors'):
|
||||
return {'t':'\n'.join(intent['errors'])}
|
||||
@@ -83,6 +74,14 @@ def answer_quote(message, conv_id):
|
||||
if any(not 0 < value <= 100000 for dims in dimensions for value in dims):
|
||||
return {'t':'请提供大于 0 的有效长宽高。'}
|
||||
from .quote_answers import answer_business
|
||||
preparation=None
|
||||
if intent.get('mode')=='preparation':
|
||||
from .shipping_faq import answer_preparation
|
||||
if intent['air_only']:return {'t':'当前美国资料未列明空运的交仓和发货要求,请确认具体空运渠道后联系人工核对。'}
|
||||
preparation=answer_preparation(intent,book)
|
||||
if preparation_topic(message)!='schedule' or not re.search(r'\d+\s*(?:KG|公斤|千克|克|吨|方|CBM)',intent['text'],re.I):
|
||||
return {'t':preparation}
|
||||
intent['mode']='cheapest' if re.search(r'最便宜|最划算|最低价',message) else 'quote'
|
||||
business=answer_business(intent,book)
|
||||
if business is not None:return {'t':business}
|
||||
if intent['air_only']:
|
||||
@@ -94,6 +93,7 @@ def answer_quote(message, conv_id):
|
||||
if postal and (postal[:3] in ('006','007','008','009','966','967','968','969','995','996','997','998','999') or postal=='89124'):
|
||||
return {'t':f'当前海派资料不接收邮编 {postal} 的地址,请提供其他目的仓码或联系人工确认可用线路。'}
|
||||
fees.append(postcode_fee(book['id'],postal) if postal else None)
|
||||
return {'t':render_rates(intent,book,destinations,fees)}
|
||||
reply=render_rates(intent,book,destinations,fees)
|
||||
return {'t':reply+('\n'+preparation if preparation else '')}
|
||||
except (sqlite3.Error, ValueError, KeyError, TypeError, InvalidOperation):
|
||||
return {'t':'报价数据暂时无法读取,请稍后重试。'}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Pre-shipment answers from the active US book, without live operational claims."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def preparation_topic(text):
|
||||
if re.search(r'理赔|赔偿|索赔|货损|丢件|破损',text):return None
|
||||
if re.search(r'截仓|截单|船期|排仓|哪天开船|(?:什么时候|几号).*开船|能.*开船|'
|
||||
r'(?:今天|明天|这周|下周).*(?:发货|发走|开船)|赶得上|来得及|(?:保证|必须|月底).*到',text):return 'schedule'
|
||||
if re.search(r'上门.*(?:提货|取货|收货|揽收)|提货服务|交仓地址|仓库地址|送哪个仓|仓库在哪|收货时间',text):return 'delivery'
|
||||
if re.search(r'报关资料|清关资料|报关.*(?:准备|材料)|申报比例|低申报|怎么申报|如何申报|认证资料',text):return 'documents'
|
||||
if re.search(r'查验.*(?:费|承担)|谁.*承担.*查验',text):return 'inspection'
|
||||
if '一件代发' not in text and re.search(r'贴标|FBA标|标签|唛头|产地标|外箱.*贴|贴.*条码|made\s+in\s+china',text,re.I):return 'labels'
|
||||
if re.search(r'哪些渠道|哪些线路|什么渠道|能发哪些仓|支持.*仓|(?:能发|能走|可以发).*美国',text):return 'coverage'
|
||||
return None
|
||||
|
||||
|
||||
def answer_preparation(intent,book):
|
||||
topic=preparation_topic(intent['text'])
|
||||
if topic is None:return None
|
||||
from .quote_fees import document
|
||||
|
||||
if topic=='coverage':
|
||||
services=list(dict.fromkeys(rate['service'] for rate in book['rates']))
|
||||
return '美国可查询的线路:'+ '、'.join(services)+'。请提供目的仓码或邮编、重量及交货仓,我再匹配具体可用线路。'
|
||||
if topic=='schedule':
|
||||
return '请提供计划交仓日期、交货仓和目的仓码。具体截仓、装柜和开船安排需联系人工核对;当前参考时效不能作为月底或指定日期必达的承诺。'
|
||||
if topic=='delivery':
|
||||
raw=document(book,('不提供上门提货服务',))
|
||||
lines=[]
|
||||
if raw:lines.append('当前美国渠道资料要求客户自行送货至对应仓库,不提供上门提货服务。')
|
||||
lines.append('请确认所选渠道及义乌或华南交仓,仓库地址、联系人和收货时间请联系人工核对后再送仓。')
|
||||
return '\n'.join(lines)
|
||||
if topic=='labels':
|
||||
label=document(book,('外箱','入仓条码','唛头'))
|
||||
origin=document(book,('产品上和包装上','产地标识'))
|
||||
lines=[]
|
||||
if label:lines.append(re.sub(r'^\s*\d+[、..]\s*','',label.strip())+'\n代贴标签具体费用需按所选服务确认。')
|
||||
if origin:lines.append('产品和包装还需有“Made in China”产地标识。')
|
||||
return '\n'.join(lines) or '请提供渠道和目的仓码,由人工核对外箱标签及代贴要求。'
|
||||
if topic=='documents':
|
||||
if re.search(r'申报比例|低申报|怎么申报|如何申报',intent['text']):
|
||||
return '请提供品名、材质、用途、实际交易金额及销售链接,由业务核对申报资料。申报价需结合实际交易信息确认,不能按统一比例直接代算。'
|
||||
raw=document(book,('单独报关需提供资料',))
|
||||
if raw:return raw.strip()+'\n请补充具体品名及渠道,涉及商检或认证的资料需逐项核对。'
|
||||
return '请提供具体品名和渠道,由人工核对装箱单、发票及对应报关、认证材料。'
|
||||
raw=document(book,('查验','不包税货物','比例均摊'))
|
||||
if raw:return raw.strip()+'\n具体金额按实际查验费用核算;包税渠道的承担方式需另行确认,不能直接套用不包税条款。'
|
||||
return '查验费用和承担方式需按渠道、税费方式及实际费用确认,当前无法给出固定金额。'
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Common pre-sale and order questions; all data and monetary values are synthetic."""
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from logistics_chat import create_app,db
|
||||
from logistics_chat.order_data import import_records
|
||||
from logistics_chat.quote_data import import_snapshot
|
||||
from test_quotes import fixture_snapshot,frames
|
||||
|
||||
|
||||
class CustomerQuestionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup)
|
||||
root=Path(folder.name)
|
||||
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
|
||||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||||
'CUSTOMER_DEMO_TENANT_ID':'qa-a','RATE_LIMIT_PER_MIN':1000})
|
||||
self.client=self.app.test_client()
|
||||
self.client.post('/api/visitor/session',json={'key':'1'*64})
|
||||
book=fixture_snapshot()
|
||||
book['warehouses'].append({'code':'YYY2','zone':'西1区','state':'AA','postal':'90001',
|
||||
'address':'','source_sheet':'合成分区','source_cell':'A10'})
|
||||
documents=['铁铝制品另外加附加费¥5/KG','客户自行送货,不提供上门提货服务',
|
||||
'外箱要贴两张入仓条码与我司唛头标签,代贴收操作费',
|
||||
'产品上和包装上必须有Made in China产地标识',
|
||||
'单独报关需提供资料:装箱单、发票、报关委托书、合同',
|
||||
'如遇查验,不包税货物的查验费用按客户所占货物比例均摊',
|
||||
'正常货物 0KG<单件重量<22KG 0CM<最长单边长度<120CM 0CM<第二边<75CM 最长边<260CM',
|
||||
'超重收+70/件']
|
||||
book['documents']=[{'sheet':'合成渠道','cell':f'A{i}','text':text} for i,text in enumerate(documents)]
|
||||
self.order={'tenant_id':'qa-a','order_no':'COMMON20260001','inbound_code':'QACOMMON20260001',
|
||||
'status':'COMPLETED','package_count':2,'country_code':'US',
|
||||
'tracking_json':[{'code':'DEPARTED_ORIGIN_PORT','actual_at':'2026-10-01 10:00'},
|
||||
{'code':'ARRIVED_DESTINATION_PORT','estimated_at':'2026-10-20 10:00'}]}
|
||||
with self.app.app_context():
|
||||
c=db.connect();import_snapshot(c,book,activate=True)
|
||||
import_records(c,[self.order,{**self.order,'tenant_id':'qa-b','order_no':'COMMON20260099','inbound_code':'QACOMMON20260099'}])
|
||||
c.commit();c.close()
|
||||
|
||||
def conversation(self):return self.client.post('/api/new_chat').json['id']
|
||||
|
||||
def events(self,text,cid=None):
|
||||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||||
result=frames(self.client.post('/stream_chat',json={'msg':text,'conv_id':cid}))
|
||||
model.assert_not_called()
|
||||
self.assertEqual(result[-1],{'d':1},result)
|
||||
return result
|
||||
|
||||
def ask(self,text,cid=None):return self.events(text,cid)[0]['t']
|
||||
|
||||
def login(self):
|
||||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||||
|
||||
def test_explicit_weights_are_billed_by_destination_and_not_reused_as_each_total(self):
|
||||
for text in ('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',
|
||||
'美国ZZZ1:20KG,YYY2:10KG,义乌普船海派'):
|
||||
answer=self.ask(text)
|
||||
self.assertIn('美国 30KG',answer)
|
||||
self.assertIn('20KG = 100元',answer)
|
||||
self.assertIn('12KG = 24元',answer)
|
||||
self.assertNotIn('30KG =',answer)
|
||||
self.assertIn('分配重量不一致',self.ask('美国总重40KG,20KG发ZZZ1,10KG发YYY2,海派'))
|
||||
|
||||
def test_allocation_follows_origin_changes_but_a_single_new_destination_clears_it(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国20KG发ZZZ1,10KG发YYY2,义乌普船海派',cid)
|
||||
text=self.ask('深圳交仓呢',cid)
|
||||
self.assertIn('20KG = 120元',text);self.assertIn('12KG = 36元',text)
|
||||
text=self.ask('换成YYY2',cid)
|
||||
self.assertNotIn('ZZZ1',text);self.assertIn('美国 30KG',text)
|
||||
|
||||
def test_allocated_cartons_keep_individual_minimums_and_overweight_fees(self):
|
||||
text=self.ask('美国2箱每箱5KG发ZZZ1,1箱20KG发YYY2,义乌普船海派')
|
||||
self.assertIn('美国 30KG',text);self.assertIn('24KG = 120元',text);self.assertIn('20KG = 40元',text)
|
||||
text=self.ask('美国1箱30KG发ZZZ1,1箱10KG发YYY2,义乌普船海派')
|
||||
self.assertIn('30KG = 220元',text);self.assertIn('最高项70元/件',text)
|
||||
text=self.ask('美国1箱10KG发ZZZ1,60x50x40cm,1箱20KG发YYY2,40x30x20cm,义乌普船海派')
|
||||
self.assertIn('20KG = 100元',text);self.assertIn('20KG = 40元',text)
|
||||
|
||||
def test_total_weight_correction_removes_old_per_carton_weight_even_on_next_followup(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国美西2箱每箱30KG,义乌普船海派',cid)
|
||||
text=self.ask('30公斤是两箱加起来的,不是每箱30公斤',cid)
|
||||
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
|
||||
text=self.ask('深圳交仓呢',cid)
|
||||
self.assertIn('美国 30KG',text);self.assertNotIn('60KG',text)
|
||||
self.assertIn('美国 50KG',self.ask('不是30KG,是50KG',cid))
|
||||
|
||||
def test_uniform_carton_count_change_preserves_piece_weight_across_followups(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国美西2箱每箱15KG,义乌普船海派',cid)
|
||||
self.assertIn('美国 60KG',self.ask('改成4箱',cid))
|
||||
self.assertIn('美国 60KG',self.ask('深圳交仓呢',cid))
|
||||
self.assertIn('美国 60KG',self.ask('改成6箱,总重不变',cid))
|
||||
self.assertIn('美国 60KG',self.ask('义乌交仓呢',cid))
|
||||
|
||||
def test_mixed_carton_count_requires_new_weights_then_accepts_a_new_total(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',cid)
|
||||
self.assertIn('请确认新的总重',self.ask('改成4箱',cid))
|
||||
text=self.ask('总重40KG',cid)
|
||||
self.assertIn('美国 40KG',text);self.assertIn('48KG = 96元',text)
|
||||
other=self.conversation()
|
||||
self.ask('美国美西1箱10KG,1箱20KG,义乌普船海派',other)
|
||||
self.ask('改成4箱,总重不变',other)
|
||||
self.assertIn('美国 30KG',self.ask('深圳交仓呢',other))
|
||||
|
||||
def test_new_dimensions_do_not_resurrect_stale_volume(self):
|
||||
cid=self.conversation()
|
||||
self.ask('美国363KG发ZZZ1,1箱,1.5方,义乌不包税海卡',cid)
|
||||
self.assertIn('1CBM = 100元',self.ask('改成每箱50x50x40cm',cid))
|
||||
self.assertIn('1CBM = 100元',self.ask('深圳交仓呢',cid))
|
||||
|
||||
def test_cbm_metal_surcharge_is_separate_instead_of_silently_included(self):
|
||||
text=self.ask('美国铁制品30KG发ZZZ1,1方,义乌不包税海卡')
|
||||
self.assertIn('1CBM = 100元',text)
|
||||
self.assertIn('铁铝附加费为5元/KG',text)
|
||||
self.assertIn('当前按方运费未包含',text)
|
||||
self.assertNotIn('= 105元',text)
|
||||
|
||||
def test_preparation_questions_use_us_material_without_promising_ship_dates(self):
|
||||
cases=(('美国可以上门提货吗','不提供上门提货'),('美国FBA外箱怎么贴标签','两张入仓条码'),
|
||||
('美国单独报关资料需要什么','装箱单'),('美国申报比例怎么算','不能按统一比例'),
|
||||
('美国不包税查验费用谁承担','比例均摊'),('美国这周哪天开船','联系人工核对'))
|
||||
for question,expected in cases:
|
||||
with self.subTest(question=question):self.assertIn(expected,self.ask(question))
|
||||
cid=self.conversation();self.ask('美国30KG海派报价',cid)
|
||||
self.assertIn('唛头',self.ask('外箱标签呢',cid))
|
||||
self.assertIn('目的国家',self.ask('可以上门提货吗'))
|
||||
self.assertIn('普船海派',self.ask('你们能发美国吗,有哪些渠道'))
|
||||
text=self.ask('美国美西30KG最便宜的,月底必须到,义乌海派')
|
||||
self.assertIn('30KG = 60元',text);self.assertIn('不能作为',text)
|
||||
|
||||
def test_order_business_status_does_not_imply_signing_shelving_or_piece_delivery(self):
|
||||
self.assertIn('业务完成不等于',self.ask('头程订单已完成是不是签收了'))
|
||||
self.assertTrue(self.events('这票上架了吗')[0]['login_required'])
|
||||
self.login();cid=self.conversation()
|
||||
self.events(self.order['inbound_code'],cid)
|
||||
text=self.ask('这票已完成为什么还没上架',cid)
|
||||
self.assertIn('已离开起运港',text)
|
||||
self.assertIn('不等于收件人签收',text)
|
||||
self.assertNotIn('已经到港',text)
|
||||
text=self.ask('怎么三天都没更新',cid)
|
||||
self.assertIn('2026-10-01',text);self.assertIn('原因无法',text)
|
||||
text=self.ask('到了几箱?少了一箱',cid)
|
||||
self.assertIn('登记总件数为2件',text);self.assertIn('没有逐箱签收明细',text)
|
||||
text=self.ask('帮我催一下',cid)
|
||||
self.assertIn('尚未提交',text)
|
||||
self.assertIn('未找到',self.ask('QACOMMON20260099上架了吗'))
|
||||
self.client.post('/api/customer/logout')
|
||||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||||
Reference in New Issue
Block a user