浏览代码

master_task_7094428397

jackson 4 天之前
父节点
当前提交
a03b1a7949

+ 1 - 0
AGENTS.md

@@ -35,6 +35,7 @@ Other repositories should link to this repository's README and project docs inst
 - `Y:/all_project_docs/mcp/overview.md`: current system summary and cross-repository ownership map.
 - The same directory's `requirements.md`, `tech-specs.md`, `user-structure.md`, and `timeline.md`: current contracts, architecture, flows, and operational handoff.
 - `Y:/all_project_docs/mcp/guides/mcp-team-sharing.md`: current team onboarding and sharing guide.
+- `Y:/all_project_docs/mcp/guides/mcp-interface-guide.md`: business-facing field catalog and usage examples (not a protocol contract).
 - `docs/superpowers/specs/`: dated design decisions and ADR-like context.
 
 The following are not authoritative and are not read by default:

文件差异内容过多而无法显示
+ 61 - 0
CONTEXT.md


+ 11 - 5
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 26 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 28 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 26 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 28 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -43,6 +43,8 @@ Gateway 保持“薄网关”边界:
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
 | `query_outbound_list` | 按业务阶段和筛选条件查询排舱列表 | `/mcp/tools/queryOutboundList` | 固定 31 列安全表格 |
 | `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` | 安全筛选项 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -68,7 +70,7 @@ Gateway 保持“薄网关”边界:
 先询问用户是柜号还是提单号,不得按格式猜测、跨字段或跨工具试查;不支持入仓单号、
 订单号、打托批次号或内部 ID。
 
-这 26 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 28 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -77,16 +79,17 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 25 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 27 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_order_receivable_cost_details`、`query_receivable_cost_list`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
 - 客户逐笔回款记录固定展示客户名称、水单号、原币/实际收款、收款日期、已/未核销和审核状态八列;所有金额必须为有限数值,未知字段关闭失败。
 - 订单应收费用明细固定展示订单号、客户/费用客户、费用项、计费数据、两项 CNY 金额及确认/关账/核销状态 13 列;金额必须为有限数值,未知字段关闭失败。
 - 应收费用单列表固定展示后台 24 个业务列;四个金额单元格必须包含有限数值和币种。配套筛选项保留整数 `0/-1` 状态值;未知字段、列重排或畸形分页关闭失败。
 - 应付费用列表按六个业务类型严格返回后台页签列集(17/17/19/15/15/18 列);五类号码字段分别 `IN` 且彼此与其他条件 `AND`,无号码时要求一组最多 31 日日期。头程 `order_numbers` 同时精确匹配单号与关联订单号(`sub_number`)。列缺失、额外、错序或非有限金额关闭失败。
+- 海外提柜列表前五类固定 30 列,DO单制作固定 11 列;DO 原/新文件只接受空串或安全 HTTP(S) URL。类型必选,提单号、柜号、日期和其他筛选均为可选。
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
-- 五个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
+- 筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
 - 五个导出提交工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
 - stdio 与公网 `tools/call` 缺少有效工具名时返回 JSON-RPC `-32602 Invalid params`,不包装为业务 `isError`。
@@ -99,11 +102,14 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 
 排舱列表查询的 `outbound_status` 必填,用户未说明排舱阶段时必须先询问;`shipping_method` 可选,未提供时查询空运、海运和陆运全部,只有用户明确指定后才按运输方式筛选。
 
+海外提柜列表的 `trailer_stage` 必填六个中文类型之一;用户未说明类型时必须先询问,不得默认或六类一起查,也不得改走 `query_outbound_list`。提单号、柜号、日期和其他筛选均可不传;日期若传入必须成对且最多 31 个目的港当地日历日。fmsoperate 注册 SQL 未执行,不得视为已启用。
+
 ## 文档入口
 
 - `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/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):组件、会话、展示、限流、追踪与日志设计。

+ 59 - 1
app.py

@@ -53,6 +53,10 @@ from tools.query_payable_cost_list import QueryPayableCostListTool
 from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
 from tools.export_payable_cost_list import ExportPayableCostListTool
 from tools.export_pallet_data import ExportPalletDataTool
