jackson 3 hodín pred
rodič
commit
63c45fb11c

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 21 - 0
CONTEXT.md


+ 5 - 3
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 30 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 32 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 30 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 32 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -47,6 +47,8 @@ Gateway 保持“薄网关”边界:
 | `list_destination_trailer_filter_options` | 查询海外提柜七类筛选项 | `/mcp/tools/listDestinationTrailerFilterOptions` | 安全筛选项 |
 | `query_order_abnormal_list` | 按六个页签查询头程问题件列表 | `/mcp/tools/queryOrderAbnormalList` | 固定 9 列安全表格 |
 | `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
+| `query_receive_volume_list` | 按入库时间查询收货量已入库订单明细 | `/mcp/tools/queryReceiveVolumeList` | 固定 9 列安全表格 |
+| `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -72,7 +74,7 @@ Gateway 保持“薄网关”边界:
 先询问用户是柜号还是提单号,不得按格式猜测、跨字段或跨工具试查;不支持入仓单号、
 订单号、打托批次号或内部 ID。
 
-这 30 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 32 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 

+ 35 - 0
app.py

@@ -61,6 +61,10 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
 
 
 def parse_int_list(value):
@@ -154,6 +158,11 @@ class GatewayApp:
             ),
             'list_order_abnormal_filter_options':
                 ListOrderAbnormalFilterOptionsTool(api_client=api_client),
+            'query_receive_volume_list': QueryReceiveVolumeListTool(
+                api_client=api_client
+            ),
+            'list_receive_volume_filter_options':
+                ListReceiveVolumeFilterOptionsTool(api_client=api_client),
         }
 
     @classmethod
@@ -353,6 +362,9 @@ class GatewayApp:
         call_parser.add_argument('--filter-type', default='')
         call_parser.add_argument('--trailer-stage', default='')
         call_parser.add_argument('--abnormal-status', default='')
+        call_parser.add_argument('--sales-ids', default='')
+        call_parser.add_argument('--merchandiser-ids', default='')
+        call_parser.add_argument('--packing-types', default='')
         call_parser.add_argument('--providers-id', type=int, default=0)
         call_parser.add_argument('--bill-status', type=int, default=None)
         call_parser.add_argument('--cabinet-type', type=int, default=0)
@@ -777,11 +789,34 @@ class GatewayApp:
                     tool_args['order_numbers'] = parse_string_list(
                         args.order_numbers
                     )
+            elif args.tool == 'query_receive_volume_list':
+                if not args.inbound_date_start or not args.inbound_date_end:
+                    raise ValueError(
+                        '--inbound-date-start and --inbound-date-end are '
+                        'required for query_receive_volume_list'
+                    )
+                tool_args['inbound_date_start'] = args.inbound_date_start
+                tool_args['inbound_date_end'] = args.inbound_date_end
+                if args.customer_ids:
+                    tool_args['customer_ids'] = parse_int_list(args.customer_ids)
+                if args.sales_ids:
+                    tool_args['sales_ids'] = parse_int_list(args.sales_ids)
+                if args.merchandiser_ids:
+                    tool_args['merchandiser_ids'] = parse_int_list(
+                        args.merchandiser_ids
+                    )
+                if args.packing_types:
+                    tool_args['packing_types'] = parse_string_list(
+                        args.packing_types
+                    )
+                if args.product_ids:
+                    tool_args['product_ids'] = parse_int_list(args.product_ids)
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
                 'list_customer_filter_options',
                 'list_destination_trailer_filter_options',
                 'list_order_abnormal_filter_options',
+                'list_receive_volume_filter_options',
             ):
                 if not args.filter_type:
                     raise ValueError(

+ 9 - 0
public_gateway.py

@@ -47,6 +47,10 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
 from utils.security import hash_gateway_session_id
 
 
@@ -121,6 +125,11 @@ class PublicGatewayApp:
             ),
             'list_order_abnormal_filter_options':
                 ListOrderAbnormalFilterOptionsTool(api_client=None),
+            'query_receive_volume_list': QueryReceiveVolumeListTool(
+                api_client=None
+            ),
+            'list_receive_volume_filter_options':
+                ListReceiveVolumeFilterOptionsTool(api_client=None),
         }
 
     def registered_tool_names(self):

+ 64 - 0
services/output_presenter.py

@@ -35,6 +35,7 @@ class OutputPresenter:
     PAYABLE_EXPORT_TOOLS = frozenset(('export_payable_cost_list',))
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
+    RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -44,6 +45,7 @@ class OutputPresenter:
         'list_customer_filter_options',
         'list_destination_trailer_filter_options',
         'list_order_abnormal_filter_options',
