170 lines
12 KiB
Python
170 lines
12 KiB
Python
"""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'
|
||
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'
|
||
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)
|
||
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())
|
||
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=[]
|
||
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)
|
||
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))
|
||
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)}]
|
||
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 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
|
||
if labeled:
|
||
if re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?',labeled.group(1)):postal=labeled.group(1)[:5]
|
||
else:errors.append('美国邮编请提供5位数字,可附加4位扩展码;当前邮编格式不正确。')
|
||
else:
|
||
remaining=WEIGHT.sub(' ',text)
|
||
remaining=re.sub(r'\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?\s*[*xX×]\s*\d+(?:\.\d+)?',' ',remaining)
|
||
match=re.search(r'(?:^|美国\s*|[,,\s])([0-9]{5})(?:-[0-9]{4})?(?=$|[,,\s])',remaining)
|
||
if match:postal=match.group(1)
|
||
cbm=re.search(r'(-?\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)',text,re.I)
|
||
origin='华南' if re.search(r'华南|深圳|东莞|south china|shenzhen',text,re.I) else ('义乌' if re.search(r'华东|义乌|yiwu',text,re.I) else None)
|
||
if re.search(r'华南|深圳|东莞',text) and re.search(r'华东|义乌',text):origin=None
|
||
services=[n for n in ('美森正班','美森加班','EXX','合德','普船') if n.lower() in text.lower()]
|
||
if '美森' in text and not any(n.startswith('美森') for n in services):services+=['美森正班','美森加班']
|
||
kinds=[]
|
||
if re.search(r'海派|快递|UPS|FEDEX',text,re.I):kinds.append('parcel')
|
||
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路',text):kinds.append('truck')
|
||
zones=[z for z in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if z in text]
|
||
if not zones and '洛杉矶' in text:zones=['美西']
|
||
if not zones and re.search(r'纽约|萨凡纳',text):zones=['美东']
|
||
air=bool(re.search(r'空运|空派|\bair\b',text,re.I))
|
||
sea=bool(re.search(r'海运|海派|海卡|海铁|\bsea\b',text,re.I))
|
||
english=not re.search(r'[\u4e00-\u9fff]',message) and bool(re.search(r'[A-Za-z]{3,}\s+[A-Za-z]',message))
|
||
return {'country':country,'weight':weight,'dims':parsed['dims'],'dim_unit':detect_dim_unit(text),
|
||
'cbm':cbm.group(1) if cbm else None,'boxes':count,'packages':packages or None,'allocations':assigned,'origin':origin,
|
||
'services':services or None,'kinds':kinds or None,'transport':parsed['channel'],
|
||
'air_only':True if air and not sea else None,'air_requested':True if air else None,
|
||
'warehouse':warehouses[0] if warehouses else None,'warehouses':warehouses or None,
|
||
'unknown_warehouses':unknown or None,'postal':postal,'zone':zones[0] if zones else None,'zones':zones or None,
|
||
'tax':'excluded' if re.search(r'不包税|自税|不含税|tax excluded',text,re.I) else ('included' if re.search(r'包税|tax included',text,re.I) else None),
|
||
'errors':errors,'mode':request_mode(text),'english':True if english else (False if re.search(r'[\u4e00-\u9fff]',message) else None),
|
||
'material':('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)),
|
||
'preserve_total':True if re.search(r'总重不变|重量不变',text) else None,'text':text}
|
||
|
||
|
||
def is_followup(message,intent):
|
||
if re.search(r'和刚才一样|其他不变|其它不变|每箱不变',message):return True
|
||
if TOPIC_BOUNDARY.search(message):return False
|
||
text=message.strip().strip(',,。.!!?? ')
|
||
if intent.get('errors') and re.search(r'邮编|重量|箱|kg',message,re.I):return True
|
||
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样|目的仓改|仓码改|只要|货物是|这个价格|这个费用)',text):return True
|
||
if intent.get('warehouse') or intent.get('unknown_warehouses'):
|
||
return not (intent['country'] and intent['weight'])
|
||
if intent.get('mode')!='quote':return not (intent['country'] and intent['weight'])
|
||
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?',text,re.I):return True
|
||
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓)?(?:呢)?',text):return True
|
||
return bool(len(text)<=60 and not (intent['country'] and intent['weight']) and any(intent.get(k) is not None for k in ('services','kinds','transport','weight','dims','cbm','tax','origin')))
|