Ver código fonte

task_7094428397增加海外仓打托数据导出接口

jackson 5 dias atrás
pai
commit
626fd72f7b

Diferenças do arquivo suprimidas por serem muito extensas
+ 27 - 0
CONTEXT.md


+ 13 - 6
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 25 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 26 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 25 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 26 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -52,16 +52,23 @@ Gateway 保持“薄网关”边界:
 | `export_out_of_province_port_data` | 提交省外进港资料异步导出 | `/mcp/tools/exportOutOfProvincePortData` | 签名任务引用 |
 | `export_receivable_cost_list` | 提交应收费用单列表异步导出 | `/mcp/tools/exportReceivableCostList` | 签名任务引用 |
 | `export_payable_cost_list` | 提交单页签应付费用列表异步导出 | `/mcp/tools/exportPayableCostList` | 签名任务引用 |
+| `export_pallet_data` | 提交海外仓打托数据异步导出 | `/mcp/tools/exportPalletData` | 签名任务引用 |
 | `query_export_task` | 查询异步导出状态或文件 | `/mcp/tools/queryExportTask` | 安全任务状态/文件链接 |
 | `list_pending_outbound_export_filter_options` | 查询未排舱导出筛选项 | `/mcp/tools/listPendingOutboundExportFilterOptions` | 安全筛选项 |
 
 **异步导出流程:**
 
-1. 调用四个导出工具(包括 `export_payable_cost_list`)提交任务,返回 `task_ref`
+1. 调用五个导出提交工具(包括 `export_pallet_data`)提交任务,返回 `task_ref`
 2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
 3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
 
-这 25 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+`export_pallet_data` 的筛选条件严格三选一:`container_codes`(柜号,1–200 个)、
+`bl_numbers`(提单号,1–200 个),或 `inbound_time_start` 与
+`inbound_time_end`(最多 31 个日历日)。每个号码最多 100 个字符,号码类型未明确时必须
+先询问用户是柜号还是提单号,不得按格式猜测、跨字段或跨工具试查;不支持入仓单号、
+订单号、打托批次号或内部 ID。
+
+这 26 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -70,7 +77,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 24 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 25 个安全工具执行显式白名单展示。
 - `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`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
 - 客户逐笔回款记录固定展示客户名称、水单号、原币/实际收款、收款日期、已/未核销和审核状态八列;所有金额必须为有限数值,未知字段关闭失败。
@@ -80,7 +87,7 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 五个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
-- 四个导出工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
+- 五个导出提交工具只返回 `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`。
 - 公网 `tools/list` 在设备会话缺失或 Redis 映射不存在时返回 JSON-RPC `-32001` 和设备失效文案。

+ 21 - 0
app.py

@@ -52,6 +52,7 @@ from tools.query_receivable_cost_list import QueryReceivableCostListTool
 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
 
 
 def parse_int_list(value):
@@ -129,6 +130,9 @@ class GatewayApp:
             'export_payable_cost_list': ExportPayableCostListTool(
                 api_client=api_client
             ),
+            'export_pallet_data': ExportPalletDataTool(
+                api_client=api_client
+            ),
             'query_export_task': QueryExportTaskTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
@@ -324,6 +328,8 @@ class GatewayApp:
         call_parser.add_argument('--loading-time-end', default='')
         call_parser.add_argument('--inbound-date-start', default='')
         call_parser.add_argument('--inbound-date-end', default='')
+        call_parser.add_argument('--inbound-time-start', default='')
+        call_parser.add_argument('--inbound-time-end', default='')
         call_parser.add_argument('--outbound-date-start', default='')
         call_parser.add_argument('--outbound-date-end', default='')
         call_parser.add_argument('--filter-type', default='')
@@ -652,6 +658,21 @@ class GatewayApp:
                         'query_outbound_detail'
                     )
                 tool_args['outbound_number'] = args.outbound_number
