Bladeren bron

master_task_7109561841

jackson 3 dagen geleden
bovenliggende
commit
2ff040da13

File diff suppressed because it is too large
+ 25 - 0
CONTEXT.md


+ 9 - 5
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 28 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 30 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 28 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 30 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -45,6 +45,8 @@ Gateway 保持“薄网关”边界:
 | `query_outbound_detail` | 按排舱单号查询排舱汇总与订单明细 | `/mcp/tools/queryOutboundDetail` | 11 项汇总 + 38 项明细 |
 | `query_destination_trailer_list` | 按六个类型查询海外提柜进度或 DO 制作列表 | `/mcp/tools/queryDestinationTrailerList` | 进度 30 列或 DO 11 列安全表格 |
 | `list_destination_trailer_filter_options` | 查询海外提柜七类筛选项 | `/mcp/tools/listDestinationTrailerFilterOptions` | 安全筛选项 |
+| `query_order_abnormal_list` | 按六个页签查询头程问题件列表 | `/mcp/tools/queryOrderAbnormalList` | 固定 9 列安全表格 |
+| `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -70,7 +72,7 @@ Gateway 保持“薄网关”边界:
 先询问用户是柜号还是提单号,不得按格式猜测、跨字段或跨工具试查;不支持入仓单号、
 订单号、打托批次号或内部 ID。
 
-这 28 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 30 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -79,7 +81,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 27 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 29 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_receivable_cost_list`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - 订单应收费用明细对外使用四项 CNY `summary`(订单总应收、订单总结算、特批金额、结算毛利)加固定 13 列 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
@@ -105,12 +107,14 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 
 海外提柜列表的 `trailer_stage` 必填六个中文类型之一;用户未说明类型时必须先询问,不得默认或六类一起查,也不得改走 `query_outbound_list`。提单号、柜号、日期和其他筛选均可不传;日期若传入必须成对且最多 31 个目的港当地日历日。fmsoperate 注册 SQL 未执行,不得视为已启用。
 
+头程问题件列表的 `abnormal_status` 必填六个中文页签之一;用户未说明状态时必须先询问,不得默认待处理或六类一起查,也不得改走 `query_order` 或作废审核。订单号可选,仅精确匹配;未标明是订单号时先询问。六个状态固定 9 列。fmsoperate 注册 SQL 用户 2026-09-11 确认已执行,首次关闭,未启用前不得视为已上线。
+
 ## 文档入口
 
 - `AGENTS.md`:项目所有权、红线、协作规则和验证命令。
 - `README.md`:当前工具目录、接入配置、运行方式、运维与排障。
 - [`Y:/all_project_docs/mcp/guides/mcp-team-sharing.md`](../all_project_docs/mcp/guides/mcp-team-sharing.md):面向团队的从 0 到 1 架构、实践与问题复盘分享稿。
-- [`Y:/all_project_docs/mcp/guides/mcp-interface-guide.md`](../all_project_docs/mcp/guides/mcp-interface-guide.md):业务侧 27 个工具的字段说明与使用示例。
+- [`Y:/all_project_docs/mcp/guides/mcp-interface-guide.md`](../all_project_docs/mcp/guides/mcp-interface-guide.md):业务侧 29 个工具的字段说明与使用示例。
 - [`Y:/all_project_docs/mcp/overview.md`](../all_project_docs/mcp/overview.md):当前系统概况与跨仓职责。
 - [`requirements.md`](../all_project_docs/mcp/requirements.md):现行协议、安全、工具选择和输出合同。
 - [`tech-specs.md`](../all_project_docs/mcp/tech-specs.md):组件、会话、展示、限流、追踪与日志设计。

+ 22 - 0
app.py

@@ -57,6 +57,10 @@ from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
 from tools.list_destination_trailer_filter_options import (
     ListDestinationTrailerFilterOptionsTool,
 )
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
 
 
 def parse_int_list(value):
@@ -145,6 +149,11 @@ class GatewayApp:
             ),
             'list_destination_trailer_filter_options':
                 ListDestinationTrailerFilterOptionsTool(api_client=api_client),
+            'query_order_abnormal_list': QueryOrderAbnormalListTool(
+                api_client=api_client
+            ),
+            'list_order_abnormal_filter_options':
+                ListOrderAbnormalFilterOptionsTool(api_client=api_client),
         }
 
     @classmethod
