Parcourir la source

导出进港数据

jackson il y a 1 semaine
Parent
commit
fa1bba85da

+ 5 - 0
app.py

@@ -15,6 +15,9 @@ from services.scoped_api_client import ScopedApiClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
 from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
@@ -53,6 +56,8 @@ class GatewayApp:
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=api_client
             ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
         }

+ 5 - 0
public_gateway.py

@@ -4,6 +4,9 @@ import uuid
 from constants import DEVICE_INVALID_MESSAGE
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
 from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
@@ -30,6 +33,8 @@ class PublicGatewayApp:
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=None
             ),
+            'export_out_of_province_port_data':
+                ExportOutOfProvincePortDataTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),
         }

+ 197 - 0
tests/test_export_out_of_province_port_data_tool.py

@@ -0,0 +1,197 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.export_out_of_province_port_data import (
+    ExportOutOfProvincePortDataTool,
+)
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code,
+            'route_path': route_path,
+            'payload': payload,
+            'request_id': request_id,
+        }
+        return {
+            'code': 'MCP_0000',
+            'data': {'file_url': 'https://files.test/port.zip'},
+        }
+
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_out_of_province_port_data']},
+        }
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test'}
+        return None
+
+
+class PublicApiClient:
+    def __init__(self, enabled=True):
+        self.enabled = enabled
+        self.calls = []
+
+    def list_enabled_tools(self, token):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': (
+                ['export_out_of_province_port_data'] if self.enabled else []
+            )},
+        }
+
+    def call_tool(self, **kwargs):
+        self.calls.append(kwargs)
+        return {'code': 'MCP_0000'}
+
+
+class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
+    def test_metadata_explains_business_fields_and_ai_decision_boundaries(self):
+        metadata = ExportOutOfProvincePortDataTool().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+
+        self.assertEqual(
+            ['outbound_numbers', 'file_type'],
+            schema['required'],
+        )
+        self.assertFalse(schema['additionalProperties'])
+        numbers = properties['outbound_numbers']
+        self.assertEqual('array', numbers['type'])
+        self.assertEqual('string', numbers['items']['type'])
+        self.assertEqual(1, numbers['items']['minLength'])
+        self.assertEqual(100, numbers['items']['maxLength'])
+        self.assertEqual(1, numbers['minItems'])
+        self.assertEqual(100, numbers['maxItems'])
+        self.assertTrue(numbers['uniqueItems'])
+        self.assertIsInstance(numbers['examples'][0], list)
+        self.assertGreater(len(numbers['examples'][0]), 1)
+
+        file_type = properties['file_type']
+        self.assertEqual(['NB', 'SH', 'MS'], file_type['enum'])
+        self.assertEqual(['NB'], file_type['examples'])
+
+        description = metadata['description']
+        for phrase in (
+            '排舱单列表',
+            '不是系统订单号',
+            '不得根据号码格式猜测',
+            '先询问',
+            '一次只能选择一种资料类型',
+        ):
+            self.assertIn(phrase, description)
+        for phrase in (
+            '排舱单号',
+            '不是系统订单号',
+            '不得根据号码格式猜测',
+        ):
+            self.assertIn(phrase, numbers['description'])
+        for phrase in ('NB=宁波', 'SH=上海', 'MS=美森', '先询问'):
+            self.assertIn(phrase, file_type['description'])
+
+    def test_call_normalizes_and_forwards_only_supported_fields(self):
+        client = RecordingApiClient()
+        tool = ExportOutOfProvincePortDataTool(api_client=client)
+
+        result = tool.call(
+            [' PC001 ', 'PC002', 'PC001'],
+            ' nb ',
+            request_id='rq_port',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            'export_out_of_province_port_data',
+            client.last_call['tool_code'],
+        )
+        self.assertEqual(
+            '/mcp/tools/exportOutOfProvincePortData',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'file_type': 'NB',
+        }, client.last_call['payload'])
+        self.assertEqual('rq_port', client.last_call['request_id'])
+
+    def test_call_rejects_invalid_inputs(self):
+        tool = ExportOutOfProvincePortDataTool(api_client=RecordingApiClient())
+
+        invalid_numbers = (
+            [],
+            'PC001',
+            [''],
+            ['PC001', 2],
+            ['X' * 101],
+            ['PC{0}'.format(index) for index in range(101)],
+        )
+        for numbers in invalid_numbers:
+            with self.subTest(numbers=numbers):
+                with self.assertRaises(ValueError):
+                    tool.call(numbers, 'NB')
+        with self.assertRaisesRegex(ValueError, 'file_type must be NB, SH, or MS'):
+            tool.call(['PC001'], 'OTHER')
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ExportOutOfProvincePortDataTool().call(['PC001'], 'NB')
+
+    def test_local_and_public_gateways_expose_enabled_tool(self):
+        local_names = {
+            item['name']
+            for item in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        public_names = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('export_out_of_province_port_data', local_names)
+        self.assertIn('export_out_of_province_port_data', public_names)
+
+    def test_public_gateway_hides_disabled_tool(self):
+        names = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(enabled=False),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertNotIn('export_out_of_province_port_data', names)
+
+    def test_public_gateway_forwards_scoped_token_route_and_payload(self):
+        api_client = PublicApiClient()
+        gateway = PublicGatewayApp(PublicSessionStore(), api_client)
+
+        gateway.call_tool(
+            'GWS_test',
+            'export_out_of_province_port_data',
+            {'outbound_numbers': ['PC001'], 'file_type': 'NB'},
+            request_id='rq_public_port',
+        )
+
+        self.assertEqual('MT_test', api_client.calls[0]['token'])
+        self.assertEqual(
+            '/mcp/tools/exportOutOfProvincePortData',
+            api_client.calls[0]['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001'],
+            'file_type': 'NB',
+        }, api_client.calls[0]['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 104 - 0
tools/export_out_of_province_port_data.py

@@ -0,0 +1,104 @@
+class ExportOutOfProvincePortDataTool:
+    name = 'export_out_of_province_port_data'
+    route_path = '/mcp/tools/exportOutOfProvincePortData'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '导出后台排舱单列表中的省外进港资料,并返回可下载文件 URL。'
+                '只能在用户明确要求导出省外进港资料,并明确提供排舱单号和'
+                '宁波、上海或美森资料类型时调用。排舱单号不是系统订单号、'
+                '客户参考号、快递单号、柜号、SO 号或 Shipment ID;不得根据'
+                '号码格式猜测。号码类型或资料类型不明确时先询问用户。'
+                '一次只能选择一种资料类型;用户要求多个类型时应分别调用。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'outbound_numbers': {
+                        'type': 'array',
+                        'items': {
+                            'type': 'string',
+                            'minLength': 1,
+                            'maxLength': 100,
+                        },
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的排舱单号数组。排舱单号是后台排舱单列表中的'
+                            '业务单号,不是系统订单号、客户参考号、快递单号、柜号、'
+                            'SO号或Shipment ID。只能使用用户明确提供并确认类型为'
+                            '排舱单号的值,不得根据号码格式猜测。'
+                        ),
+                        'examples': [[
+                            'PC202607140001',
+                            'PC202607140002',
+                        ]],
+                    },
+                    'file_type': {
+                        'type': 'string',
+                        'enum': ['NB', 'SH', 'MS'],
+                        'description': (
+                            '进港资料类型:NB=宁波进港资料,SH=上海进港资料,'
+                            'MS=美森进港资料。必须根据用户明确选择传值;'
+                            '用户未说明时先询问,不得默认选择。'
+                        ),
+                        'examples': ['NB'],
+                    },
+                },
+                'required': ['outbound_numbers', 'file_type'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_numbers,
+        file_type,
+        request_id='rq_export_out_of_province_port_data',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for export_out_of_province_port_data'
+            )
+
+        numbers = self._normalize_outbound_numbers(outbound_numbers)
+        normalized_type = str(file_type or '').strip().upper()
+        if normalized_type not in ('NB', 'SH', 'MS'):
+            raise ValueError('file_type must be NB, SH, or MS')
+
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'outbound_numbers': numbers,
+                'file_type': normalized_type,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _normalize_outbound_numbers(values):
+        if not isinstance(values, list):
+            raise ValueError('outbound_numbers must be an array')
+
+        numbers = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('outbound_numbers must contain strings')
+            value = value.strip()
+            if not value:
+                continue
+            if len(value) > 100:
+                raise ValueError('outbound number is too long')
+            if value not in numbers:
+                numbers.append(value)
+
+        if not numbers or len(numbers) > 100:
+            raise ValueError('outbound_numbers must contain 1 to 100 values')
+        return numbers