# -*- coding: utf-8 -*- import unittest from unittest import mock from flask import Flask, session from routes import ai from services import ai_decision from services.ai_datasources import AI_DATASOURCES class AiAnalysisP2Test(unittest.TestCase): def setUp(self): self.app = Flask(__name__) self.app.secret_key = 'test' def _handlers(self): return { 'product_profile': {'columns': [], 'rows': [{ 'product_code': 'SKU1', '货品名称': '测试成品', '品牌': '测试品牌', '产品类型': '成品', '生产货期': 10, '物流时间': 2, '采购计划': '计划员', '采购跟单': '跟单员', '供应商': '供应商A'}]}, 'planning_metrics': {'columns': [], 'rows': [{ 'product_code': 'SKU1', '日销量预测': 10, '总支撑天数': 10, '库存可支撑天数': 10, '建议下单数量': 190, '库存': 100, '加工厂库存': 0, '生产在途': 0, '采购在途': 0}]}, 'product_sales': {'columns': [], 'rows': [{'product_code': 'SKU1', '30天平均销量': 10}]}, 'product_inventory': {'columns': [], 'rows': [{'product_code': 'SKU1', '库存': 100}]}, 'orders_in_transit': {'columns': [], 'rows': [{'product_code': 'SKU1', '国内未到数量': 0, '海外未到数量': 0, '包材未到数量': 0}]}, 'bom_supply_chain': {'columns': [], 'rows': [{ 'product_code': 'SKU1', 'material_code': 'MAT1', '物料名称': '测试彩盒', '物料供应商': '包材供应商', '单位用量': 1, '可支撑成品数量': 50}]}, } def _patch_handlers(self): results = self._handlers() old = {key: AI_DATASOURCES[key]['handler'] for key in results} for key, result in results.items(): AI_DATASOURCES[key]['handler'] = mock.Mock(return_value=result) return old def test_scenario_simulation_is_read_only_and_calculates_bom_gap(self): old = self._patch_handlers() try: result = ai_decision.simulate_product( 'SKU1', sales_change_pct=100, supplier_delay_days=5, planned_order_qty=300, horizon_days=30, allowed_sources=list(self._handlers())) finally: for key, handler in old.items(): AI_DATASOURCES[key]['handler'] = handler self.assertEqual(20, result['scenario']['daily_sales']) self.assertEqual(5, result['scenario']['support_days']) self.assertEqual(24, result['scenario']['threshold_days']) self.assertEqual(300, result['scenario']['support_after_order_days'] * 20 - 100) self.assertEqual(1, len(result['bom_gaps'])) self.assertEqual(250, result['bom_gaps'][0]['material_gap']) self.assertIn('不会修改', result['assumptions'][0]) def test_forecast_marks_future_shortage(self): old = self._patch_handlers() try: with mock.patch.object(ai_decision, 'cache_get', return_value=None), \ mock.patch.object(ai_decision, 'cache_set'): result = ai_decision.forecast_supply_risks( 30, 20, allowed_sources=['product_profile', 'planning_metrics', 'bom_supply_chain']) finally: for key, handler in old.items(): AI_DATASOURCES[key]['handler'] = handler self.assertEqual(1, result['total']) self.assertEqual('高', result['items'][0]['risk_level']) self.assertEqual(1, result['items'][0]['bom_shortage_count']) self.assertEqual(190, result['items'][0]['suggested_order_qty']) def test_action_drafts_never_claim_execution(self): forecast = {'items': [{ 'product_code': 'SKU1', 'risk_level': '高', 'estimated_shortage_date': '2026-10-10', 'support_days': 9, 'threshold_days': 19, 'suggested_order_qty': 190, 'planner': '计划员', 'supplier': '供应商A'}]} with mock.patch.object(ai_decision, 'forecast_supply_risks', return_value=forecast), \ mock.patch.object(ai_decision, 'get_operational_insights', return_value={'items': []}), \ mock.patch.object(ai_decision, 'filter_insights', return_value={'items': []}): result = ai_decision.build_action_drafts( ['planning_metrics', 'product_profile'], {'name': '采购计划'}, 30, 20) self.assertEqual('待人工确认', result['items'][0]['status']) self.assertIn('只读草稿', result['notice']) self.assertNotIn('已执行', result['notice']) def test_simulation_api_returns_result_and_only_audits(self): simulated = {'product_code': 'SKU1', 'inputs': {}, 'conclusion': '建议复核', 'scenario': {}} with self.app.test_request_context('/api/ai/simulate', method='POST', json={'product_code': 'SKU1'}): session['user_id'] = 1 session['role'] = 'super_admin' with mock.patch.object(ai, 'operation_required', return_value=True), \ mock.patch.object(ai, '_can_view_datasource', return_value=True), \ mock.patch('services.ai_decision.simulate_product', return_value=simulated) as simulate, \ mock.patch.object(ai, 'add_operation_log') as audit: response = ai.ai_simulate.__wrapped__() payload = response.get_json() self.assertEqual(200, payload['code']) self.assertEqual('SKU1', payload['data']['product_code']) self.assertTrue(simulate.called) self.assertTrue(audit.called) def test_frontend_has_decision_simulation_forecast_and_drafts(self): with open('static/js/app.js', encoding='utf-8') as fh: text = fh.read() self.assertIn('aiModeDecision', text) self.assertIn('/api/ai/simulate', text) self.assertIn('/api/ai/forecast', text) self.assertIn('/api/ai/action-drafts', text) self.assertIn('待人工确认的行动草稿', text) self.assertIn('不会修改库存、订单或待下单数据', text) if __name__ == '__main__': unittest.main()