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

284 lines
15 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 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