284 lines
15 KiB
Python
284 lines
15 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 any(name in rate['service'] for name in intent.get('excluded_services') or []):
|
||
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 exact_number(value):
|
||
text=format(Decimal(value),'f')
|
||
return text.rstrip('0').rstrip('.') if '.' in text else text
|
||
|
||
|
||
def quantity_number(value,intent):
|
||
if Decimal(value)==Decimal(intent.get('weight') or '0') or Decimal(value).as_tuple().exponent>=-6:
|
||
return exact_number(value)
|
||
return '约'+number(value,6)
|
||
|
||
|
||
def select_timed_rates(rates,slowest=False,by_origin=False):
|
||
"""Compare numeric day ranges within one region, service kind and billing basis."""
|
||
ranked=[]
|
||
best={}
|
||
for rate in rates:
|
||
match=re.search(r'(?<![\d.])(\d+(?:\.\d+)?)\s*(?:[-—–~~至]\s*(\d+(?:\.\d+)?))?\s*(?:天|days?)',rate['eta'],re.I)
|
||
if not match:continue
|
||
low=Decimal(match[1])
|
||
high=Decimal(match[2] or match[1])
|
||
if low<=0 or high<low:continue
|
||
key=(rate['zone'],rate['kind'],rate['unit'],rate['tax'])
|
||
if by_origin:key+=(rate['origin'],)
|
||
timing=(high,low) if slowest else (low,high)
|
||
ranked.append((rate,key,timing))
|
||
choose=max if slowest else min
|
||
best[key]=choose(best.get(key,timing),timing)
|
||
return [rate for rate,key,timing in ranked if timing==best[key]]
|
||
|
||
|
||
def select_cheapest_rates(intent,book,rates,fees,zone=None):
|
||
from .quote_fees import charge_details
|
||
|
||
ranked=[]
|
||
best={}
|
||
for rate in rates:
|
||
amount=quantity(intent,rate)
|
||
if amount is None:continue
|
||
key=(zone or rate['zone'],rate['unit'],rate['tax'])
|
||
value=amount*Decimal(rate['price'])+charge_details(intent,rate,fees,book)['cny']
|
||
ranked.append((rate,key,value))
|
||
best[key]=min(best.get(key,value),value)
|
||
return [rate for rate,key,value in ranked if value==best[key]]
|
||
|
||
|
||
def render_rates(intent, book, destinations, remote_fees):
|
||
from .quote_fees import charge_details
|
||
from .quote_language import translate_quote
|
||
|
||
lines = []
|
||
rate_selection=intent.get('selection') or intent.get('mode')
|
||
if intent.get('mode')=='time':
|
||
lines.append('美国线路参考时效:')
|
||
elif intent['weight']:
|
||
lines.append(f"美国 {exact_number(intent['weight'])}KG 基础运费:")
|
||
else:
|
||
lines.append('美国线路报价:')
|
||
if intent.get('quantity_note'):lines.append(intent['quantity_note'])
|
||
if intent.get('mode')=='recommend':
|
||
lines.append({'fastest':'按您优先时效的要求,可考虑下面的较快方案。',
|
||
'cheapest':'按您优先运费的要求,可考虑下面的较低价方案。'}.get(rate_selection,
|
||
'更看重时效可选下面的较快方案,更看重运费可选较低价方案。'))
|
||
if intent.get('allocations'):
|
||
lines.append('按各目的仓分票计算,箱规需逐票核对:')
|
||
if intent.get('metal_chargeable_weight') is not None:
|
||
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)
|
||
if part and intent.get('metal_chargeable_weight') is not None:
|
||
billing.update(metal_chargeable_weight=None,metal_scope='partial')
|
||
rates = match_rates(billing, book, destination)
|
||
if rate_selection=='cheapest' and rates:
|
||
rates=select_cheapest_rates(billing,book,rates,fees,zone=destination['zone']) or rates
|
||
lines.append('按相同区域、计费单位及税费方式比较的较低价方案:')
|
||
if not rates:
|
||
lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。")
|
||
continue
|
||
if destination['label'] != '美国':
|
||
lines.append(destination['label']+':')
|
||
selections=[('',rates)]
|
||
if rate_selection in ('fastest','slowest','speed_compare','fastest_cheapest'):
|
||
selections=[]
|
||
if rate_selection in ('fastest','speed_compare','fastest_cheapest'):
|
||
selections.append(('最快参考方案',select_timed_rates(rates,by_origin=intent.get('mode')=='difference')))
|
||
if rate_selection in ('slowest','speed_compare'):
|
||
selections.append(('最慢参考方案',select_timed_rates(rates,slowest=True,by_origin=intent.get('mode')=='difference')))
|
||
if rate_selection=='fastest_cheapest':
|
||
selections.append(('最便宜参考方案',select_cheapest_rates(billing,book,rates,fees,zone=destination['zone'])))
|
||
lines.append('按相同区域、运输方式、计费单位及税费方式比较。' if rate_selection=='fastest_cheapest'
|
||
else '按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。')
|
||
grouped = {}
|
||
for selection,selected_rates in selections:
|
||
if selection and not selected_rates:
|
||
lines.append(selection+('暂时无法确定,请提供货物重量和箱规。' if selection=='最便宜参考方案' else '暂时无法确定,匹配线路未列明可比较的天数。'))
|
||
for rate in selected_rates:
|
||
key = (selection, rate['service'], rate['origin'], rate['unit'], rate['tax'], rate['eta'])
|
||
grouped.setdefault(key, []).append(rate)
|
||
last_selection=None
|
||
for (selection,service, origin, unit, tax,eta_source), rows in grouped.items():
|
||
if selection and selection!=last_selection:
|
||
lines.append(selection)
|
||
last_selection=selection
|
||
eta = re.sub(r'\s*无时效赔偿', '', eta_source).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
|
||
if intent.get('mode')=='time':
|
||
lines.append(f'- {service} · {origin},{eta or "当前资料未列明时效"}。')
|
||
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",{quantity_number(amount,billing)}{'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)
|
||
lines.append(f"- {service} · {origin} · {'包税' if tax=='included' else '不包税'}:"+';'.join(parts)
|
||
+ (f'({eta})' if eta else ''))
|
||
if intent.get('mode')=='difference':
|
||
from .quote_comparison import speed_difference_lines
|
||
differences=speed_difference_lines(billing,book,rates,fees)
|
||
if differences:
|
||
compared='最便宜' if rate_selection=='fastest_cheapest' else '最慢'
|
||
lines.append(f'以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去{compared}方案。')
|
||
lines.extend(differences)
|
||
else:
|
||
lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。')
|
||
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':
|
||
lines.append('以上时效按开船后到提取计算,送达时间需结合末端派送进度核对。')
|
||
if re.search(r'哪天|几号|什么时候|何时|收到',intent['text']):
|
||
lines.append('请补充预计开船日期和目的仓码或邮编,再核对具体到货日期。')
|
||
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计费,再按整票计费重选择重量档位,乘对应单价,并单列已知附加费。')
|
||
if intent.get('mode')=='recommend' and rate_selection=='fastest_cheapest':
|
||
lines.append('您更看重预算还是时效?提供目的仓码或邮编、交仓地后,可继续缩小方案。')
|
||
text='\n'.join(lines)
|
||
return translate_quote(text) if intent.get('english') else text
|