Files
baiyue-logistics-chat/logistics_chat/quote_engine.py
T

192 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)