460 lines
28 KiB
Python
460 lines
28 KiB
Python
"""Statistics use explicit exception reasons and actual, order-level progress."""
|
||
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
import sqlite3
|
||
import tempfile
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
from logistics_chat import create_app,db,order_data
|
||
from logistics_chat.order_tracking import current_stage
|
||
|
||
|
||
def fact(code,shipment='',forecast=False):
|
||
return {'code':code,'shipment_key':shipment,
|
||
'estimated_at' if forecast else 'actual_at':'2026-10-01 10:00'}
|
||
|
||
|
||
class OrderStatisticsTests(unittest.TestCase):
|
||
def setUp(self):
|
||
folder=tempfile.TemporaryDirectory();self.addCleanup(folder.cleanup);root=Path(folder.name)
|
||
self.app=create_app({'TESTING':True,'DATABASE':root/'data.db','UPLOAD_DIR':root/'uploads',
|
||
'BACKUP_DIR':root/'backups','LOG_DIR':root/'logs','DEEPSEEK_API_KEY':'',
|
||
'CUSTOMER_DEMO_TENANT_ID':'a','RATE_LIMIT_PER_MIN':1000})
|
||
self.client=self.app.test_client();self.client.post('/api/visitor/session',json={'key':'1'*64})
|
||
rows=[{'tenant_id':'a','order_no':f'STAT2026{i:04}','inbound_code':f'INSTAT2026{i:04}',
|
||
'status':'PROCESSING','source_updated_at':f'2026-10-{i+1:02} 10:00',
|
||
'tracking_json':[fact('IN_TRANSIT'),fact('ARRIVED_DESTINATION_PORT'),fact('EMPTY_RETURNED',forecast=True)],
|
||
'exception_reason':f'待核实资料{i}' if i<6 else None} for i in range(10)]
|
||
rows += [{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING',
|
||
'tracking_json':[fact('ARRIVED_DESTINATION_PORT'),fact('PICKUP_READY')],'exception_reason':'等待客户资料'},
|
||
{'tenant_id':'a','order_no':'CLEAR20260002','status':'PROCESSING','tracking_json':[fact('PICKUP_READY')]},
|
||
{'tenant_id':'a','order_no':'MIXED20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B')]},
|
||
{'tenant_id':'a','order_no':'FORECAST20260001','tracking_json':[fact('ARRIVED_DESTINATION_PORT',forecast=True)]},
|
||
{'tenant_id':'a','order_no':'BUSINESS20260001','status':'COMPLETED','tracking_json':'[]'},
|
||
{'tenant_id':'a','order_no':'REJECTED20260001','status':'REJECTED','exception_reason':' \t\n'},
|
||
{'tenant_id':'b','order_no':'SECRET20260001','status':'PROCESSING','tracking_json':[fact('ARRIVED_DESTINATION_PORT')],'exception_reason':'不能泄露'}]
|
||
with self.app.app_context():
|
||
c=db.connect();order_data.import_records(c,rows);c.commit();c.close()
|
||
|
||
def login(self):
|
||
self.assertTrue(self.client.post('/api/customer/login',json={'username':'admin','password':'123456'}).json['ok'])
|
||
|
||
def ask(self,q,cid=None):
|
||
with patch('logistics_chat.ai_client.post_completion') as model:
|
||
response=self.client.post('/stream_chat',json={'msg':q,'conv_id':cid})
|
||
events=[json.loads(line[6:]) for line in response.get_data(as_text=True).split('\n\n') if line.startswith('data: ')]
|
||
model.assert_not_called()
|
||
self.assertEqual(events[-1],{'d':1},events)
|
||
self.assertNotIn('_order_list_state',str(events));self.assertNotIn('_customer_data',str(events))
|
||
self.assertNotIn('_order_reference',str(events))
|
||
self.assertNotIn('SECRET',str(events));self.assertNotIn('不能泄露',str(events))
|
||
return events[0]
|
||
|
||
def test_guests_never_read_aggregate_or_exception_facts(self):
|
||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||
for q in ('我有多少异常订单','到港的有多少运单','各个轨迹节点分别有多少订单','查看全部','下一页'):
|
||
self.assertTrue(self.ask(q)['login_required'])
|
||
read.assert_not_called()
|
||
|
||
def test_overview_is_requested_in_chat_and_saved_in_customer_protected_history(self):
|
||
with patch('logistics_chat.order_listing.read_overview') as read:
|
||
for question in ('我的订单概览','查看订单情况','Order overview'):
|
||
self.assertTrue(self.ask(question)['login_required'])
|
||
read.assert_not_called()
|
||
self.login()
|
||
with patch('logistics_chat.order_listing.read_overview') as read:
|
||
self.assertNotIn('overview',self.client.get('/api/customer/session?overview=1').json)
|
||
read.assert_not_called()
|
||
cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask('我的订单概览',cid)['t']
|
||
self.assertIn('共有 16 票订单,履约中 12 票,异常订单 7 票',text)
|
||
self.assertIn('船舶已到目的港:10票',text)
|
||
self.assertEqual(text.count('- 入仓码:'),5)
|
||
self.assertEqual(text.count('异常原因:'),5)
|
||
response=self.client.get('/api/get_messages',query_string={'conv_id':cid})
|
||
self.assertIn('no-store',response.headers['Cache-Control'])
|
||
self.assertTrue(any(row['role']=='assistant' and row['content']==text for row in response.json))
|
||
self.client.post('/api/customer/logout')
|
||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||
self.login()
|
||
self.assertTrue(any(row['content']==text for row in self.client.get('/api/get_messages',query_string={'conv_id':cid}).json))
|
||
|
||
def test_overview_keeps_old_exception_ahead_of_new_processing_orders_and_refreshes(self):
|
||
self.login()
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
c.execute("UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id='a' AND order_no<>'STAT20260000'")
|
||
c.commit();c.close()
|
||
from logistics_chat.order_listing import read_overview
|
||
with self.app.app_context():overview=read_overview(('a',))
|
||
self.assertEqual(overview['orders'][0]['order_no'],'STAT20260000')
|
||
self.assertEqual(len({order['order_no'] for order in overview['orders']}),5)
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEW20260001','status':'PROCESSING','exception_reason':'资料待核实'}])
|
||
c.commit();c.close()
|
||
refreshed=self.ask('订单概览')['t']
|
||
self.assertIn('共有 17 票订单,履约中 13 票,异常订单 2 票',refreshed)
|
||
|
||
def test_overview_read_failure_keeps_login_available_and_no_facts_leak(self):
|
||
self.login()
|
||
with patch('logistics_chat.order_listing.read_overview',side_effect=sqlite3.OperationalError('private details')):
|
||
result=self.ask('订单概览')
|
||
self.assertTrue(self.client.get('/api/customer/session').json['authenticated'])
|
||
self.assertIn('暂时无法读取',result['t'])
|
||
self.assertNotIn('共有',result['t'])
|
||
self.assertNotIn('private details',str(result))
|
||
|
||
def test_summaries_show_at_most_five_recent_rows_and_keep_intersecting_filters(self):
|
||
self.login()
|
||
total=self.ask('我有多少个订单')['t']
|
||
self.assertIn('共有 16 票',total);self.assertEqual(total.count('- 入仓码:'),5)
|
||
abnormal=self.ask('我有多少个异常订单')['t']
|
||
self.assertIn('7 票明确标记异常',abnormal);self.assertEqual(abnormal.count('- 入仓码:'),5)
|
||
self.assertNotIn('REJECTED20260001',abnormal)
|
||
arrived=self.ask('现在到港的有多少运单')['t']
|
||
self.assertIn('10 票当前节点为“船舶已到目的港”',arrived)
|
||
self.assertEqual(arrived.count('- 入仓码:'),5)
|
||
self.assertIn('STAT20260009',arrived);self.assertNotIn('STAT20260000',arrived)
|
||
self.assertNotIn('CLEAR20260001',arrived)
|
||
text=self.ask('已清关的异常订单有多少')['t']
|
||
self.assertIn('1 票当前节点为“已清关、可提柜”',text);self.assertIn('等待客户资料',text)
|
||
|
||
def test_combined_counts_answer_each_question_and_expand_the_last_filter(self):
|
||
question='我的订单有多少个,异常有多少个'
|
||
with patch('logistics_chat.order_listing.read_orders') as read:
|
||
self.assertTrue(self.ask(question)['login_required'])
|
||
self.assertTrue(self.ask('异常的有多少条?')['login_required'])
|
||
read.assert_not_called()
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask(question,cid)['t']
|
||
self.assertIn('共有 16 票订单',text)
|
||
self.assertIn('7 票明确标记异常',text)
|
||
self.assertEqual(text.count('- 入仓码:'),5)
|
||
self.assertEqual(text.count('异常原因:'),5)
|
||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||
self.assertIn('7 票明确标记异常',self.ask('异常的有多少条?',cid)['t'])
|
||
self.assertIn('共7票',self.ask('查看全部',cid)['t'])
|
||
|
||
def test_spoken_count_questions_tolerate_greetings_and_locate_all_current_nodes(self):
|
||
self.login()
|
||
text=self.ask('你好你好,现在我的订单在哪里了?有多少个?')['t']
|
||
self.assertIn('共有 16 票订单',text)
|
||
self.assertIn('船舶已到目的港:10票',text)
|
||
self.assertIn('暂无实际节点:3票',text)
|
||
self.assertIn('共有 16 票订单',self.ask('或者订单有多少条?')['t'])
|
||
|
||
def test_combined_statistics_keep_node_distribution_before_exception_count(self):
|
||
self.login()
|
||
text=self.ask('我的订单各个轨迹节点分别有多少,异常的有多少条')['t']
|
||
self.assertIn('共有 16 票订单',text)
|
||
self.assertIn('船舶已到目的港:10票',text)
|
||
self.assertIn('已清关、可提柜:2票',text)
|
||
self.assertIn('暂无实际节点:3票',text)
|
||
self.assertIn('7 票明确标记异常',text)
|
||
self.assertEqual(text.count('- 入仓码:'),5)
|
||
|
||
def test_subset_followups_keep_previous_stage_and_show_recorded_reasons(self):
|
||
self.assertTrue(self.ask('异常的呢')['login_required'])
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('已清关的订单有多少',cid)
|
||
text=self.ask('其中异常的呢',cid)['t']
|
||
self.assertIn('1 票当前节点为“已清关、可提柜”',text)
|
||
self.assertIn('等待客户资料',text)
|
||
self.assertNotIn('待核实资料',text)
|
||
self.assertIn('1 票当前节点',self.ask('这些一共多少票',cid)['t'])
|
||
text=self.ask('异常原因呢',cid)['t']
|
||
self.assertIn('等待客户资料',text)
|
||
self.assertNotIn('业务状态:',text)
|
||
self.assertIn('共1票',self.ask('查看全部',cid)['t'])
|
||
|
||
def test_ordinal_uses_the_displayed_reply_even_when_new_orders_arrive(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask('我有多少异常订单',cid)['t']
|
||
shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
order_data.import_records(c,[{'tenant_id':'a','order_no':'NEWEST20260001','inbound_code':'NEWESTIN20260001',
|
||
'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'新记录'}])
|
||
c.commit();c.close()
|
||
card=self.ask('第一票的轨迹',cid)['card']
|
||
self.assertEqual(card['no'],shown[0][1])
|
||
card=self.ask('第二个呢',cid)['card']
|
||
self.assertEqual(card['no'],shown[1][1])
|
||
self.assertNotIn('NEWEST',str(card))
|
||
|
||
def test_selected_order_survives_acknowledgements_and_long_detail_followups(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
first=self.ask('第一个呢',cid)['card']['no']
|
||
for _ in range(8):
|
||
self.ask('谢谢',cid)
|
||
text=self.ask('异常原因呢',cid)['t']
|
||
self.assertNotIn('请提供',text)
|
||
self.assertEqual(self.ask('重量呢',cid)['card']['no'],first)
|
||
|
||
def test_empty_or_out_of_range_ordinals_never_choose_an_unshown_order(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
self.assertIn('展示了5票',self.ask('第6票的轨迹',cid)['t'])
|
||
self.ask('已还柜的订单有多少',cid)
|
||
self.assertIn('当前列表没有',self.ask('第一个呢',cid)['t'])
|
||
self.assertIn('0 票当前节点为“已还柜”',self.ask('到哪了',cid)['t'])
|
||
|
||
def test_ordinals_and_subsets_require_the_current_owned_topic_and_login(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
self.ask('美国30KG海运报价',cid)
|
||
text=self.ask('第一票的轨迹',cid)['t']
|
||
self.assertIn('请先查看',text)
|
||
self.assertNotIn('INSTAT',text)
|
||
self.ask('我有多少异常订单',cid)
|
||
self.client.post('/api/customer/logout')
|
||
self.assertTrue(self.ask('第一个呢',cid)['login_required'])
|
||
self.assertTrue(self.ask('其中异常的呢',cid)['login_required'])
|
||
|
||
def test_conflicting_subset_conditions_request_a_scope_change(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('已清关的订单有多少',cid)
|
||
self.assertIn('确认要切换筛选条件',self.ask('其中已到港的呢',cid)['t'])
|
||
self.assertIn('10 票当前节点',self.ask('那已到港的呢',cid)['t'])
|
||
|
||
def test_subset_clarification_keeps_the_original_scope_for_the_next_answer(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('已清关的订单有多少',cid)
|
||
self.assertIn('请明确',self.ask('其中今天的异常呢',cid)['t'])
|
||
self.assertIn('1 票当前节点',self.ask('其中异常的呢',cid)['t'])
|
||
|
||
def test_a_shipping_quote_starting_with_these_is_a_new_quote_topic(self):
|
||
from logistics_chat.quote_data import import_snapshot
|
||
from test_quotes import fixture_snapshot
|
||
with self.app.app_context():
|
||
c=db.connect();import_snapshot(c,fixture_snapshot(),activate=True);c.commit();c.close()
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少个订单',cid)
|
||
text=self.ask('这些货物美国30KG海运报价',cid)['t']
|
||
self.assertIn('美国 30KG',text)
|
||
self.assertNotIn('订单范围',text)
|
||
self.assertIn('请先查看',self.ask('第一个订单呢',cid)['t'])
|
||
|
||
def test_legacy_list_state_can_resolve_ordinals_without_saved_identifiers(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask('我有多少异常订单',cid)['t']
|
||
first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1]
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
state=json.loads(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0])
|
||
del state['shown']
|
||
c.execute('UPDATE chat_history SET order_list_state=? WHERE id=?',(json.dumps(state),cid))
|
||
c.commit();c.close()
|
||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first)
|
||
|
||
def test_ordinal_uses_saved_identifiers_when_a_field_contains_display_separators(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
order_data.import_records(c,[{'tenant_id':'a','order_no':'STRUCTURED20260001',
|
||
'inbound_code':'VALUE;订单号:OTHER20260001','status':'PROCESSING',
|
||
'source_updated_at':'2030-01-01','exception_reason':'待核实'}])
|
||
c.commit();c.close()
|
||
self.ask('我有多少异常订单',cid)
|
||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],'STRUCTURED20260001')
|
||
|
||
def test_adjacent_order_navigation_keeps_the_displayed_rows_and_current_selection(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask('我有多少异常订单',cid)['t']
|
||
shown=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)
|
||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],shown[0][1])
|
||
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],shown[1][1])
|
||
self.ask('谢谢',cid)
|
||
self.assertEqual(self.ask('上一票的轨迹',cid)['card']['no'],shown[0][1])
|
||
self.assertIn('未登记申报毛重',self.ask('那这个有多重',cid)['t'])
|
||
self.assertEqual(self.ask('下一票的轨迹',cid)['card']['no'],shown[1][1])
|
||
|
||
def test_adjacent_navigation_stops_at_visible_edges_and_resets_on_page_changes(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
order_data.import_records(c,[{'tenant_id':'a','order_no':f'PAGEX2026{i:04}','inbound_code':f'PAGEIN2026{i:04}',
|
||
'status':'PROCESSING'} for i in range(30)])
|
||
c.commit();c.close()
|
||
self.ask('列出我的全部订单',cid)
|
||
self.ask('第20票的轨迹',cid)
|
||
self.assertIn('最后一票',self.ask('下一票呢',cid)['t'])
|
||
text=self.ask('下一页',cid)['t']
|
||
first=re.findall(r'^- 入仓码:([^;]+);订单号:([^;]+);',text,re.M)[0][1]
|
||
self.assertEqual(self.ask('第一个呢',cid)['card']['no'],first)
|
||
self.assertIn('第一票',self.ask('上一票呢',cid)['t'])
|
||
|
||
def test_adjacent_navigation_needs_an_owned_list_and_a_selected_row(self):
|
||
for question in ('下一票呢','上一票的轨迹'):
|
||
self.assertTrue(self.ask(question)['login_required'])
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
|
||
self.ask('我有多少异常订单',cid)
|
||
self.assertIn('先在当前列表中选择',self.ask('下一票呢',cid)['t'])
|
||
self.ask('第一个呢',cid)
|
||
self.client.post('/api/customer/logout')
|
||
self.assertTrue(self.ask('下一票呢',cid)['login_required'])
|
||
|
||
def test_navigation_and_weights_do_not_reuse_orders_after_a_quote_topic(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
self.ask('第一个呢',cid)
|
||
self.ask('美国30KG海运报价',cid)
|
||
self.assertIn('先查看订单列表',self.ask('下一票呢',cid)['t'])
|
||
|
||
def test_implicit_count_without_an_established_range_asks_for_the_missing_scope(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
text=self.ask('异常的呢',cid)['t']
|
||
self.assertIn('说明要统计的订单范围',text)
|
||
self.assertNotIn('7 票',text)
|
||
|
||
def test_list_selection_keeps_the_order_when_its_old_inbound_code_is_reassigned(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
order_data.import_records(c,[{'tenant_id':'a','order_no':'ORIGINAL20260001','inbound_code':'REUSEDINBOUND1',
|
||
'status':'PROCESSING','source_updated_at':'2030-01-01','exception_reason':'原订单资料'},
|
||
{'tenant_id':'a','order_no':'OTHER20260001','status':'PROCESSING',
|
||
'source_updated_at':'2029-01-01','exception_reason':'另一票资料'}])
|
||
c.commit();c.close()
|
||
self.ask('我有多少异常订单',cid)
|
||
with self.app.app_context():
|
||
c=db.connect()
|
||
c.execute("UPDATE order_test_data SET inbound_code='NEWINBOUND1' WHERE tenant_id='a' AND order_no='ORIGINAL20260001'")
|
||
c.execute("UPDATE order_test_data SET inbound_code='REUSEDINBOUND1' WHERE tenant_id='a' AND order_no='OTHER20260001'")
|
||
c.commit();c.close()
|
||
card=self.ask('第一个呢',cid)['card']
|
||
self.assertEqual(card['no'],'ORIGINAL20260001')
|
||
self.assertIn({'key':'inbound','value':'NEWINBOUND1'},card['fields'])
|
||
self.assertIn('原订单资料',self.ask('异常原因呢',cid)['t'])
|
||
self.assertEqual(self.ask('下一票呢',cid)['card']['no'],'OTHER20260001')
|
||
|
||
def test_adjacent_selection_follows_actual_cards_after_overlapping_requests(self):
|
||
from logistics_chat.tracking import CARD_PREFIX
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
second=self.ask('第二个呢',cid)['card']
|
||
self.ask('第一个呢',cid)
|
||
with self.app.app_context():
|
||
for _ in range(2):
|
||
db.save_message(cid,'user','下一票呢')
|
||
db.save_message(cid,'assistant',CARD_PREFIX+json.dumps(second,ensure_ascii=False))
|
||
third=self.ask('下一票呢',cid)['card']['no']
|
||
self.assertEqual(third,self.ask('第三个呢',cid)['card']['no'])
|
||
|
||
def test_ambiguous_speech_answers_the_clear_count_and_requests_the_missing_meaning(self):
|
||
self.login()
|
||
text=self.ask('当前,我的订单有多少条,一层有多少条?')['t']
|
||
self.assertIn('共有 16 票订单',text)
|
||
self.assertIn('“一层有多少条”的筛选条件尚未明确',text)
|
||
self.assertNotIn('7 票明确标记异常',text)
|
||
text=self.ask('我的订单有多少个,今天异常订单有多少个')['t']
|
||
self.assertIn('共有 16 票订单',text)
|
||
self.assertIn('“今天异常订单有多少个”的筛选条件尚未明确',text)
|
||
|
||
def test_distribution_counts_each_order_once_and_never_uses_forecasts_or_business_completion(self):
|
||
self.login()
|
||
text=self.ask('各个轨迹节点分别有多少订单')['t']
|
||
for token in ('船舶已到目的港:10票','已清关、可提柜:2票','多柜进度不同:1票','暂无实际节点:3票'):
|
||
self.assertIn(token,text)
|
||
self.assertNotIn('已还柜:',text);self.assertNotIn('已签收:',text)
|
||
|
||
def test_english_homepage_questions_apply_their_intended_filters(self):
|
||
self.assertTrue(self.ask('How many exception orders do I have?')['login_required'])
|
||
self.login()
|
||
self.assertIn('7 票明确标记异常',self.ask('How many exception orders do I have?')['t'])
|
||
text=self.ask('How many orders are at each tracking stage?')['t']
|
||
self.assertIn('船舶已到目的港:10票',text)
|
||
self.assertIn('暂无实际节点:3票',text)
|
||
|
||
def test_expand_and_pages_keep_filters_and_refresh_counts(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
text=self.ask('下一页',cid)['t']
|
||
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),2)
|
||
text=self.ask('查看全部',cid)['t']
|
||
self.assertIn('共7票',text);self.assertEqual(text.count('- 入仓码:'),7)
|
||
self.assertIn('明确标记异常',text)
|
||
with self.app.app_context():
|
||
c=db.connect();c.execute('UPDATE order_test_data SET exception_reason=NULL WHERE tenant_id=?',('a',));c.commit();c.close()
|
||
self.assertIn('没有符合条件',self.ask('下一页',cid)['t'])
|
||
|
||
def test_pages_do_not_cross_conversations_or_login_boundaries(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('到港的订单有多少',cid)
|
||
self.assertIn('第2页',self.ask('下一页',cid)['t'])
|
||
self.ask('当前订单总数',cid)
|
||
self.assertIn('共16票',self.ask('查看全部',cid)['t'])
|
||
self.client.post('/api/customer/logout')
|
||
self.assertTrue(self.ask('下一页',cid)['login_required'])
|
||
self.assertEqual(self.client.get('/api/get_messages',query_string={'conv_id':cid}).json,[])
|
||
self.login();self.assertIn('共16票',self.ask('查看全部',cid)['t'])
|
||
other=self.client.post('/api/new_chat').json['id']
|
||
self.assertIn('请先',self.ask('下一页',other)['t'])
|
||
|
||
def test_unknown_or_ambiguous_filters_do_not_degenerate_to_totals(self):
|
||
self.login()
|
||
for q in ('今天有多少异常订单','未到港的订单有多少','到港和已清关分别有多少订单','已签收的有多少运单'):
|
||
with self.subTest(q=q):self.assertIn('暂不支持按这个条件统计',self.ask(q)['t'])
|
||
|
||
def test_exception_reasons_are_editable_and_old_reimports_preserve_them(self):
|
||
self.login()
|
||
with self.app.app_context():
|
||
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','status':'PROCESSING'}],replace=True);c.commit();c.close()
|
||
self.assertIn('等待客户资料',self.ask('CLEAR20260001的异常原因是什么')['t'])
|
||
cid=self.client.post('/api/new_chat').json['id'];self.ask('CLEAR20260001',cid)
|
||
self.assertIn('等待客户资料',self.ask('为什么异常',cid)['t'])
|
||
with self.app.app_context():
|
||
c=db.connect();order_data.import_records(c,[{'tenant_id':'a','order_no':'CLEAR20260001','exception_reason':''}],replace=True);c.commit();c.close()
|
||
self.assertIn('未登记异常原因',self.ask('CLEAR20260001的异常原因是什么')['t'])
|
||
|
||
def test_missing_actuals_and_bad_tracking_do_not_count_as_arrived(self):
|
||
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT','A'),fact('PICKUP_READY','B',True)]),'MIXED')
|
||
self.assertEqual(current_stage([fact('ARRIVED_DESTINATION_PORT',forecast=True)]),'NO_ACTUAL')
|
||
self.assertEqual(current_stage('{broken'),'NO_ACTUAL')
|
||
self.assertEqual(current_stage([fact('UNKNOWN')]),'NO_ACTUAL')
|
||
self.assertEqual(current_stage([fact('DOMESTIC_RECEIVED'),fact('ARRIVED_DESTINATION_PORT','A',True)]),'DOMESTIC_RECEIVED')
|
||
|
||
def test_node_names_do_not_collide_with_business_status_or_drop_negations(self):
|
||
from logistics_chat.order_query import parse_query
|
||
intent=parse_query('已完成装柜的订单有多少')
|
||
self.assertFalse(intent['unsupported']);self.assertIsNone(intent['status'])
|
||
self.assertEqual(intent['stage'],'LOADING_COMPLETED')
|
||
self.assertTrue(parse_query('未清关的订单有多少')['unsupported'])
|
||
self.assertTrue(parse_query('没有异常的订单有多少')['unsupported'])
|
||
|
||
def test_state_survives_app_restart_and_rejects_late_reply_updates(self):
|
||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||
self.ask('我有多少异常订单',cid)
|
||
restarted=create_app(dict(self.app.config,INITIALIZE_RUNTIME=False))
|
||
client=restarted.test_client()
|
||
# Copy opaque cookies without exporting them.
|
||
for cookie in self.client._cookies.values():client.set_cookie(cookie.key,cookie.value)
|
||
response=client.post('/stream_chat',json={'msg':'下一页','conv_id':cid}).get_data(as_text=True)
|
||
self.assertIn('第2页',response)
|
||
from logistics_chat.order_listing import save_state
|
||
cookies='; '.join(cookie.key+'='+cookie.value for cookie in self.client._cookies.values())
|
||
with self.app.test_request_context('/',headers={'Cookie':cookies}):
|
||
self.app.preprocess_request()
|
||
from logistics_chat.customer_service import current_customer_id
|
||
self.assertIsNotNone(current_customer_id())
|
||
c=db.connect();prior=c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0]
|
||
state=json.loads(prior);c.close()
|
||
db.save_message(cid,'user','换个话题,我要报价')
|
||
save_state(cid,{'query':state['query'],'page':1,'size':5},state['message_id'])
|
||
c=db.connect();self.assertEqual(c.execute('SELECT order_list_state FROM chat_history WHERE id=?',(cid,)).fetchone()[0],prior);c.close()
|
||
|
||
def test_schema_migration_preserves_old_orders(self):
|
||
c=sqlite3.connect(':memory:')
|
||
try:
|
||
c.execute('CREATE TABLE order_test_data (id INTEGER PRIMARY KEY,tenant_id TEXT,order_no TEXT,status TEXT,inbound_code TEXT,shipment_id TEXT)')
|
||
c.execute("INSERT INTO order_test_data VALUES(1,'a','OLD20260001','PROCESSING',NULL,NULL)")
|
||
order_data.init_order_table(c);order_data.init_order_table(c)
|
||
self.assertEqual(c.execute('SELECT order_no,exception_reason,tracking_json FROM order_test_data').fetchone(),('OLD20260001',None,'[]'))
|
||
finally:c.close()
|