213 lines
11 KiB
Python
213 lines
11 KiB
Python
"""Bounded quote matching and Decimal arithmetic, independent of Flask/models."""
|
||
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
import re
|
||
|
||
from .pricing import dims_to_cm
|
||
|
||
|
||
from .quote_intent import US, QUOTE_WORDS, parse_intent, is_followup
|
||
|
||
|
||
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):
|
||
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)]
|
||
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']]
|
||
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)
|
||
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
|
||
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,package_charge)
|
||
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):
|
||
from .quote_fees import charge_details
|
||
from .quote_language import translate_quote
|
||
|
||
lines = []
|
||
if intent.get('mode')=='time':
|
||
lines.append('美国线路参考时效:')
|
||
elif intent['weight']:
|
||
lines.append(f"美国 {number(intent['weight'])}KG 基础运费:")
|
||
else:
|
||
lines.append('美国线路报价:')
|
||
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):
|
||
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(billing,r) is not None]
|
||
best={}
|
||
for rate in eligible:
|
||
key=(destination['zone'] or rate['zone'],rate['unit'],rate['tax'])
|
||
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(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):
|
||
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
|
||
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():
|
||
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(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(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'])+')'
|
||
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:
|
||
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 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('请补充箱数、每箱重量和尺寸,继续核算计费重及超重超尺寸费用。')
|
||
if intent.get('mode')=='calculation':
|
||
lines.append('计算过程:各箱实重与体积重取大;海派每箱不足12KG按12KG计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
|
||
text='\n'.join(lines)
|
||
return translate_quote(text) if intent.get('english') else text
|