Przeglądaj źródła

增加获取报关数据接口

jackson 1 tydzień temu
rodzic
commit
062ec99cbe

+ 19 - 1
README.md

@@ -20,7 +20,8 @@ Gateway 保持“薄网关”边界:
 - 支持 `query_order` 订单查询工具。
 - 支持 `query_track` 轨迹查询工具。
 - 支持 `query_order_exact` 精准订单查询和 `list_order_filter_options` 权限筛选项。
-- 支持未排舱订单导出、导出筛选项和省外进港资料导出。
+- 支持 `query_customs_declaration_files` 按排舱单号或订单号批量查询报关资料文件链接。
+- 支持未排舱订单导出、导出筛选项和按排舱单号、柜号、提单号或 SO 号四选一的省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
@@ -30,6 +31,7 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
 - `query_order_exact`、`query_track` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- `query_customs_declaration_files` 对外只展示排舱单号、订单号、文件名、文件类型、文件链接和分页信息。
 - 两个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
 - 两个导出工具返回 `files[].label + files[].url`,不暴露后端 `file_url` 键。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
@@ -37,6 +39,8 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 
 详细设计见 `../base/project-docs/mcp-output-field-presentation-design.md`。
 
+省外进港资料导出使用 `outbound_numbers`、`container_codes`、`bl_numbers`、`so_numbers` 四个号码数组之一,并同时提供 `file_type=NB/SH/MS`。用户未明确号码类型时,AI 必须先让用户从排舱单号、柜号、提单号、SO 号中选择,确认前不得调用;提单号指后台排舱单列表的普通提单号,SO 号对应 `fms_booking_detail.so_number`。
+
 ## 两种运行模式
 
 ### 本地 stdio 模式
@@ -225,6 +229,20 @@ python app.py call --tool query_order --keyword USC26070371955 --page 1 --limit
 python app.py call --tool query_track --order-number USC26070371955
 ```
 
+按订单号批量查询报关资料:
+
+```powershell
+python app.py call --tool query_customs_declaration_files --order-numbers ORD001,ORD002 --page 1 --limit 20
+```
+
+按排舱单号批量查询报关资料:
+
+```powershell
+python app.py call --tool query_customs_declaration_files --outbound-numbers PC001,PC002 --page 1 --limit 20
+```
+
+`order_numbers` 只接收后台订单列表及排舱详情“订单号”列展示的订单号,不接收系统单号、内部 `order_id/id`、客户参考号、快递单号或排舱单号。只有用户明确说明“订单号”或“排舱单号”后才能调用;如果未明确号码类型,AI 必须先提问让用户二选一,确认前不得调用。两种号码数组不能同时传入。
+
 运行全部测试:
 
 ```powershell

+ 14 - 0
app.py

@@ -22,6 +22,9 @@ from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
 from tools.query_order import QueryOrderTool
+from tools.query_customs_declaration_files import (
+    QueryCustomsDeclarationFilesTool,
+)
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_track import QueryTrackTool
 
@@ -50,6 +53,8 @@ class GatewayApp:
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
             'query_order_exact': QueryOrderExactTool(api_client=api_client),
+            'query_customs_declaration_files':
+                QueryCustomsDeclarationFilesTool(api_client=api_client),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=api_client
             ),
@@ -300,6 +305,15 @@ class GatewayApp:
                     tool_args['sales_id'] = args.sales_id
                 if args.department_id > 0:
                     tool_args['department_id'] = args.department_id
+            elif args.tool == 'query_customs_declaration_files':
+                if args.outbound_numbers:
+                    tool_args['outbound_numbers'] = parse_string_list(
+                        args.outbound_numbers
+                    )
+                if args.order_numbers:
+                    tool_args['order_numbers'] = parse_string_list(
+                        args.order_numbers
+                    )
             elif args.tool == 'list_order_filter_options':
                 if not args.filter_type:
                     raise ValueError(

+ 5 - 0
public_gateway.py

@@ -11,6 +11,9 @@ from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
 from tools.query_order import QueryOrderTool
+from tools.query_customs_declaration_files import (
+    QueryCustomsDeclarationFilesTool,
+)
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_track import QueryTrackTool
 from utils.security import hash_gateway_session_id
@@ -27,6 +30,8 @@ class PublicGatewayApp:
             'query_order': QueryOrderTool(api_client=None),
             'query_track': QueryTrackTool(api_client=None),
             'query_order_exact': QueryOrderExactTool(api_client=None),
+            'query_customs_declaration_files':
+                QueryCustomsDeclarationFilesTool(api_client=None),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=None
             ),

+ 89 - 7
services/output_presenter.py