@@ -343,6 +352,7 @@ class GatewayApp:
         call_parser.add_argument('--outbound-date-end', default='')
         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('--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)
@@ -756,10 +766,22 @@ class GatewayApp:
                 for field, value in trailer_dates.items():
                     if value:
                         tool_args[field] = value
+            elif args.tool == 'query_order_abnormal_list':
+                if not args.abnormal_status:
+                    raise ValueError(
+                        '--abnormal-status is required for '
+                        'query_order_abnormal_list'
+                    )
+                tool_args['abnormal_status'] = args.abnormal_status
+                if args.order_numbers:
+                    tool_args['order_numbers'] = parse_string_list(
+                        args.order_numbers
+                    )
             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',
             ):
                 if not args.filter_type:
                     raise ValueError(

+ 9 - 0
public_gateway.py

@@ -43,6 +43,10 @@ from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
 from tools.list_destination_trailer_filter_options import (
     ListDestinationTrailerFilterOptionsTool,
 )
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
 from utils.security import hash_gateway_session_id
 
 
@@ -112,6 +116,11 @@ class PublicGatewayApp:
             ),
             'list_destination_trailer_filter_options':
                 ListDestinationTrailerFilterOptionsTool(api_client=None),
+            'query_order_abnormal_list': QueryOrderAbnormalListTool(
+                api_client=None
+            ),
+            'list_order_abnormal_filter_options':
+                ListOrderAbnormalFilterOptionsTool(api_client=None),
         }
 
     def registered_tool_names(self):

+ 58 - 0
services/output_presenter.py

@@ -34,6 +34,7 @@ class OutputPresenter:
     PAYABLE_FILTER_TOOLS = frozenset(('list_payable_cost_filter_options',))
     PAYABLE_EXPORT_TOOLS = frozenset(('export_payable_cost_list',))
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
+    ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -42,6 +43,7 @@ class OutputPresenter:
         'list_pending_outbound_export_filter_options',
         'list_customer_filter_options',
         'list_destination_trailer_filter_options',
+        'list_order_abnormal_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
@@ -56,6 +58,7 @@ class OutputPresenter:
         | RECEIVABLE_LIST_TOOLS | RECEIVABLE_FILTER_TOOLS
         | PAYABLE_COST_TOOLS | PAYABLE_FILTER_TOOLS | PAYABLE_EXPORT_TOOLS
         | DESTINATION_TRAILER_TOOLS
+        | ORDER_ABNORMAL_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -547,6 +550,16 @@ class OutputPresenter:
     DESTINATION_TRAILER_PROGRESS_STAGES = frozenset((
         '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)',
     ))