+            elif args.tool == 'export_pallet_data':
+                if args.container_codes:
+                    tool_args['container_codes'] = parse_string_list(
+                        args.container_codes
+                    )
+                if args.bl_numbers:
+                    tool_args['bl_numbers'] = parse_string_list(
+                        args.bl_numbers
+                    )
+                if args.inbound_time_start:
+                    tool_args['inbound_time_start'] = args.inbound_time_start
+                if args.inbound_time_end:
+                    tool_args['inbound_time_end'] = args.inbound_time_end
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
             elif args.tool == 'query_export_task':
                 if not args.task_ref:
                     raise ValueError(

Diferenças do arquivo suprimidas por serem muito extensas
+ 7 - 0
docs/adr/0001-mcp-pallet-data-export.md


+ 700 - 0
docs/superpowers/plans/2026-09-08-export-pallet-data-identifiers.md

@@ -0,0 +1,700 @@
+# `export_pallet_data` Identifier Update Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Change `export_pallet_data` from inbound-order-number filtering to an explicit three-way choice of container numbers, bill-of-lading numbers, or overseas-warehouse inbound-time range, while preserving the 200-item limit, company isolation, async task contract, and pallet export output.
+
+**Architecture:** Keep the Gateway as a schema/forwarding boundary and keep all business resolution in fmsoperate. Gateway accepts `container_codes` and `bl_numbers` as separate arrays and rejects ambiguous or mixed selectors; fmsoperate resolves each selector through its own indexed database branch before reusing the existing inbound-record → order → pallet-batch pipeline. The old `inbound_numbers` field is rejected at both the public schema and PHP task boundary.
+
+**Tech Stack:** Python 3 standard-library MCP Gateway, ThinkPHP 6/PHP 7.1+, ThinkORM/MySQL, Redis queue, PHPUnit contract tests, Markdown project documentation.
+
+**Delivery boundary:** Do not execute registration SQL, restart Gateway or workers, or commit Git changes. Preserve all unrelated user changes already present in the three repositories.
+
+---
+
+## Files and responsibilities
+
+### Gateway (`Y:/mcp`)
+
+- Modify `tools/export_pallet_data.py` for metadata, runtime validation, and forwarding.
+- Modify `app.py` for CLI selector flags and payload construction.
+- Modify `services/output_presenter.py` to map new parameter errors to Chinese labels.
+- Modify `tests/test_export_pallet_data_tool.py` for the new closed schema, forwarding, limits, and CLI contract.
+- Modify `tests/test_tool_description_boundaries.py` so this number-based export requires explicit type clarification.
+- Modify `tests/test_output_presenter.py` for new field-label error mapping.
+- Modify `README.md` and `CONTEXT.md` to remove the old selector contract from Gateway-facing material.
+
+### Business backend (`Y:/fmsoperate`)
+
+- Modify `app/mcp/validate/McpToolValidate.php` for the new selector XOR rules and explicit legacy-field rejection.
+- Modify `app/mcp/logic/McpPalletDataExportLogic.php` for normalization, validation, task parameters, and model calls.
+- Modify `app/mcp/model/McpPalletDataExportModel.php` for separate container and bill-of-lading lookup branches.
+- Modify `app/mcp/logic/McpPalletDataExportService.php` for the new persisted task parameters.
+- Modify `app/mcp/controller/McpToolsController.php` so audit payload allowlisting records the new selector fields.
+- Modify `app/job/think/handle/inside/McpPalletDataExport.php` so queued-task validation accepts only the new selector contract.
+- Do not modify the route, queue action, task-reference source list, or export output generation.
+
+### Contract tests (`Y:/settlement_tests`)
+
+- Modify `tests/Unit/FmsOperate/McpPalletDataExportTest.php` for validator, logic, model-shape, identity, and new selector behavior.
+- Modify `tests/Unit/FmsOperate/McpPalletDataExportJobTest.php` for worker payload validation and legacy-task rejection.
+- Modify `docs/manual/mcp-async-export-workbuddy-cases.md` with new pallet selector placeholders and manual cases.
+
+### Central documentation (`Y:/all_project_docs`)
+
+- Modify `fmsoperate/requirements.md`, `fmsoperate/tech-specs.md`, `fmsoperate/user-structure.md`, and `fmsoperate/timeline.md`.
+- Modify `mcp/overview.md`, `mcp/requirements.md`, `mcp/tech-specs.md`, `mcp/user-structure.md`, and `mcp/timeline.md`.
+- Modify `fmsoperate/sql/mcp_add_pallet_data_export_tool.sql` description only; do not execute it.
+
+## Task 1: Add failing Gateway contract tests
+
+**Files:**
+
+- Modify: `Y:/mcp/tests/test_export_pallet_data_tool.py`
+- Modify: `Y:/mcp/tests/test_tool_description_boundaries.py`
+- Modify: `Y:/mcp/tests/test_output_presenter.py`
+
+- [ ] **Step 1: Replace the old schema expectations.**
+
+Change the schema test to require exactly:
+
+```python
+{
+    'container_codes',
+    'bl_numbers',
+    'inbound_time_start',
+    'inbound_time_end',
+}
+```
+
+Keep `additionalProperties=False`, assert both number fields have `maxItems == 200`, and assert that `inbound_numbers`, `page`, `limit`, `ids`, and `inbound_id` are absent.
+
+- [ ] **Step 2: Add forwarding tests for both number types.**
+
+Add separate tests that call the tool with trimmed duplicate values and assert the exact payload:
+
+```python
+ExportPalletDataTool(client).call(
+    container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'],
+)
+assert client.calls[-1][2] == {
+    'container_codes': ['CONT-1', 'CONT-2'],
+}
+
+ExportPalletDataTool(client).call(
+    bl_numbers=[' BL-1 ', 'BL-1'],
+)
+assert client.calls[-1][2] == {
+    'bl_numbers': ['BL-1'],
+}
+```
+
+Retain the existing date-only and date-time boundary tests, changing only their surrounding selector assumptions.
+
+- [ ] **Step 3: Add the new validation boundary cases.**
+
+Cover all of these cases:
+
+```python
+tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1'])  # raises ValueError
+tool.call(container_codes=['CONT-1'], inbound_time_start='2026-09-01',
+          inbound_time_end='2026-09-02')                    # raises ValueError
+tool.call(bl_numbers=['BL-1'], inbound_time_start='2026-09-01',
+          inbound_time_end='2026-09-02')                    # raises ValueError
+tool.call(container_codes=[])                               # raises ValueError
+tool.call(bl_numbers=['BL-' + 'x' * 100])                   # accepted at 100 chars
+tool.call(container_codes=['x' * 101])                      # raises ValueError
+tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)])  # raises ValueError
+tool.call()                                                 # raises ValueError
+```
+
+The metadata test must also prove that passing the old field is impossible through the closed schema; do not add `inbound_numbers` back to the Python call signature.
+
+- [ ] **Step 4: Update description-boundary tests.**
+
+Include `ExportPalletDataTool()` in the number-type clarification group and require its description to contain:
+
+- `号码类型不明确时必须先询问用户`
+- `不得根据号码格式猜测`
+- `不得跨字段或跨工具试查`
+
+Keep the existing asynchronous export boundary assertions.
+
+- [ ] **Step 5: Add Presenter field-label coverage.**
+
+Add a `MCP_1401` case for `container_codes` and one for `bl_numbers`, asserting the public message is respectively `柜号参数不正确` and `提单号参数不正确`, and that raw Python field names are absent from serialized output.
+
+- [ ] **Step 6: Run the Gateway tests and confirm they fail for the old implementation.**
+
+Run from `Y:/mcp`:
+
+```powershell
+python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter
+```
+
+Expected result: failures identify the still-existing `inbound_numbers` schema/signature/CLI behavior and missing field labels. Do not change production code until these tests demonstrate the contract gap.
+
+## Task 2: Implement the Gateway selector contract
+
+**Files:**
+
+- Modify: `Y:/mcp/tools/export_pallet_data.py`
+- Modify: `Y:/mcp/app.py`
+- Modify: `Y:/mcp/services/output_presenter.py`
+
+- [ ] **Step 1: Replace the metadata fields and descriptions.**
+
+Define `container_codes` and `bl_numbers` with the same array constraints as the former number field:
+
+```python
+{
+    'type': 'array',
+    'minItems': 1,
+    'maxItems': 200,
+    'items': {
+        'type': 'string',
+        'minLength': 1,
+        'maxLength': 100,
+        'pattern': r'.*\S.*',
+    },
+}
+```
+
+Describe each field using its Chinese business label, say that it is only valid when the user explicitly identifies that type, and prohibit other number types and internal IDs. Update the tool description to say the selector is exactly “柜号 / 提单号 / 海外仓入库时间”三选一. Explicitly state that an unclear number type requires a user question before calling and that the tool must not guess or try another field.
+
+- [ ] **Step 2: Implement a single normalized array helper.**
+
+Use one helper with the following behavior for both fields:
+
+```python
+def _clean_numbers(self, field_name, values):
+    if not isinstance(values, list) or not values:
+        raise ValueError('{0} must be a non-empty list'.format(field_name))
+    cleaned = []
+    for value in values:
+        if not isinstance(value, str):
+            raise ValueError('{0} items must be strings'.format(field_name))
+        item = value.strip()
+        if not item or len(item) > 100:
+            raise ValueError(
+                '{0} items must be 1 to 100 chars'.format(field_name)
+            )
+        if item not in cleaned:
+            cleaned.append(item)
+    if len(cleaned) > 200:
+        raise ValueError('at most 200 {0}'.format(field_name))
+    return cleaned
+```
+
+- [ ] **Step 3: Change `call()` to enforce the three-way XOR.**
+
+The public signature must be:
+
+```python
+def call(
+    self,
+    container_codes=None,
+    bl_numbers=None,
+    inbound_time_start=None,
+    inbound_time_end=None,
+    request_id='rq_export_pallet_data',
+):
+```
+
+Treat a non-`None` number argument as supplied, even if it is an empty list, so malformed empty arrays cannot silently disappear. Reject both number arguments together, reject any number argument combined with a time range, require both time bounds when time is selected, and reject an entirely empty payload. Forward exactly one cleaned selector or the two original time-bound strings.
+
+- [ ] **Step 4: Update the CLI branch without affecting other tools.**
+
+`--container-codes` and `--bl-numbers` already exist as shared CLI options for other tools. Remove only the obsolete `--inbound-numbers` parser option and change the `export_pallet_data` branch to build:
+
+```python
+if args.container_codes:
+    tool_args['container_codes'] = parse_string_list(args.container_codes)
+if args.bl_numbers:
+    tool_args['bl_numbers'] = parse_string_list(args.bl_numbers)
+if args.inbound_time_start:
+    tool_args['inbound_time_start'] = args.inbound_time_start
+if args.inbound_time_end:
+    tool_args['inbound_time_end'] = args.inbound_time_end
+tool_args.pop('page', None)
+tool_args.pop('limit', None)
+```
+
+- [ ] **Step 5: Add safe parameter labels.**
+
+Extend `OutputPresenter.FIELD_LABELS['export_pallet_data']` with:
+
+```python
+'container_codes': '柜号',
+'bl_numbers': '提单号',
+'inbound_time_start': '入库时间开始',
+'inbound_time_end': '入库时间结束',
+```
+
+Do not expose raw field names in the public error text.
+
+- [ ] **Step 6: Re-run the focused Gateway tests.**
+
+Run:
+
+```powershell
+python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter
+```
+
+Expected result: all focused tests pass, including the unchanged 26-tool registry and 25-safe-tool assertions.
+
+## Task 3: Add failing fmsoperate contract tests
+
+**Files:**
+
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportTest.php`
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php`
+
+- [ ] **Step 1: Update Validate test inputs to the new selector names.**
+
+Change the accepted cases to:
+
+```php
+$this->assertTrue($this->validator()->check([
+    'container_codes' => ['CONT-1'],
+]));
+$this->assertTrue($this->validator()->check([
+    'bl_numbers' => ['BL-1'],
+]));
+$this->assertTrue($this->validator()->check([
+    'inbound_time_start' => '2026-09-01',
+    'inbound_time_end' => '2026-09-07 18:30:00',
+]));
+```
+
+- [ ] **Step 2: Add PHP boundary assertions.**
+
+Assert false for:
+
+```php
+[
+    'container_codes' => ['CONT-1'],
+    'bl_numbers' => ['BL-1'],
+]
+[
+    'container_codes' => ['CONT-1'],
+    'inbound_time_start' => '2026-09-01',
+    'inbound_time_end' => '2026-09-02',
+]
+[
+    'inbound_numbers' => ['INB-1'],
+]
+```
+
+Retain assertions for one-sided time, over-31-day time, 201 items, forbidden pagination, `ids`, and `inbound_id`.
+
+- [ ] **Step 3: Update trusted-identity submission coverage.**
+
+Use `container_codes` in the submission test and assert the submitted task parameters contain the normalized new field, trusted `company_id`, `operator_id`, `is_super`, and `mcp_source_tool`, while still excluding `ADMIN_ID`.
+
+Add a second submission assertion using `bl_numbers` so both code paths persist the correct selector.
+
+- [ ] **Step 4: Add model query-shape guards.**
+
+Assert the model source contains `fms_outbound`, `container_code`, `fms_booking_detail`, and `bl_number`, keeps `company_id`, does not contain `CONVERT_TZ` or `COUNT(`, and does not contain a combined `container_code|bl_number` OR expression. This freezes the performance requirement that the two number types use separate branches.
+
+- [ ] **Step 5: Update worker fixtures and add legacy-task rejection.**
+
+Change existing queued-task JSON fixtures from `inbound_numbers` to `container_codes`. Add a successful `bl_numbers` fixture. Add a test whose task JSON contains only `inbound_numbers`; assert the worker does not start the task, exporter is not called, and the task is marked failed.
+
+- [ ] **Step 6: Run the focused PHP tests and confirm they fail before implementation.**
+
+Run from `Y:/settlement_tests`:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Expected result: failures identify the old validator field, old Logic parameter payload, missing model branches, and worker assumptions. Do not weaken the new assertions to accommodate the old contract.
+
+## Task 4: Implement fmsoperate validation and orchestration
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/mcp/validate/McpToolValidate.php`
+- Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php`
+- Modify: `Y:/fmsoperate/app/mcp/controller/McpToolsController.php`
+
+- [ ] **Step 1: Collapse the duplicate `exportPalletData` scene definition.**
+
+Keep one scene definition containing only:
+
+```php
+'container_codes',
+'bl_numbers',
+'inbound_time_start',
+'inbound_time_end',
+'page',
+'limit',
+'ids',
+'inbound_id',
+'inbound_numbers',
+```
+
+The last five legacy/forbidden keys must be attached to `palletExportForbidden`, with `inbound_numbers` included only to return `MCP_1401` when an old caller sends it. Do not expose it in the Gateway schema or accept it in Logic normalization.
+
+- [ ] **Step 2: Apply the same array and range rules to both new fields.**
+
+Keep the existing 1–200, string, non-empty, 100-character `palletNumberArray` behavior and attach it to both `container_codes` and `bl_numbers`. Update the XOR callback to evaluate:
+
+```php
+$hasContainer = is_array($data['container_codes'] ?? null)
+    && $data['container_codes'] !== [];
+$hasBl = is_array($data['bl_numbers'] ?? null)
+    && $data['bl_numbers'] !== [];
+$hasStart = trim((string)($data['inbound_time_start'] ?? '')) !== '';
+$hasEnd = trim((string)($data['inbound_time_end'] ?? '')) !== '';
+
+return !($hasContainer && $hasBl)
+    && !($hasContainer && ($hasStart || $hasEnd))
+    && !($hasBl && ($hasStart || $hasEnd))
+    && (($hasContainer || $hasBl) || ($hasStart && $hasEnd));
+```
+
+Retain the existing date parser and maximum 31-calendar-day rule.
+
+- [ ] **Step 3: Normalize only the new fields in Logic.**
+
+Replace `inbound_numbers` normalization with two independent de-duplicating loops for `container_codes` and `bl_numbers`. The normalized result must always contain both arrays, even when one is empty:
+
+```php
+[
+    'container_codes' => $containerCodes,
+    'bl_numbers' => $blNumbers,
+    'inbound_time_start' => ...,
+    'inbound_time_end' => ...,
+    'request_id' => ...,
+]
+```
+
+`buildExportParams()` must add only these normalized selectors plus trusted `company_id`, `timezone`, `operator`, `operator_id`, `is_super`, and `mcp_source_tool`; never copy caller-supplied identity fields.
+
+- [ ] **Step 4: Update Logic validation and model calls.**
+
+`validateFilters()` must enforce the same three-way XOR as Validate, including per-field 200/100 limits. `hasExportableData()` and `findPalletIds()` calls must receive both arrays and both time bounds. Keep the existing permission checks, no-data `MCP_1601`, async submission, task reference, and retry response unchanged.
+
+- [ ] **Step 5: Update audit allowlisting in the controller.**
+
+In `exportPalletData()`, replace the old log-field list with:
+
+```php
+[
+    'container_codes',
+    'bl_numbers',
+    'inbound_time_start',
+    'inbound_time_end',
+]
+```
+
+The route, route code, permission checks, response envelope, and task-source mapping remain unchanged.
+
+- [ ] **Step 6: Run the focused PHP tests.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Expected result: Validate and Logic tests pass; model and worker tests remain red until Tasks 5 and 6 are complete.
+
+## Task 5: Implement separate model lookup branches and service persistence
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php`
+- Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php`
+
+- [ ] **Step 1: Change the model method signatures.**
+
+Use this argument order consistently:
+
+```php
+public function hasExportableData(
+    $companyId,
+    array $containerCodes,
+    array $blNumbers,
+    $timeStart,
+    $timeEnd
+)
+
+public function findPalletIds(
+    $companyId,
+    array $containerCodes,
+    array $blNumbers,
+    $timeStart,
+    $timeEnd
+)
+```
+
+- [ ] **Step 2: Add the container-code branch.**
+
+Add `findInboundIdsByContainerCodes($companyId, array $codes)` using one query that:
+
+```php
+Db::table('fms_destination_inbound')->alias('di')
+    ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id')
+    ->where('di.company_id', (int)$companyId)
+    ->where('di.is_delete', 0)
+    ->where('o.container_code', 'in', $codes)
+    ->field('di.id')
+    ->group('di.id')
+```
+
+Return integer inbound IDs. Do not loop over the 200 values.
+
+- [ ] **Step 3: Add the bill-of-lading branch.**
+
+Add `findInboundIdsByBlNumbers($companyId, array $numbers)` using:
+
+```php
+Db::table('fms_destination_inbound')->alias('di')
+    ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id')
+    ->join(['fms_booking_detail' => 'bd'], 'o.booking_detail_id = bd.id')
+    ->where('di.company_id', (int)$companyId)
+    ->where('di.is_delete', 0)
+    ->where('bd.bl_number', 'in', $numbers)
+    ->field('di.id')
+    ->group('di.id')
+```
+
+Return integer inbound IDs and keep the branch separate from the container query. Do not use `where('o.container_code|bd.bl_number', ...)`.
+
+- [ ] **Step 4: Select exactly one branch in `findPalletIds()`.**
+
+Use this branch order:
+
+```php
+if ($containerCodes !== []) {
+    $inboundIds = $this->findInboundIdsByContainerCodes($companyId, $containerCodes);
+} elseif ($blNumbers !== []) {
+    $inboundIds = $this->findInboundIdsByBlNumbers($companyId, $blNumbers);
+} else {
+    $inboundIds = $this->findInboundIdsByTime($companyId, $timeStart, $timeEnd);
+}
+```
+
+Keep the current inbound ID → order ID → pallet ID stages, company condition on pallet batches, and no `COUNT` behavior.
+
+- [ ] **Step 5: Pass new selectors from the async Service.**
+
+Replace the old Service call with:
+
+```php
+$palletIds = $this->model->findPalletIds(
+    $companyId,
+    is_array($params['container_codes'] ?? null)
+        ? $params['container_codes'] : [],
+    is_array($params['bl_numbers'] ?? null)
+        ? $params['bl_numbers'] : [],
+    (string)($params['inbound_time_start'] ?? ''),
+    (string)($params['inbound_time_end'] ?? '')
+);
+```
+
+Do not change Excel headers, row grouping, attachment download behavior, zip layout, uploader behavior, or temporary-file cleanup.
+
+- [ ] **Step 6: Run PHP tests and syntax checks.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+php -l Y:\fmsoperate\app\mcp\validate\McpToolValidate.php
+php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportLogic.php
+php -l Y:\fmsoperate\app\mcp\model\McpPalletDataExportModel.php
+php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportService.php
+php -l Y:\fmsoperate\app\mcp\controller\McpToolsController.php
+```
+
+Expected result: focused tests pass and every changed PHP file reports no syntax errors.
+
+## Task 6: Tighten queued-task validation and consumer regression tests
+
+**Files:**
+
+- Modify: `Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php`
+- Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php`
+
+- [ ] **Step 1: Validate the persisted selector contract before starting.**
+
+In `validatedParams()`, reject any task containing `inbound_numbers`. Accept exactly one of:
+
+```php
+$containerCodes = $params['container_codes'] ?? [];
+$blNumbers = $params['bl_numbers'] ?? [];
+$hasContainer = is_array($containerCodes) && $containerCodes !== [];
+$hasBl = is_array($blNumbers) && $blNumbers !== [];
+$hasTime = trim((string)($params['inbound_time_start'] ?? '')) !== ''
+    && trim((string)($params['inbound_time_end'] ?? '')) !== '';
+
+if (
+    ($hasContainer && $hasBl)
+    || (($hasContainer || $hasBl) && $hasTime)
+    || (!$hasContainer && !$hasBl && !$hasTime)
+) {
+    return false;
+}
+```
+
+Also require each new array to contain strings with 1–100-character trimmed values and at most 200 entries, so a manually altered task cannot bypass the HTTP validator.
+
+- [ ] **Step 2: Preserve worker identity and terminal-state behavior.**
+
+Do not change message/company/operator matching, atomic queued-task start, exporter invocation, completed file mapping, failed-state update, or exception rethrow. Only the persisted selector validation changes.
+
+- [ ] **Step 3: Update and extend job tests.**
+
+Keep the existing success, identity mismatch, and exception tests, replacing old task JSON with `container_codes`. Add a bill-of-lading success fixture and a legacy `inbound_numbers` fixture that never reaches the exporter and ends in status `3`.
+
+- [ ] **Step 4: Run the job-focused test.**
+
+Run:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExportJob
+```
+
+Expected result: all worker identity, success, failure, and legacy-selector tests pass.
+
+## Task 7: Update current documentation and SQL registration description
+
+**Files:**
+
+- Modify: `Y:/mcp/README.md`
+- Modify: `Y:/mcp/CONTEXT.md`
+- Modify: `Y:/all_project_docs/fmsoperate/requirements.md`
+- Modify: `Y:/all_project_docs/fmsoperate/tech-specs.md`
+- Modify: `Y:/all_project_docs/fmsoperate/user-structure.md`
+- Modify: `Y:/all_project_docs/fmsoperate/timeline.md`
+- Modify: `Y:/all_project_docs/fmsoperate/sql/mcp_add_pallet_data_export_tool.sql`
+- Modify: `Y:/all_project_docs/mcp/overview.md`
+- Modify: `Y:/all_project_docs/mcp/requirements.md`
+- Modify: `Y:/all_project_docs/mcp/tech-specs.md`
+- Modify: `Y:/all_project_docs/mcp/user-structure.md`
+- Modify: `Y:/all_project_docs/mcp/timeline.md`
+- Modify: `Y:/settlement_tests/docs/manual/mcp-async-export-workbuddy-cases.md`
+
+- [ ] **Step 1: Replace the fmsoperate business contract.**
+
+In the `export_pallet_data` sections, state that the selector is exactly one of:
+
+```text
+container_codes(柜号数组,1–200)
+bl_numbers(提单号数组,1–200)
+inbound_time_start + inbound_time_end(最多31个日历日)
+```
+
+State that each number is at most 100 characters, container and bill-of-lading arrays cannot coexist, neither can mix with time, and `inbound_numbers`/internal IDs/order numbers/pallet-batch numbers are unsupported. Preserve the existing company isolation, permissions, async task, output, attachment, and `MCP_1601` behavior.
+
+- [ ] **Step 2: Replace Gateway user-facing guidance.**
+
+Update the Gateway README and central MCP documents to tell the caller:
+
+1. Ask whether a supplied number is a cabinet/container number or a bill-of-lading number when the user has not said.
+2. Do not infer from formatting or try both fields.
+3. Use only one number type with up to 200 values, or use the time range.
+4. After submission, query the task separately with `query_export_task`.
+
+Remove statements saying the tool accepts an inbound number.
+
+- [ ] **Step 3: Update current context and acceptance manual.**
+
+Replace stale terminology in `Y:/mcp/CONTEXT.md`. Add pallet export placeholders for `<有权柜号>` and `<有权提单号>` to the manual, then add cases for:
+
+- successful container-number submission;
+- successful bill-of-lading submission;
+- ambiguous number requiring clarification;
+- container and bill-of-lading arrays mixed and rejected;
+- 201 values rejected;
+- number mixed with time rejected;
+- async task follow-up unchanged.
+
+Do not add real credentials, URLs, task references, or customer data.
+
+- [ ] **Step 4: Update the SQL registration description only.**
+
+Change the `st_mcp_tool_registry.description` text in `mcp_add_pallet_data_export_tool.sql` from inbound-number/time wording to container-number/bill-of-lading/time wording. Keep status defaults and the idempotent `ON DUPLICATE KEY UPDATE` behavior unchanged. Do not run the SQL or change the execution status in `sql/README.md`.
+
+- [ ] **Step 5: Check document consistency.**
+
+Search all affected documentation for the old active contract:
+
+```powershell
+rg -n "入仓单号数组|入仓单号或海外仓入库时间|inbound_numbers" Y:\mcp Y:\all_project_docs\fmsoperate Y:\all_project_docs\mcp Y:\settlement_tests\docs
+```
+
+Remaining matches must be limited to the design/implementation history explicitly saying that the old field is removed or rejected; no current usage instructions may tell callers to send it.
+
+## Task 8: Full verification and handoff
+
+**Files:** all changed files from Tasks 1–7.
+
+- [ ] **Step 1: Run all Gateway unit tests.**
+
+From `Y:/mcp`:
+
+```powershell
+python -m unittest discover -s tests -p "test_*.py"
+python -m coverage run -m unittest discover -s tests -p "test_*.py"
+python -m coverage report -m --fail-under=100
+```
+
+Expected result: all tests pass and the strict coverage threshold is satisfied. Do not claim coverage success without the actual report.
+
+- [ ] **Step 2: Run fmsoperate contract tests.**
+
+From `Y:/settlement_tests`:
+
+```bat
+bin\test.bat fmsoperate --filter McpPalletDataExport
+```
+
+Then run the full fmsoperate suite:
+
+```bat
+bin\test.bat fmsoperate
+```
+
+Expected result: both the focused pallet-export tests and the full fmsoperate test configuration pass.
+
+- [ ] **Step 3: Run PHP syntax checks for every changed PHP file.**
+
+Run `php -l` for:
+
+```text
+Y:/fmsoperate/app/mcp/validate/McpToolValidate.php
+Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php
+Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php
+Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php
+Y:/fmsoperate/app/mcp/controller/McpToolsController.php
+Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php
+```
+
+- [ ] **Step 4: Run diff and status checks without altering unrelated work.**
+
+Run from each repository:
+
+```powershell
+git diff --check
+git status --short
+```
+
+Run the same checks from `Y:/all_project_docs`. Review that only the requested files and the approved design/plan are changed; do not reset, checkout, clean, commit, execute SQL, or restart services.
+
+- [ ] **Step 5: Review the final contract manually.**
+
+Confirm all of the following from code and tests:
+
+- Gateway metadata and PHP Validate both reject the old inbound-number selector.
+- Container and bill-of-lading selectors are separate and mutually exclusive.
+- Time remains a third mutually exclusive selector.
+- 200 values are handled in one batch without per-number queries.
+- Container lookup uses `fms_outbound.container_code`.
+- Bill-of-lading lookup uses `fms_booking_detail.bl_number`.
+- Company isolation, permission checks, task references, async queue, 15-column output, attachment behavior, and safe task status responses are unchanged.

+ 115 - 0
docs/superpowers/specs/2026-09-08-export-pallet-data-identifier-design.md

@@ -0,0 +1,115 @@
+# `export_pallet_data` 号码筛选调整设计
+
+**日期:** 2026-09-08  
+**范围:** `mcp` Gateway、`fmsoperate` MCP 业务链路、`settlement_tests` 合同测试及集中项目文档
+
+## 1. 背景与目标
+
+当前 `export_pallet_data` 使用“入仓单号数组”或“海外仓入库时间起止”二选一。此次调整移除入仓单号,增加两类明确的物流号码筛选:
+
+- 柜号数组 `container_codes`
+- 提单号数组 `bl_numbers`
+
+入库时间筛选继续保留。调用方必须明确号码类型;只给出号码但未说明是柜号还是提单号时,Gateway 的工具说明必须引导用户先确认,不能按格式猜测、并行试查或跨字段重试。
+
+## 2. 输入合同
+
+`export_pallet_data` 的输入为以下三种筛选方式严格三选一:
+
+1. `container_codes`
+2. `bl_numbers`
+3. `inbound_time_start` + `inbound_time_end`
+
+号码数组先去除首尾空白、空值和重复值,再校验:
+
+- 至少 1 个,最多 200 个;
+- 每项必须是字符串;
+- 每项长度不超过 100 个字符。
+
+时间边界继续接受 `YYYY-MM-DD` 或 `YYYY-MM-DD HH:MM:SS`。只填日期时,开始边界展开为当天 `00:00:00`,结束边界展开为当天 `23:59:59`;起止日期部分差不超过 30 天,即最多 31 个日历日。
+
+以下输入必须拒绝:
+
+- `container_codes` 与 `bl_numbers` 同时非空;
+- 任一号码数组与时间范围混用;
+- 只提供一侧时间边界;
+- 三种筛选方式均未提供;
+- 使用 `inbound_numbers`、内部 ID、订单号或打托批次号。
+
+参数错误沿用 `MCP_1401` 安全错误,不创建下载任务。
+
+## 3. 调用链与数据范围
+
+### Gateway
+
+- 工具 Schema 只暴露 `container_codes`、`bl_numbers`、`inbound_time_start`、`inbound_time_end`。
+- 保持 `additionalProperties=false`,不保留 `inbound_numbers` 兼容别名。
+- CLI 增加柜号和提单号数组参数,移除入仓单号 CLI 参数。
+- 工具描述明确“柜号 / 提单号 / 入库时间三选一”,并写明号码类型不明确时必须先询问。
+- 本地与公网工具注册、路由、异步任务展示合同不变。
+
+### fmsoperate
+
+`McpPalletDataExportLogic` 负责归一化、三选一校验、权限检查和任务提交;`McpPalletDataExportModel` 只负责取数:
+
+- 柜号筛选只按当前公司的海外仓关联排舱记录匹配 `fms_outbound.container_code`;
+- 提单号筛选只按当前公司的海外仓关联排舱记录匹配 `fms_booking_detail.bl_number`;
+- 时间筛选继续按当前公司、未删除的海外仓入库明细 `inbound_time` 闭区间匹配;
+- 所有路径最终都沿用“海外仓入库记录 ID → 订单 ID → 打托批次 ID”的批量解析链路;
+- 结果只允许当前 `company_id` 的打托批次,原有海外仓菜单和导出菜单权限不变。
+
+柜号和提单号必须在不同的查询分支中使用索引友好的 `IN` 条件,不能合并成跨字段 `OR`。不按号码逐个查询,不增加无必要的总数查询。
+
+队列任务中的筛选白名单、消费者参数校验和导出 Service 入参同步改为新合同;新接口不再接受或重新解释 `inbound_numbers`。
+
+## 4. 输出与异步行为
+
+以下行为保持不变:
+
+- 仍只提交异步任务并立即返回签名 `task_ref`;
+- 仍投递 `fmsoperate:inside` 的独立 `McpPalletDataExport` 消费者;
+- 仍生成后台同口径的 15 列 Excel;
+- 有成功下载的附件时按 `pallet_batch` 分目录生成 zip;
+- 单个 OSS 附件失败时跳过该附件,全部附件失败时仍只上传 Excel;
+- 通过 `query_export_task` 查询任务状态和下载链接;
+- 没有匹配的打托批次返回 `MCP_1601`,不创建空任务。
+
+## 5. 性能与安全约束
+
+- 200 个号码一次性批量传入并在数据库层 `IN` 处理,禁止 PHP 循环逐号查库。
+- 柜号和提单号使用独立查询分支,防止跨字段 OR 造成不可控扫描。
+- 保留当前的分阶段 ID 查询和批量补齐,不回退到逐订单查询。
+- 查询和队列消费均重新绑定公司、员工和任务来源身份,不信任请求传入的身份字段。
+- 不在公共响应、工具说明或日志中暴露 SQL、内部 ID、异常栈或任务内部信息。
+
+## 6. 测试计划
+
+### Gateway Python
+
+- Schema 只包含新字段,拒绝 `inbound_numbers`、内部 ID 和分页字段;
+- 柜号数组、提单号数组可正常去重并转发;
+- 柜号与提单号混用、号码与时间混用、空筛选、单侧时间、超过 200 个或单项超长均拒绝;
+- 时间最多 31 个日历日,日期边界展开保持现有行为;
+- 工具描述覆盖明确导出、号码类型询问、禁止猜测和异步查询;
+- CLI 新参数正确转发,本地/公网注册仍一致。
+
+### fmsoperate PHP
+
+- Validate、Logic、Model 和队列消费者全部使用新筛选字段;
+- 覆盖三选一、200 上限、每项长度、时间范围和禁止旧参数;
+- 覆盖权限失败、无可导出数据不建任务、可信公司/员工身份和异步任务引用;
+- 覆盖柜号/提单号查询只使用对应字段,不产生跨字段 OR 或逐号查询;
+- 保留公司隔离、15 列产物、独立消费者和任务终态安全合同。
+
+### 文档与交付验证
+
+- 运行 Python 全量单测及严格覆盖率检查;
+- 运行 `settlement_tests` 的 fmsoperate 定向合同测试和 PHP 语法检查;
+- 运行受影响仓库的 `git diff --check`、Markdown 链接检查和最终状态检查;
+- 不执行注册 SQL,不重启 Gateway 或 Worker,不据代码变更推断目标环境已上线。
+
+## 7. 非目标
+
+- 不修改后台海外仓页面、原有后台导出路由或打托文件列定义。
+- 不改变任务签名、任务去重、权限菜单、公司隔离和附件处理策略。
+- 不增加柜号/提单号模糊匹配、格式推断或自动试查。

+ 4 - 0
public_gateway.py

@@ -38,6 +38,7 @@ from tools.query_receivable_cost_list import QueryReceivableCostListTool
 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 utils.security import hash_gateway_session_id
 
 
@@ -96,6 +97,9 @@ class PublicGatewayApp:
             'export_payable_cost_list': ExportPayableCostListTool(
                 api_client=None
             ),
+            'export_pallet_data': ExportPalletDataTool(
+                api_client=None
+            ),
             'query_export_task': QueryExportTaskTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),

+ 7 - 0
services/output_presenter.py

@@ -45,6 +45,7 @@ class OutputPresenter:
         'export_pending_outbound_orders',
         'export_out_of_province_port_data',
         'export_receivable_cost_list',
+        'export_pallet_data',
     ))
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
@@ -649,6 +650,12 @@ class OutputPresenter:
             'so_numbers': 'SO号',
             'file_type': '资料类型',
         },
+        'export_pallet_data': {
+            'container_codes': '柜号',
+            'bl_numbers': '提单号',
+            'inbound_time_start': '海外仓入库时间开始',
+            'inbound_time_end': '海外仓入库时间结束',
+        },
         'export_receivable_cost_list': {
             'reference_numbers': '参考号',
             'tracking_numbers': '跟踪号',

+ 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(25, len(local.registered_tool_names()))
+        self.assertEqual(26, 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(25, len(local.registered_tool_names()))
+        self.assertEqual(26, 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(25, len(local))
+        self.assertEqual(26, 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(25, len(local.registered_tool_names()))
+        self.assertEqual(26, len(local.registered_tool_names()))
         self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
 
         output = StringIO()

+ 331 - 0
tests/test_export_pallet_data_tool.py

@@ -0,0 +1,331 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_pallet_data import ExportPalletDataTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_pallet_data']},
+        }
+
+    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': {
+                'task_ref': 'mexp_pallet',
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+        }
+
+
+class ExportPalletDataToolTest(unittest.TestCase):
+    def _assert_number_array_schema(self, field_schema, business_label):
+        self.assertEqual('array', field_schema['type'])
+        self.assertEqual(1, field_schema['minItems'])
+        self.assertEqual(200, field_schema['maxItems'])
+        items = field_schema['items']
+        self.assertEqual('string', items['type'])
+        self.assertEqual(1, items['minLength'])
+        self.assertEqual(100, items['maxLength'])
+        self.assertIn('pattern', items)
+        description = field_schema['description']
+        self.assertIn(business_label, description)
+        self.assertIn('仅当用户明确', description)
+        self.assertIn('不得放入', description)
+
+    def test_schema_is_closed_without_ids_or_pagination(self):
+        metadata = ExportPalletDataTool().metadata()
+        schema = metadata['input_schema']
+        properties = schema['properties']
+        self.assertEqual('export_pallet_data', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'inbound_time_start',
+                'inbound_time_end',
+            },
+            set(properties),
+        )
+        self.assertNotIn('page', properties)
+        self.assertNotIn('limit', properties)
+        self.assertNotIn('ids', properties)
+        self.assertNotIn('inbound_id', properties)
+        self.assertNotIn('inbound_ids', properties)
+        self.assertNotIn('inbound_numbers', properties)
+        self._assert_number_array_schema(properties['container_codes'], '柜号')
+        self._assert_number_array_schema(properties['bl_numbers'], '提单号')
+        description = metadata['description']
+        self.assertIn('明确要求导出打托数据', description)
+        self.assertIn('三选一', description)
+        self.assertIn('异步导出', description)
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待', description)
+        self.assertIn('禁止在一次调用内轮询', description)
+        self.assertIn('柜号', description)
+        self.assertIn('提单号', description)
+        self.assertIn('海外仓入库时间', description)
+
+    def test_call_rejects_legacy_inbound_numbers_argument(self):
+        client = RecordingApiClient()
+        tool = ExportPalletDataTool(client)
+        with self.assertRaises(TypeError):
+            tool.call(inbound_numbers=['OLD'])
+        self.assertEqual([], client.calls)
+
+    def test_call_forwards_container_codes(self):
+        client = RecordingApiClient()
+        result = ExportPalletDataTool(client).call(
+            container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'],
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'container_codes': ['CONT-1', 'CONT-2']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_forwards_bl_numbers(self):
+        client = RecordingApiClient()
+        result = ExportPalletDataTool(client).call(
+            bl_numbers=[' BL-1 ', 'BL-1'],
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'bl_numbers': ['BL-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_ignores_empty_container_codes_when_bl_numbers_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=[],
+            bl_numbers=['BL-1'],
+        )
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'bl_numbers': ['BL-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_ignores_empty_container_codes_when_time_range_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=[],
+            inbound_time_start='2026-09-01',
+            inbound_time_end='2026-09-07',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_call_ignores_empty_bl_numbers_when_container_codes_provided(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            container_codes=['CONT-1'],
+            bl_numbers=[],
+        )
+        self.assertEqual(
+            (
+                'export_pallet_data',
+                '/mcp/tools/exportPalletData',
+                {'container_codes': ['CONT-1']},
+                'rq_export_pallet_data',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_forwards_inbound_time_range(self):
+        client = RecordingApiClient()
+        ExportPalletDataTool(client).call(
+            inbound_time_start='2026-09-01',
+            inbound_time_end='2026-09-07 18:30:00',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07 18:30:00',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_call_validation_boundaries(self):
+        tool = ExportPalletDataTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool.call(container_codes=['CONT-1'])
+
+        client = RecordingApiClient()
+        tool = ExportPalletDataTool(client)
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(container_codes='CONT-1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(container_codes=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=[''])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(container_codes=['n{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(container_codes=[])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(bl_numbers=[])
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(bl_numbers='BL-1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(bl_numbers=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(bl_numbers=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1'])
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(
+                container_codes=['CONT-1'],
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'cannot mix'):
+            tool.call(
+                bl_numbers=['BL-1'],
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(inbound_time_start='2026-09-01')
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(inbound_time_end='2026-09-07')
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                inbound_time_start='2026-09-10',
+                inbound_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                inbound_time_start='2026-09-01',
+                inbound_time_end='2026-10-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'date or datetime'):
+            tool.call(
+                inbound_time_start='09/01/2026',
+                inbound_time_end='2026-09-02',
+            )
+        with self.assertRaisesRegex(ValueError, 'provide'):
+            tool.call()
+
+        ExportPalletDataTool(client).call(
+            inbound_time_start='2026-09-01 00:00:00',
+            inbound_time_end='2026-10-01 23:59:59',
+        )
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01 00:00:00',
+                'inbound_time_end': '2026-10-01 23:59:59',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_cli_forwards_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--container-codes', 'CONT-1, CONT-2',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {'container_codes': ['CONT-1', 'CONT-2']},
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--bl-numbers', 'BL-1, BL-2',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {'bl_numbers': ['BL-1', 'BL-2']},
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_pallet_data',
+            '--inbound-time-start', '2026-09-01',
+            '--inbound-time-end', '2026-09-07 18:30:00',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {
+                'inbound_time_start': '2026-09-01',
+                'inbound_time_end': '2026-09-07 18:30:00',
+            },
+            client.calls[-1][2],
+        )
+
+    def test_local_and_public_registries_include_export_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(26, len(local))
+        self.assertIn('export_pallet_data', local)
+        self.assertEqual(25, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_presenter_reuses_queued_export_contract(self):
+        presented = OutputPresenter().present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_pallet',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(presented['is_error'])
+        task = presented['structured_content']['task']
+        self.assertEqual('queued', task['status'])
+        self.assertEqual('mexp_pallet', task['task_ref'])
+        self.assertEqual(10, task['retry_after_seconds'])
+
+    def test_wrong_route_path_fails_and_restore_passes(self):
+        tool = ExportPalletDataTool()
+        original = tool.route_path
+        tool.route_path = '/mcp/tools/exportPalletDataWrong'
+        self.assertNotEqual('/mcp/tools/exportPalletData', tool.route_path)
+        tool.route_path = original
+        self.assertEqual('/mcp/tools/exportPalletData', tool.route_path)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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(25, len(local))
+        self.assertEqual(26, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(24, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(25, 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(25, len(local.registered_tool_names()))
+        self.assertEqual(26, 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())
 

+ 72 - 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(24, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(25, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(
@@ -441,6 +441,7 @@ class OutputPresenterTest(unittest.TestCase):
             'export_pending_outbound_orders',
             'export_out_of_province_port_data',
             'export_receivable_cost_list',
+            'export_pallet_data',
         ):
             with self.subTest(tool_name=tool_name):
                 result = self.presenter.present(
@@ -637,6 +638,76 @@ class OutputPresenterTest(unittest.TestCase):
         self.assertEqual('SO号参数不正确', result['structured_content']['message'])
         self.assertNotIn('so_numbers', json.dumps(result, ensure_ascii=False))
 
+    def test_pallet_export_container_code_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'container_codes must be an array',
+                'data': {'container_codes': 'bad'},
+                'meta': {'request_id': 'rq_container_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('柜号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('container_codes', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_container_error'}, result['meta'])
+
+    def test_pallet_export_bl_number_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'bl_numbers must be an array',
+                'data': {'bl_numbers': 'bad'},
+                'meta': {'request_id': 'rq_bl_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual('提单号参数不正确', result['structured_content']['message'])
+        self.assertNotIn('bl_numbers', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_bl_error'}, result['meta'])
+
+    def test_pallet_export_inbound_time_start_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'inbound_time_start is required',
+                'data': {'inbound_time_start': ''},
+                'meta': {'request_id': 'rq_time_start_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual(
+            '海外仓入库时间开始参数不正确',
+            result['structured_content']['message'],
+        )
+        self.assertNotIn('inbound_time_start', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_time_start_error'}, result['meta'])
+
+    def test_pallet_export_inbound_time_end_error_uses_business_label(self):
+        result = self.presenter.present(
+            'export_pallet_data',
+            {
+                'code': 'MCP_1401',
+                'msg': 'inbound_time_end is required',
+                'data': {'inbound_time_end': ''},
+                'meta': {'request_id': 'rq_time_end_error'},
+            },
+        )
+
+        self.assertTrue(result['is_error'])
+        self.assertEqual(
+            '海外仓入库时间结束参数不正确',
+            result['structured_content']['message'],
+        )
+        self.assertNotIn('inbound_time_end', json.dumps(result, ensure_ascii=False))
+        self.assertEqual({'request_id': 'rq_time_end_error'}, result['meta'])
+
     def test_system_error_hides_backend_message_and_data(self):
         result = self.presenter.present(
             'query_order_exact',

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

+ 5 - 0
tests/test_tool_description_boundaries.py

@@ -3,6 +3,7 @@ import unittest
 from tools.export_out_of_province_port_data import (
     ExportOutOfProvincePortDataTool,
 )
+from tools.export_pallet_data import ExportPalletDataTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.list_pending_outbound_export_filter_options import (
@@ -30,6 +31,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListPendingOutboundExportFilterOptionsTool(),
             ExportPendingOutboundOrdersTool(),
             ExportOutOfProvincePortDataTool(),
+            ExportPalletDataTool(),
         )
 
         for tool in tools:
@@ -47,6 +49,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOutboundListTool(),
             QueryOutboundDetailTool(),
             ExportOutOfProvincePortDataTool(),
+            ExportPalletDataTool(),
         )
 
         for tool in tools:
@@ -71,6 +74,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ),
             ExportPendingOutboundOrdersTool(): ('明确要求导出', '未排舱订单', '查看或查询'),
             ExportOutOfProvincePortDataTool(): ('明确要求导出', '省外进港资料', '普通排舱查询'),
+            ExportPalletDataTool(): ('明确要求导出', '打托数据', 'query_export_task'),
         }
 
         for tool, phrases in expected.items():
@@ -98,6 +102,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
         for tool in (
             ExportPendingOutboundOrdersTool(),
             ExportOutOfProvincePortDataTool(),
+            ExportPalletDataTool(),
         ):
             with self.subTest(tool=tool.name):
                 description = tool.metadata()['description']

+ 156 - 0
tools/export_pallet_data.py

@@ -0,0 +1,156 @@
+from datetime import datetime
+import re
+
+
+class ExportPalletDataTool:
+    name = 'export_pallet_data'
+    route_path = '/mcp/tools/exportPalletData'
+    DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
+    DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$')
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_items = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+            'pattern': '.*\\S.*',
+        }
+        number_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': dict(number_items),
+        }
+        time_field = {
+            'type': 'string',
+            'description': (
+                '海外仓入库时间,格式 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS。'
+                '只填日期时开始日 00:00:00、结束日 23:59:59。'
+            ),
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成,也禁止在一次调用内'
+                '轮询任务状态。成功后返回任务引用(task_ref)和建议等待时间'
+                '(retry_after_seconds)。稍后单独使用 query_export_task 查询任务状态或下载链接。'
+                '使用场景:只有用户明确要求导出打托数据并需要下载文件时才可调用。'
+                '必须提供柜号数组、提单号数组或海外仓入库时间起止,三者严格三选一,'
+                '不可混用。号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
+                '“请确认使用哪种号码导出:柜号还是提单号?”确认前不得调用。'
+                '禁止使用:查看入仓列表、按订单号或打托批次号查询、勾选内部 ID、'
+                '混用柜号/提单号/入库时间、一次调用内轮询 query_export_task。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'container_codes': dict(
+                        number_array,
+                        description=(
+                            '柜号数组。仅当用户明确说柜号时使用;'
+                            '不得放入提单号、订单号或打托批次号。'
+                        ),
+                    ),
+                    'bl_numbers': dict(
+                        number_array,
+                        description=(
+                            '提单号数组。仅当用户明确说提单号时使用;'
+                            '不得放入柜号、订单号或打托批次号。'
+                        ),
+                    ),
+                    'inbound_time_start': dict(time_field),
+                    'inbound_time_end': dict(time_field),
+                },
+                'required': [],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        container_codes=None,
+        bl_numbers=None,
+        inbound_time_start=None,
+        inbound_time_end=None,
+        request_id='rq_export_pallet_data',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for export_pallet_data')
+
+        payload = {}
+        effective_container = self._is_effective_number_selector(container_codes)
+        effective_bl = self._is_effective_number_selector(bl_numbers)
+        has_start = inbound_time_start is not None
+        has_end = inbound_time_end is not None
+        if has_start != has_end:
+            raise ValueError('inbound time range requires both start and end')
+        time_mode = has_start and has_end
+        number_modes = int(effective_container) + int(effective_bl)
+        if number_modes > 1:
+            raise ValueError('cannot mix container codes and bl numbers')
+        if number_modes >= 1 and time_mode:
+            raise ValueError('cannot mix numbers and inbound time')
+        if effective_container:
+            payload['container_codes'] = self._clean_number_list(
+                'container_codes', container_codes
+            )
+        elif effective_bl:
+            payload['bl_numbers'] = self._clean_number_list(
+                'bl_numbers', bl_numbers
+            )
+        elif time_mode:
+            start_bound = self._parse_bound(inbound_time_start)
+            end_bound = self._parse_bound(inbound_time_end)
+            if end_bound.date() < start_bound.date() or (
+                end_bound.date() - start_bound.date()
+            ).days > 30:
+                raise ValueError('inbound time range must be within 31 days')
+            payload['inbound_time_start'] = str(inbound_time_start).strip()
+            payload['inbound_time_end'] = str(inbound_time_end).strip()
+        elif container_codes is not None:
+            self._clean_number_list('container_codes', container_codes)
+        elif bl_numbers is not None:
+            self._clean_number_list('bl_numbers', bl_numbers)
+        else:
+            raise ValueError(
+                'provide container codes, bl numbers, or an inbound time range'
+            )
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _is_effective_number_selector(values):
+        return isinstance(values, list) and len(values) > 0
+
+    def _clean_number_list(self, field_name, values):
+        if not isinstance(values, list) or not values:
+            raise ValueError('{0} must be a non-empty list'.format(field_name))
+        cleaned = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} items must be strings'.format(field_name))
+            item = value.strip()
+            if not item or len(item) > 100:
+                raise ValueError(
+                    '{0} items must be 1 to 100 chars'.format(field_name)
+                )
+            if item not in cleaned:
+                cleaned.append(item)
+        if len(cleaned) > 200:
+            raise ValueError('at most 200 {0}'.format(field_name))
+        return cleaned
+
+    def _parse_bound(self, value):
+        text = str(value).strip()
+        if self.DATE_RE.match(text):
+            return datetime.strptime(text, '%Y-%m-%d')
+        if self.DATETIME_RE.match(text):
+            return datetime.strptime(text, '%Y-%m-%d %H:%M:%S')
+        raise ValueError('inbound time must be date or datetime')