+from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
 
 
 def parse_int_list(value):
@@ -136,6 +140,11 @@ class GatewayApp:
             'query_export_task': QueryExportTaskTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
+            'query_destination_trailer_list': QueryDestinationTrailerListTool(
+                api_client=api_client
+            ),
+            'list_destination_trailer_filter_options':
+                ListDestinationTrailerFilterOptionsTool(api_client=api_client),
         }
 
     @classmethod
@@ -333,6 +342,16 @@ class GatewayApp:
         call_parser.add_argument('--outbound-date-start', default='')
         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('--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)
+        call_parser.add_argument('--eta-start', default='')
+        call_parser.add_argument('--eta-end', default='')
+        call_parser.add_argument('--pickup-time-start', default='')
+        call_parser.add_argument('--pickup-time-end', default='')
+        call_parser.add_argument('--container-return-time-start', default='')
+        call_parser.add_argument('--container-return-time-end', default='')
         call_parser.add_argument('--task-ref', default='')
         call_parser.add_argument('--page', type=int, default=1)
         call_parser.add_argument('--limit', type=int, default=20)
@@ -699,9 +718,48 @@ class GatewayApp:
                 tool_args['keyword'] = args.keyword
                 if args.customer_id > 0:
                     tool_args['customer_id'] = args.customer_id
+            elif args.tool == 'query_destination_trailer_list':
+                if not args.trailer_stage:
+                    raise ValueError(
+                        '--trailer-stage is required for '
+                        'query_destination_trailer_list'
+                    )
+                tool_args['trailer_stage'] = args.trailer_stage
+                if args.bl_numbers:
+                    tool_args['bl_numbers'] = parse_string_list(args.bl_numbers)
+                if args.container_codes:
+                    tool_args['container_codes'] = parse_string_list(
+                        args.container_codes
+                    )
+                if args.providers_id > 0:
+                    tool_args['providers_id'] = args.providers_id
+                if args.warehouse_id > 0:
+                    tool_args['warehouse_id'] = args.warehouse_id
+                if args.is_direct_send is not None:
+                    tool_args['is_direct_send'] = args.is_direct_send
+                if args.bill_status is not None:
+                    tool_args['bill_status'] = args.bill_status
+                if args.cabinet_type > 0:
+                    tool_args['cabinet_type'] = args.cabinet_type
+                if args.shipping_method > 0:
+                    tool_args['shipping_method'] = args.shipping_method
+                trailer_dates = {
+                    'eta_start': args.eta_start,
+                    'eta_end': args.eta_end,
+                    'pickup_time_start': args.pickup_time_start,
+                    'pickup_time_end': args.pickup_time_end,
+                    'container_return_time_start':
+                        args.container_return_time_start,
+                    'container_return_time_end':
+                        args.container_return_time_end,
+                }
+                for field, value in trailer_dates.items():
+                    if value:
+                        tool_args[field] = value
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
-                'list_customer_filter_options'
+                'list_customer_filter_options',
+                'list_destination_trailer_filter_options',
             ):
                 if not args.filter_type:
                     raise ValueError(

+ 9 - 0
public_gateway.py

@@ -39,6 +39,10 @@ from tools.query_payable_cost_list import QueryPayableCostListTool
 from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
 from tools.export_payable_cost_list import ExportPayableCostListTool
 from tools.export_pallet_data import ExportPalletDataTool
+from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
 from utils.security import hash_gateway_session_id
 
 
@@ -103,6 +107,11 @@ class PublicGatewayApp:
             'query_export_task': QueryExportTaskTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),
+            'query_destination_trailer_list': QueryDestinationTrailerListTool(
+                api_client=None
+            ),
+            'list_destination_trailer_filter_options':
+                ListDestinationTrailerFilterOptionsTool(api_client=None),
         }
 
     def registered_tool_names(self):

+ 89 - 0
services/output_presenter.py

@@ -33,6 +33,7 @@ class OutputPresenter:
     PAYABLE_COST_TOOLS = frozenset(('query_payable_cost_list',))
     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',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -40,6 +41,7 @@ class OutputPresenter:
         'list_outbound_filter_options',
         'list_pending_outbound_export_filter_options',
         'list_customer_filter_options',
