193 lines
11 KiB
Python
193 lines
11 KiB
Python
"""Local order snapshots, exact lookup and conversational order queries."""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
import tempfile
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
from logistics_chat import create_app, db, order_data
|
||
from logistics_chat.tracking import CARD_PREFIX, card_to_text
|
||
|
||
|
||
class OrderDataTests(unittest.TestCase):
|
||
def setUp(self):
|
||
folder = tempfile.TemporaryDirectory()
|
||
self.addCleanup(folder.cleanup)
|
||
root = Path(folder.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':'', 'ORDER_DATA_TENANT_ID':'', 'RATE_LIMIT_PER_MIN':1000})
|
||
self.client = self.app.test_client()
|
||
self.context = self.app.app_context()
|
||
self.context.push()
|
||
self.addCleanup(self.context.pop)
|
||
self.record = {'source_order_id':'999999999999999999', 'tenant_id':'tenant-a',
|
||
'order_no':'FL202610290001', 'inbound_code':'INB20261029001-01', 'shipment_id':'shipment00208',
|
||
'status':'PROCESSING', 'country_code':'US', 'transport_mode':'SEA', 'warehouse_code':'TEST1',
|
||
'order_role':'MASTER', 'package_count':3, 'gross_weight_kg':'30.000', 'volume_cbm':'0.2500',
|
||
'history_json':[{'time':'2026-10-28 10:00', 'status':'SUBMITTED'},
|
||
{'time':'2026-10-29 10:00', 'status':'PROCESSING'}]}
|
||
self.import_rows([self.record])
|
||
|
||
def import_rows(self, rows, replace=False):
|
||
connection = db.connect()
|
||
try:
|
||
with connection:
|
||
return order_data.import_records(connection, rows, replace=replace)
|
||
finally:
|
||
connection.close()
|
||
|
||
def events(self, message, cid=None):
|
||
response = self.client.post('/stream_chat', json={'msg':message, 'conv_id':cid})
|
||
self.assertEqual(response.status_code, 200)
|
||
return [json.loads(frame[6:]) for frame in response.get_data(as_text=True).split('\n\n') if frame.startswith('data: ')]
|
||
|
||
def test_identifiers_are_exact_case_insensitive_and_preserve_suffixes(self):
|
||
for value in ('fl202610290001', 'INB20261029001-01', 'shipment00208'):
|
||
self.assertEqual(order_data.lookup_order(value)[0]['order_no'], self.record['order_no'])
|
||
self.assertEqual(self.events('查询 '+value)[0]['card']['no'], self.record['order_no'])
|
||
self.assertEqual(order_data.lookup_order('FL20261029000'), [])
|
||
self.assertEqual(order_data.extract_reference('查询 INB20261029001-01'), ['INB20261029001-01'])
|
||
self.import_rows([{**self.record,'order_no':'BOX100x100x100'}])
|
||
self.assertEqual(self.events('查询 BOX100x100x100')[0]['card']['no'],'BOX100x100x100')
|
||
|
||
def test_weight_dates_and_attachment_urls_are_not_order_identifiers(self):
|
||
for text in ('2000KG海运报价', '100000 kg海运报价', '30.000000 KG', '2026-10-29',
|
||
'2026-10-29T16:53:00', '100x100x100cm', '1200 × 800 × 600 mm',
|
||
'1.2x0.8x0.6m', '[文件]/uploads/20261029100145_6787.txt'):
|
||
self.assertEqual(order_data.extract_reference(text), [])
|
||
events = self.events('帮我查询物流轨迹')
|
||
self.assertIn('订单号', events[0]['t'])
|
||
self.assertEqual(events[-1], {'d':1})
|
||
|
||
def test_order_identifiers_remain_queryable_alongside_quantities_and_dates(self):
|
||
for message in ('查询 FL202610290001,2026-10-29T16:53:00 提交的',
|
||
'FL202610290001 30.000000 KG', 'FL202610290001 100x100x100cm',
|
||
'FL202610290001 运费多少钱?'):
|
||
with self.subTest(message=message):
|
||
event = self.events(message)[0]
|
||
if '运费' in message:
|
||
self.assertIn('未记录实际运费', event['t'])
|
||
else:
|
||
self.assertEqual(event['card']['no'], self.record['order_no'])
|
||
|
||
def test_unknown_orders_never_produce_fake_tracking_or_call_model(self):
|
||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||
events = self.events('查询 UNKNOWN20260001')
|
||
self.assertIn('未找到', events[0]['t'])
|
||
self.assertFalse(any('card' in event for event in events))
|
||
self.assertEqual(events[-1], {'d':1})
|
||
model.assert_not_called()
|
||
|
||
def test_unimplemented_bulk_queries_ask_for_identifier_instead_of_inventing_records(self):
|
||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||
events = self.events('列出所有订单')
|
||
self.assertIn('请提供订单号', events[0]['t'])
|
||
model.assert_not_called()
|
||
|
||
def test_server_tenant_scope_and_ambiguous_identifiers(self):
|
||
self.import_rows([{**self.record, 'tenant_id':'tenant-b', 'gross_weight_kg':'99'}])
|
||
self.assertIn('多个订单', self.events(self.record['order_no'])[0]['t'])
|
||
self.app.config['ORDER_DATA_TENANT_ID'] = 'tenant-a'
|
||
rows = order_data.lookup_order(self.record['order_no'])
|
||
self.assertEqual(len(rows), 1)
|
||
self.assertEqual(rows[0]['gross_weight_kg'], '30.000')
|
||
self.app.config['ORDER_DATA_TENANT_ID'] = 'unknown-tenant'
|
||
self.assertEqual(order_data.lookup_order(self.record['order_no']), [])
|
||
|
||
def test_manual_changes_are_visible_and_reimport_preserves_them_by_default(self):
|
||
connection = db.connect()
|
||
connection.execute("UPDATE order_test_data SET gross_weight_kg='42.000' WHERE order_no=?", (self.record['order_no'],))
|
||
connection.commit()
|
||
connection.close()
|
||
self.assertEqual(self.import_rows([self.record]), {'imported':0, 'preserved':1})
|
||
fields = self.events(self.record['order_no'])[0]['card']['fields']
|
||
self.assertEqual(next(field['value'] for field in fields if field['key']=='weight'), '42.000 kg')
|
||
self.import_rows([self.record], replace=True)
|
||
self.assertEqual(order_data.lookup_order(self.record['order_no'])[0]['gross_weight_kg'], '30.000')
|
||
|
||
def test_new_table_records_can_be_added_without_touching_source_database(self):
|
||
self.import_rows([{**self.record, 'order_no':'LOCAL20260002', 'shipment_id':'localship002'}])
|
||
self.assertEqual(self.events('查询 LOCAL20260002')[0]['card']['no'], 'LOCAL20260002')
|
||
|
||
def test_business_status_history_does_not_claim_delivery_or_invent_transport_events(self):
|
||
self.import_rows([{**self.record, 'status':'COMPLETED'}], replace=True)
|
||
card = self.events(self.record['order_no'])[0]['card']
|
||
self.assertEqual(card['status'], '头程订单已完成')
|
||
self.assertNotIn('已签收', json.dumps(card, ensure_ascii=False))
|
||
self.assertEqual([node['time'] for node in card['nodes']], ['2026-10-29 10:00','2026-10-28 10:00'])
|
||
self.assertTrue(all(not node['place'] for node in card['nodes']))
|
||
|
||
def test_children_do_not_reuse_parent_total_weight(self):
|
||
self.import_rows([{**self.record, 'order_no':'CHILD20260001-01', 'order_role':'CHILD',
|
||
'gross_weight_kg':None, 'package_count':None, 'volume_cbm':None,
|
||
'parent_order_no':self.record['order_no'], 'data_note':'子单独立货物数据未提供'}])
|
||
card = self.events('CHILD20260001-01')[0]['card']
|
||
self.assertFalse(any(field['key']=='weight' for field in card['fields']))
|
||
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='parent'), self.record['order_no'])
|
||
|
||
def test_followups_refresh_local_data_and_remain_within_conversation(self):
|
||
cid = self.client.post('/api/new_chat').json['id']
|
||
self.events(self.record['order_no'], cid)
|
||
connection = db.connect()
|
||
connection.execute("UPDATE order_test_data SET gross_weight_kg='45.000' WHERE order_no=?", (self.record['order_no'],))
|
||
connection.commit()
|
||
connection.close()
|
||
card = self.events('这票重量是多少?',cid)[0]['card']
|
||
self.assertEqual(card['no'], self.record['order_no'])
|
||
self.assertEqual(next(field['value'] for field in card['fields'] if field['key']=='weight'), '45.000 kg')
|
||
self.assertIn('未记录实际运费', self.events('这票运费多少钱?',cid)[0]['t'])
|
||
self.assertEqual(self.events('那状态呢?',cid)[0]['card']['no'], self.record['order_no'])
|
||
self.events('查询 UNKNOWN20260001',cid)
|
||
self.assertIn('未找到', self.events('这票重量呢?',cid)[0]['t'])
|
||
other_cid = self.client.post('/api/new_chat').json['id']
|
||
self.assertIsNone(order_data.answer_query('这票重量是多少?', other_cid))
|
||
|
||
def test_new_shipping_questions_do_not_reuse_previous_order(self):
|
||
cid = self.client.post('/api/new_chat').json['id']
|
||
self.events(self.record['order_no'], cid)
|
||
for message in ('现在有30KG货物想发美国,运费多少?', '美国海运费用怎么算?', '报价30公斤到德国', '物流状态是什么意思?'):
|
||
with self.subTest(message=message):
|
||
self.assertIsNone(order_data.answer_query(message, cid))
|
||
db.save_message(cid, 'user', '美国海运费用怎么算?')
|
||
db.save_message(cid, 'assistant', '海运费用与重量及目的地有关。')
|
||
self.assertIsNone(order_data.answer_query('那状态呢?', cid))
|
||
|
||
def test_short_order_followups_read_facts_without_calling_model(self):
|
||
cid = self.client.post('/api/new_chat').json['id']
|
||
self.events(self.record['order_no'], cid)
|
||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||
for message in ('多少公斤?', '有几箱?', '体积呢?', '那状态呢?'):
|
||
with self.subTest(message=message):
|
||
self.assertEqual(self.events(message, cid)[0]['card']['no'], self.record['order_no'])
|
||
model.assert_not_called()
|
||
|
||
def test_card_storage_and_model_context_contain_only_selected_facts(self):
|
||
card = self.events(self.record['order_no'])[0]['card']
|
||
text = card_to_text(CARD_PREFIX+json.dumps(card))
|
||
self.assertIn('30.000', text)
|
||
self.assertIn('US', text)
|
||
self.assertNotIn('测试', text)
|
||
self.assertNotIn('tenant_id', text)
|
||
self.assertNotIn('customer_name', json.dumps(card))
|
||
|
||
def test_invalid_history_is_safe_and_read_failure_is_explicit(self):
|
||
connection = db.connect()
|
||
connection.execute("UPDATE order_test_data SET history_json='invalid'")
|
||
connection.commit()
|
||
self.assertEqual(self.events(self.record['order_no'])[0]['card']['nodes'], [])
|
||
connection.execute('DROP TABLE order_test_data')
|
||
connection.commit()
|
||
connection.close()
|
||
self.assertIn('暂时无法读取', self.events(self.record['order_no'])[0]['t'])
|
||
|
||
def test_import_failure_rolls_back_partial_rows(self):
|
||
with self.assertRaises(ValueError):
|
||
self.import_rows([{**self.record,'order_no':'ROLLBACK20260001'}, {'order_no':''}])
|
||
self.assertEqual(order_data.lookup_order('ROLLBACK20260001'), [])
|
||
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|