@@ -8,6 +8,7 @@ class OutputPresenter:
     TABLE_TOOLS = frozenset((
         'query_order_exact',
         'query_track',
+        'query_customs_declaration_files',
     ))
     OPTION_TOOLS = frozenset((
         'list_order_filter_options',
@@ -19,6 +20,66 @@ class OutputPresenter:
     ))
     SAFE_TOOLS = TABLE_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
 
+    TABLE_COLUMNS = {
+        'query_order_exact': {
+            'order_number': ('订单号',),
+            'reference_number': ('客户参考号',),
+            'status_txt_name': ('状态',),
+            'check_status_txt_name': ('是否已查验',),
+            'customer_name': ('客户名称',),
+            'customer_account_type_name': ('客户属性',),
+            'inbound_date': ('入库时间',),
+            'wo_num': ('未完成工单',),
+            'product_name': ('物流产品',),
+            'inbound_pieces': ('件数',),
+            'inbound_volume': ('体积(CBM)',),
+            'inbound_weight': ('重量(KG)',),
+            'pro_cn_name': ('品名',),
+            'export_declaration_type': ('报关方式',),
+            'merge_declare_number': ('合并报关单号',),
+            'delivery_address': ('派送地址',),
+            'container_code': ('柜号',),
+            'out_status_txt': ('排舱单状态',),
+            'hinge_of_destination': ('目的港',),
+            'etd': ('ETD',),
+            'atd': ('ATD',),
+            'eta': ('ETA',),
+            'ata': ('ATA',),
+            'release_time': ('清关放行时间',),
+            'oversea_inbound_date': ('海外入库时间',),
+            'appt_time': ('APPT时间',),
+            'est_loading_time': ('预计装柜时间',),
+            'pickup_time': ('海外提柜时间',),
+            'delivery_way_title': ('派送方式',),
+            'tracking_number': ('快递单号', '承运商跟踪号码'),
+            'shipment_id': ('Shipment ID',),
+            'goods_attribute': ('商品属性',),
+            'sku': ('SKU',),
+            'sales_user': ('商务经理',),
+            'service_user': ('客户经理',),
+            'department_name': ('事业部',),
+            'remark': ('订单备注',),
+            'importer_name': ('进口商',),
+            'warehouse_name': ('交货仓库',),
+            'paid_status_name': ('付款状态',),
+        },
+        'query_track': {
+            'status': ('轨迹节点', '轨迹状态,如:开始集港、离港放行、清关、配送等'),
+            'location': ('轨迹地点', '发生地点'),
+            'time': ('时间', '轨迹发生时间(已转换为用户时区)'),
+            'content': ('轨迹内容', '详细描述'),
+            'tracking_number': ('跟踪号', '快递单号'),
+            'shipment_id': ('Shipment ID', '包裹ID'),
+        },
+        'query_customs_declaration_files': {
+            'outbound_number': ('排舱单号',),
+            'order_number': ('订单号',),
+            'file_name': ('文件名',),
+            'file_type': ('文件类型',),
+            'file_url': ('文件链接',),
+        },
+    }
+
     FIELD_LABELS = {
         'query_order_exact': {
             'order_number': '订单号',
@@ -54,6 +115,12 @@ class OutputPresenter:
             'page': '页码',
             'limit': '每页数量',
         },
+        'query_customs_declaration_files': {
+            'outbound_numbers': '排舱单号',
+            'order_numbers': '订单号',
+            'page': '页码',
+            'limit': '每页数量',
+        },
         'list_order_filter_options': {
             'filter_type': '筛选项类型',
             'keyword': '关键词',
@@ -93,6 +160,9 @@ class OutputPresenter:
         },
         'export_out_of_province_port_data': {
             'outbound_numbers': '排舱单号',
+            'container_codes': '柜号',
+            'bl_numbers': '提单号',
+            'so_numbers': 'SO号',
             'file_type': '资料类型',
         },
     }
@@ -129,7 +199,12 @@ class OutputPresenter:
             return self._format_error(meta)
 
         if tool_name in self.TABLE_TOOLS:
-            return self._present_table(data, tool_result.get('meta'), meta)
+            return self._present_table(
+                tool_name,
+                data,
+                tool_result.get('meta'),
+                meta,
+            )
         if tool_name in self.OPTION_TOOLS:
             return self._present_options(data, tool_result.get('meta'), meta)
         return self._present_export(data, meta)
@@ -157,12 +232,16 @@ class OutputPresenter:
             True,
         )
 
-    def _present_table(self, data, raw_meta, meta):
+    def _present_table(self, tool_name, data, raw_meta, meta):
         columns = data.get('columns')
         records = data.get('records')
         if not isinstance(columns, list) or not columns or not isinstance(records, list):
             return self._format_error(meta)
 