+        'list_destination_trailer_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
@@ -53,6 +55,7 @@ class OutputPresenter:
         | PAYMENT_DETAIL_TOOLS | PAYMENT_RECORD_TOOLS | RECEIVABLE_COST_TOOLS
         | RECEIVABLE_LIST_TOOLS | RECEIVABLE_FILTER_TOOLS
         | PAYABLE_COST_TOOLS | PAYABLE_FILTER_TOOLS | PAYABLE_EXPORT_TOOLS
+        | DESTINATION_TRAILER_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -495,6 +498,41 @@ class OutputPresenter:
         'is_overdue': '是否逾期',
     }
 
+    DESTINATION_TRAILER_COLUMNS = {
+        'progress': (
+            ('bl_number', '提单号'), ('container_code', '柜号'),
+            ('container_type', '柜型'), ('warehouse_name', '仓库名称'),
+            ('providers_name', '拖车行'), ('trailer_status', '提柜状态'),
+            ('latest_remark', '最新操作备注'), ('is_direct_send', '直送'),
+            ('cabinet_type', '整拼类型'), ('vessel_name', '船名航次'),
+            ('route', '航线'), ('clearance_check', '清关查验'),
+            ('etd', 'ETD'), ('atd', 'ATD'), ('eta', 'ETA'), ('ata', 'ATA'),
+            ('loading_port', '起运港'), ('destination_port', '目的港'),
+            ('wharf_name', '到港码头'), ('appt_time', 'APPT时间'),
+            ('delivery_end_time', '卡车实际派送时间'),
+            ('est_pickup_time', '预计提柜时间'),
+            ('pickup_time', '实际提柜时间'), ('inbound_date', '到仓时间'),
+            ('container_return_time', '还柜时间'),
+            ('pickup_prescription', '提柜时效'),
+            ('return_prescription', '还柜时效'),
+            ('wharf_wait_time', '码头等待时间'),
+            ('amazon_wait_time', '亚马逊等待时间'),
+            ('bill_status', '应付费用状态'),
+        ),
+        'do': (
+            ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
+            ('container_code', '柜号'), ('container_type', '柜型'),
+            ('bl_number', '提单号'), ('is_direct_send', '是否直送'),
+            ('original_do_file', '原DO单'), ('new_do_file', '新DO单'),
+            ('email_subject', '邮件主题'), ('email_from', '邮件发送人'),
+            ('received_at', '邮件接收时间'),
+        ),
+    }
+    DESTINATION_TRAILER_FILE_KEYS = frozenset(('original_do_file', 'new_do_file'))
+    DESTINATION_TRAILER_PROGRESS_STAGES = frozenset((
+        '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)',
+    ))
+
     OUTBOUND_DETAIL_SUMMARY = {
         'bl_number': '提单号',
         'container_code': '柜号',
@@ -766,6 +804,10 @@ class OutputPresenter:
             return self._present_receivable_cost_filter_options(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.DESTINATION_TRAILER_TOOLS:
+            return self._present_destination_trailer_list(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1497,6 +1539,53 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_destination_trailer_list(self, data, raw_meta, meta):
+        if set(data) != {'trailer_stage', 'columns', 'records'}:
+            return self._format_error(meta)
+        trailer_stage = data.get('trailer_stage')
+        if trailer_stage == 'DO单制作':
+            expected = list(self.DESTINATION_TRAILER_COLUMNS['do'])
+        elif trailer_stage in self.DESTINATION_TRAILER_PROGRESS_STAGES:
+            expected = list(self.DESTINATION_TRAILER_COLUMNS['progress'])
+        else:
+            return self._format_error(meta)
+        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 in self.DESTINATION_TRAILER_FILE_KEYS:
+                    if value != '' and not self._valid_http_url(value):
+                        return self._format_error(meta)
+                elif not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'trailer_stage': trailer_stage,
+            '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(26, len(local.registered_tool_names()))
+        self.assertEqual(28, 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(26, len(local.registered_tool_names()))
+        self.assertEqual(28, 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(26, len(local))
+        self.assertEqual(28, 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(26, len(local.registered_tool_names()))
+        self.assertEqual(28, len(local.registered_tool_names()))
         self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
 
         output = StringIO()

+ 496 - 0
tests/test_destination_trailer_tools.py

@@ -0,0 +1,496 @@
+import importlib
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+PROGRESS_COLUMNS = [
+    ('bl_number', '提单号'), ('container_code', '柜号'),
+    ('container_type', '柜型'), ('warehouse_name', '仓库名称'),
+    ('providers_name', '拖车行'), ('trailer_status', '提柜状态'),
+    ('latest_remark', '最新操作备注'), ('is_direct_send', '直送'),
+    ('cabinet_type', '整拼类型'), ('vessel_name', '船名航次'),
+    ('route', '航线'), ('clearance_check', '清关查验'),
+    ('etd', 'ETD'), ('atd', 'ATD'), ('eta', 'ETA'), ('ata', 'ATA'),
+    ('loading_port', '起运港'), ('destination_port', '目的港'),
+    ('wharf_name', '到港码头'), ('appt_time', 'APPT时间'),
+    ('delivery_end_time', '卡车实际派送时间'),
+    ('est_pickup_time', '预计提柜时间'), ('pickup_time', '实际提柜时间'),
+    ('inbound_date', '到仓时间'), ('container_return_time', '还柜时间'),
+    ('pickup_prescription', '提柜时效'), ('return_prescription', '还柜时效'),
+    ('wharf_wait_time', '码头等待时间'), ('amazon_wait_time', '亚马逊等待时间'),
+    ('bill_status', '应付费用状态'),
+]
+DO_COLUMNS = [
+    ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
+    ('container_code', '柜号'), ('container_type', '柜型'),
+    ('bl_number', '提单号'), ('is_direct_send', '是否直送'),
+    ('original_do_file', '原DO单'), ('new_do_file', '新DO单'),
+    ('email_subject', '邮件主题'), ('email_from', '邮件发送人'),
+    ('received_at', '邮件接收时间'),
+]
+FILTER_TYPES = [
+    '海外提柜类型', '拖车行', '海外仓', '直送柜', '应付费用', '整拼类型', '运输方式',
+]
+STAGES = [
+    '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作',
+]
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_destination_trailer_list',
+                    'list_destination_trailer_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 DestinationTrailerToolContractTest(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_stage_and_forbids_identity(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_destination_trailer_list', metadata['name'])
+        self.assertEqual('/mcp/tools/queryDestinationTrailerList', cls.route_path)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['trailer_stage'], schema['required'])
+        self.assertEqual(STAGES, schema['properties']['trailer_stage']['enum'])
+        self.assertNotIn('default', schema['properties']['trailer_stage'])
+        for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_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('query_outbound_list', metadata['description'])
+        self.assertIn('提单号、柜号、日期和其他筛选均为可选', metadata['description'])
+        self.assertNotIn('必须再有', metadata['description'])
+
+    def test_query_forwards_numbers_dates_and_rejects_invalid(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            trailer_stage='待提柜',
+            bl_numbers=[' BL-1 ', 'BL-1'],
+            container_codes=['CONT-1'],
+            providers_id=8,
+            warehouse_id=3,
+            is_direct_send=0,
+            bill_status=10,
+            cabinet_type=1,
+            shipping_method=2,
+            eta_start='2026-07-01',
+            eta_end='2026-07-31',
+            page=2,
+            limit=30,
+            request_id='rq_dt',
+        )
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'bl_numbers': ['BL-1'],
+                'container_codes': ['CONT-1'],
+                'providers_id': 8,
+                'warehouse_id': 3,
+                'is_direct_send': 0,
+                'bill_status': 10,
+                'cabinet_type': 1,
+                'shipping_method': 2,
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-31',
+                'page': 2,
+                'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        self.assertEqual(
+            '/mcp/tools/queryDestinationTrailerList',
+            client.calls[-1][1],
+        )
+        cls(client).call(trailer_stage='待提柜')
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+        cls(client).call(trailer_stage='待提柜', providers_id=8)
+        with self.assertRaises(RuntimeError):
+            cls().call(trailer_stage='待提柜')
+        invalid = (
+            {'trailer_stage': '全部', 'bl_numbers': ['BL-1']},
+            {'trailer_stage': '待提柜', 'eta_start': '2026-07-01'},
+            {
+                'trailer_stage': '待提柜',
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-08-01',
+            },
+            {
+                'trailer_stage': 'DO单制作',
+                'bl_numbers': ['BL-1'],
+                'providers_id': 8,
+            },
+            {
+                'trailer_stage': 'DO单制作',
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-02',
+            },
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    cls(client).call(**arguments)
+
+    def test_filter_schema_and_forwarding(self):
+        cls = self.tool_class(
+            'list_destination_trailer_filter_options',
+            'ListDestinationTrailerFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
+        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('海外提柜类型', limit=True)
+
+    def test_query_covers_remaining_validation_branches(self):
+        cls = self.tool_class(
+            'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
+        )
+        client = RecordingApiClient()
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['X'] * 201,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-07-02',
+                eta_end='2026-07-01',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                bill_status=99,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers='BL-1')
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers=[1])
+        with self.assertRaises(ValueError):
+            cls(client).call(trailer_stage='待提柜', bl_numbers=[' '])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start=20260701,
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-99-01',
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='2026-7-01',
+                eta_end='2026-07-02',
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                providers_id=True,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                warehouse_id=0,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                page=0,
+            )
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                bl_numbers=['BL-1'],
+                limit=True,
+            )
+        cls(client).call(
+            trailer_stage='待安排拖车',
+            pickup_time_start='2026-07-01',
+            pickup_time_end='2026-07-02',
+            providers_id=8,
+            bill_status=20,
+        )
+        cls(client).call(trailer_stage='DO单制作', bl_numbers=['BL-1'])
+        with self.assertRaises(ValueError):
+            cls(client).call(
+                trailer_stage='待提柜',
+                eta_start='20260701',
+                eta_end='2026-07-02',
+            )
+
+    def test_cli_forwards_optional_trailer_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'query_destination_trailer_list',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_destination_trailer_list',
+            '--trailer-stage', '待还柜(海)',
+            '--container-codes', 'C1',
+            '--providers-id', '8',
+            '--warehouse-id', '3',
+            '--is-direct-send', '1',
+            '--bill-status', '30',
+            '--cabinet-type', '2',
+            '--shipping-method', '2',
+            '--eta-start', '2026-07-01',
+            '--eta-end', '2026-07-02',
+            '--pickup-time-start', '2026-07-03',
+            '--pickup-time-end', '2026-07-04',
+            '--container-return-time-start', '2026-07-05',
+            '--container-return-time-end', '2026-07-06',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'trailer_stage': '待还柜(海)',
+                'container_codes': ['C1'],
+                'providers_id': 8,
+                'warehouse_id': 3,
+                'is_direct_send': 1,
+                'bill_status': 30,
+                'cabinet_type': 2,
+                'shipping_method': 2,
+                'eta_start': '2026-07-01',
+                'eta_end': '2026-07-02',
+                'pickup_time_start': '2026-07-03',
+                'pickup_time_end': '2026-07-04',
+                'container_return_time_start': '2026-07-05',
+                'container_return_time_end': '2026-07-06',
+                'page': 1,
+                'limit': 20,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_presenter_fails_closed_on_unknown_shape(self):
+        presenter = OutputPresenter()
+        meta = {
+            'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+        }
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {'trailer_stage': '待提柜', 'columns': [], 'records': [], 'extra': 1},
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '未知',
+                'columns': [],
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [{'key': 'bl_number', 'name': '提单号'}],
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        columns = [{'key': key, 'name': name} for key, name in PROGRESS_COLUMNS]
+        columns[0] = {'key': 'bl_number', 'name': '错'}
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': columns,
+                'records': [],
+            },
+            'meta': meta,
+        })['is_error'])
+        record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
+        record['bl_number'] = 1
+        self.assertTrue(presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [record],
+            },
+            'meta': meta,
+        })['is_error'])
+
+    def test_local_public_registry_cli_and_safe_counts(self):
+        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.assertIn('query_destination_trailer_list', local)
+        self.assertIn('list_destination_trailer_filter_options', local)
+
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_destination_trailer_list',
+            '--trailer-stage', '待提柜', '--bl-numbers', ' BL-1,BL-2 ',
+            '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'trailer_stage': '待提柜',
+                'bl_numbers': ['BL-1', 'BL-2'],
+                'page': 2,
+                'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_destination_trailer_filter_options',
+            ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_destination_trailer_filter_options',
+            '--filter-type', '海外提柜类型',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            'list_destination_trailer_filter_options',
+            client.calls[-1][0],
+        )
+
+    def test_presenter_locks_thirty_and_eleven_columns(self):
+        presenter = OutputPresenter()
+        progress_record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
+        progress_record['trailer_status'] = '待提柜'
+        result = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [progress_record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_p',
+            },
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(30, len(result['structured_content']['headers']))
+        self.assertEqual('待提柜', result['structured_content']['trailer_stage'])
+
+        do_record = {key: 'd' for key, _ in DO_COLUMNS}
+        do_record['is_direct_send'] = '否'
+        do_record['original_do_file'] = ''
+        do_record['new_do_file'] = 'https://files.example/do.pdf'
+        do_ok = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': 'DO单制作',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in DO_COLUMNS
+                ],
+                'records': [do_record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_d',
+            },
+        })
+        self.assertFalse(do_ok['is_error'])
+        self.assertEqual(11, len(do_ok['structured_content']['headers']))
+
+        bad_url = dict(do_record)
+        bad_url['new_do_file'] = 'javascript:alert(1)'
+        bad = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': 'DO单制作',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in DO_COLUMNS
+                ],
+                'records': [bad_url],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_b',
+            },
+        })
+        self.assertTrue(bad['is_error'])
+        extra = dict(progress_record)
+        extra['outbound_number'] = 'OB1'
+        extra_result = presenter.present('query_destination_trailer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_stage': '待提柜',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
+                ],
+                'records': [extra],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_e',
+            },
+        })
+        self.assertTrue(extra_result['is_error'])

