dy-dashboard/tests/test_ai_inventory_accuracy.py
2026-10-28 11:14:17 +08:00

157 lines
9.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""防止预览截断、排序剔零和模型生成数字改变品牌库存合计。"""
import json
import unittest
from unittest import mock
from flask import Flask, session
from routes import ai
from services import ai_datasources as sources
QUESTIONS = [
'查询品牌为Aromat Herbs的产品,库存*单价的总成本是多少',
'Aromat Herbs品牌库存总成本的具体数值是多少?',
'Aromat Herbs查一下这个品牌的商品数据目前的库存和库存金额分别是多少?',
]
class InventoryAccuracyTest(unittest.TestCase):
def setUp(self):
self.rows = [{'product_code': f'SKU-{i:03}', '产品名称': f'商品{i}',
'品牌': 'Aromat Herbs', '库存': 0 if i < 25 else 2,
'成本价': 0.1, '库存金额': 0 if i < 25 else 0.2}
for i in range(251)]
self.rows.append({'product_code': 'OTHER', '产品名称': '其他品牌',
'品牌': 'Aromat Herbs Plus', '库存': 999, '成本价': 10, '库存金额': 9990})
def _inventory(self, params):
with mock.patch.object(sources, '_get_latest_inv_date', return_value='2026-10-14'), \
mock.patch.object(sources, '_inventory_value_rows', return_value=self.rows):
return sources._h_inventory_value(params)
def test_totals_cover_more_than_200_and_do_not_change_with_sort_or_limit(self):
for options in ({'limit': 1}, {'limit': 30}, {'limit': 200},
{'limit': 200, 'sort_by': '库存金额'},
{'limit': 7, 'sort_by': '库存', 'sort_order': 'asc'}):
with self.subTest(options=options):
result = self._inventory({'brand': ' Aromat Herbs ', **options})
summary = result['summary']
self.assertEqual(251, summary['sku_count'])
self.assertEqual(452, summary['stock_qty'])
self.assertEqual(45.2, summary['inventory_value'])
self.assertEqual(25, summary['zero_stock_sku_count'])
self.assertLessEqual(len(result['rows']), options['limit'])
self.assertNotIn('OTHER', [r['product_code'] for r in result['rows']])
def test_brand_and_detail_sources_produce_same_totals(self):
detail = self._inventory({'brand': 'Aromat Herbs', 'limit': 200, 'sort_by': '库存金额'})
with mock.patch.object(sources, '_get_latest_inv_date', return_value='2026-10-14'), \
mock.patch.object(sources, '_inventory_value_rows', return_value=self.rows), \
mock.patch.object(sources, 'compute_product_sales_cached', return_value={}), \
mock.patch.object(sources, 'query', return_value=[]):
brand = sources._h_brand_overview({'brand': 'Aromat Herbs', 'limit': 1, 'sort_by': '库存金额'})
for field in ('sku_count', 'stock_qty', 'inventory_value', 'zero_stock_sku_count', 'date'):
self.assertEqual(detail['summary'][field], brand['summary'][field])
self.assertEqual(1, brand['summary']['row_count'])
self.assertEqual(251, brand['rows'][0]['SKU数'])
self.assertEqual(45.2, brand['rows'][0]['库存金额'])
def test_source_uses_single_date_active_products_and_decimal_rounding(self):
with mock.patch.object(sources, '_get_latest_inv_date', side_effect=['2026-10-14', '2026-10-15']) as latest, \
mock.patch.object(sources, 'query', return_value=[{'product_code': 'A', '库存': 1, '成本价': 0.005}]) as query, \
mock.patch.object(sources, '_products_map', return_value={'A': {'brand': ' Test ', 'product_type': '成品'}}) as products:
result = sources._h_inventory_value({'brand': 'Test'})
latest.assert_called_once()
products.assert_called_once_with(['A'], active_only=True)
self.assertEqual(('2026-10-14',), query.call_args.args[1])
self.assertEqual(0.01, result['summary']['inventory_value'])
self.assertEqual('2026-10-14', result['summary']['date'])
def test_wrong_model_values_are_removed_from_all_narrative_sections(self):
dataset = {'key': 'inventory_value', 'name': '商品库存金额',
**self._inventory({'brand': 'Aromat Herbs'})}
wrong = {k: ['错误总额999999.99元'] for k in ('findings', 'evidence', 'risks', 'actions', 'limitations', 'follow_ups')}
wrong.update(summary='错误总額999999.99元', title='999999.99')
result = ai._apply_authoritative_inventory_summary(wrong, [dataset], QUESTIONS[0])
self.assertNotIn('999999.99', json.dumps(result, ensure_ascii=False))
self.assertIn('251个SKU', result['summary'])
self.assertIn('45.20元', result['summary'])
def test_inventory_guard_does_not_replace_cross_business_or_sales_analysis(self):
dataset = {'key': 'inventory_value', 'name': '商品库存金额',
**self._inventory({'brand': 'Aromat Herbs'})}
original = {'summary': '建议核对BOM', 'actions': ['核对包材']}
self.assertIs(original, ai._apply_authoritative_inventory_summary(
original, [dataset, {'key': 'bom_supply_chain'}], '库存不足,需要下单吗?'))
self.assertIs(original, ai._apply_authoritative_inventory_summary(
original, [dataset], '该品牌库存和销量变化的原因是什么?'))
def test_empty_and_unpriced_inventory_are_explicit(self):
empty = self._inventory({'brand': '不存在'})
self.assertEqual(0, empty['summary']['sku_count'])
dataset = {'key': 'inventory_value', 'name': '商品库存金额', **empty}
self.assertIn('0个SKU', ai._apply_authoritative_inventory_summary(None, [dataset], '库存多少钱')['summary'])
self.rows[30]['成本价'] = 0
self.rows[30]['库存金额'] = 0
dataset = {'key': 'inventory_value', 'name': '商品库存金额', **self._inventory({'brand': 'Aromat Herbs'})}
result = ai._apply_authoritative_inventory_summary(None, [dataset], '库存多少钱')
self.assertEqual('中', result['confidence'])
self.assertIn('1个有库存', result['limitations'][0])
def test_planner_extracts_same_brand_and_does_not_hijack_other_scopes(self):
with mock.patch.object(ai, 'query', return_value=[{'brand': 'Aromat Herbs'}, {'brand': 'PUNOMO'}]):
for question in QUESTIONS:
self.assertEqual('Aromat Herbs', ai._plan_brand_inventory_lookup(question)['tasks'][0]['params']['brand'])
for question in ('Aromat Herbs和PUNOMO库存分别多少?', 'Aromat Herbs的包材库存多少?',
'Aromat Herbs中AH-CP-4842-TMGJ库存多少?', 'Aromat Herbs昨日库存多少?'):
self.assertIsNone(ai._plan_brand_inventory_lookup(question))
top = ai._plan_brand_inventory_lookup('Aromat Herbs库存金额排名前10的产品?')
self.assertEqual(10, top['tasks'][0]['params']['limit'])
def test_api_different_questions_return_same_facts_without_calling_llm(self):
app = Flask(__name__)
app.secret_key = 'test'
old_handler = sources.AI_DATASOURCES['inventory_value']['handler']
sources.AI_DATASOURCES['inventory_value']['handler'] = lambda params: self._inventory(params)
try:
for question in QUESTIONS:
with app.test_request_context('/api/ai/ask', method='POST', json={'question': question}):
session.update(user_id=1, role='super_admin')
with mock.patch.object(ai, 'query', return_value=[{'brand': 'Aromat Herbs'}]), \
mock.patch.object(ai, 'operation_required', return_value=True), \
mock.patch.object(ai, '_can_view_datasource', return_value=True), \
mock.patch.object(ai, '_can_export_ai', return_value=True), \
mock.patch.object(ai, 'datasource_meta', return_value={'name': '商品库存金额', 'data_date': '旧日期'}), \
mock.patch.object(ai, '_plan_analysis', side_effect=AssertionError('must not call LLM')), \
mock.patch.object(ai, '_synthesize_analysis', side_effect=AssertionError('must not call LLM')), \
mock.patch.object(ai, '_save_history'), mock.patch.object(ai, 'add_operation_log'):
payload = ai.ai_ask.__wrapped__().get_json()['data']
self.assertIn('251个SKU', payload['summary'])
self.assertIn('45.20元', payload['summary'])
self.assertEqual(251, payload['total_rows'])
self.assertEqual('2026-10-14', payload['data_date'])
self.assertEqual(200, len(payload['rows']))
finally:
sources.AI_DATASOURCES['inventory_value']['handler'] = old_handler
def test_permission_denial_never_returns_authoritative_money(self):
app = Flask(__name__)
app.secret_key = 'test'
with app.test_request_context('/api/ai/ask', method='POST', json={'question': QUESTIONS[0]}):
session.update(user_id=1, role='viewer')
with mock.patch.object(ai, 'query', return_value=[{'brand': 'Aromat Herbs'}]), \
mock.patch.object(ai, 'operation_required', return_value=True), \
mock.patch.object(ai, '_can_view_datasource', return_value=False), \
mock.patch.object(ai, '_can_export_ai', return_value=False), \
mock.patch.object(ai, '_save_history'):
payload = ai.ai_ask.__wrapped__().get_json()['data']
self.assertEqual([], payload['rows'])
self.assertIn('没有查看', payload['summary'])
self.assertNotIn('45.20', json.dumps(payload))
if __name__ == '__main__':
unittest.main()