+        allowed_columns = self.TABLE_COLUMNS.get(tool_name)
+        if not isinstance(allowed_columns, dict):
+            return self._format_error(meta)
+
         headers = []
         keys = []
         for column in columns:
@@ -174,11 +253,14 @@ class OutputPresenter:
                 return self._format_error(meta)
             if not isinstance(label, str) or not label.strip():
                 return self._format_error(meta)
-            keys.append(key.strip())
-            header = {'label': label.strip()}
-            description = column.get('description')
-            if isinstance(description, str) and description.strip():
-                header['description'] = description.strip()
+            key = key.strip()
+            definition = allowed_columns.get(key)
+            if not isinstance(definition, tuple) or not definition:
+                return self._format_error(meta)
+            keys.append(key)
+            header = {'label': definition[0]}
+            if len(definition) > 1:
+                header['description'] = definition[1]
             headers.append(header)
 
         rows = []

+ 74 - 21
tests/test_export_out_of_province_port_data_tool.py

@@ -61,21 +61,30 @@ class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
         schema = metadata['input_schema']
         properties = schema['properties']
 
-        self.assertEqual(
-            ['outbound_numbers', 'file_type'],
-            schema['required'],
-        )
+        self.assertEqual(['file_type'], schema['required'])
+        self.assertEqual([
+            {'required': ['outbound_numbers']},
+            {'required': ['container_codes']},
+            {'required': ['bl_numbers']},
+            {'required': ['so_numbers']},
+        ], schema['oneOf'])
         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)
+        for field in (
+            'outbound_numbers',
+            'container_codes',
+            'bl_numbers',
+            'so_numbers',
+        ):
+            numbers = properties[field]
+            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'])
@@ -84,18 +93,23 @@ class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
         description = metadata['description']
         for phrase in (
             '排舱单列表',
-            '不是系统订单号',
+            '柜号',
+            '提单号',
+            'SO号',
+            '请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号',
+            '确认前不得调用',
             '不得根据号码格式猜测',
             '先询问',
             '一次只能选择一种资料类型',
         ):
             self.assertIn(phrase, description)
-        for phrase in (
-            '排舱单号',
-            '不是系统订单号',
-            '不得根据号码格式猜测',
-        ):
-            self.assertIn(phrase, numbers['description'])
+        self.assertIn('排舱单号', properties['outbound_numbers']['description'])
+        self.assertIn('柜号', properties['container_codes']['description'])
+        self.assertIn('集装箱号', properties['container_codes']['description'])
+        self.assertIn('提单号', properties['bl_numbers']['description'])
+        self.assertIn('不是系统提单号', properties['bl_numbers']['description'])
+        self.assertIn('SO号', properties['so_numbers']['description'])
+        self.assertIn('fms_booking_detail.so_number', properties['so_numbers']['description'])
         for phrase in ('NB=宁波', 'SH=上海', 'MS=美森', '先询问'):
             self.assertIn(phrase, file_type['description'])
 
@@ -124,6 +138,44 @@ class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
         }, client.last_call['payload'])
         self.assertEqual('rq_port', client.last_call['request_id'])
 
+    def test_call_forwards_all_supported_alternative_number_types(self):
+        cases = (
+            (
+                {'container_codes': [' MSCU001 ', 'MSCU002', 'MSCU001']},
+                {'container_codes': ['MSCU001', 'MSCU002'], 'file_type': 'SH'},
+            ),
+            (
+                {'bl_numbers': [' BL001 ', 'BL002', 'BL001']},
+                {'bl_numbers': ['BL001', 'BL002'], 'file_type': 'SH'},
+            ),
+            (
+                {'so_numbers': [' SO001 ', 'SO002', 'SO001']},
+                {'so_numbers': ['SO001', 'SO002'], 'file_type': 'SH'},
+            ),
+        )
+        for arguments, expected_payload in cases:
+            with self.subTest(arguments=arguments):
+                client = RecordingApiClient()
+                tool = ExportOutOfProvincePortDataTool(api_client=client)
+                tool.call(file_type='SH', **arguments)
+                self.assertEqual(expected_payload, client.last_call['payload'])
+
+    def test_call_requires_exactly_one_explicit_number_type(self):
+        tool = ExportOutOfProvincePortDataTool(api_client=RecordingApiClient())
+        invalid = (
+            {},
+            {'outbound_numbers': ['PC001'], 'container_codes': ['MSCU001']},
+            {'container_codes': ['MSCU001'], 'bl_numbers': ['BL001']},
+            {'bl_numbers': ['BL001'], 'so_numbers': ['SO001']},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaisesRegex(
+                    ValueError,
+                    'provide exactly one number type',
+                ):
+                    tool.call(file_type='NB', **arguments)
+
     def test_call_rejects_invalid_inputs(self):
         tool = ExportOutOfProvincePortDataTool(api_client=RecordingApiClient())
 
@@ -134,6 +186,7 @@ class ExportOutOfProvincePortDataToolTest(unittest.TestCase):
             ['PC001', 2],
             ['X' * 101],
             ['PC{0}'.format(index) for index in range(101)],
+            ['PC001'] * 101,
         )
         for numbers in invalid_numbers:
             with self.subTest(numbers=numbers):