+        'list_receive_volume_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
@@ -59,6 +61,7 @@ class OutputPresenter:
         | PAYABLE_COST_TOOLS | PAYABLE_FILTER_TOOLS | PAYABLE_EXPORT_TOOLS
         | DESTINATION_TRAILER_TOOLS
         | ORDER_ABNORMAL_TOOLS
+        | RECEIVE_VOLUME_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -560,6 +563,13 @@ class OutputPresenter:
     ORDER_ABNORMAL_STATUSES = frozenset((
         '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
     ))
+    RECEIVE_VOLUME_COLUMNS = (
+        ('order_number', '订单号'), ('customer_name', '客户名称'),
+        ('packing_type', '货物类型'), ('inbound_volume', '入库体积'),
+        ('inbound_date', '入库时间'), ('sales_name', '商务经理'),
+        ('merchandiser_name', '客户经理'), ('department_name', '事业部'),
+        ('product_name', '物流产品'),
+    )
 
     OUTBOUND_DETAIL_SUMMARY = {
         'bl_number': '提单号',
@@ -840,6 +850,10 @@ class OutputPresenter:
             return self._present_order_abnormal_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.RECEIVE_VOLUME_TOOLS:
+            return self._present_receive_volume_list(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1703,6 +1717,56 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_receive_volume_list(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        expected = list(self.RECEIVE_VOLUME_COLUMNS)
+        pagination = self._customer_pagination(raw_meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, (key, name) in zip(columns, expected):
+            if column != {'key': key, 'name': name}:
+                return self._format_error(meta)
+        rows = []
+        expected_keys = [key for key, _ in expected]
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if key == 'inbound_volume':
+                    if not self._valid_volume_cell(value):
+                        return self._format_error(meta)
+                    row.append(value)
+                    continue
+                if not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
+    @staticmethod
+    def _valid_volume_cell(value):
+        return (
+            not isinstance(value, bool)
+            and isinstance(value, (int, float))
+            and math.isfinite(float(value))
+        )
+
     @staticmethod
     def _valid_amount_cell(value):
         if not isinstance(value, dict) or set(value) != {'amount', 'currency'}:

+ 1 - 1
tests/test_customer_payment_followup_tool.py

@@ -112,7 +112,7 @@ class CustomerPaymentFollowupToolTest(unittest.TestCase):
         local = GatewayApp(api_client=RecordingApiClient())
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(30, len(local.registered_tool_names()))
+        self.assertEqual(32, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_followup', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 1 - 1
tests/test_customer_payment_records_tool.py

@@ -118,7 +118,7 @@ class CustomerPaymentRecordsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(30, len(local.registered_tool_names()))
+        self.assertEqual(32, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 1 - 1
tests/test_customer_query_tools.py

@@ -68,7 +68,7 @@ class CustomerQueryToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
+        self.assertEqual(32, len(local))
         self.assertIn('query_customer_list', local)
         self.assertIn('list_customer_filter_options', local)
 

+ 1 - 1
tests/test_customer_unverified_bill_details_tool.py

@@ -61,7 +61,7 @@ class CustomerUnverifiedBillDetailsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(30, len(local.registered_tool_names()))
+        self.assertEqual(32, len(local.registered_tool_names()))
         self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
 
         output = StringIO()

+ 2 - 2
tests/test_destination_trailer_tools.py

@@ -387,8 +387,8 @@ class DestinationTrailerToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(32, len(local))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_destination_trailer_list', local)
         self.assertIn('list_destination_trailer_filter_options', local)
 

+ 2 - 2
tests/test_export_pallet_data_tool.py

@@ -296,9 +296,9 @@ class ExportPalletDataToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
+        self.assertEqual(32, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 2 - 2
tests/test_export_receivable_cost_list_tool.py

@@ -168,9 +168,9 @@ class ExportReceivableCostListToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
+        self.assertEqual(32, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 2 - 2
tests/test_order_abnormal_tools.py

@@ -341,7 +341,7 @@ class OrderAbnormalToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(32, len(local))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_order_abnormal_list', local)
         self.assertIn('list_order_abnormal_filter_options', local)

+ 1 - 1
tests/test_order_receivable_cost_details_tool.py

@@ -86,7 +86,7 @@ class OrderReceivableCostDetailsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(30, len(local.registered_tool_names()))
+        self.assertEqual(32, len(local.registered_tool_names()))
         self.assertIn('query_order_receivable_cost_details', local.registered_tool_names())
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 

+ 1 - 1
tests/test_output_presenter.py

@@ -203,7 +203,7 @@ class OutputPresenterTest(unittest.TestCase):
     def test_customer_tools_are_safe_and_total_is_24(self):
         self.assertTrue(self.presenter.handles(QueryCustomerListTool.name))
         self.assertTrue(self.presenter.handles(ListCustomerFilterOptionsTool.name))
-        self.assertEqual(29, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(31, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(

+ 2 - 2
tests/test_payable_cost_tools.py

@@ -231,8 +231,8 @@ class PayableCostToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(32, len(local))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_payable_cost_list',

+ 1 - 1
tests/test_query_export_task_tool.py

@@ -63,7 +63,7 @@ class QueryExportTaskToolTest(unittest.TestCase):
         public = PublicGatewayApp(None, None).registered_tool_names()
 
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
+        self.assertEqual(32, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 2 - 2
tests/test_receivable_cost_list_tools.py

@@ -274,8 +274,8 @@ class ReceivableCostIntegrationTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(30, len(local))
-        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(32, len(local))
+        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_receivable_cost_list',

+ 412 - 0
tests/test_receive_volume_tools.py

@@ -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)

+ 12 - 0
tests/test_tool_description_boundaries.py

@@ -23,6 +23,10 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_receive_volume_list import QueryReceiveVolumeListTool
+from tools.list_receive_volume_filter_options import (
+    ListReceiveVolumeFilterOptionsTool,
+)
 from tools.query_track import QueryTrackTool
 
 
@@ -44,6 +48,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListDestinationTrailerFilterOptionsTool(),
             QueryOrderAbnormalListTool(),
             ListOrderAbnormalFilterOptionsTool(),
+            QueryReceiveVolumeListTool(),
+            ListReceiveVolumeFilterOptionsTool(),
         )
 
         for tool in tools:
@@ -101,6 +107,12 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListOrderAbnormalFilterOptionsTool(): (
                 'query_order_abnormal_list', '禁止猜测', '作废审核',
             ),
+            QueryReceiveVolumeListTool(): (
+                '收货量', 'admin/Report/receiveForm', 'query_order',
+            ),
+            ListReceiveVolumeFilterOptionsTool(): (
+                'query_receive_volume_list', '禁止猜测', '导出',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 86 - 0
tools/list_receive_volume_filter_options.py

@@ -0,0 +1,86 @@
+class ListReceiveVolumeFilterOptionsTool:
+    name = 'list_receive_volume_filter_options'
+    route_path = '/mcp/tools/listReceiveVolumeFilterOptions'
+    FILTER_TYPES = (
+        '客户名称', '商务经理', '客户经理', '货物类型', '物流产品',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户需要筛选收货量列表时,先按中文筛选类别调用本工具,'
+                '取得后台同源的中文名称和可传值,再把选中的值传给query_receive_volume_list。'
+                '禁止使用:本工具不返回收货量业务数据,禁止猜测任何筛选值,也禁止用于'
+                '订单列表、应收费用或后台收货量导出。参数只用于工具内部调用;'
+                '最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的英文参数名,'
+                '不得使用“筛选字段=内部值”形式。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string',
+                        'enum': list(self.FILTER_TYPES),
+                        'description': '要查询的中文筛选类别。',
+                    },
+                    'keyword': {
+                        'type': 'string', 'maxLength': 100,
+                        'description': '按中文显示名称搜索;可不传以分页列出全部。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['filter_type'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_receive_volume_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_receive_volume_filter_options'
+            )
+        filter_type = str(filter_type or '').strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('unsupported filter_type')
+        keyword = str(keyword or '').strip()
+        if len(keyword) > 100:
+            raise ValueError('keyword is too long')
+        page = self._bounded_integer(page, 'page')
+        limit = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'filter_type': filter_type,
+                'keyword': keyword,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError(field + ' is invalid')
+        if value < 1 or value > 100:
+            raise ValueError(field + ' is invalid')
+        return value

+ 192 - 0
tools/query_receive_volume_list.py

@@ -0,0 +1,192 @@
+from datetime import datetime
+
+
+class QueryReceiveVolumeListTool:
+    name = 'query_receive_volume_list'
+    route_path = '/mcp/tools/queryReceiveVolumeList'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        date_field = {
+            'type': 'string',
+            'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        id_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {'type': 'integer', 'minimum': 1},
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要看收货量明细(已入库头程订单一行一单)时,'
+                '必须先确认员工时区下最多31个日历日的入库时间闭区间,再调用'
+                'list_receive_volume_filter_options取得客户、商务经理、客户经理、'
+                '货物类型或物流产品的可传值。对照后台admin/Report/receiveForm的'
+                '已入库集合,不是按客户汇总,也不是业务发生时间。'
+                '禁止使用:导出、散货/整柜页脚、订单号筛选,不得改走query_order、'
+                'query_order_exact或后台收货量导出。参数只用于工具内部调用;'
+                '最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的'
+                '英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'inbound_date_start': dict(
+                        date_field,
+                        description='入库时间开始日期,员工时区 YYYY-MM-DD。',
+                    ),
+                    'inbound_date_end': dict(
+                        date_field,
+                        description='入库时间结束日期,员工时区 YYYY-MM-DD,且与开始日期跨度不超过31个日历日。',
+                    ),
+                    'customer_ids': dict(
+                        id_array,
+                        description='客户ID数组。必须先调用list_receive_volume_filter_options选择「客户名称」取得value。',
+                    ),
+                    'sales_ids': dict(
+                        id_array,
+                        description='商务经理员工ID数组。必须先调用筛选项取得value。',
+                    ),
+                    'merchandiser_ids': dict(
+                        id_array,
+                        description='客户经理员工ID数组。必须先调用筛选项取得value。',
+                    ),
+                    'packing_types': {
+                        'type': 'array',
+                        'minItems': 1,
+                        'maxItems': 200,
+                        'items': {
+                            'type': 'string',
+                            'minLength': 1,
+                            'maxLength': 100,
+                            'pattern': '.*\\S.*',
+                        },
+                        'description': '货物类型名称数组。必须先调用筛选项选择「货物类型」取得value。',
+                    },
+                    'product_ids': dict(
+                        id_array,
+                        description='物流产品ID数组。必须先调用筛选项选择「物流产品」取得value。',
+                    ),
+                    'page': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['inbound_date_start', 'inbound_date_end'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        inbound_date_start,
+        inbound_date_end,
+        customer_ids=None,
+        sales_ids=None,
+        merchandiser_ids=None,
+        packing_types=None,
+        product_ids=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_receive_volume_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_receive_volume_list'
+            )
+        payload = {
+            'inbound_date_start': self._date(inbound_date_start, 'inbound_date_start'),
+            'inbound_date_end': self._date(inbound_date_end, 'inbound_date_end'),
+        }
+        self._assert_window(
+            payload['inbound_date_start'], payload['inbound_date_end']
+        )
+        if customer_ids is not None:
+            payload['customer_ids'] = self._id_list(customer_ids, 'customer_ids')
+        if sales_ids is not None:
+            payload['sales_ids'] = self._id_list(sales_ids, 'sales_ids')
+        if merchandiser_ids is not None:
+            payload['merchandiser_ids'] = self._id_list(
+                merchandiser_ids, 'merchandiser_ids'
+            )
+        if packing_types is not None:
+            payload['packing_types'] = self._name_list(
+                packing_types, 'packing_types'
+            )
+        if product_ids is not None:
+            payload['product_ids'] = self._id_list(product_ids, 'product_ids')
+        payload['page'] = self._bounded_integer(page, 'page')
+        payload['limit'] = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+    @staticmethod
+    def _date(value, field):
+        text = str(value or '').strip()
+        try:
+            parsed = datetime.strptime(text, '%Y-%m-%d').date()
+        except ValueError:
+            raise ValueError(field + ' is invalid')
+        if parsed.strftime('%Y-%m-%d') != text:
+            raise ValueError(field + ' is invalid')
+        return text
+
+    @staticmethod
+    def _assert_window(start, end):
+        start_date = datetime.strptime(start, '%Y-%m-%d').date()
+        end_date = datetime.strptime(end, '%Y-%m-%d').date()
+        if end_date < start_date or (end_date - start_date).days > 30:
+            raise ValueError('inbound date window is invalid')
+
+    @staticmethod
+    def _id_list(values, field):
+        if not isinstance(values, list) or not values:
+            raise ValueError(field + ' is invalid')
+        result = []
+        for value in values:
+            if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+                raise ValueError(field + ' is invalid')
+            if value not in result:
+                result.append(value)
+        if len(result) > 200:
+            raise ValueError('at most 200 ids are allowed')
+        return result
+
+    @staticmethod
+    def _name_list(values, field):
+        if not isinstance(values, list) or not values:
+            raise ValueError(field + ' is invalid')
+        result = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError(field + ' is invalid')
+            value = value.strip()
+            if not value or len(value) > 100:
+                raise ValueError(field + ' is invalid')
+            if value not in result:
+                result.append(value)
+        if len(result) > 200:
+            raise ValueError('at most 200 names are allowed')
+        return result
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError(field + ' is invalid')
+        if value < 1 or value > 100:
+            raise ValueError(field + ' is invalid')
+        return value