diff --git a/AGENTS.md b/AGENTS.md index 4e192e6..393e807 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/` | Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` | | Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery | | Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` | -| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`; `quote_answers.py`, `quote_fees.py`: local business terms and fees | +| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`, `quote_question_text`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`, `select_cheapest_rates`; `quote_answers.py`: `timing_liability`; `quote_fees.py`: local business terms and fees | | US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` | | Common customer questions | `logistics_chat/shipping_faq.py`: pre-shipment material; `quote_allocations.py`: per-warehouse weights; `quote_context.py`: chronological edits; `order_explanations.py`: business status, latest actual nodes and operation boundaries | | Reply boundaries / order summaries | `logistics_chat/customer_faq.py`: supported actions and acknowledgements; `quote_scope.py`: US-only fallback; `quote_edits.py`: rejected conditions and quantity adjustments; `quote_comparison.py`: comparable fast/slow costs; `quote_material.py`: partial metal chargeable weight; `order_query.py`: bounded filters; `order_listing.py`: scoped five-row summaries and conversation pages; `order_followups.py`: list ordinals, adjacent rows and subset questions; `order_tracking.py`: `current_stage` | diff --git a/docs/architecture.md b/docs/architecture.md index d179a72..c92c1bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,8 @@ tests/ # 按业务模块组织的回归 连续追问的列表指代和子集条件由 `order_followups.py` 处理。“第一票”定位服务端保存的已展示回复,取数时重新校验当前客户范围。“其中”沿用已有筛选条件,普通状态追问可以切换条件。礼貌确认语保留当前话题,显式返回报价由 `quote_service.py` 恢复最近报价条件。`quote_intent.py` 分别保存渠道选择和本轮询问主题,计算总价、查询时效与税费时沿用已选方案。 +报价支持同时比较最快和最便宜方案,推荐追问保留货物条件并按预算、时效分别列出选择。明确指定渠道会替换先前的比较选择。`quote_question_text` 从混合时效与赔付问句中提取报价条件,`timing_liability` 只引用当前匹配渠道已有的时效赔偿条款,其他费用由人工核实。独立理赔话题保留原有话题边界,其他目的地的上下文需先确认国家再使用美国渠道。 + 报价增减量按上一轮已确认的重量、体积和分箱明细计算。不同箱规的减箱追问需要更新后的逐箱明细。明确排除的渠道保存在报价条件中,快慢差额由 `quote_comparison.py` 按相同交仓地、区域、计费方式与税费口径计算。列表指代绑定原订单号,相邻票以实际已返回的订单卡片为起点,在当前已展示的序号范围内导航,到边界时引导翻页。 - 路由只解析请求并包装响应;服务组织业务;规则模块提供确定性计算。依赖方向为 `routes → services → rules / db / ai_client`,下层不导入路由或根入口。 diff --git a/logistics_chat/quote_answers.py b/logistics_chat/quote_answers.py index 2de78f5..6122403 100644 --- a/logistics_chat/quote_answers.py +++ b/logistics_chat/quote_answers.py @@ -6,6 +6,22 @@ import re from .quote_fees import document, amount, material_fee, limit_fees +def timing_liability(intent,book,destinations): + from .quote_engine import match_rates + + rates=[rate for destination in destinations for rate in match_rates(intent,book,destination)] + services=list(dict.fromkeys(rate['service'] for rate in rates)) + lines=[] + for name in services: + terms=[rate['eta'] for rate in rates if rate['service']==name] + if all('无时效赔偿' in term for term in terms): + lines.append(name+'的渠道资料注明“无时效赔偿”。') + elif any('无时效赔偿' in term for term in terms): + lines.append(name+'部分匹配方案注明“无时效赔偿”,需按交仓地和目的仓核对适用条款。') + lines.append('未按参考时效到达所产生的其他费用,需由人工根据实际承运条款核实,当前无法确认赔付金额。') + return '\n'.join(lines) + + def answer_business(intent,book): from .quote_engine import number,exact_number diff --git a/logistics_chat/quote_comparison.py b/logistics_chat/quote_comparison.py index 33e0dff..0d9ae79 100644 --- a/logistics_chat/quote_comparison.py +++ b/logistics_chat/quote_comparison.py @@ -6,7 +6,7 @@ from .quote_fees import charge_details def speed_difference_lines(intent, book, rates, fees): - from .quote_engine import quantity, number, select_timed_rates + from .quote_engine import quantity, number, select_timed_rates,select_cheapest_rates groups = {} for rate in rates: @@ -15,7 +15,10 @@ def speed_difference_lines(intent, book, rates, fees): lines = [] for (origin, zone, kind, unit, tax), candidates in groups.items(): costs = [] - for selected in (select_timed_rates(candidates), select_timed_rates(candidates, slowest=True)): + cheapest=intent.get('selection')=='fastest_cheapest' + comparison=(select_cheapest_rates(intent,book,candidates,fees) if cheapest + else select_timed_rates(candidates,slowest=True)) + for selected in (select_timed_rates(candidates),comparison): values = [] for rate in selected: amount = quantity(intent, rate) @@ -30,5 +33,6 @@ def speed_difference_lines(intent, book, rates, fees): label = ' · '.join(value for value in (origin, zone, '包税' if tax == 'included' else '不包税', '海派' if kind == 'parcel' else '海卡/海铁') if value) difference = number(low) if low == high else number(low) + '至' + number(high) - lines.append(f'- {label},快慢方案可计算运费差额为{difference}元。') + pair='最快与最便宜方案' if cheapest else '快慢方案' + lines.append(f'- {label},{pair}可计算运费差额为{difference}元。') return lines diff --git a/logistics_chat/quote_context.py b/logistics_chat/quote_context.py index 430230a..365868c 100644 --- a/logistics_chat/quote_context.py +++ b/logistics_chat/quote_context.py @@ -143,7 +143,7 @@ def merge_intent(current,previous): elif pending is not None and current['weight'] is None and current.get('pending_weight') is None: result['pending_weight']=pending result['errors'].append(MISSING_WEIGHT_UNIT) - if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice'): + if not current.get('selection') and not current.get('clear_selection') and not current.get('clear_choice') and not current.get('services'): result['selection']=previous.get('selection') if current.get('clear_selection'): excluded.update(('services','kinds','transport','air_only','air_requested')) diff --git a/logistics_chat/quote_engine.py b/logistics_chat/quote_engine.py index bf12dc4..bc2631f 100644 --- a/logistics_chat/quote_engine.py +++ b/logistics_chat/quote_engine.py @@ -148,6 +148,21 @@ def select_timed_rates(rates,slowest=False,by_origin=False): 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 @@ -161,6 +176,10 @@ def render_rates(intent, book, destinations, remote_fees): 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: @@ -177,13 +196,7 @@ def render_rates(intent, book, destinations, remote_fees): billing.update(metal_chargeable_weight=None,metal_scope='partial') rates = match_rates(billing, book, destination) if rate_selection=='cheapest' and rates: - 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 + rates=select_cheapest_rates(billing,book,rates,fees,zone=destination['zone']) or rates lines.append('按相同区域、计费单位及税费方式比较的较低价方案:') if not rates: lines.append(f"{destination['label']}:当前资料没有匹配的线路价格,提供目的仓码或邮编可继续查询。") @@ -191,17 +204,20 @@ def render_rates(intent, book, destinations, remote_fees): if destination['label'] != '美国': lines.append(destination['label']+':') selections=[('',rates)] - if rate_selection in ('fastest','slowest','speed_compare'): + if rate_selection in ('fastest','slowest','speed_compare','fastest_cheapest'): selections=[] - if rate_selection in ('fastest','speed_compare'): + 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'))) - lines.append('按相同区域、运输方式、计费单位及税费方式比较参考时效。最快按天数下限、最慢按天数上限选择。') + 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+'暂时无法确定,匹配线路未列明可比较的天数。') + 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) @@ -239,7 +255,8 @@ def render_rates(intent, book, destinations, remote_fees): from .quote_comparison import speed_difference_lines differences=speed_difference_lines(billing,book,rates,fees) if differences: - lines.append('以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去最慢方案。') + compared='最便宜' if rate_selection=='fastest_cheapest' else '最慢' + lines.append(f'以下按同一交仓地、区域、运输方式和税费口径计算,差额为最快方案减去{compared}方案。') lines.extend(differences) else: lines.append('请提供重量、箱规及有明确数值时效的渠道,继续核算快慢方案差额。') @@ -260,5 +277,7 @@ def render_rates(intent, book, destinations, remote_fees): 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 diff --git a/logistics_chat/quote_intent.py b/logistics_chat/quote_intent.py index baa09bc..4c22dc5 100644 --- a/logistics_chat/quote_intent.py +++ b/logistics_chat/quote_intent.py @@ -13,6 +13,31 @@ WEIGHT_UNITS = r'kilograms?|kgs?|公斤|千克|grams?|克|g|tons?|吨|lbs?|磅' WEIGHT = re.compile(r'(?