+ 102 - 5
tests/test_output_presenter.py

@@ -74,7 +74,77 @@ class OutputPresenterTest(unittest.TestCase):
         self.assertIn('订单号', result['text'])
         self.assertIn('SO001', result['text'])
 
-    def test_table_missing_value_is_empty_and_duplicate_labels_are_preserved(self):
+    def test_table_tools_reject_unknown_backend_columns(self):
+        for tool_name in (
+            'query_order_exact',
+            'query_track',
+            'query_customs_declaration_files',
+        ):
+            with self.subTest(tool_name=tool_name):
+                result = self.presenter.present(
+                    tool_name,
+                    {
+                        'code': 'MCP_0000',
+                        'data': {
+                            'columns': [
+                                {'key': 'order_id', 'name': '内部订单ID'},
+                            ],
+                            'records': [{'order_id': 99}],
+                        },
+                    },
+                )
+
+                self.assertTrue(result['is_error'])
+                self.assertEqual(
+                    '工具返回格式异常',
+                    result['structured_content']['message'],
+                )
+                self.assertNotIn('99', result['text'])
+
+    def test_table_tools_use_fixed_labels_instead_of_backend_labels(self):
+        result = self.presenter.present(
+            'query_customs_declaration_files',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'columns': [
+                        {'key': 'order_number', 'name': '内部名称'},
+                    ],
+                    'records': [{'order_number': 'ORD001'}],
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': '订单号'}],
+            result['structured_content']['headers'],
+        )
+        self.assertNotIn('内部名称', result['text'])
+
+    def test_table_tool_without_column_definition_fails_closed(self):
+        definition = self.presenter.TABLE_COLUMNS.pop('query_track')
+        try:
+            result = self.presenter.present(
+                'query_track',
+                {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'columns': [{'key': 'status', 'name': '轨迹节点'}],
+                        'records': [],
+                    },
+                },
+            )
+        finally:
+            self.presenter.TABLE_COLUMNS['query_track'] = definition
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual(
+            '工具返回格式异常',
+            result['structured_content']['message'],
+        )
+
+    def test_table_missing_value_is_empty_and_fixed_labels_are_used(self):
         result = self.presenter.present(
             'query_track',
             {
@@ -92,7 +162,13 @@ class OutputPresenterTest(unittest.TestCase):
         )
 
         self.assertEqual(
-            [{'label': '状态'}, {'label': '状态'}],
+            [
+                {
+                    'label': '轨迹节点',
+                    'description': '轨迹状态,如:开始集港、离港放行、清关、配送等',
+                },
+                {'label': '轨迹内容', 'description': '详细描述'},
+            ],
             result['structured_content']['headers'],
         )
         self.assertEqual([['已发货', '']], result['structured_content']['rows'])
@@ -112,7 +188,13 @@ class OutputPresenterTest(unittest.TestCase):
             },
         )
 
-        self.assertEqual([{'label': '轨迹节点'}], result['structured_content']['headers'])
+        self.assertEqual(
+            [{
+                'label': '轨迹节点',
+                'description': '轨迹状态,如:开始集港、离港放行、清关、配送等',
+            }],
+            result['structured_content']['headers'],
+        )
         self.assertEqual([], result['structured_content']['rows'])
         self.assertIn('未查询到轨迹信息', result['text'])
 
@@ -206,6 +288,21 @@ class OutputPresenterTest(unittest.TestCase):
         self.assertNotIn('tracking_number', json.dumps(result, ensure_ascii=False))
         self.assertEqual({'request_id': 'rq_error'}, result['meta'])
 
