192 lines
10 KiB
Python
192 lines
10 KiB
Python
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
|
||
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
import re
|
||
|
||
from .pricing import parse_shipping_intent, detect_dim_unit, dims_to_cm, 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)
|
||
|
||
|
||
def parse_intent(message, warehouse_codes):
|
||
parsed = parse_shipping_intent(message)
|
||
country = 'US' if US.search(message) else ('other' if shipping_region(message) is not None else None)
|
||
tokens = re.findall(r'(?<![A-Za-z0-9])[A-Za-z][A-Za-z0-9]{2,7}(?![A-Za-z0-9])', message.upper())
|
||
warehouse = next((token for token in tokens if token in warehouse_codes), None)
|
||
postal = re.search(r'(?<![0-9])(\d{5})(?:-\d{4})?(?![0-9])', message)
|
||
cbm = re.search(r'(\d+(?:\.\d+)?)\s*(?:cbm|立方米|立方|方)', message, re.I)
|
||
boxes = re.search(r'(\d+)\s*(?:箱|件|cartons?|boxes?)', message, re.I)
|
||
origin = '华南' if re.search(r'华南|深圳|东莞', message) else ('义乌' if re.search(r'华东|义乌', message) else None)
|
||
if re.search(r'华南|深圳|东莞', message) and re.search(r'华东|义乌', message):
|
||
origin = None
|
||
services = [name for name in ('美森正班','美森加班','EXX','合德','普船') if name.lower() in message.lower()]
|
||
if '美森' in message and not any(name.startswith('美森') for name in services):
|
||
services += ['美森正班','美森加班']
|
||
kinds = []
|
||
if re.search(r'海派|快递|UPS|FEDEX', message, re.I):
|
||
kinds.append('parcel')
|
||
if re.search(r'海卡|海铁|全卡|卡派|卡车|铁路', message):
|
||
kinds.append('truck')
|
||
air_only = bool(re.search(r'空运|空派|\bair\b', message, re.I) and not re.search(r'海运|海派|海卡|海铁|\bsea\b', message, re.I))
|
||
zone = next((value for value in ('美西','美中','美东','东1区','东2区','中1区','中2区','西1区','西2区') if value in message), None)
|
||
each = re.search(r'每(?:箱|件)\s*(\d+(?:\.\d+)?)\s*(?:kg|公斤|千克)', message, re.I)
|
||
weight = str(parsed['weight']) if parsed['weight'] is not None else None
|
||
count = int(boxes.group(1)) if boxes else None
|
||
if each and count:
|
||
weight = str(Decimal(each.group(1))*count)
|
||
return {'country':country, 'weight':weight, 'dims':parsed['dims'], 'dim_unit':detect_dim_unit(message),
|
||
'cbm':cbm.group(1) if cbm else None, 'boxes':count, 'origin':origin, 'services':services or None,
|
||
'kinds':kinds or None, 'transport':parsed['channel'], 'air_only':True if air_only else None, 'warehouse':warehouse,
|
||
'postal':postal.group(1) if postal else None, 'zone':zone,
|
||
'tax':'excluded' if re.search(r'不包税|自税|不含税', message) else ('included' if '包税' in message else None)}
|
||
|
||
|
||
def is_followup(message, intent):
|
||
if re.search(r'新(?:的)?一(?:票|批)|另外|另一|换个话题|理赔|赔偿|破损|丢失|退件|投诉|查单|轨迹|签收', message):
|
||
return False
|
||
text = message.strip().strip(',,。.!!?? ')
|
||
if re.match(r'^(?:那(?:么)?|换成|改成|改为|如果|还是|同样)', text):
|
||
return True
|
||
if intent['warehouse'] and re.fullmatch(r'(?:目的仓(?:是)?|仓码(?:是)?)?\s*[A-Za-z0-9]+(?:仓)?(?:呢)?', text):
|
||
return True
|
||
if re.fullmatch(r'(?:邮编|zip|postcode)?\s*\d{5}(?:-\d{4})?(?:呢)?', text, re.I):
|
||
return True
|
||
if re.fullmatch(r'(?:义乌|华东|深圳|华南|美国|美西|美中|美东|东[12]区|西[12]区|中[12]区|不包税|包税)(?:交仓|仓|呢)?', text):
|
||
return True
|
||
if len(text) <= 45 and not (intent['country'] and intent['weight']) and (
|
||
intent['services'] or intent['kinds'] or intent['transport'] or intent['air_only'] or intent['weight'] or intent['dims'] or intent['cbm']):
|
||
return True
|
||
return bool(re.fullmatch(r'(?:价格|运费|费用|时效|多少钱)(?:呢|是多少|怎么样)?', text))
|
||
|
||
|
||
def broad_zone(postal):
|
||
if not postal:
|
||
return None
|
||
if postal.startswith(('8','9')):
|
||
return '美西'
|
||
if postal.startswith('46') or postal[0] in '0123':
|
||
return '美东'
|
||
return '美中'
|
||
|
||
|
||
def variants(intent, 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']]
|
||
if zoned:
|
||
unique = {}
|
||
for row in zoned:
|
||
postal = intent['postal'] or row['postal'][:5]
|
||
key = (row['zone'], postal)
|
||
unique.setdefault(key, {'label':f"{warehouse} · {row['zone']}", 'postal':postal,
|
||
'zone':broad_zone(postal), 'warehouse':warehouse})
|
||
return list(unique.values())
|
||
postal = intent['postal']
|
||
if not postal and found:
|
||
match = re.search(r'(?<![0-9])([0-9]{5})(?:-[0-9]{4})?(?![0-9])', found[0]['address'])
|
||
postal = match.group(1) if match else None
|
||
zone = intent['zone']
|
||
if zone and zone[0] in '东中西':
|
||
zone = {'东':'美东','中':'美中','西':'美西'}[zone[0]]
|
||
return [{'label':warehouse or postal or zone or '美国', 'postal':postal, 'zone':zone or broad_zone(postal), 'warehouse':warehouse}]
|
||
|
||
|
||
def quantity(intent, rate):
|
||
real = Decimal(intent['weight'] or '0')
|
||
volume = Decimal(intent['cbm']) if intent['cbm'] else None
|
||
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)
|
||
if rate['unit'] == 'cbm':
|
||
if volume is None:
|
||
return None
|
||
return max(volume, real/Decimal(363))
|
||
volume_weight = ((volume or Decimal(0))*1000000/6000 if rate['kind']=='parcel'
|
||
else (volume or Decimal(0))*167)
|
||
charge = max(real, volume_weight)
|
||
if rate['kind'] == 'parcel':
|
||
charge = max(charge, Decimal(12)*(intent['boxes'] or 1))
|
||
return charge if real or volume else None
|
||
|
||
|
||
def match_rates(intent, book, variant):
|
||
selected = []
|
||
kinds = intent['kinds'] or (['truck','parcel'] if variant['warehouse'] else ['parcel'])
|
||
for rate in book['rates']:
|
||
if rate['kind'] not in kinds or (intent['origin'] and intent['origin'] != rate['origin']):
|
||
continue
|
||
if intent['services'] and not any(name in rate['service'] for name in intent['services']):
|
||
continue
|
||
if intent['tax'] and rate['tax'] != intent['tax']:
|
||
continue
|
||
if rate['kind'] == 'truck':
|
||
if not variant['warehouse'] or variant['warehouse'] not in [value.upper() for value in rate['destinations']]:
|
||
continue
|
||
elif variant['zone'] and rate['zone'] != variant['zone']:
|
||
continue
|
||
selected.append(rate)
|
||
groups = {}
|
||
for rate in selected:
|
||
key = (rate['service'],rate['origin'],rate['zone'],rate['unit'],rate['tax'])
|
||
charge = quantity(intent,rate)
|
||
if rate['kind'] == 'parcel' and Decimal(rate['min_weight']) > (charge or Decimal(12)):
|
||
continue
|
||
if key not in groups or Decimal(rate['min_weight']) > Decimal(groups[key]['min_weight']):
|
||
groups[key] = rate
|
||
return list(groups.values())
|
||
|
||
|
||
def number(value, places=2):
|
||
return format(Decimal(value).quantize(Decimal(10)**-places, rounding=ROUND_HALF_UP), 'f').rstrip('0').rstrip('.') if places else str(value)
|
||
|
||
|
||
def render_rates(intent, book, destinations, remote_fees):
|
||
lines = []
|
||
if intent['weight']:
|
||
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
|
||
else:
|
||
lines.append('美国线路报价:')
|
||
for destination, fees in zip(destinations, remote_fees):
|
||
rates = match_rates(intent, book, destination)
|
||
if not rates:
|
||
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||
continue
|
||
if destination['label'] != '美国':
|
||
lines.append(destination['label']+':')
|
||
grouped = {}
|
||
for rate in rates:
|
||
key = (rate['service'], rate['origin'], rate['unit'], rate['tax'])
|
||
grouped.setdefault(key, []).append(rate)
|
||
for (service, origin, unit, tax), rows in grouped.items():
|
||
parts = []
|
||
for rate in rows:
|
||
amount = quantity(intent,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
|
||
if fees and fees['fee'] is not None and rate['kind'] == 'parcel':
|
||
total += amount*Decimal(fees['fee'])
|
||
rate_text += f",{number(amount,3)}{'KG' if unit == 'kg' else 'CBM'} = {number(total)}元"
|
||
parts.append(rate_text)
|
||
eta = re.sub(r'\s*无时效赔偿', '', rows[0]['eta']).strip()
|
||
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||
+ (f'({eta})' if eta else ''))
|
||
if fees:
|
||
if fees['fee'] is None:
|
||
lines.append('该邮编属于遥远地址,派送附加费需单独确认。')
|
||
else:
|
||
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']:
|
||
lines.append('货物多少公斤、几箱?')
|
||
elif not intent['boxes'] or not intent['dims']:
|
||
lines.append('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
|
||
return '\n'.join(lines)
|