+    ORDER_ABNORMAL_COLUMNS = (
+        ('order_number', '订单号'), ('customer_name', '客户'),
+        ('product_name', '物流产品'), ('abnormal_status', '问题件状态'),
+        ('abnormal_type', '问题件类型'), ('abnormal_desc', '问题件描述'),
+        ('outbound_status', '排舱单状态'), ('inbound_date', '入库时间'),
+        ('marker_name', '标记人'),
+    )
+    ORDER_ABNORMAL_STATUSES = frozenset((
+        '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
+    ))
 
     OUTBOUND_DETAIL_SUMMARY = {
         'bl_number': '提单号',
@@ -823,6 +836,10 @@ class OutputPresenter:
             return self._present_destination_trailer_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.ORDER_ABNORMAL_TOOLS:
+            return self._present_order_abnormal_list(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1645,6 +1662,47 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_order_abnormal_list(self, data, raw_meta, meta):
+        if set(data) != {'abnormal_status', 'columns', 'records'}:
+            return self._format_error(meta)
+        abnormal_status = data.get('abnormal_status')
+        if abnormal_status not in self.ORDER_ABNORMAL_STATUSES:
+            return self._format_error(meta)
+        expected = list(self.ORDER_ABNORMAL_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 not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'abnormal_status': abnormal_status,
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
     @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(28, len(local.registered_tool_names()))
+        self.assertEqual(30, 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(28, len(local.registered_tool_names()))
+        self.assertEqual(30, 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(28, len(local))
+        self.assertEqual(30, 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(28, len(local.registered_tool_names()))
+        self.assertEqual(30, 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(28, len(local))
-        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(30, len(local))
+        self.assertEqual(29, 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(28, len(local))
+        self.assertEqual(30, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(29, 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(28, len(local))
+        self.assertEqual(30, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 347 - 0
tests/test_order_abnormal_tools.py

@@ -0,0 +1,347 @@
+import importlib
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+COLUMNS = [
+    ('order_number', '订单号'), ('customer_name', '客户'),
+    ('product_name', '物流产品'), ('abnormal_status', '问题件状态'),
+    ('abnormal_type', '问题件类型'), ('abnormal_desc', '问题件描述'),
+    ('outbound_status', '排舱单状态'), ('inbound_date', '入库时间'),
+    ('marker_name', '标记人'),
+]
+STATUSES = [
+    '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_order_abnormal_list',
+                    'list_order_abnormal_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 OrderAbnormalToolContractTest(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_status_and_forbids_identity(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_order_abnormal_list', metadata['name'])
+        self.assertEqual('/mcp/tools/queryOrderAbnormalList', cls.route_path)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['abnormal_status'], schema['required'])
+        self.assertEqual(STATUSES, schema['properties']['abnormal_status']['enum'])
+        self.assertNotIn('default', schema['properties']['abnormal_status'])
+        for forbidden in (
+            'company_id', 'admin_id', 'is_super', 'customer_id', 'product_id',
+        ):
+            self.assertNotIn(forbidden, schema['properties'])
+        self.assertIn('使用场景:', metadata['description'])
+        self.assertIn('禁止使用:', metadata['description'])
+        self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
+        self.assertIn('不得根据号码格式猜测', metadata['description'])
+        self.assertIn('不得跨字段或跨工具试查', metadata['description'])
+        self.assertIn('admin/Order/abnormal', metadata['description'])
+        self.assertIn('订单号可选', metadata['description'])
+
+    def test_query_forwards_optional_order_numbers(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            abnormal_status='包裹放行',
+            order_numbers=[' FMS-1 ', 'FMS-1'],
+            page=2,
+            limit=30,
+            request_id='rq_abn',
+        )
+        self.assertEqual(
+            (
+                'query_order_abnormal_list',
+                '/mcp/tools/queryOrderAbnormalList',
+                {
+                    'abnormal_status': '包裹放行',
+                    'order_numbers': ['FMS-1'],
+                    'page': 2,
+                    'limit': 30,
+                },
+                'rq_abn',
+            ),
+            client.calls[-1],
+        )
+        cls(client).call(abnormal_status='待处理')
+        self.assertEqual(
+            {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='结案')
+        with self.assertRaises(RuntimeError):
+            cls().call(abnormal_status='待处理')
+
+    def test_filter_schema_and_call(self):
+        cls = self.tool_class(
+            'list_order_abnormal_filter_options',
+            'ListOrderAbnormalFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('list_order_abnormal_filter_options', metadata['name'])
+        self.assertEqual(
+            '/mcp/tools/listOrderAbnormalFilterOptions', cls.route_path
+        )
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(['问题件状态'], schema['properties']['filter_type']['enum'])
+        self.assertIn('query_order_abnormal_list', 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('问题件状态', limit=True)
+
+    def test_query_covers_remaining_validation_branches(self):
+        cls = self.tool_class(
+            'query_order_abnormal_list', 'QueryOrderAbnormalListTool'
+        )
+        client = RecordingApiClient()
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                abnormal_status='待处理',
+                order_numbers=['X%s' % i for i in range(201)],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers='FMS-1')
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[1])
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[' '])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                abnormal_status='待处理',
+                order_numbers=['X' * 101],
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', page=0)
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', limit=True)
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', order_numbers=[])
+        with self.assertRaises(ValueError):
+            cls(client).call(abnormal_status='待处理', page=101)
+
+    def test_cli_forwards_status_and_order_numbers(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_order_abnormal_list',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_order_abnormal_list',
+            '--abnormal-status', '退件待审核',
+            '--order-numbers', ' FMS-1,FMS-2 ',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'abnormal_status': '退件待审核',
+                'order_numbers': ['FMS-1', 'FMS-2'],
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'query_order_abnormal_list',
+            '--abnormal-status', '待处理',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {'abnormal_status': '待处理', 'page': 1, 'limit': 20},
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'list_order_abnormal_filter_options',
+            '--filter-type', '问题件状态',
+        ], stdout=io.StringIO())
+        self.assertEqual('list_order_abnormal_filter_options', client.calls[-1][0])
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_order_abnormal_filter_options',
+            ], stdout=io.StringIO())
+
+    def test_presenter_renders_nine_columns_and_fails_closed(self):
+        presenter = OutputPresenter()
+        meta = {
+            'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+        }
+        columns = [{'key': key, 'name': name} for key, name in COLUMNS]
+        record = {key: 'v' for key, _ in COLUMNS}
+        record['abnormal_status'] = '待处理'
+        ok = presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [record],
+            },
+            'meta': meta,
+        })
+        self.assertFalse(ok['is_error'])
+        self.assertEqual(
+            ['订单号', '客户', '物流产品', '问题件状态', '问题件类型',
+             '问题件描述', '排舱单状态', '入库时间', '标记人'],
+            [item['label'] for item in ok['structured_content']['headers']],
+        )
+
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': [],
+                'records': [],
+                'extra': 1,
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '结案',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        bad_columns = list(columns)
+        bad_columns[0] = {'key': 'order_number', 'name': '错'}
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': bad_columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        bad_record = dict(record)
+        bad_record['order_number'] = 1
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [bad_record],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': 'bad',
+            },
+            'meta': meta,
+        })['is_error'])
+        extra_record = dict(record)
+        extra_record['deal_remark'] = 'x'
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [extra_record],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': None,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [None],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_order_abnormal_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'abnormal_status': '待处理',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_x', 'total': 1,
+            },
+        })['is_error'])
+
+        options = presenter.present('list_order_abnormal_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(30, len(local))
+        self.assertEqual(29, 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(28, len(local.registered_tool_names()))
+        self.assertEqual(30, 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(27, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(29, 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(28, len(local))
-        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(30, len(local))
+        self.assertEqual(29, 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(28, len(local))
+        self.assertEqual(30, 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(28, len(local))
-        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(30, len(local))
+        self.assertEqual(29, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_receivable_cost_list',

+ 13 - 0
tests/test_tool_description_boundaries.py

@@ -19,6 +19,10 @@ from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
 from tools.list_destination_trailer_filter_options import (
     ListDestinationTrailerFilterOptionsTool,
 )
+from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
+from tools.list_order_abnormal_filter_options import (
+    ListOrderAbnormalFilterOptionsTool,
+)
 from tools.query_track import QueryTrackTool
 
 
@@ -38,6 +42,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportPalletDataTool(),
             QueryDestinationTrailerListTool(),
             ListDestinationTrailerFilterOptionsTool(),
+            QueryOrderAbnormalListTool(),
+            ListOrderAbnormalFilterOptionsTool(),
         )
 
         for tool in tools:
@@ -57,6 +63,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportOutOfProvincePortDataTool(),
             ExportPalletDataTool(),
             QueryDestinationTrailerListTool(),
+            QueryOrderAbnormalListTool(),
         )
 
         for tool in tools:
@@ -88,6 +95,12 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListDestinationTrailerFilterOptionsTool(): (
                 'query_destination_trailer_list', '禁止猜测', '排舱列表',
             ),
+            QueryOrderAbnormalListTool(): (
+                '头程问题件', 'admin/Order/abnormal', 'query_order',
+            ),
+            ListOrderAbnormalFilterOptionsTool(): (
+                'query_order_abnormal_list', '禁止猜测', '作废审核',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 84 - 0
tools/list_order_abnormal_filter_options.py

@@ -0,0 +1,84 @@
+class ListOrderAbnormalFilterOptionsTool:
+    name = 'list_order_abnormal_filter_options'
+    route_path = '/mcp/tools/listOrderAbnormalFilterOptions'
+    FILTER_TYPES = ('问题件状态',)
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户需要筛选头程问题件列表时,先按中文筛选类别调用本工具,'
+                '取得后台同源的中文名称和可传值,再把选中的值传给query_order_abnormal_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_order_abnormal_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_order_abnormal_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

+ 123 - 0
tools/query_order_abnormal_list.py

@@ -0,0 +1,123 @@
+from typing import Literal
+
+
+class QueryOrderAbnormalListTool:
+    name = 'query_order_abnormal_list'
+    route_path = '/mcp/tools/queryOrderAbnormalList'
+    STATUSES = (
+        '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要看头程问题件列表时,先调用'
+                'list_order_abnormal_filter_options取得六个中文问题件状态之一即可查询当前页签;'
+                '订单号可选。对照后台admin/Order/abnormal,一行一条问题件记录。'
+                '号码类型不明确时必须先询问用户,'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查,也不得改走query_order或作废审核。'
+                '禁止使用:关闭异常、已读、重新取号、编辑、导出,以及混查参考号、跟踪号、柜号或SO。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称;'
+                '描述筛选条件时不得展示筛选字段的英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'abnormal_status': {
+                        'type': 'string',
+                        'enum': list[Literal[
+                            '待处理', '放行待审核', '包裹放行',
+                            '退件待审核', '退件驳回', '已退件',
+                        ]](self.STATUSES),
+                        'description': (
+                            '问题件状态。必须先调用list_order_abnormal_filter_options并选择'
+                            '“问题件状态”取得六个中文页签之一;用户未明确时必须先询问,'
+                            '不得默认待处理或六类一起查。'
+                        ),
+                    },
+                    'order_numbers': {
+                        'type': 'array',
+                        'minItems': 1,
+                        'maxItems': 200,
+                        'items': {
+                            'type': 'string',
+                            'minLength': 1,
+                            'maxLength': 100,
+                            'pattern': '.*\\S.*',
+                        },
+                        'description': (
+                            '订单号数组。仅当用户明确说订单号时使用;不得放入参考号、跟踪号、柜号或SO。'
+                        ),
+                    },
+                    'page': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['abnormal_status'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        abnormal_status,
+        order_numbers=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_order_abnormal_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_order_abnormal_list'
+            )
+        status = str(abnormal_status or '').strip()
+        if status not in self.STATUSES:
+            raise ValueError('abnormal_status is invalid')
+        payload = {'abnormal_status': status}
+        if order_numbers is not None:
+            payload['order_numbers'] = self._number_list(
+                order_numbers, 'order_numbers'
+            )
+        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 _number_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 numbers 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