+    def test_port_export_so_number_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_out_of_province_port_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'so_numbers must be an array',
+                'data': {'so_numbers': 'bad'},
+                'meta': {'request_id': 'rq_so_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('SO号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('so_numbers', json.dumps(result, ensure_ascii=False))
+
     def test_system_error_hides_backend_message_and_data(self):
         result = self.presenter.present(
             'query_order_exact',
@@ -344,9 +441,9 @@ class OutputPresenterTest(unittest.TestCase):
                     'summary': None,
                     'columns': [
                         {'key': 'status', 'name': '状态', 'description': ''},
-                        {'key': 'detail', 'name': '详情', 'description': 123},
+                        {'key': 'content', 'name': '详情', 'description': 123},
                     ],
-                    'records': [{'status': {'name': '已发货'}, 'detail': ['A']}],
+                    'records': [{'status': {'name': '已发货'}, 'content': ['A']}],
                     'tips': ['', None, '有效提示'],
                 },
                 'meta': {'request_id': ' ', 'page': 1},

+ 314 - 0
tests/test_query_customs_declaration_files_tool.py

@@ -0,0 +1,314 @@
+import importlib
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RecordingApiClient:
+    def __init__(self, enabled=True):
+        self.enabled = enabled
+        self.last_call = None
+
+    def list_enabled_tools(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': (
+                ['query_customs_declaration_files'] if self.enabled else []
+            )},
+        }
+
+    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': {'columns': [], 'records': []}}
+
+
+class PublicSessionStore:
+    def get(self, gateway_session_id):
+        if gateway_session_id == 'GWS_test':
+            return {'mcp_token': 'MT_test', 'company_id': 7, 'admin_id': 9}
+        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': (
+                ['query_customs_declaration_files'] if self.enabled else []
+            )},
+        }
+
+    def call_tool(self, **kwargs):
+        self.calls.append(kwargs)
+        return {'code': 'MCP_0000'}
+
+
+class QueryCustomsDeclarationFilesToolTest(unittest.TestCase):
+    def tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customs_declaration_files.py',
+        )
+        self.assertTrue(os.path.exists(path), path)
+        module = importlib.import_module(
+            'tools.query_customs_declaration_files'
+        )
+        return module.QueryCustomsDeclarationFilesTool
+
+    def test_metadata_teaches_ai_exact_order_number_semantics(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([
+            {'required': ['outbound_numbers']},
+            {'required': ['order_numbers']},
+        ], schema['oneOf'])
+        for field in ('outbound_numbers', 'order_numbers'):
+            prop = properties[field]
+            self.assertEqual('array', prop['type'])
+            self.assertEqual('string', prop['items']['type'])
+            self.assertEqual(1, prop['minItems'])
+            self.assertEqual(100, prop['maxItems'])
+            self.assertNotIn('uniqueItems', prop)
+
+        order_description = properties['order_numbers']['description']
+        for phrase in (
+            '订单号',
+            '后台订单列表',
+            '排舱详情',
+            '不是系统单号',
+            'order_id/id',
+            '不是客户参考号',
+            '快递单号',
+            '排舱单号',
+        ):
+            self.assertIn(phrase, order_description)
+        self.assertNotIn('系统订单号', order_description)
+
+        description = metadata['description']
+        for phrase in (
+            '用户明确说“订单号”',
+            '用户明确说“排舱单号”',
+            '没有明确说明是订单号还是排舱单号',
+            '必须先提问,让用户选择“订单号”或“排舱单号”',
+            '只说“单号”时必须先追问',
+            '不是系统单号',
+            '用户说“系统单号”时也不得当作订单号',
+            '不得根据号码格式猜测',
+            '不得跨字段重试',
+            '不得展示内部参数名',
+        ):
+            self.assertIn(phrase, description)
+        self.assertNotIn('系统订单号', description)
+
+    def test_outbound_batch_normalizes_and_forwards_supported_fields(self):
+        client = RecordingApiClient()
+        tool = self.tool_class()(api_client=client)
+
+        result = tool.call(
+            outbound_numbers=[' PC001 ', 'PC002', 'PC001'],
+            page=2,
+            limit=50,
+            request_id='rq_customs',
+        )
+
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            'query_customs_declaration_files',
+            client.last_call['tool_code'],
+        )
+        self.assertEqual(
+            '/mcp/tools/queryCustomsDeclarationFiles',
+            client.last_call['route_path'],
+        )
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'page': 2,
+            'limit': 50,
+        }, client.last_call['payload'])
+
+    def test_order_batch_uses_order_numbers_business_field(self):
+        client = RecordingApiClient()
+        tool = self.tool_class()(api_client=client)
+
+        tool.call(order_numbers=[' ORD001 ', 'ORD002'])
+
+        self.assertEqual({
+            'order_numbers': ['ORD001', 'ORD002'],
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_mixed_missing_and_invalid_batches(self):
+        tool = self.tool_class()(api_client=RecordingApiClient())
+        invalid = (
+            {},
+            {'outbound_numbers': ['PC001'], 'order_numbers': ['ORD001']},
+            {'order_numbers': []},
+            {'order_numbers': 'ORD001'},
+            {'order_numbers': ['']},
+            {'order_numbers': ['ORD001', 2]},
+            {'order_numbers': ['X' * 101]},
+            {'order_numbers': ['ORD{0}'.format(i) for i in range(101)]},
+            {'order_numbers': ['ORD001'], 'page': 0},
+            {'order_numbers': ['ORD001'], 'page': 101},
+            {'order_numbers': ['ORD001'], 'limit': 101},
+            {'order_numbers': ['ORD001'], 'page': True},
+            {'order_numbers': ['ORD001'], 'page': 'not-a-number'},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            self.tool_class()().call(order_numbers=['ORD001'])
+
+    def test_local_and_public_gateways_follow_dynamic_registry(self):
+        local_enabled = {
+            item['name']
+            for item in GatewayApp(api_client=RecordingApiClient()).list_tools()
+        }
+        local_disabled = {
+            item['name']
+            for item in GatewayApp(
+                api_client=RecordingApiClient(enabled=False)
+            ).list_tools()
+        }
+        public_enabled = {
+            item['name']
+            for item in PublicGatewayApp(
+                PublicSessionStore(),
+                PublicApiClient(),
+            ).list_tools('GWS_test')
+        }
+
+        self.assertIn('query_customs_declaration_files', local_enabled)
+        self.assertNotIn('query_customs_declaration_files', local_disabled)
+        self.assertIn('query_customs_declaration_files', public_enabled)
+
+    def test_public_gateway_forwards_request_scoped_token_without_company_input(self):
+        api_client = PublicApiClient()
+        gateway = PublicGatewayApp(PublicSessionStore(), api_client)
+        self.assertIn(
+            'query_customs_declaration_files',
+            gateway.registered_tool_names(),
+        )
+
+        gateway.call_tool(
+            'GWS_test',
+            'query_customs_declaration_files',
+            {'order_numbers': ['ORD001']},
+            request_id='rq_public_customs',
+        )
+
+        call = api_client.calls[0]
+        self.assertEqual('MT_test', call['token'])
+        self.assertEqual(
+            '/mcp/tools/queryCustomsDeclarationFiles',
+            call['route_path'],
+        )
+        self.assertEqual({'order_numbers': ['ORD001']}, call['payload'])
+        self.assertNotIn('company_id', call['payload'])
+
+    def test_output_presenter_hides_internal_fields_and_keeps_links(self):
+        result = OutputPresenter().present(
+            'query_customs_declaration_files',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'summary': '当前页返回 1 个订单的报关资料',
+                    'columns': [
+                        {'key': 'outbound_number', 'name': '排舱单号'},
+                        {'key': 'order_number', 'name': '订单号'},
+                        {'key': 'file_name', 'name': '文件名'},
+                        {'key': 'file_url', 'name': '文件链接'},
+                    ],
+                    'records': [{
+                        'outbound_number': 'PC001',
+                        'order_number': 'ORD001',
+                        'file_name': '报关单.pdf',
+                        'file_url': 'https://files.test/a.pdf',
+                        'order_id': 99,
+                    }],
+                },
+                'meta': {
+                    'page': 1,
+                    'limit': 20,
+                    'has_more': False,
+                    'request_id': 'rq_present',
+                },
+            },
+        )
+
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            ['排舱单号', '订单号', '文件名', '文件链接'],
+            [header['label'] for header in result['structured_content']['headers']],
+        )
+        self.assertIn('https://files.test/a.pdf', result['text'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        for internal in (
+            'outbound_number',
+            'order_number',
+            'file_name',
+            'file_url',
+            'order_id',
+        ):
+            self.assertNotIn(internal, serialized)
+
+    def test_cli_forwards_batch_arguments(self):
+        client = RecordingApiClient()
+        stdout = io.StringIO()
+        app = GatewayApp(api_client=client)
+        self.assertIn(
+            'query_customs_declaration_files',
+            app.registered_tool_names(),
+        )
+
+        result = app.run_cli([
+            'call',
+            '--tool', 'query_customs_declaration_files',
+            '--order-numbers', 'ORD001,ORD002',
+            '--page', '2',
+            '--limit', '10',
+        ], stdout=stdout)
+
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'order_numbers': ['ORD001', 'ORD002'],
+            'page': 2,
+            'limit': 10,
+        }, client.last_call['payload'])
+
+        app.run_cli([
+            'call',
+            '--tool', 'query_customs_declaration_files',
+            '--outbound-numbers', 'PC001,PC002',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'outbound_numbers': ['PC001', 'PC002'],
+            'page': 1,
+            'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 92 - 19
tools/export_out_of_province_port_data.py

@@ -6,14 +6,21 @@ class ExportOutOfProvincePortDataTool:
         self.api_client = api_client
 
     def metadata(self):
+        number_items = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
         return {
             'name': self.name,
             'description': (
                 '导出后台排舱单列表中的省外进港资料,并返回可下载文件 URL。'
-                '只能在用户明确要求导出省外进港资料,并明确提供排舱单号和'
-                '宁波、上海或美森资料类型时调用。排舱单号不是系统订单号、'
-                '客户参考号、快递单号、柜号、SO 号或 Shipment ID;不得根据'
-                '号码格式猜测。号码类型或资料类型不明确时先询问用户。'
+                '只能在用户明确要求导出省外进港资料,并明确提供排舱单号、'
+                '柜号、提单号或SO号中的一种,以及宁波、上海或美森资料类型时调用。'
+                '用户没有明确号码类型时必须先询问:'
+                '“请确认使用哪种单号导出:排舱单号、柜号、提单号还是SO号?”'
+                '确认前不得调用。不得根据号码格式猜测,也不得在查询失败后'
+                '切换号码类型重试。一次只能选择一种号码类型。'
                 '一次只能选择一种资料类型;用户要求多个类型时应分别调用。'
                 '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
                 '不得展示内部参数名。'
@@ -23,11 +30,7 @@ class ExportOutOfProvincePortDataTool:
                 'properties': {
                     'outbound_numbers': {
                         'type': 'array',
-                        'items': {
-                            'type': 'string',
-                            'minLength': 1,
-                            'maxLength': 100,
-                        },
+                        'items': dict(number_items),
                         'minItems': 1,
                         'maxItems': 100,
                         'uniqueItems': True,
@@ -42,6 +45,52 @@ class ExportOutOfProvincePortDataTool:
                             'PC202607140002',
                         ]],
                     },
+                    'container_codes': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的柜号或集装箱号数组。仅当用户明确说明号码类型'
+                            '为“柜号”或“集装箱号”时使用,不得放入排舱单号或提单号。'
+                        ),
+                        'examples': [[
+                            'MSCU1234567',
+                            'TGHU7654321',
+                        ]],
+                    },
+                    'bl_numbers': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的提单号数组,对应后台排舱单列表中的“提单号”。'
+                            '不是系统提单号;仅当用户明确说明号码类型为“提单号”时使用。'
+                        ),
+                        'examples': [[
+                            'BL202607150001',
+                            'BL202607150002',
+                        ]],
+                    },
+                    'so_numbers': {
+                        'type': 'array',
+                        'items': dict(number_items),
+                        'minItems': 1,
+                        'maxItems': 100,
+                        'uniqueItems': True,
+                        'description': (
+                            '要导出的SO号数组,对应后台排舱单列表中的“SO号”,'
+                            '数据字段为fms_booking_detail.so_number。仅当用户'
+                            '明确说明号码类型为“SO号”时使用。'
+                        ),
+                        'examples': [[
+                            'SO202607160001',
+                            'SO202607160002',
+                        ]],
+                    },
                     'file_type': {
                         'type': 'string',
                         'enum': ['NB', 'SH', 'MS'],
@@ -53,23 +102,45 @@ class ExportOutOfProvincePortDataTool:
                         'examples': ['NB'],
                     },
                 },
