修复报价上下文并完善订单节点和异常统计

This commit is contained in:
2026-11-05 17:18:00 +08:00
parent 97ec6ed44b
commit 5f1c4043d4
18 changed files with 572 additions and 151 deletions
+48
View File
@@ -103,6 +103,47 @@ class CustomerGapTests(unittest.TestCase):
self.assertIn('最高项70元/件',text)
self.assertNotIn('请补充箱数',text)
def test_box_units_are_local_and_each_axis_can_carry_its_own_unit(self):
cases=(('12英寸显示器,包装60x40x30cm',[60,40,30],'cm',Decimal('30')),
('80cm×70cm×60cm',[80,70,60],'cm',Decimal('56')),
('80cmx70cmx60cm',[80,70,60],'cm',Decimal('56')),
('800mm*700mm*600mm',[800,700,600],'mm',Decimal('56')),
('长80厘米宽70厘米高60厘米',[80,70,60],'cm',Decimal('56')),
('0.8m×70cm×600mm',[80,70,60],'cm',Decimal('56')),
('12英寸显示器,包装60x40x30',[60,40,30],'cm',Decimal('30')))
for wording,dims,unit,charge in cases:
with self.subTest(wording=wording):
intent=parse_intent('美国美西30KG,'+wording+',1箱义乌普船海派',set())
self.assertEqual(intent['dims'],dims);self.assertEqual(intent['dim_unit'],unit)
self.assertEqual(quantity(intent,{'kind':'parcel','unit':'kg'}),charge)
text=self.ask('美国美西30KG,'+wording+',1箱义乌普船海派')
self.assertIn(f'{charge}KG =',text)
self.assertNotIn('请补充箱数',text)
def test_oral_partial_weight_edits_never_overwrite_the_whole_shipment(self):
cid=self.conversation()
self.ask('美国美西30KG,部分铁制品,铁铝部分计费重10KG,义乌普船海派',cid)
for q,amount in (('铁铝部分还是10KG',110),('铁铝部分改成8KG',100),('铁铝部分仍是8KG',100)):
text=self.ask(q,cid)
self.assertIn('美国 30KG',text);self.assertIn(f'30KG = {amount}元',text)
self.assertIn('大于0',self.ask('铁铝部分改为-1KG',cid))
fresh=self.conversation()
self.assertIn('请确认铁铝部分的计费重',self.ask('美国美西30KG,铁铝部分还是10KG,义乌普船海派',fresh))
self.assertNotIn('×10KG',self.ask('一共多少钱,给我最终总价',fresh))
def test_long_quote_chain_retains_conditions_but_topic_changes_stop_reuse(self):
cid=self.conversation();self.ask('美国美西30KG,义乌普船海派',cid)
for _ in range(15):self.assertIn('每箱不足12KG',self.ask('这个报价按毛重还是净重?',cid))
self.assertIn('美国 50KG',self.ask('50KG呢',cid))
self.assertIn('美国渠道',self.ask('这个物流服务范围具体怎么执行?',cid))
self.assertIn('请先',self.ask('下一页',cid))
from logistics_chat.quote_service import answer_quote
from logistics_chat.quote_scope import answer_us_fallback
with self.app.test_request_context('/'):
self.assertIsNone(answer_quote('新的一票50KG发德国报价',cid))
db.save_message(cid,'user','换个话题,我要查订单')
self.assertIsNone(answer_us_fallback('50KG呢',cid))
def test_comparing_two_weights_is_not_treated_as_two_cartons(self):
cid=self.conversation();self.ask('美国30KG海派报价',cid)
text=self.ask('单箱12.5KG会不会按13KG收费?',cid)
@@ -194,6 +235,13 @@ class CustomerGapTests(unittest.TestCase):
self.ask('美国30KG海派报价',cid)
self.assertIn('请先',self.ask('下一页',cid))
def test_count_interruptions_do_not_erase_the_active_list_page(self):
self.seed_pages();self.login();cid=self.conversation()
self.ask('列出我的全部订单',cid)
self.ask('我有多少个运单',cid)
text=self.ask('下一页',cid)
self.assertIn('第2页',text);self.assertEqual(text.count('- 入仓码:'),6)
def test_clearance_followup_modification_and_unknown_payment_do_not_call_model(self):
self.assertTrue(self.events('我的订单已清关了吗')[0]['login_required'])
self.login();cid=self.conversation();self.events('QAFOCUS20260001',cid)
+5 -4
View File
@@ -78,14 +78,15 @@ class OrderCountTests(unittest.TestCase):
'What is the number of my orders?'):
with self.subTest(question=question):
events = self.events(question,cid)
self.assertEqual(events[0],{'t':'您当前共有 2 票订单,其中 1 票已登记入仓码。'})
self.assertTrue(events[0]['t'].startswith('您当前共有 2 票订单,其中 1 票已登记入仓码。'))
self.assertEqual(events[0]['t'].count('- 入仓码:'),2)
self.assertEqual(events[-1],{'d':1})
model.assert_not_called()
with self.app.app_context():
connection = db.connect()
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260004','inbound_code':'INB20260004'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
def test_business_status_filters_are_exact_and_unknown_conditions_never_return_the_total(self):
self.login()
@@ -152,7 +153,7 @@ class OrderCountTests(unittest.TestCase):
with self.app.app_context():
with self.assertRaises(order_data.OrderDataUnavailable):
order_data.count_orders(('tenant-a',))
with patch('logistics_chat.order_data.count_orders',side_effect=order_data.OrderDataUnavailable):
with patch('logistics_chat.order_listing.read_orders',side_effect=sqlite3.OperationalError):
events = self.events('我有多少个运单')
self.assertIn('暂时无法读取',events[0]['t'])
@@ -164,7 +165,7 @@ class OrderCountTests(unittest.TestCase):
(' \t\r\n','COUNT20260002','tenant-a'))
order_data.import_records(connection,[{'tenant_id':'tenant-a','order_no':'COUNT20260005','inbound_code':'INB20260001'}])
connection.commit(); connection.close()
self.assertEqual(self.events('我有多少个运单')[0]['t'],'您当前共有 3 票订单,其中 2 票已登记入仓码。')
self.assertTrue(self.events('我有多少个运单')[0]['t'].startswith('您当前共有 3 票订单,其中 2 票已登记入仓码。'))
with self.app.app_context():
connection = db.connect()
connection.execute("DELETE FROM order_test_data WHERE tenant_id='tenant-a'")
+166
View File
@@ -0,0 +1,166 @@
"""Statistics use explicit exception reasons and actual, order-level progress."""
import json
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('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_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_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_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()