498 lines
28 KiB
Python
498 lines
28 KiB
Python
"""Structured shipment edits and quote facts, scoped to one original user turn."""
|
|
|
|
from copy import deepcopy
|
|
from decimal import Decimal, InvalidOperation
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
from uuid import uuid4
|
|
|
|
from .conversation_state import empty_state, load_state
|
|
|
|
|
|
DECIMAL_SCHEMA = {'anyOf': [{'type': 'string'}, {'type': 'number'}]}
|
|
DIMENSIONS_SCHEMA = {'type': 'array', 'items': DECIMAL_SCHEMA, 'minItems': 3, 'maxItems': 3}
|
|
SHIPMENT_SCHEMA = {'type': 'object', 'additionalProperties': False, 'properties': {
|
|
'country': {'type': 'string', 'description': '明确目的国家的 ISO 两字母代码,例如 US、GB。'},
|
|
'total_weight_kg': DECIMAL_SCHEMA, 'total_volume_cbm': DECIMAL_SCHEMA,
|
|
'boxes': {'type': 'integer', 'minimum': 1, 'maximum': 10000},
|
|
'dimensions_cm': DIMENSIONS_SCHEMA,
|
|
'packages': {'type': 'array', 'maxItems': 200, 'items': {
|
|
'type': 'object', 'additionalProperties': False,
|
|
'properties': {'count': {'type': 'integer', 'minimum': 1},
|
|
'weight_kg': DECIMAL_SCHEMA, 'dimensions_cm': DIMENSIONS_SCHEMA},
|
|
'required': ['count', 'weight_kg']}},
|
|
'warehouse': {'type': 'string'}, 'postal': {'type': 'string'},
|
|
'zone': {'type': 'string', 'enum': ['美西', '美中', '美东', '东1区', '东2区', '中1区', '中2区', '西1区', '西2区']},
|
|
'origin': {'type': 'string', 'enum': ['义乌', '华南']},
|
|
'kinds': {'type': 'array', 'items': {'type': 'string', 'enum': ['parcel', 'truck']}},
|
|
'tax': {'type': 'string', 'enum': ['included', 'excluded']},
|
|
'address_type': {'type': 'string', 'enum': ['fba', 'non_fba'],
|
|
'description': '明确为亚马逊仓或非亚马逊的住宅/商业地址;未确认时省略。'},
|
|
'material': {'type': 'string', 'enum': ['ordinary', 'metal', 'unknown_metal']},
|
|
'metal_scope': {'type': 'string', 'enum': ['all', 'partial']},
|
|
'metal_chargeable_weight_kg': DECIMAL_SCHEMA,
|
|
}}
|
|
CHANGE_FIELDS = [key for key in SHIPMENT_SCHEMA['properties'] if key not in ('packages', 'kinds', 'dimensions_cm')]
|
|
CHANGES_SCHEMA = {'type': 'array', 'maxItems': 20, 'items': {
|
|
'type': 'object', 'additionalProperties': False,
|
|
'properties': {'field': {'type': 'string', 'enum': CHANGE_FIELDS},
|
|
'operation': {'type': 'string', 'enum': ['set', 'add']},
|
|
'value': DECIMAL_SCHEMA},
|
|
'required': ['field', 'operation', 'value']}}
|
|
FIELD_MAP = {'total_weight_kg': 'weight', 'total_volume_cbm': 'cbm',
|
|
'dimensions_cm': 'dims', 'metal_chargeable_weight_kg': 'metal_chargeable_weight'}
|
|
QUANTITIES = {'total_weight_kg', 'total_volume_cbm', 'metal_chargeable_weight_kg'}
|
|
|
|
|
|
def decimal_text(value, *, positive=True):
|
|
if isinstance(value, bool) or not isinstance(value, (str, int, float, Decimal)):
|
|
raise ValueError('数量必须是明确的数字。')
|
|
if len(str(value)) > 40:
|
|
raise ValueError('数量超出有效范围。')
|
|
try:
|
|
number = Decimal(str(value))
|
|
except InvalidOperation:
|
|
raise ValueError('数量必须是明确的数字。') from None
|
|
if not number.is_finite() or abs(number) > 1000000 or positive and number <= 0:
|
|
raise ValueError('请提供大于0且不超过1000000的有效数量。')
|
|
return format(number.normalize(), 'f')
|
|
|
|
|
|
def normalize_field(field, value):
|
|
if field in QUANTITIES:
|
|
return decimal_text(value)
|
|
if field == 'boxes':
|
|
if type(value) is not int or not 0 < value <= 10000:
|
|
raise ValueError('箱数必须是1至10000之间的整数。')
|
|
return value
|
|
if field == 'dimensions_cm':
|
|
if not isinstance(value, list) or len(value) != 3:
|
|
raise ValueError('请提供以厘米为单位的完整长宽高。')
|
|
result = [decimal_text(item) for item in value]
|
|
if any(Decimal(item) > 100000 for item in result):
|
|
raise ValueError('请提供有效的厘米箱规。')
|
|
return result
|
|
if field == 'packages':
|
|
if not isinstance(value, list) or not 1 <= len(value) <= 200:
|
|
raise ValueError('请提供有效的逐箱明细。')
|
|
result = []
|
|
for item in value:
|
|
if (not isinstance(item, dict) or not {'count', 'weight_kg'} <= set(item)
|
|
or set(item) - {'count', 'weight_kg', 'dimensions_cm'}):
|
|
raise ValueError('逐箱明细需要箱数和每箱重量,箱规使用厘米。')
|
|
piece = {'count': normalize_field('boxes', item['count']),
|
|
'weight_kg': decimal_text(item['weight_kg'])}
|
|
if 'dimensions_cm' in item:
|
|
piece['dimensions_cm'] = normalize_field('dimensions_cm', item['dimensions_cm'])
|
|
result.append(piece)
|
|
return result
|
|
if field == 'kinds':
|
|
if not isinstance(value, list) or not value or any(item not in ('parcel', 'truck') for item in value):
|
|
raise ValueError('当前运输方式仅支持海派或海卡。')
|
|
return list(dict.fromkeys(value))
|
|
if field not in SHIPMENT_SCHEMA['properties'] or not isinstance(value, str):
|
|
raise ValueError('包含不支持的货物字段。')
|
|
value = value.strip()
|
|
if not value or len(value) > 100:
|
|
raise ValueError('请提供有效的货物字段。')
|
|
choices = SHIPMENT_SCHEMA['properties'][field].get('enum')
|
|
if choices and value not in choices:
|
|
raise ValueError('货物条件不在当前支持的范围内。')
|
|
if field == 'country':
|
|
value = value.upper()
|
|
if not re.fullmatch(r'[A-Z]{2}', value):
|
|
raise ValueError('目的国家请使用明确的两字母代码。')
|
|
if field == 'warehouse':
|
|
value = value.upper()
|
|
if not re.fullmatch(r'[A-Z0-9_-]{2,20}', value):
|
|
raise ValueError('请核对目的仓码。')
|
|
if field == 'postal' and not re.fullmatch(r'[0-9]{5}(?:-[0-9]{4})?', value):
|
|
raise ValueError('美国邮编请提供5位数字,可附加4位扩展码。')
|
|
return value[:5] if field == 'postal' else value
|
|
|
|
|
|
def blank_intent(text=''):
|
|
keys = ('country weight dims cbm boxes packages allocations origin services kinds transport air_only air_requested '
|
|
'warehouse warehouses unknown_warehouses postal zone zones tax excluded_services clear_choice '
|
|
'choice_pending tax_pending quantity_change quantity_question pending_weight weight_unit material '
|
|
'metal_scope metal_chargeable_weight metal_weight_update address_type preserve_total')
|
|
result = {key: None for key in keys.split()}
|
|
result.update(dim_unit='cm', errors=[], mode='quote', selection=None, plan_reference=None,
|
|
plan_handled=False, clear_selection=False, quantity_uncertain=False,
|
|
volume_pending=False, compare_services=False, text=text, english=False)
|
|
return result
|
|
|
|
|
|
def public_inputs(intent):
|
|
from .pricing import dims_to_cm
|
|
|
|
result = {}
|
|
for field in SHIPMENT_SCHEMA['properties']:
|
|
key = FIELD_MAP.get(field, field)
|
|
value = intent.get(key)
|
|
if value is None:
|
|
continue
|
|
if field == 'country':
|
|
value = intent.get('_country_code') or ('US' if value == 'US' else None)
|
|
elif field == 'dimensions_cm':
|
|
value = [str(item) for item in dims_to_cm(value, intent.get('dim_unit', 'cm'))]
|
|
elif field == 'packages':
|
|
value = [{'count': item['count'], 'weight_kg': item['weight'],
|
|
**({'dimensions_cm': [str(number) for number in dims_to_cm(item['dims'], item.get('dim_unit', 'cm'))]}
|
|
if item.get('dims') else {})} for item in value]
|
|
if value is not None:
|
|
result[field] = deepcopy(value)
|
|
return result
|
|
|
|
|
|
def intent_from_inputs(shipment, text=''):
|
|
result = blank_intent(text)
|
|
for field, value in shipment.items():
|
|
if field == 'packages':
|
|
result['packages'] = [{'count': item['count'], 'weight': item['weight_kg'],
|
|
'dims': item.get('dimensions_cm'), 'dim_unit': 'cm'} for item in value]
|
|
else:
|
|
result[FIELD_MAP.get(field, field)] = deepcopy(value)
|
|
if result['country']:
|
|
result['_country_code'] = result['country']
|
|
result['country'] = 'US' if result['country'] == 'US' else 'other'
|
|
if result['warehouse']:
|
|
result['warehouses'] = [result['warehouse']]
|
|
result['address_type'] = result['address_type'] or 'fba'
|
|
if result['zone']:
|
|
result['zones'] = [result['zone']]
|
|
result['english'] = not bool(re.search(r'[\u4e00-\u9fff]', text)) and bool(re.search('[A-Za-z]{3,}', text))
|
|
return result
|
|
|
|
|
|
def legacy_state(conv_id):
|
|
"""Use saved conditions only while later messages have not crossed a topic boundary."""
|
|
if not conv_id:
|
|
return None
|
|
try:
|
|
state = load_state(conv_id)
|
|
except sqlite3.OperationalError as error:
|
|
if 'no such column' in str(error):
|
|
return None # Compatibility before the state migration has been installed.
|
|
raise
|
|
if not state or not state.get('intent'):
|
|
return None
|
|
from .db import connect
|
|
from .quote_intent import TOPIC_BOUNDARY, US
|
|
from .pricing import shipping_region
|
|
connection = connect()
|
|
try:
|
|
later = connection.execute("SELECT content FROM messages WHERE conv_id=? AND role='user' AND id>? ORDER BY id",
|
|
(conv_id, state.get('history_id', 0))).fetchall()
|
|
finally:
|
|
connection.close()
|
|
for (message,) in later:
|
|
if TOPIC_BOUNDARY.search(message) or shipping_region(message) is not None and not US.search(message):
|
|
return None
|
|
return state
|
|
|
|
|
|
def state_candidate(base, intent, book, options):
|
|
clean = deepcopy(intent)
|
|
clean.update(text='', errors=[], quantity_change=None, quantity_note=None,
|
|
quantity_question=None, plan_reference=None, plan_handled=False)
|
|
return {**deepcopy(base), 'schema': 1, 'shipment_id': base.get('shipment_id') or uuid4().hex,
|
|
'intent': clean, 'options': deepcopy(options), 'book_id': book['id'] if book else None}
|
|
|
|
|
|
def migrate_history(conv_id, book):
|
|
"""Recover only past messages, never reapply this turn's amendment while migrating."""
|
|
from .quote_service import previous_quote_intents
|
|
from .quote_context import merge_intent
|
|
|
|
codes = {row['code'] for row in book['warehouses']}
|
|
codes.update(code.upper() for rate in book['rates'] for code in rate['destinations'])
|
|
previous = previous_quote_intents(conv_id, codes) if conv_id else []
|
|
if not previous:
|
|
return blank_intent()
|
|
result = previous[-1]
|
|
for update in reversed(previous[:-1]):
|
|
result = merge_intent(update, result)
|
|
return result
|
|
|
|
|
|
def option_facts(intent, book, shipment_id):
|
|
"""Return exact calculated rate facts; these are not inferred display ordinals."""
|
|
from .quote_data import postcode_fee
|
|
from .quote_engine import variants, match_rates, quantity, select_timed_rates, select_cheapest_rates
|
|
from .quote_fees import charge_details
|
|
|
|
results = []
|
|
for destination in variants(intent, book):
|
|
if destination.get('allocation'):
|
|
continue # Legacy allocation quotes remain supported but are not guessed into new references.
|
|
fee = postcode_fee(book['id'], destination['postal']) if destination['postal'] else None
|
|
rates = match_rates(intent, book, destination)
|
|
selection = intent.get('selection') or intent.get('mode')
|
|
if selection in ('fastest', 'slowest'):
|
|
rates = select_timed_rates(rates, slowest=selection == 'slowest', by_origin=intent.get('mode') == 'quote_detail')
|
|
elif selection == 'cheapest':
|
|
rates = select_cheapest_rates(intent, book, rates, fee, zone=destination['zone']) or rates
|
|
elif selection in ('speed_compare', 'fastest_cheapest'):
|
|
fast = select_timed_rates(rates, by_origin=intent.get('mode') == 'quote_detail')
|
|
other = (select_timed_rates(rates, slowest=True, by_origin=intent.get('mode') == 'quote_detail')
|
|
if selection == 'speed_compare' else select_cheapest_rates(intent, book, rates, fee, zone=destination['zone']))
|
|
rates = fast + [rate for rate in other if rate not in fast]
|
|
for rate in rates:
|
|
amount = quantity(intent, rate)
|
|
if amount is None:
|
|
continue
|
|
details = charge_details(intent, rate, fee, book)
|
|
identity = [book['id'], shipment_id, public_inputs(intent), destination, rate]
|
|
option_id = 'quote_' + hashlib.sha256(json.dumps(identity, sort_keys=True, ensure_ascii=False, default=str).encode()).hexdigest()[:24]
|
|
base_freight = amount * Decimal(rate['price'])
|
|
results.append({'option_id': option_id, 'service': rate['service'], 'origin': rate['origin'],
|
|
'tax': rate['tax'], 'unit': rate['unit'], 'kind': rate['kind'], 'currency': 'CNY',
|
|
'quantity': str(amount), 'unit_price': rate['price'], 'base_freight': str(base_freight),
|
|
'known_surcharges': str(details['cny']), 'known_subtotal': str(base_freight + details['cny']),
|
|
'eta': rate['eta'], 'destination': deepcopy(destination),
|
|
'warnings': details['notes'], 'source_sheet': rate.get('source_sheet'), 'source_cell': rate.get('source_cell'),
|
|
'data_version': book['id']})
|
|
return results
|
|
|
|
|
|
class QuoteTurn:
|
|
def __init__(self, conv_id, question, book):
|
|
self.conv_id, self.question, self.book = conv_id, question, deepcopy(book)
|
|
self.base_state = load_state(conv_id) if conv_id else empty_state()
|
|
if self.base_state and self.base_state.get('intent'):
|
|
from .quote_intent import resumes_quote
|
|
if not resumes_quote(question) and legacy_state(conv_id) is None:
|
|
self.base_state = {**self.base_state, 'intent': None, 'options': [], 'shipment_id': None}
|
|
self.expected_version = self.base_state['version'] if self.base_state else None
|
|
self.candidate = None
|
|
self._applied = {}
|
|
self._new = False
|
|
self._conflict = False
|
|
self._intent = deepcopy(self.base_state.get('intent')) if self.base_state else None
|
|
self._shipment_id = (self.base_state or {}).get('shipment_id') or uuid4().hex
|
|
self._options = []
|
|
self._legacy_cache = {}
|
|
|
|
def context(self):
|
|
state = self.candidate or self.base_state
|
|
if state is None:
|
|
return {'status': 'forbidden'}
|
|
current_book = self.book['id'] if self.book else None
|
|
return {'confirmed_inputs': public_inputs(state['intent']) if state.get('intent') else {},
|
|
'options': deepcopy(state.get('options', [])) if state.get('book_id') == current_book else [],
|
|
'data_version': current_book}
|
|
|
|
def replay_confirmed(self, state):
|
|
"""A login retry may reread the original quote without changing current state."""
|
|
self.base_state = {**deepcopy(state), 'version': self.expected_version}
|
|
self._intent = deepcopy(state['intent'])
|
|
self._shipment_id = state['shipment_id']
|
|
|
|
def _result(self, text, status, intent=None, options=None, missing=None):
|
|
confirmed = public_inputs(intent or self._intent or {})
|
|
valid_candidate = self.candidate is not None or (
|
|
not self.conv_id and intent is not None and not self._conflict and status != 'error')
|
|
basis = None
|
|
if valid_candidate:
|
|
conditions = {'shipment_id': self._shipment_id, 'inputs': confirmed,
|
|
'allocations': (intent or self._intent or {}).get('allocations')}
|
|
basis = hashlib.sha256(json.dumps(conditions, sort_keys=True, ensure_ascii=False,
|
|
default=str).encode()).hexdigest()[:24]
|
|
return {'t': text, 'status': status, 'data': {'confirmed_inputs': confirmed, 'options': options or []},
|
|
'_confirmed_quote': confirmed, '_quote_basis': basis, 'missing_fields': missing or [],
|
|
'data_version': self.book['id'] if self.book else None}
|
|
|
|
def run(self, arguments):
|
|
if self.base_state is None:
|
|
return self._result('该会话暂时无法访问,请新建对话后重试。', 'error')
|
|
if self.conv_id:
|
|
current = load_state(self.conv_id)
|
|
if current is None:
|
|
self.candidate = None
|
|
return {'t': '该会话暂时无法访问,请新建对话后重试。', 'status': 'error',
|
|
'data': {}, 'data_version': None}
|
|
if current['version'] != self.expected_version:
|
|
self.candidate = None
|
|
return self._result('会话条件已被另一条消息更新,请按最新条件重新查询。', 'needs_clarification')
|
|
if self._conflict:
|
|
return self._result('同一条消息中的货物更正相互冲突,请确认本次修改后的条件。', 'needs_clarification')
|
|
if not self.book:
|
|
return self._result('当前尚未包含可用的报价资料,请联系人工确认。', 'not_found')
|
|
try:
|
|
return self._run(arguments)
|
|
except (ValueError, InvalidOperation) as error:
|
|
return self._result(str(error), 'needs_clarification')
|
|
except TypeError:
|
|
return self._result('货物条件暂时无法核对,请确认重量、箱数及箱规后重试。', 'error')
|
|
|
|
def _run(self, arguments):
|
|
from .quote_service import answer_quote_intent, intent_with_context
|
|
question = arguments.get('question') or self.question
|
|
structured = any(key in arguments for key in ('shipment', 'changes', 'new_shipment', 'option_id'))
|
|
shipment = arguments.get('shipment', {})
|
|
changes = arguments.get('changes', [])
|
|
if not isinstance(shipment, dict) or set(shipment) - set(SHIPMENT_SCHEMA['properties']):
|
|
raise ValueError('包含不支持的货物字段。')
|
|
if not isinstance(changes, list) or len(changes) > 20 or type(arguments.get('new_shipment', False)) is not bool:
|
|
raise ValueError('货物修改格式无效。')
|
|
edits = {key: [('set', normalize_field(key, value))] for key, value in shipment.items()}
|
|
for change in changes:
|
|
if not isinstance(change, dict) or set(change) != {'field', 'operation', 'value'}:
|
|
raise ValueError('货物修改需要字段、操作和数值。')
|
|
field, operation = change['field'], change['operation']
|
|
if field not in CHANGE_FIELDS or operation not in ('set', 'add'):
|
|
raise ValueError('不支持该货物修改方式。')
|
|
if operation == 'add':
|
|
if field not in ('total_weight_kg', 'total_volume_cbm', 'boxes'):
|
|
raise ValueError('只有重量、体积和箱数支持增减。')
|
|
value = decimal_text(change['value'], positive=False)
|
|
if field == 'boxes' and Decimal(value) != Decimal(value).to_integral_value():
|
|
raise ValueError('增减箱数必须是整数。')
|
|
else:
|
|
value = normalize_field(field, change['value'])
|
|
actions = edits.setdefault(field, [])
|
|
action = (operation, value)
|
|
if action not in actions:
|
|
if any(existing[0] == operation for existing in actions):
|
|
raise ValueError('同一字段给出了不同的修改值,请确认本次修改后的条件。')
|
|
actions.append(action)
|
|
for field, actions in edits.items():
|
|
signature = json.dumps(actions, sort_keys=True)
|
|
if field in self._applied and self._applied[field] != signature:
|
|
self._conflict, self.candidate = True, None
|
|
raise ValueError('同一条消息中的货物更正相互冲突,请确认本次修改后的条件。')
|
|
new = arguments.get('new_shipment', False)
|
|
new_created = False
|
|
if new and not self._new:
|
|
if self._applied:
|
|
self._conflict, self.candidate = True, None
|
|
raise ValueError('请确认本次是在修改原货物,还是新的一票货物。')
|
|
self._intent, self._shipment_id, self._new = blank_intent(question), uuid4().hex, True
|
|
new_created = True
|
|
if self._intent is None:
|
|
# Migration only: legacy conversations may have established conditions.
|
|
# New structured shipments never go through text-to-intent parsing.
|
|
self._intent = (blank_intent(question) if shipment or new else migrate_history(self.conv_id, self.book)) if structured else intent_with_context(question, self.conv_id, self.book)
|
|
if not structured:
|
|
if question not in self._legacy_cache:
|
|
self._legacy_cache[question] = intent_with_context(question, self.conv_id, self.book)
|
|
self._intent = deepcopy(self._legacy_cache[question])
|
|
current = public_inputs(self._intent)
|
|
updated_fields = set()
|
|
for field, actions in edits.items():
|
|
if field in self._applied:
|
|
continue
|
|
value = current.get(field)
|
|
for operation, operand in actions:
|
|
if operation == 'set':
|
|
value = operand
|
|
else:
|
|
if value is None:
|
|
raise ValueError('请先确认原来的总重、体积或箱数,再说明增减量。')
|
|
number = Decimal(str(value)) + Decimal(operand)
|
|
value = normalize_field(field, int(number) if field == 'boxes' else str(number))
|
|
if value != current.get(field):
|
|
updated_fields.add(field)
|
|
current[field] = value
|
|
if 'country' in updated_fields and public_inputs(self._intent).get('country') not in (None, current['country']):
|
|
current = {field: current[field] for field in edits}
|
|
self._shipment_id = uuid4().hex
|
|
if updated_fields & {'warehouse', 'postal', 'zone'}:
|
|
for field in {'warehouse', 'postal', 'zone'} - set(edits):
|
|
current.pop(field, None)
|
|
if 'total_weight_kg' in updated_fields and 'packages' not in edits:
|
|
current.pop('packages', None)
|
|
if 'boxes' in updated_fields and 'packages' not in edits:
|
|
current.pop('packages', None)
|
|
if 'total_weight_kg' not in edits:
|
|
current.pop('total_weight_kg', None)
|
|
if 'dimensions_cm' in updated_fields:
|
|
current.pop('total_volume_cbm', None)
|
|
if 'packages' in current:
|
|
for piece in current['packages']:
|
|
piece['dimensions_cm'] = current['dimensions_cm']
|
|
if 'total_volume_cbm' in updated_fields:
|
|
current.pop('dimensions_cm', None)
|
|
for piece in current.get('packages', []):
|
|
piece.pop('dimensions_cm', None)
|
|
if updated_fields & {'total_weight_kg', 'total_volume_cbm', 'boxes', 'dimensions_cm', 'packages'}:
|
|
if 'metal_chargeable_weight_kg' not in edits:
|
|
current.pop('metal_chargeable_weight_kg', None)
|
|
if current.get('material') == 'ordinary':
|
|
current.pop('metal_scope', None)
|
|
current.pop('metal_chargeable_weight_kg', None)
|
|
if current.get('packages'):
|
|
count = sum(piece['count'] for piece in current['packages'])
|
|
weight = sum(Decimal(piece['weight_kg']) * piece['count'] for piece in current['packages'])
|
|
if count > 10000 or current.get('boxes', count) != count or Decimal(current.get('total_weight_kg', str(weight))) != weight:
|
|
raise ValueError('总箱数或总重与逐箱明细不一致,请确认。')
|
|
current.update(boxes=count, total_weight_kg=decimal_text(weight))
|
|
invalidates_allocation = updated_fields & {'country', 'total_weight_kg', 'total_volume_cbm', 'boxes', 'dimensions_cm', 'packages', 'warehouse', 'postal', 'zone'}
|
|
if self._intent.get('allocations') and invalidates_allocation and not new:
|
|
raise ValueError('原货物按多个目的仓分票,请分别确认各仓重量和箱规,不能把整票修改自动分配到各仓。')
|
|
intent = intent_from_inputs(current, question) if structured or edits else deepcopy(self._intent)
|
|
if structured and 'country' not in updated_fields and not new:
|
|
intent['services'] = deepcopy(self._intent.get('services'))
|
|
if not invalidates_allocation:
|
|
for key in ('allocations', 'warehouses', 'zones', 'plan_unit', 'plan_destination'):
|
|
if self._intent.get(key) is not None:
|
|
intent[key] = deepcopy(self._intent[key])
|
|
intent['text'] = question
|
|
if structured or edits:
|
|
intent['errors'] = []
|
|
if intent.get('errors'):
|
|
return self._result('\n'.join(intent['errors']), 'needs_clarification', intent)
|
|
request = {key: arguments.get(key, default) for key, default in
|
|
(('request', 'quote'), ('selection', 'current'), ('services', []), ('policy_topic', 'auto'))}
|
|
if request['selection'] != 'current':
|
|
intent['services'] = request['services'] or None
|
|
intent.pop('plan_unit', None)
|
|
intent.pop('plan_destination', None)
|
|
elif request['services']:
|
|
intent['services'] = request['services']
|
|
option_id = arguments.get('option_id')
|
|
if option_id:
|
|
option = next((option for option in self.base_state['options'] if option.get('option_id') == option_id), None)
|
|
if (not option or self.base_state.get('book_id') != self.book['id'] or updated_fields or new or self._applied or self._new):
|
|
raise ValueError('所选方案已失效或条件发生变化,请重新查看报价后选择。')
|
|
intent.update(services=[option['service']], origin=option['origin'], tax=option['tax'],
|
|
kinds=[option['kind']], plan_unit=option['unit'])
|
|
destination = option['destination']
|
|
intent.update(warehouse=destination['warehouse'], postal=destination['postal'], zone=destination['zone'])
|
|
request.update(selection='current', services=[option['service']])
|
|
for field, actions in edits.items():
|
|
self._applied[field] = json.dumps(actions, sort_keys=True)
|
|
if updated_fields or new_created:
|
|
self._options = []
|
|
self._intent = deepcopy(intent)
|
|
base = {**self.base_state, 'shipment_id': self._shipment_id}
|
|
if intent.get('country') != 'US':
|
|
self.candidate = state_candidate(base, intent, self.book, []) if self.conv_id else None
|
|
return self._result('请确认目的国家;当前结构化报价仅支持已导入的美国资料,其他目的地需人工核对。',
|
|
'needs_clarification' if not intent.get('country') else 'unsupported', intent, missing=['country'] if not intent.get('country') else [])
|
|
known_codes = {row['code'] for row in self.book['warehouses']}
|
|
known_codes.update(code.upper() for rate in self.book['rates'] for code in rate['destinations'])
|
|
if intent['warehouse'] and intent['warehouse'] not in known_codes:
|
|
intent['unknown_warehouses'] = [intent['warehouse']]
|
|
answer = answer_quote_intent(intent, self.book, request) or {'t': '请补充货物条件,以便继续核对报价。'}
|
|
# Apply the same selection to exact rate facts without parsing generated prose.
|
|
fact_intent = answer.get('_quote_intent', intent)
|
|
can_quote = answer.get('_quote_calculated') and request['request'] != 'policy'
|
|
options = option_facts(fact_intent, self.book, self._shipment_id) if can_quote else []
|
|
if request['request'] == 'policy' and not self._options and not updated_fields and not new_created and self.base_state.get('book_id') == self.book['id']:
|
|
self._options = deepcopy(self.base_state['options'])
|
|
self._options = list({option['option_id']: option for option in self._options + options}.values())
|
|
self.candidate = state_candidate(base, intent, self.book, self._options) if self.conv_id else None
|
|
missing = [] if request['request'] in ('time', 'policy') or any(intent.get(key) for key in ('weight', 'cbm', 'dims', 'packages')) else ['total_weight_kg']
|
|
status = 'needs_clarification' if missing else 'ok'
|
|
if intent.get('unknown_warehouses'):
|
|
status = 'not_found'
|
|
elif answer.get('status') == 'error':
|
|
status = 'error'
|
|
elif request['request'] not in ('policy', 'time') and not options and not missing:
|
|
status = 'not_found' if answer.get('_quote_calculated') else 'needs_clarification'
|
|
return self._result(answer['t'], status, intent, options, missing)
|