-                'required': ['outbound_numbers', 'file_type'],
+                'required': ['file_type'],
+                'oneOf': [
+                    {'required': ['outbound_numbers']},
+                    {'required': ['container_codes']},
+                    {'required': ['bl_numbers']},
+                    {'required': ['so_numbers']},
+                ],
                 'additionalProperties': False,
             },
         }
 
     def call(
         self,
-        outbound_numbers,
-        file_type,
+        outbound_numbers=None,
+        file_type=None,
         request_id='rq_export_out_of_province_port_data',
+        container_codes=None,
+        bl_numbers=None,
+        so_numbers=None,
     ):
         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)
+        number_fields = {
+            'outbound_numbers': outbound_numbers,
+            'container_codes': container_codes,
+            'bl_numbers': bl_numbers,
+            'so_numbers': so_numbers,
+        }
+        provided = [
+            field for field, values in number_fields.items()
+            if values is not None
+        ]
+        if len(provided) != 1:
+            raise ValueError('provide exactly one number type')
+        number_field = provided[0]
+        numbers = self._normalize_numbers(number_fields[number_field], number_field)
         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')
@@ -78,29 +149,31 @@ class ExportOutOfProvincePortDataTool:
             self.name,
             self.route_path,
             {
-                'outbound_numbers': numbers,
+                number_field: numbers,
                 'file_type': normalized_type,
             },
             request_id,
         )
 
     @staticmethod
