"""Structured quotes preserve exact quantities and transaction-owned turn state.""" from hashlib import sha256 from decimal import Decimal from pathlib import Path import tempfile import unittest from unittest.mock import patch from flask import g from logistics_chat import create_app, db from logistics_chat.conversation_state import commit_state, init_state_schema, load_state from logistics_chat.quote_data import import_snapshot, load_book from logistics_chat.quote_service import answer_quote from logistics_chat.quote_tool import QuoteTurn from test_quote_timing import timing_snapshot class QuoteToolTests(unittest.TestCase): def setUp(self): directory = tempfile.TemporaryDirectory() self.addCleanup(directory.cleanup) root = Path(directory.name) self.app = create_app({'TESTING': True, 'DATABASE': root / 'history.db', 'UPLOAD_DIR': root / 'uploads', 'BACKUP_DIR': root / 'backups', 'LOG_DIR': root / 'logs', 'DEEPSEEK_API_KEY': '', 'CHAT_API_KEY': '', 'RATE_LIMIT_PER_MIN': 1000}) client = self.app.test_client() client.post('/api/visitor/session', json={'key': '7' * 64}) self.cid = client.post('/api/new_chat').json['id'] self.context = self.app.test_request_context() self.context.push() self.addCleanup(self.context.pop) g.visitor_id = sha256(('7' * 64).encode()).hexdigest() connection = db.connect() init_state_schema(connection) import_snapshot(connection, timing_snapshot(), activate=True) connection.commit() connection.close() self.book = load_book() def arguments(self, **extra): return {'question': '核对报价', 'request': 'quote', 'selection': 'current', 'services': [], 'policy_topic': 'auto', **extra} def start(self, **shipment): turn = QuoteTurn(self.cid, '一票新货', self.book) answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000', 'zone': '美西', 'origin': '义乌', **shipment})) self.assertEqual(answer['status'], 'ok', answer) self.persist(turn) return answer def persist(self, turn, question='已确认货物条件'): connection = db.connect() try: connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(?,'user',?,'2026-11-06 12:00:00')", (self.cid, question)) self.assertTrue(commit_state(connection, self.cid, turn.candidate, turn.expected_version)) connection.commit() finally: connection.close() def test_same_turn_addition_applies_once_across_comparisons(self): self.start() turn = QuoteTurn(self.cid, '再加100KG,最快最慢都查', self.book) change = [{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}] for selection in ('fastest', 'slowest', 'fastest'): answer = turn.run(self.arguments(changes=change, selection=selection)) self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100') self.assertIn('1100KG', answer['t']) self.persist(turn, '再加100KG') later = QuoteTurn(self.cid, '再加100KG', self.book) answer = later.run(self.arguments(changes=change)) self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1200') def test_conflicting_same_turn_edits_do_not_commit_partial_change(self): self.start() turn = QuoteTurn(self.cid, '修改重量', self.book) for delta in ('100', '200'): answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': delta}])) self.assertEqual(answer['status'], 'needs_clarification') self.assertIsNone(turn.candidate) self.assertEqual(load_state(self.cid)['intent']['weight'], '1000') def test_add_without_base_and_bad_dimensions_are_clarifications(self): turn = QuoteTurn(self.cid, '加100', self.book) answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}])) self.assertEqual(answer['status'], 'needs_clarification') self.assertIsNone(turn.candidate) for dims in ([20, 0, 30], [20, 30], ['NaN', 20, 30]): answer = turn.run(self.arguments(shipment={'country': 'US', 'dimensions_cm': dims})) self.assertEqual(answer['status'], 'needs_clarification') def test_structured_dimensions_use_decimal_without_reparsing_question(self): turn = QuoteTurn(self.cid, '箱规已经提供', self.book) with patch('logistics_chat.quote_service.intent_with_context', side_effect=AssertionError('must not parse')): answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '10.5', 'boxes': 1, 'dimensions_cm': ['60', '40', '30'], 'zone': '美西', 'origin': '义乌'})) self.assertEqual(answer['status'], 'ok', answer) self.assertIn('12KG', answer['t']) self.assertEqual(Decimal(answer['data']['options'][0]['quantity']), Decimal('12')) def test_carton_validation_and_weight_edit_invalidate_derived_fields(self): turn = QuoteTurn(self.cid, '两箱', self.book) invalid = {'country': 'US', 'total_weight_kg': '50', 'boxes': 2, 'packages': [{'count': 2, 'weight_kg': '20'}]} self.assertEqual(turn.run(self.arguments(shipment=invalid))['status'], 'needs_clarification') invalid['total_weight_kg'] = '40' invalid.update(material='metal', metal_scope='partial', metal_chargeable_weight_kg='10') self.assertEqual(turn.run(self.arguments(shipment=invalid))['status'], 'ok') self.persist(turn) update = QuoteTurn(self.cid, '总重改成100', self.book) answer = update.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'set', 'value': '100'}])) self.assertNotIn('packages', answer['data']['confirmed_inputs']) self.assertNotIn('metal_chargeable_weight_kg', answer['data']['confirmed_inputs']) def test_country_switch_clears_old_quantities_and_destination(self): self.start(warehouse='ZZZ1') turn = QuoteTurn(self.cid, '这票改发英国', self.book) answer = turn.run(self.arguments(shipment={'country': 'GB'})) self.assertEqual(answer['status'], 'unsupported') self.assertEqual(answer['data']['confirmed_inputs'], {'country': 'GB'}) self.assertEqual(turn.candidate['options'], []) def test_fixed_book_survives_active_version_switch(self): turn = QuoteTurn(self.cid, '1000KG美国', self.book) newer = timing_snapshot() newer['source_sha256'] = '2' * 64 for rate in newer['rates']: rate['price'] = '99' connection = db.connect() import_snapshot(connection, newer, activate=True) connection.commit() connection.close() with patch('logistics_chat.quote_service.load_book', side_effect=AssertionError('must pin book')): answer = turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000', 'zone': '美西', 'origin': '义乌'})) self.assertEqual(answer['data_version'], self.book['id']) self.assertIn('8000元', answer['t']) self.assertNotIn('99000元', answer['t']) def test_stale_deleted_and_unowned_state_cannot_be_committed(self): first, second = (QuoteTurn(self.cid, '1000KG', self.book) for _ in range(2)) for turn in (first, second): turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'})) self.persist(first) connection = db.connect() self.assertFalse(commit_state(connection, self.cid, second.candidate, second.expected_version)) connection.close() g.visitor_id = 'other' forbidden = QuoteTurn(self.cid, '查报价', self.book) self.assertEqual(forbidden.run(self.arguments())['status'], 'error') self.assertIsNone(load_state(self.cid)) g.visitor_id = sha256(('7' * 64).encode()).hexdigest() latest = QuoteTurn(self.cid, '1000KG', self.book) latest.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'})) connection = db.connect() connection.execute('DELETE FROM chat_history WHERE id=?', (self.cid,)) self.assertFalse(commit_state(connection, self.cid, latest.candidate, latest.expected_version)) connection.commit() connection.close() def test_state_write_rolls_back_with_the_reply_transaction(self): turn = QuoteTurn(self.cid, '1000KG', self.book) turn.run(self.arguments(shipment={'country': 'US', 'total_weight_kg': '1000'})) connection = db.connect() connection.execute('BEGIN IMMEDIATE') self.assertTrue(commit_state(connection, self.cid, turn.candidate, turn.expected_version)) connection.rollback() connection.close() self.assertEqual(load_state(self.cid)['version'], 0) def test_legacy_fallback_updates_saved_conditions_without_double_add(self): self.start() answer = answer_quote('再加100KG', self.cid) self.assertIn('1100KG', answer['t']) self.assertEqual(answer['_quote_state_candidate']['intent']['weight'], '1100') connection = db.connect() connection.execute("INSERT INTO messages(conv_id,role,content,ctime) VALUES(?,'user',?,'2026-11-06 12:01:00')", (self.cid, '再加100KG')) self.assertTrue(commit_state(connection, self.cid, answer['_quote_state_candidate'], answer['_quote_state_expected_version'])) connection.commit() connection.close() turn = QuoteTurn(self.cid, '最快呢', self.book) answer = turn.run(self.arguments(shipment={}, selection='fastest')) self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100') self.assertIn('1100KG', answer['t']) def test_old_numbered_plan_is_not_guessed_from_reply_text(self): self.start() answer = answer_quote('方案2多少钱', self.cid) self.assertIn('不能根据旧回复猜测方案编号', answer['t']) def test_option_reference_rejects_stale_price_version(self): initial = self.start() option = initial['data']['options'][0]['option_id'] newer = {**self.book, 'id': self.book['id'] + 100} turn = QuoteTurn(self.cid, '按这个方案', newer) answer = turn.run(self.arguments(option_id=option)) self.assertEqual(answer['status'], 'needs_clarification') self.assertIn('已失效', answer['t']) def test_changes_only_can_migrate_legacy_history_once(self): db.save_message(self.cid, 'user', '1000KG到美国,美西,义乌交仓') turn = QuoteTurn(self.cid, '再加100KG', self.book) answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}])) self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100') def test_later_dimensions_in_same_turn_invalidate_earlier_option_facts(self): self.start(total_weight_kg='10') turn = QuoteTurn(self.cid, '补充箱规', self.book) first = turn.run(self.arguments(shipment={}, selection='fastest')) old_ids = {item['option_id'] for item in first['data']['options']} answer = turn.run(self.arguments(shipment={'boxes': 1, 'total_weight_kg': '10', 'dimensions_cm': [100, 100, 100]})) self.assertEqual(answer['status'], 'ok') self.assertTrue(old_ids.isdisjoint({item['option_id'] for item in turn.candidate['options']})) def test_duplicate_change_in_one_call_is_not_applied_twice(self): self.start() turn = QuoteTurn(self.cid, '加100KG', self.book) change = {'field': 'total_weight_kg', 'operation': 'add', 'value': '100'} answer = turn.run(self.arguments(changes=[change, change])) self.assertEqual(answer['data']['confirmed_inputs']['total_weight_kg'], '1100') def test_other_customer_or_deleted_conversation_cannot_reuse_turn_snapshot(self): self.start() turn = QuoteTurn(self.cid, '最快呢', self.book) g.visitor_id = 'other' answer = turn.run(self.arguments(shipment={})) self.assertEqual(answer['status'], 'error') self.assertEqual(answer['data'], {}) def test_all_channels_does_not_inherit_a_previously_selected_service(self): self.start() turn = QuoteTurn(self.cid, '查普船', self.book) answer = turn.run(self.arguments(shipment={}, services=['普船'])) self.assertNotIn('美森正班海派', answer['t']) self.persist(turn) later = QuoteTurn(self.cid, '全部渠道', self.book) answer = later.run(self.arguments(shipment={}, selection='all')) self.assertIn('美森正班海派', answer['t']) def test_new_shipment_is_reset_once_and_keeps_both_comparison_results(self): self.start(total_weight_kg='2000') turn = QuoteTurn(self.cid, '新一票100KG,最快最慢', self.book) payload = {'country': 'US', 'total_weight_kg': '100', 'zone': '美西', 'origin': '义乌'} fast = turn.run(self.arguments(shipment=payload, new_shipment=True, selection='fastest')) slow = turn.run(self.arguments(shipment=payload, new_shipment=True, selection='slowest')) expected = {item['option_id'] for answer in (fast, slow) for item in answer['data']['options']} self.assertEqual({item['option_id'] for item in turn.candidate['options']}, expected) self.assertEqual(turn.candidate['intent']['weight'], '100') def test_policy_followup_keeps_existing_option_references(self): initial = self.start() turn = QuoteTurn(self.cid, '怎么算计费重', self.book) turn.run(self.arguments(shipment={}, request='policy', policy_topic='weight_basis')) self.assertEqual(turn.candidate['options'], initial['data']['options']) def test_legacy_tool_question_does_not_discard_missing_unit_error(self): self.start() turn = QuoteTurn(self.cid, '重量改成100', self.book) answer = turn.run(self.arguments(question='重量改成100')) self.assertEqual(answer['status'], 'needs_clarification') self.assertIn('单位', answer['t']) self.assertIsNone(turn.candidate) def test_non_fba_address_fee_warning_survives_structured_followup(self): initial = self.start(postal='90001', address_type='non_fba') self.assertIn('住宅/商业派送附加费', initial['t']) turn = QuoteTurn(self.cid, '改100KG', self.book) answer = turn.run(self.arguments(changes=[{'field': 'total_weight_kg', 'operation': 'set', 'value': '100'}])) self.assertEqual(answer['data']['confirmed_inputs']['address_type'], 'non_fba') self.assertIn('住宅/商业派送附加费', answer['t']) def test_legacy_country_switch_invalidates_old_model_context(self): self.start() self.assertIsNone(answer_quote('100KG发英国', self.cid)) db.save_message(self.cid, 'user', '100KG发英国') turn = QuoteTurn(self.cid, '最快呢', self.book) self.assertEqual(turn.context()['confirmed_inputs'], {}) self.assertEqual(turn.context()['options'], []) answer = turn.run(self.arguments(shipment={}, selection='fastest')) self.assertEqual(answer['status'], 'needs_clarification') self.assertNotIn('1000KG', answer['t']) if __name__ == '__main__': unittest.main()