|
|
@@ -0,0 +1,412 @@
|
|
|
+import importlib
|
|
|
+import io
|
|
|
+import math
|
|
|
+import unittest
|
|
|
+
|
|
|
+from app import GatewayApp
|
|
|
+from public_gateway import PublicGatewayApp
|
|
|
+from services.output_presenter import OutputPresenter
|
|
|
+
|
|
|
+
|
|
|
+COLUMNS = [
|
|
|
+ ('order_number', '订单号'),
|
|
|
+ ('customer_name', '客户名称'),
|
|
|
+ ('packing_type', '货物类型'),
|
|
|
+ ('inbound_volume', '入库体积'),
|
|
|
+ ('inbound_date', '入库时间'),
|
|
|
+ ('sales_name', '商务经理'),
|
|
|
+ ('merchandiser_name', '客户经理'),
|
|
|
+ ('department_name', '事业部'),
|
|
|
+ ('product_name', '物流产品'),
|
|
|
+]
|
|
|
+FILTER_TYPES = ['客户名称', '商务经理', '客户经理', '货物类型', '物流产品']
|
|
|
+
|
|
|
+
|
|
|
+class RecordingApiClient:
|
|
|
+ def __init__(self):
|
|
|
+ self.calls = []
|
|
|
+
|
|
|
+ def list_enabled_tools(self, request_id=''):
|
|
|
+ return {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'tool_codes': [
|
|
|
+ 'query_receive_volume_list',
|
|
|
+ 'list_receive_volume_filter_options',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ def call_tool(self, tool_code, route_path, payload, request_id):
|
|
|
+ self.calls.append((tool_code, route_path, payload, request_id))
|
|
|
+ return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
|
|
|
+
|
|
|
+
|
|
|
+class ReceiveVolumeToolContractTest(unittest.TestCase):
|
|
|
+ def tool_class(self, module_name, class_name):
|
|
|
+ return getattr(importlib.import_module('tools.' + module_name), class_name)
|
|
|
+
|
|
|
+ def test_query_schema_requires_inbound_dates_and_forbids_identity(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
|
|
|
+ )
|
|
|
+ metadata = cls().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('query_receive_volume_list', metadata['name'])
|
|
|
+ self.assertEqual('/mcp/tools/queryReceiveVolumeList', cls.route_path)
|
|
|
+ self.assertFalse(schema['additionalProperties'])
|
|
|
+ self.assertEqual(
|
|
|
+ ['inbound_date_start', 'inbound_date_end'],
|
|
|
+ schema['required'],
|
|
|
+ )
|
|
|
+ for forbidden in (
|
|
|
+ 'company_id', 'admin_id', 'is_super', 'order_numbers',
|
|
|
+ 'business_date_start',
|
|
|
+ ):
|
|
|
+ self.assertNotIn(forbidden, schema['properties'])
|
|
|
+ self.assertIn('使用场景:', metadata['description'])
|
|
|
+ self.assertIn('禁止使用:', metadata['description'])
|
|
|
+ self.assertIn('入库时间', metadata['description'])
|
|
|
+ self.assertIn('admin/Report/receiveForm', metadata['description'])
|
|
|
+ self.assertIn('list_receive_volume_filter_options', metadata['description'])
|
|
|
+ self.assertIn('不得改走query_order', metadata['description'])
|
|
|
+
|
|
|
+ def test_query_forwards_optional_filters(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
|
|
|
+ )
|
|
|
+ client = RecordingApiClient()
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ customer_ids=[11, 11, 12],
|
|
|
+ sales_ids=[3],
|
|
|
+ merchandiser_ids=[4],
|
|
|
+ packing_types=[' 散货 ', '散货'],
|
|
|
+ product_ids=[8],
|
|
|
+ page=2,
|
|
|
+ limit=30,
|
|
|
+ request_id='rq_rv',
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ (
|
|
|
+ 'query_receive_volume_list',
|
|
|
+ '/mcp/tools/queryReceiveVolumeList',
|
|
|
+ {
|
|
|
+ 'inbound_date_start': '2026-09-01',
|
|
|
+ 'inbound_date_end': '2026-09-30',
|
|
|
+ 'customer_ids': [11, 12],
|
|
|
+ 'sales_ids': [3],
|
|
|
+ 'merchandiser_ids': [4],
|
|
|
+ 'packing_types': ['散货'],
|
|
|
+ 'product_ids': [8],
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 30,
|
|
|
+ },
|
|
|
+ 'rq_rv',
|
|
|
+ ),
|
|
|
+ client.calls[-1],
|
|
|
+ )
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-01',
|
|
|
+ )
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'inbound_date_start': '2026-09-01',
|
|
|
+ 'inbound_date_end': '2026-09-01',
|
|
|
+ 'page': 1,
|
|
|
+ 'limit': 20,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
+ cls().call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_query_rejects_invalid_window_and_ids(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
|
|
|
+ )
|
|
|
+ client = RecordingApiClient()
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-30',
|
|
|
+ inbound_date_end='2026-09-01',
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-08-01',
|
|
|
+ inbound_date_end='2026-09-01',
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ customer_ids=[0],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ packing_types=[''],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ sales_ids=list(range(1, 202)),
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ packing_types=['x' * 101],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ packing_types=[1],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ packing_types=[],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='bad',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-9-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ packing_types=['n{0}'.format(i) for i in range(201)],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ product_ids=['8'],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ merchandiser_ids=[],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ page=101,
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call(
|
|
|
+ inbound_date_start='2026-09-01',
|
|
|
+ inbound_date_end='2026-09-30',
|
|
|
+ limit=True,
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_filter_schema_and_call(self):
|
|
|
+ cls = self.tool_class(
|
|
|
+ 'list_receive_volume_filter_options',
|
|
|
+ 'ListReceiveVolumeFilterOptionsTool',
|
|
|
+ )
|
|
|
+ metadata = cls().metadata()
|
|
|
+ schema = metadata['input_schema']
|
|
|
+ self.assertEqual('list_receive_volume_filter_options', metadata['name'])
|
|
|
+ self.assertEqual(
|
|
|
+ '/mcp/tools/listReceiveVolumeFilterOptions', cls.route_path
|
|
|
+ )
|
|
|
+ self.assertEqual(['filter_type'], schema['required'])
|
|
|
+ self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
|
|
|
+ self.assertIn('query_receive_volume_list', metadata['description'])
|
|
|
+ self.assertIn('禁止猜测', metadata['description'])
|
|
|
+ self.assertIn('使用场景:', metadata['description'])
|
|
|
+ self.assertIn('禁止使用:', metadata['description'])
|
|
|
+
|
|
|
+ client = RecordingApiClient()
|
|
|
+ cls(client).call('货物类型', keyword='散', page=2, limit=10)
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'filter_type': '货物类型',
|
|
|
+ 'keyword': '散',
|
|
|
+ 'page': 2,
|
|
|
+ 'limit': 10,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('事业部')
|
|
|
+ with self.assertRaises(RuntimeError):
|
|
|
+ cls().call('客户名称')
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('客户名称', keyword='x' * 101)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('客户名称', page=0)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ cls(client).call('客户名称', page=True)
|
|
|
+
|
|
|
+ def test_cli_forwards_dates_and_filters(self):
|
|
|
+ client = RecordingApiClient()
|
|
|
+ app = GatewayApp(api_client=client)
|
|
|
+ with self.assertRaises(ValueError):
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_receive_volume_list',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_receive_volume_list',
|
|
|
+ '--inbound-date-start', '2026-09-01',
|
|
|
+ '--inbound-date-end', '2026-09-30',
|
|
|
+ '--customer-ids', '11,12',
|
|
|
+ '--sales-ids', '3',
|
|
|
+ '--merchandiser-ids', '4',
|
|
|
+ '--packing-types', '散货,整柜',
|
|
|
+ '--product-ids', '8',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'inbound_date_start': '2026-09-01',
|
|
|
+ 'inbound_date_end': '2026-09-30',
|
|
|
+ 'customer_ids': [11, 12],
|
|
|
+ 'sales_ids': [3],
|
|
|
+ 'merchandiser_ids': [4],
|
|
|
+ 'packing_types': ['散货', '整柜'],
|
|
|
+ 'product_ids': [8],
|
|
|
+ 'page': 1,
|
|
|
+ 'limit': 20,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'query_receive_volume_list',
|
|
|
+ '--inbound-date-start', '2026-09-01',
|
|
|
+ '--inbound-date-end', '2026-09-02',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'inbound_date_start': '2026-09-01',
|
|
|
+ 'inbound_date_end': '2026-09-02',
|
|
|
+ 'page': 1,
|
|
|
+ 'limit': 20,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+ app.run_cli([
|
|
|
+ 'call', '--tool', 'list_receive_volume_filter_options',
|
|
|
+ '--filter-type', '物流产品',
|
|
|
+ ], stdout=io.StringIO())
|
|
|
+ self.assertEqual(
|
|
|
+ {
|
|
|
+ 'filter_type': '物流产品',
|
|
|
+ 'keyword': '',
|
|
|
+ 'page': 1,
|
|
|
+ 'limit': 20,
|
|
|
+ },
|
|
|
+ client.calls[-1][2],
|
|
|
+ )
|
|
|
+
|
|
|
+ def test_presenter_accepts_nine_columns_and_finite_volume(self):
|
|
|
+ presenter = OutputPresenter()
|
|
|
+ columns = [{'key': key, 'name': name} for key, name in COLUMNS]
|
|
|
+ record = {
|
|
|
+ 'order_number': 'FMS-1',
|
|
|
+ 'customer_name': '客户甲',
|
|
|
+ 'packing_type': '散货',
|
|
|
+ 'inbound_volume': 12.5,
|
|
|
+ 'inbound_date': '2026-09-01',
|
|
|
+ 'sales_name': '张三',
|
|
|
+ 'merchandiser_name': '李四',
|
|
|
+ 'department_name': '事业部A',
|
|
|
+ 'product_name': '美线-US01',
|
|
|
+ }
|
|
|
+ meta = {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
|
|
|
+ }
|
|
|
+ ok = presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [record]},
|
|
|
+ 'meta': meta,
|
|
|
+ })
|
|
|
+ self.assertFalse(ok['is_error'])
|
|
|
+ self.assertEqual(12.5, ok['structured_content']['rows'][0][3])
|
|
|
+
|
|
|
+ string_volume = dict(record)
|
|
|
+ string_volume['inbound_volume'] = '12.5'
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [string_volume]},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ inf_volume = dict(record)
|
|
|
+ inf_volume['inbound_volume'] = math.inf
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [inf_volume]},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ extra = dict(record)
|
|
|
+ extra['inbound_pieces'] = '1'
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [extra]},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [], 'extra': 1},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ bad_columns = list(columns)
|
|
|
+ bad_columns[0] = {'key': 'order_number', 'name': '错'}
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': bad_columns, 'records': []},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ non_string = dict(record)
|
|
|
+ non_string['order_number'] = 1
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': [non_string]},
|
|
|
+ 'meta': meta,
|
|
|
+ })['is_error'])
|
|
|
+ self.assertTrue(presenter.present('query_receive_volume_list', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {'columns': columns, 'records': []},
|
|
|
+ 'meta': {
|
|
|
+ 'page': 1, 'limit': 20, 'has_more': False,
|
|
|
+ 'request_id': 'rq_x', 'total': 1,
|
|
|
+ },
|
|
|
+ })['is_error'])
|
|
|
+
|
|
|
+ options = presenter.present('list_receive_volume_filter_options', {
|
|
|
+ 'code': 'MCP_0000',
|
|
|
+ 'data': {
|
|
|
+ 'records': [
|
|
|
+ {'value': '散货', 'label': '散货', 'code': ''},
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ 'meta': meta,
|
|
|
+ })
|
|
|
+ self.assertFalse(options['is_error'])
|
|
|
+
|
|
|
+ def test_local_public_registry_includes_both_tools(self):
|
|
|
+ local = GatewayApp().registered_tool_names()
|
|
|
+ public = PublicGatewayApp(None, None).registered_tool_names()
|
|
|
+ self.assertEqual(local, public)
|
|
|
+ self.assertEqual(32, len(local))
|
|
|
+ self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
|
|
|
+ self.assertIn('query_receive_volume_list', local)
|
|
|
+ self.assertIn('list_receive_volume_filter_options', local)
|