117 lines
6.1 KiB
Python
117 lines
6.1 KiB
Python
# -*- 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()
|