-    def _normalize_outbound_numbers(values):
+    def _normalize_numbers(values, field):
         if not isinstance(values, list):
-            raise ValueError('outbound_numbers must be an array')
+            raise ValueError('{0} must be an array'.format(field))
+        if len(values) > 100:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
 
         numbers = []
         for value in values:
             if not isinstance(value, str):
-                raise ValueError('outbound_numbers must contain strings')
+                raise ValueError('{0} must contain strings'.format(field))
             value = value.strip()
             if not value:
                 continue
             if len(value) > 100:
-                raise ValueError('outbound number is too long')
+                raise ValueError('{0} contains a number that is too long'.format(field))
             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')
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
         return numbers

+ 152 - 0
tools/query_customs_declaration_files.py

@@ -0,0 +1,152 @@
+class QueryCustomsDeclarationFilesTool:
+    name = 'query_customs_declaration_files'
+    route_path = '/mcp/tools/queryCustomsDeclarationFiles'
+    MAX_NUMBERS = 100
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_item = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '批量查询订单的报关资料文件链接。用户明确说“订单号”时使用'
+                '订单号数组;用户明确说“排舱单号”时使用排舱单号数组。'
+                '这里的订单号是后台订单列表及排舱详情“订单号”列显示的号码,'
+                '不是系统单号、数据库 order_id/id、客户参考号、快递单号或排舱单号。'
+                '一次调用只能选择一种号码,单个号码也必须放入数组。'
+                '如果用户没有明确说明是订单号还是排舱单号,必须先提问,'
+                '让用户选择“订单号”或“排舱单号”,确认前不得调用工具。'
+                '用户只说“单号”时必须先追问是订单号还是排舱单号。'
+                '用户说“系统单号”时也不得当作订单号;必须先确认并取得订单号。'
+                '不得根据号码格式猜测。'
+                '查询无结果后不得切换号码类型,不得跨字段重试。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'outbound_numbers': {
+                        'type': 'array',
+                        'items': dict(number_item),
+                        'minItems': 1,
+                        'maxItems': self.MAX_NUMBERS,
+                        'description': (
+                            '多个排舱单号。仅当用户明确说“排舱单号”时使用;'
+                            '不是订单号、客户参考号或快递单号。'
+                        ),
+                        'examples': [['PC202607150001', 'PC202607150002']],
+                    },
+                    'order_numbers': {
+                        'type': 'array',
+                        'items': dict(number_item),
+                        'minItems': 1,
+                        'maxItems': self.MAX_NUMBERS,
+                        'description': (
+                            '多个订单号,对应后台订单列表及排舱详情“订单号”列的值。'
+                            '仅当用户明确说“订单号”时使用;不是系统单号或数据库'
+                            ' order_id/id,也不是客户参考号、快递单号或排舱单号。'
+                            '用户提供“系统单号”时不得填入此字段,必须先追问取得订单号。'
+                        ),
+                        'examples': [['ORD202607150001', 'ORD202607150002']],
+                    },
+                    'page': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 1,
+                        'description': '页码,从 1 开始,最大 100。',
+                    },
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 20,
+                        'description': '每页订单数量,范围 1 到 100。',
+                    },
+                },
+                'oneOf': [
+                    {'required': ['outbound_numbers']},
+                    {'required': ['order_numbers']},
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        outbound_numbers=None,
+        order_numbers=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_customs_declaration_files',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_customs_declaration_files'
+            )
+
+        has_outbound = outbound_numbers is not None
+        has_order = order_numbers is not None
+        if has_outbound == has_order:
+            raise ValueError(
+                'provide outbound_numbers or order_numbers'
+            )
+
+        field = 'outbound_numbers' if has_outbound else 'order_numbers'
+        values = outbound_numbers if has_outbound else order_numbers
+        numbers = self._normalize_numbers(values, field)
+        page = self._bounded_integer(page, 'page', 100)
+        limit = self._bounded_integer(limit, 'limit', 100)
+
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                field: numbers,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @classmethod
+    def _normalize_numbers(cls, values, field):
+        if not isinstance(values, list):
+            raise ValueError('{0} must be an array'.format(field))
+        if len(values) > cls.MAX_NUMBERS:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+
+        numbers = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} must contain strings'.format(field))
+            value = value.strip()
+            if not value:
+                continue
+            if len(value) > 100:
+                raise ValueError('{0} contains an invalid number'.format(field))
+            if value not in numbers:
+                numbers.append(value)
+
+        if not numbers:
+            raise ValueError('{0} must contain 1 to 100 values'.format(field))
+        return numbers
+
+    @staticmethod
+    def _bounded_integer(value, field, maximum):
+        if isinstance(value, bool):
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        try:
+            value = int(value)
+        except (TypeError, ValueError):
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        if value < 1 or value > maximum:
+            raise ValueError('{0} must be between 1 and {1}'.format(field, maximum))
+        return value