+ 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(26, len(local))
+        self.assertEqual(28, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(25, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(27, 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(26, len(local))
+        self.assertEqual(28, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(25, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 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(26, len(local.registered_tool_names()))
+        self.assertEqual(28, 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(25, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(27, 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(26, len(local))
-        self.assertEqual(25, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(28, len(local))
+        self.assertEqual(27, 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(26, len(local))
+        self.assertEqual(28, 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(26, len(local))
-        self.assertEqual(25, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(28, len(local))
+        self.assertEqual(27, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_receivable_cost_list',

+ 13 - 0
tests/test_tool_description_boundaries.py

@@ -15,6 +15,10 @@ from tools.query_order_detail import QueryOrderDetailTool
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
+from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.list_destination_trailer_filter_options import (
+    ListDestinationTrailerFilterOptionsTool,
+)
 from tools.query_track import QueryTrackTool
 
 
@@ -32,6 +36,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportPendingOutboundOrdersTool(),
             ExportOutOfProvincePortDataTool(),
             ExportPalletDataTool(),
+            QueryDestinationTrailerListTool(),
+            ListDestinationTrailerFilterOptionsTool(),
         )
 
         for tool in tools:
@@ -50,6 +56,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOutboundDetailTool(),
             ExportOutOfProvincePortDataTool(),
             ExportPalletDataTool(),
+            QueryDestinationTrailerListTool(),
         )
 
         for tool in tools:
@@ -75,6 +82,12 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportPendingOutboundOrdersTool(): ('明确要求导出', '未排舱订单', '查看或查询'),
             ExportOutOfProvincePortDataTool(): ('明确要求导出', '省外进港资料', '普通排舱查询'),
             ExportPalletDataTool(): ('明确要求导出', '打托数据', 'query_export_task'),
+            QueryDestinationTrailerListTool(): (
+                '海外提柜', 'query_outbound_list', '六个类型',
+            ),
+            ListDestinationTrailerFilterOptionsTool(): (
+                'query_destination_trailer_list', '禁止猜测', '排舱列表',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 87 - 0
tools/list_destination_trailer_filter_options.py

@@ -0,0 +1,87 @@
+class ListDestinationTrailerFilterOptionsTool:
+    name = 'list_destination_trailer_filter_options'
+    route_path = '/mcp/tools/listDestinationTrailerFilterOptions'
+    FILTER_TYPES = (
+        '海外提柜类型', '拖车行', '海外仓', '直送柜', '应付费用', '整拼类型', '运输方式',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户需要筛选海外提柜列表时,先按一个中文筛选类别调用本工具,'
+                '取得后台同源的中文名称和可传值,再把选中的值传给query_destination_trailer_list。'
+                '海外提柜类型未明确时先列出六个中文类型并让用户选择。拖车行或海外仓唯一命中可直接'
+                '使用,多条命中必须让用户选择。禁止使用:本工具不返回提柜或DO业务数据,禁止猜测'
+                '任何筛选值,也禁止用于排舱列表或其他工具。参数只用于工具内部调用;最终回答只能'
+                '展示中文业务名称;描述筛选条件时不得展示筛选字段的英文参数名,不得使用'
+                '“筛选字段=内部值”形式。'
+            ),
+            '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_destination_trailer_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_destination_trailer_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

+ 225 - 0
tools/query_destination_trailer_list.py

@@ -0,0 +1,225 @@
+from typing import Literal
+
+
+from datetime import date
+
+
+class QueryDestinationTrailerListTool:
+    name = 'query_destination_trailer_list'
+    route_path = '/mcp/tools/queryDestinationTrailerList'
+    STAGES = (
+        '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作',
+    )
+    DO_FORBIDDEN = (
+        'providers_id', 'warehouse_id', 'is_direct_send', 'bill_status',
+        'cabinet_type', 'shipping_method', 'eta_start', 'eta_end',
+        'pickup_time_start', 'pickup_time_end',
+        'container_return_time_start', 'container_return_time_end',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_array = {
+            'type': 'array', 'minItems': 1, 'maxItems': 200,
+            'items': {
+                'type': 'string', 'minLength': 1, 'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+        date_field = {
+            'type': 'string', 'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        properties = {
+            'trailer_stage': {
+                'type': 'string',
+                'enum': list[Literal['待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作']](self.STAGES),
+                'description': (
+                    '海外提柜类型。必须先调用list_destination_trailer_filter_options并选择'
+                    '“海外提柜类型”取得六个中文值之一;用户未明确时必须先询问,不得默认某一类'
+                    '或六类一起查,也不得改走排舱列表。'
+                ),
+            },
+            'bl_numbers': dict(
+                number_array,
+                description='提单号数组。仅当用户明确说提单号时使用;不得放入柜号或其他号码。',
+            ),
+            'container_codes': dict(
+                number_array,
+                description='柜号数组。仅当用户明确说柜号时使用;不得放入提单号或其他号码。',
+            ),
+            'providers_id': {
+                'type': 'integer', 'minimum': 1,
+                'description': '拖车行筛选值,必须先通过筛选项工具取得。',
+            },
+            'warehouse_id': {
+                'type': 'integer', 'minimum': 1,
+                'description': '海外仓筛选值,必须先通过筛选项工具取得;只命中非直送。',
+            },
+            'is_direct_send': {
+                'type': 'integer', 'enum': [0, 1],
+                'description': '直送柜筛选值,先通过筛选项工具取得。',
+            },
+            'bill_status': {
+                'type': 'integer', 'enum': [10, 20, 30, 35, 40],
+                'description': '应付费用状态筛选值,先通过筛选项工具取得。',
+            },
+            'cabinet_type': {
+                'type': 'integer', 'enum': [1, 2, 3, 4],
+                'description': '整拼类型筛选值,先通过筛选项工具取得。',
+            },
+            'shipping_method': {
+                'type': 'integer', 'enum': [1, 2, 3],
+                'description': '运输方式筛选值,先通过筛选项工具取得。',
+            },
+            'eta_start': dict(date_field),
+            'eta_end': dict(date_field),
+            'pickup_time_start': dict(date_field),
+            'pickup_time_end': dict(date_field),
+            'container_return_time_start': dict(date_field),
+            'container_return_time_end': dict(date_field),
+            'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
+            'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要看海外提柜进度或DO单制作列表时,先确认六个类型之一即可查询当前页签;'
+                '提单号、柜号、日期和其他筛选均为可选。传了号码则类型必须明确,'
+                '号码类型不明确时必须先询问用户,'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查,也不得把海外提柜当成排舱列表调用'
+                'query_outbound_list。日期若传入必须成对且最多31个目的港当地日历日。'
+                '禁止使用:安排拖车、确认还柜、港后数据/应付/入仓单/POD/'
+                '装柜顺序导出,以及混查订单号、拖车单号、排舱单号或SO。参数只用于工具内部调用;'
+                '最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': properties,
+                'required': ['trailer_stage'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, trailer_stage, bl_numbers=None, container_codes=None,
+        providers_id=None, warehouse_id=None, is_direct_send=None,
+        bill_status=None, cabinet_type=None, shipping_method=None,
+        eta_start=None, eta_end=None, pickup_time_start=None,
+        pickup_time_end=None, container_return_time_start=None,
+        container_return_time_end=None, page=1, limit=20,
+        request_id='rq_query_destination_trailer_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_destination_trailer_list'
+            )
+        stage = str(trailer_stage or '').strip()
+        if stage not in self.STAGES:
+            raise ValueError('trailer_stage is invalid')
+        payload = {'trailer_stage': stage}
+        total = 0
+        for field, values in (
+            ('bl_numbers', bl_numbers),
+            ('container_codes', container_codes),
+        ):
+            if values is None:
+                continue
+            cleaned = self._number_list(values, field)
+            payload[field] = cleaned
+            total += len(values)
+        if total > 200:
+            raise ValueError('at most 200 numbers are allowed')
+
+        for start_name, start_value, end_value in (
+            ('eta_start', eta_start, eta_end),
+            ('pickup_time_start', pickup_time_start, pickup_time_end),
+            (
+                'container_return_time_start',
+                container_return_time_start,
+                container_return_time_end,
+            ),
+        ):
+            end_name = start_name.replace('_start', '_end')
+            start = self._optional_date(start_value, start_name)
+            end = self._optional_date(end_value, end_name)
+            if bool(start) != bool(end):
+                raise ValueError(start_name + ' range is invalid')
+            if start:
+                if (date.fromisoformat(end) - date.fromisoformat(start)).days > 30:
+                    raise ValueError(start_name + ' range is invalid')
+                if end < start:
+                    raise ValueError(start_name + ' range is invalid')
+                payload[start_name] = start
+                payload[end_name] = end
+
+        for field, value, allowed in (
+            ('providers_id', providers_id, None),
+            ('warehouse_id', warehouse_id, None),
+            ('is_direct_send', is_direct_send, (0, 1)),
+            ('bill_status', bill_status, (10, 20, 30, 35, 40)),
+            ('cabinet_type', cabinet_type, (1, 2, 3, 4)),
+            ('shipping_method', shipping_method, (1, 2, 3)),
+        ):
+            if value is None:
+                continue
+            checked = self._integer(value, field, 0 if field == 'is_direct_send' else 1)
+            if allowed is not None and checked not in allowed:
+                raise ValueError(field + ' is invalid')
+            payload[field] = checked
+        if stage == 'DO单制作':
+            for field in self.DO_FORBIDDEN:
+                if field in payload:
+                    raise ValueError('DO stage does not accept extra filters')
+        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)
+        return result
+
+    @staticmethod
+    def _optional_date(value, field):
+        if value is None or value == '':
+            return ''
+        if not isinstance(value, str):
+            raise ValueError(field + ' is invalid')
+        try:
+            parsed = date.fromisoformat(value)
+        except ValueError:
+            raise ValueError(field + ' is invalid')
+        if parsed.isoformat() != value:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @staticmethod
+    def _integer(value, field, minimum=None):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError(field + ' is invalid')
+        if minimum is not None and value < minimum:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @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