jackson 3 órája%!(EXTRA string=)
szülő
commit
0c554b042d

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 9 - 0
CONTEXT.md


+ 10 - 6
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 32 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 34 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 32 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 34 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -49,6 +49,8 @@ Gateway 保持“薄网关”边界:
 | `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
 | `query_receive_volume_list` | 按入库时间查询收货量已入库订单明细 | `/mcp/tools/queryReceiveVolumeList` | 固定 9 列安全表格 |
 | `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
+| `query_container_timeliness_list` | 按柜号、提单号或干线实际出发时间查询柜子时效 | `/mcp/tools/queryContainerTimelinessList` | 固定 54 列安全表格 |
+| `export_container_timeliness_report` | 提交柜子时效统计报表异步导出 | `/mcp/tools/exportContainerTimelinessReport` | 签名任务引用 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -64,7 +66,7 @@ Gateway 保持“薄网关”边界:
 
 **异步导出流程:**
 
-1. 调用个导出提交工具(包括 `export_pallet_data`)提交任务,返回 `task_ref`
+1. 调用个导出提交工具(包括 `export_pallet_data` 与 `export_container_timeliness_report`)提交任务,返回 `task_ref`
 2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
 3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
 
@@ -74,7 +76,9 @@ Gateway 保持“薄网关”边界:
 先询问用户是柜号还是提单号,不得按格式猜测、跨字段或跨工具试查;不支持入仓单号、
 订单号、打托批次号或内部 ID。
 
-这 32 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+`export_container_timeliness_report` 的筛选与查询相同:柜号、提单号可同时传入并按 AND 收窄,合计最多 200 个;没有号码时必须提供最多 31 个起运港当地日历日的干线实际出发时间闭区间。Excel 对齐后台 `OPERATE_REPORT_OUTBOUND`(序号 + 54 列)。看表格改走 `query_container_timeliness_list`。
+
+这 34 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -83,7 +87,7 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 29 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 33 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_receivable_cost_list`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - 订单应收费用明细对外使用四项 CNY `summary`(订单总应收、订单总结算、特批金额、结算毛利)加固定 13 列 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
@@ -95,7 +99,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` 和设备失效文案。

+ 28 - 0
app.py

@@ -65,6 +65,10 @@ from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
 
 
 def parse_int_list(value):
@@ -163,6 +167,11 @@ class GatewayApp:
             ),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=api_client),
+            'query_container_timeliness_list': QueryContainerTimelinessListTool(
+                api_client=api_client
+            ),
+            'export_container_timeliness_report':
+                ExportContainerTimelinessReportTool(api_client=api_client),
         }
 
     @classmethod
@@ -357,6 +366,8 @@ class GatewayApp:
         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('--departure-time-start', default='')
+        call_parser.add_argument('--departure-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='')
@@ -811,6 +822,23 @@ class GatewayApp:
                     )
                 if args.product_ids:
                     tool_args['product_ids'] = parse_int_list(args.product_ids)
+            elif args.tool in (
+                'query_container_timeliness_list',
+                'export_container_timeliness_report',
+            ):
+                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.departure_time_start:
+                    tool_args['departure_time_start'] = args.departure_time_start
+                if args.departure_time_end:
+                    tool_args['departure_time_end'] = args.departure_time_end
+                if args.tool == 'export_container_timeliness_report':
+                    tool_args.pop('page', None)
+                    tool_args.pop('limit', None)
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
                 'list_customer_filter_options',

+ 9 - 0
public_gateway.py

@@ -51,6 +51,10 @@ from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
 from utils.security import hash_gateway_session_id
 
 
@@ -130,6 +134,11 @@ class PublicGatewayApp:
             ),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=None),
+            'query_container_timeliness_list': QueryContainerTimelinessListTool(
+                api_client=None
+            ),
+            'export_container_timeliness_report':
+                ExportContainerTimelinessReportTool(api_client=None),
         }
 
     def registered_tool_names(self):

+ 91 - 0
services/output_presenter.py

@@ -36,6 +36,7 @@ class OutputPresenter:
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
+    CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -52,6 +53,7 @@ class OutputPresenter:
         'export_out_of_province_port_data',
         'export_receivable_cost_list',
         'export_pallet_data',
+        'export_container_timeliness_report',
     ))
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
@@ -62,6 +64,7 @@ class OutputPresenter:
         | DESTINATION_TRAILER_TOOLS
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
+        | CONTAINER_TIMELINESS_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -570,6 +573,39 @@ class OutputPresenter:
         ('merchandiser_name', '客户经理'), ('department_name', '事业部'),
         ('product_name', '物流产品'),
     )
+    CONTAINER_TIMELINESS_COLUMNS = (
+        ('ship_company', '船公司'), ('bl_number', '提单号'),
+        ('container_code', '柜号'), ('container_type', '柜型'),
+        ('is_direct_send_name', '是否直送'), ('shipping_method', '运输方式'),
+        ('route', '航线'), ('vessel_name', '航名航次'),
+        ('wharf_name', '到港码头'), ('providers_name', '拖车行'),
+        ('dt_status_name', '柜子状态'), ('warehouse_name', '交货仓库'),
+        ('order_total', '件数'), ('total_volume', '体积'),
+        ('total_weight', '重量'), ('loading_name', '起运港'),
+        ('destination_name', '目的港'), ('delivery_address', '派送地址'),
+        ('has_throw_off_name', '甩柜'), ('throw_off_time', '甩柜时间'),
+        ('ed_check_name', '报关查验'), ('ed_check_time', '报关查验时间'),
+        ('qg_check_name', '清关查验'), ('check_time', '清关查验时间'),
+        ('in_inspection_site', '进查验场时间'),
+        ('out_inspection_site', '出查验场时间'),
+        ('is_close_area_name', '封闭区'), ('start_close_area', '进封闭区时间'),
+        ('end_close_area', '出封闭区时间'), ('outbound_date', '出库时间'),
+        ('export_release_time', '出口报关放行时间'),
+        ('etd', '干线预计出发时间'), ('eta', '干线预计到达时间'),
+        ('gxyjsx', '干线预计时效'), ('departure_time', '干线实际出发时间'),
+        ('arrival_time', '干线实际到达时间'), ('gxsjsx', '干线实际时效'),
+        ('hcsx', '航程时效'), ('start_clearance_time', '开始清关时间'),
+        ('import_release_time', '海外清关放行时间'),
+        ('wharf_wait_time', '码头等待时间'), ('pickup_time', '码头提柜时间'),
+        ('second_pickup_time', '二次提柜时间'), ('tgsx', '提柜时效'),
+        ('max_appointment_delivery_time', 'APPT时间'),
+        ('delivery_end_time', '卡车实际派送时间'),
+        ('oversea_warehouse_name', '海外仓库名称'),
+        ('inbound_date', '到仓时间'), ('inbound_time', '海外仓入库时间'),
+        ('container_return_date', '还空柜时间'), ('hksx', '还空时效'),
+        ('amazon_wait_time', '亚马逊等待时间'),
+        ('inner_remark', '内部备注'), ('financial_remark', '财务备注'),
+    )
 
     OUTBOUND_DETAIL_SUMMARY = {
         'bl_number': '提单号',
@@ -732,6 +768,20 @@ class OutputPresenter:
             'inbound_time_start': '海外仓入库时间开始',
             'inbound_time_end': '海外仓入库时间结束',
         },
+        'query_container_timeliness_list': {
+            'container_codes': '柜号',
+            'bl_numbers': '提单号',
+            'departure_time_start': '干线实际出发时间开始',
+            'departure_time_end': '干线实际出发时间结束',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'export_container_timeliness_report': {
+            'container_codes': '柜号',
+            'bl_numbers': '提单号',
+            'departure_time_start': '干线实际出发时间开始',
+            'departure_time_end': '干线实际出发时间结束',
+        },
         'export_receivable_cost_list': {
             'reference_numbers': '参考号',
             'tracking_numbers': '跟踪号',
@@ -854,6 +904,10 @@ class OutputPresenter:
             return self._present_receive_volume_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.CONTAINER_TIMELINESS_TOOLS:
+            return self._present_container_timeliness_list(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1759,6 +1813,43 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_container_timeliness_list(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        expected = list(self.CONTAINER_TIMELINESS_COLUMNS)
+        pagination = self._customer_pagination(raw_meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, (key, name) in zip(columns, expected):
+            if column != {'key': key, 'name': name}:
+                return self._format_error(meta)
+        rows = []
+        expected_keys = [key for key, _ in expected]
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
     @staticmethod
     def _valid_volume_cell(value):
         return (

+ 306 - 0
tests/test_container_timeliness_tools.py

@@ -0,0 +1,306 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_container_timeliness_list import (
+    COLUMNS,
+    QueryContainerTimelinessListTool,
+)
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_container_timeliness_list',
+                    'export_container_timeliness_report',
+                ],
+            },
+        }
+
+    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 ContainerTimelinessToolContractTest(unittest.TestCase):
+    def test_query_schema_is_closed_without_identity_or_page_filters(self):
+        metadata = QueryContainerTimelinessListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_container_timeliness_list', metadata['name'])
+        self.assertEqual(
+            '/mcp/tools/queryContainerTimelinessList',
+            QueryContainerTimelinessListTool.route_path,
+        )
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'departure_time_start',
+                'departure_time_end',
+                'page',
+                'limit',
+            },
+            set(schema['properties']),
+        )
+        for forbidden in (
+            'company_id', 'admin_id', 'is_super', 'outbound_numbers',
+            'ship_company', 'loading_id',
+        ):
+            self.assertNotIn(forbidden, schema['properties'])
+        description = metadata['description']
+        self.assertIn('使用场景:', description)
+        self.assertIn('禁止使用:', description)
+        self.assertIn('admin/Report/outboundReport', description)
+        self.assertIn('query_outbound_list', description)
+        self.assertIn('干线实际出发时间', description)
+        self.assertIn('54列', description)
+        self.assertIn('号码类型不明确时必须先询问用户', description)
+        self.assertIn('不得根据号码格式猜测', description)
+        self.assertIn('不得跨字段或跨工具试查', description)
+
+    def test_query_forwards_numbers_and_optional_departure_window(self):
+        client = RecordingApiClient()
+        QueryContainerTimelinessListTool(client).call(
+            container_codes=['CONT-1', ' CONT-1 '],
+            bl_numbers=['BL-1'],
+            departure_time_start='2026-09-01',
+            departure_time_end='2026-09-30',
+            page=2,
+            limit=10,
+        )
+        self.assertEqual(
+            (
+                'query_container_timeliness_list',
+                '/mcp/tools/queryContainerTimelinessList',
+                {
+                    'container_codes': ['CONT-1'],
+                    'bl_numbers': ['BL-1'],
+                    'departure_time_start': '2026-09-01',
+                    'departure_time_end': '2026-09-30',
+                    'page': 2,
+                    'limit': 10,
+                },
+                'rq_query_container_timeliness_list',
+            ),
+            client.calls[-1],
+        )
+
+    def test_query_rejects_empty_and_oversize_window(self):
+        tool = QueryContainerTimelinessListTool(RecordingApiClient())
+        with self.assertRaisesRegex(ValueError, 'provide'):
+            tool.call()
+        with self.assertRaisesRegex(ValueError, 'departure time range requires both'):
+            tool.call(departure_time_start='2026-09-01')
+        with self.assertRaisesRegex(ValueError, 'departure time window'):
+            tool.call(
+                departure_time_start='2026-08-01',
+                departure_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'is invalid'):
+            tool.call(departure_time_start='09/01/2026', departure_time_end='2026-09-02')
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(
+                container_codes=['C1'],
+                bl_numbers=['B{0}'.format(i) for i in range(200)],
+            )
+        with self.assertRaisesRegex(ValueError, 'non-empty'):
+            tool.call(container_codes=[])
+        with self.assertRaisesRegex(ValueError, 'strings'):
+            tool.call(container_codes=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=[''])
+        with self.assertRaisesRegex(ValueError, 'at most 200 container_codes'):
+            tool.call(container_codes=['C{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'page is invalid'):
+            tool.call(container_codes=['CONT-1'], page=True)
+        with self.assertRaisesRegex(ValueError, 'page is invalid'):
+            tool.call(container_codes=['CONT-1'], page=0)
+        with self.assertRaisesRegex(ValueError, 'limit is invalid'):
+            tool.call(container_codes=['CONT-1'], limit='20')
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(container_codes=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'departure time window'):
+            tool.call(
+                departure_time_start='2026-09-30',
+                departure_time_end='2026-09-01',
+            )
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryContainerTimelinessListTool().call(container_codes=['CONT-1'])
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ExportContainerTimelinessReportTool().call(container_codes=['CONT-1'])
+
+    def test_export_schema_forbids_pagination_and_requires_async_follow_up(self):
+        metadata = ExportContainerTimelinessReportTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_container_timeliness_report', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {
+                'container_codes',
+                'bl_numbers',
+                'departure_time_start',
+                'departure_time_end',
+            },
+            set(schema['properties']),
+        )
+        self.assertNotIn('page', schema['properties'])
+        description = metadata['description']
+        self.assertIn('明确要求导出', description)
+        self.assertIn('柜子时效', description)
+        self.assertIn('异步导出任务', description)
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待文件生成', description)
+        self.assertIn('query_container_timeliness_list', description)
+
+    def test_export_forwards_locator_without_page(self):
+        client = RecordingApiClient()
+        ExportContainerTimelinessReportTool(client).call(
+            bl_numbers=['BL-1'],
+        )
+        self.assertEqual(
+            {
+                'bl_numbers': ['BL-1'],
+            },
+            client.calls[-1][2],
+        )
+        self.assertNotIn('page', client.calls[-1][2])
+
+    def test_cli_forwards_query_and_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'query_container_timeliness_list',
+            '--container-codes', 'CONT-1',
+            '--departure-time-start', '2026-09-01',
+            '--departure-time-end', '2026-09-14',
+            '--page', '2',
+            '--limit', '10',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual(
+            {
+                'container_codes': ['CONT-1'],
+                'departure_time_start': '2026-09-01',
+                'departure_time_end': '2026-09-14',
+                'page': 2,
+                'limit': 10,
+            },
+            client.calls[-1][2],
+        )
+
+        code = app.run_cli([
+            'call', '--tool', 'export_container_timeliness_report',
+            '--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],
+        )
+        self.assertNotIn('page', client.calls[-1][2])
+
+    def test_presenter_accepts_fifty_four_string_columns(self):
+        presenter = OutputPresenter()
+        columns = [{'key': key, 'name': name} for key, name in COLUMNS]
+        record = {key: 'v_{0}'.format(key) for key, _ in COLUMNS}
+        ok = presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [record]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })
+        self.assertFalse(ok['is_error'])
+        self.assertEqual(54, len(ok['structured_content']['headers']))
+        self.assertEqual('v_container_code', ok['structured_content']['rows'][0][2])
+
+        numeric = dict(record)
+        numeric['total_volume'] = 1.2
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [numeric]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [], 'extra': 1},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_x', 'total': 1,
+            },
+        })['is_error'])
+        bad_columns = list(columns)
+        bad_columns[0] = {'key': 'ship_company', 'name': '错'}
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': bad_columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        extra = dict(record)
+        extra['seq'] = '1'
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': [extra]},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': 'bad'},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        short_columns = columns[:-1]
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': short_columns, 'records': []},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+        self.assertTrue(presenter.present('query_container_timeliness_list', {
+            'code': 'MCP_0000',
+            'data': {'columns': columns, 'records': ['bad']},
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
+            },
+        })['is_error'])
+
+    def test_local_and_public_registries_include_both_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(34, len(local))
+        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
+        self.assertIn('query_container_timeliness_list', local)
+        self.assertIn('export_container_timeliness_report', local)
+
+
+if __name__ == '__main__':
+    unittest.main()

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

+ 2 - 2
tests/test_destination_trailer_tools.py

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

+ 2 - 2
tests/test_export_pallet_data_tool.py

@@ -296,9 +296,9 @@ class ExportPalletDataToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(32, len(local))
+        self.assertEqual(34, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(33, 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(32, len(local))
+        self.assertEqual(34, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 2 - 2
tests/test_order_abnormal_tools.py

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

+ 1 - 1
tests/test_order_receivable_cost_details_tool.py

@@ -86,7 +86,7 @@ class OrderReceivableCostDetailsToolTest(unittest.TestCase):
         local = GatewayApp(api_client=client)
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(32, len(local.registered_tool_names()))
+        self.assertEqual(34, 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())
 

+ 2 - 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(31, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(33, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(
@@ -442,6 +442,7 @@ class OutputPresenterTest(unittest.TestCase):
             'export_out_of_province_port_data',
             'export_receivable_cost_list',
             'export_pallet_data',
+            'export_container_timeliness_report',
         ):
             with self.subTest(tool_name=tool_name):
                 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(32, len(local))
-        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(34, len(local))
+        self.assertEqual(33, 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(32, len(local))
+        self.assertEqual(34, 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(32, len(local))
-        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(34, len(local))
+        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
         self.assertEqual(
             (
                 'query_receivable_cost_list',

+ 2 - 2
tests/test_receive_volume_tools.py

@@ -406,7 +406,7 @@ class ReceiveVolumeToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(32, len(local))
-        self.assertEqual(31, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(34, len(local))
+        self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_receive_volume_list', local)
         self.assertIn('list_receive_volume_filter_options', local)

+ 15 - 0
tests/test_tool_description_boundaries.py

@@ -27,6 +27,10 @@ from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.export_container_timeliness_report import (
+    ExportContainerTimelinessReportTool,
+)
 from tools.query_track import QueryTrackTool
 
 
@@ -50,6 +54,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListOrderAbnormalFilterOptionsTool(),
             QueryReceiveVolumeListTool(),
             ListReceiveVolumeFilterOptionsTool(),
+            QueryContainerTimelinessListTool(),
+            ExportContainerTimelinessReportTool(),
         )
 
         for tool in tools:
@@ -70,6 +76,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportPalletDataTool(),
             QueryDestinationTrailerListTool(),
             QueryOrderAbnormalListTool(),
+            QueryContainerTimelinessListTool(),
+            ExportContainerTimelinessReportTool(),
         )
 
         for tool in tools:
@@ -113,6 +121,12 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ListReceiveVolumeFilterOptionsTool(): (
                 'query_receive_volume_list', '禁止猜测', '导出',
             ),
+            QueryContainerTimelinessListTool(): (
+                '柜子时效', 'admin/Report/outboundReport', 'query_outbound_list',
+            ),
+            ExportContainerTimelinessReportTool(): (
+                '明确要求导出', '柜子时效', 'query_export_task',
+            ),
         }
 
         for tool, phrases in expected.items():
@@ -141,6 +155,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             ExportPendingOutboundOrdersTool(),
             ExportOutOfProvincePortDataTool(),
             ExportPalletDataTool(),
+            ExportContainerTimelinessReportTool(),
         ):
             with self.subTest(tool=tool.name):
                 description = tool.metadata()['description']

+ 99 - 0
tools/export_container_timeliness_report.py

@@ -0,0 +1,99 @@
+from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+
+
+class ExportContainerTimelinessReportTool:
+    name = 'export_container_timeliness_report'
+    route_path = '/mcp/tools/exportContainerTimelinessReport'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        date_field = {
+            'type': 'string',
+            'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        number_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成,也禁止在一次调用内'
+                '轮询任务状态。成功后返回任务引用(task_ref)和建议等待时间'
+                '(retry_after_seconds)。稍后单独使用 query_export_task 查询任务状态或下载链接。'
+                '使用场景:只有用户明确要求导出柜子时效统计报表并需要下载文件时才可调用。'
+                '必须提供柜号数组、提单号数组或最多31个起运港当地日历日的干线实际出发时间闭区间;'
+                '没有号码时干线实际出发时间必填,有号码时时间可选。柜号与提单号可同时传入并按AND收窄。'
+                '号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
+                '“请确认使用哪种号码导出:柜号还是提单号?”确认前不得调用。'
+                '禁止使用:查看柜子时效表格、排舱列表、混用出库时间或其他后台页面筛选、'
+                '一次调用内轮询 query_export_task。看表格改走 query_container_timeliness_list。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
+                '不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'container_codes': dict(
+                        number_array,
+                        description=(
+                            '柜号数组。仅当用户明确说柜号时使用;'
+                            '不得放入其他类型号码。'
+                        ),
+                    ),
+                    'bl_numbers': dict(
+                        number_array,
+                        description=(
+                            '提单号数组。仅当用户明确说提单号时使用;'
+                            '不得放入其他类型号码。'
+                        ),
+                    ),
+                    'departure_time_start': dict(
+                        date_field,
+                        description='干线实际出发时间开始日期,起运港当地日历日 YYYY-MM-DD。',
+                    ),
+                    'departure_time_end': dict(
+                        date_field,
+                        description=(
+                            '干线实际出发时间结束日期,起运港当地日历日 YYYY-MM-DD,'
+                            '且与开始日期跨度不超过31个日历日。'
+                        ),
+                    ),
+                },
+                'required': [],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        container_codes=None,
+        bl_numbers=None,
+        departure_time_start=None,
+        departure_time_end=None,
+        request_id='rq_export_container_timeliness_report',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for export_container_timeliness_report'
+            )
+        payload = QueryContainerTimelinessListTool._locator_payload(
+            container_codes,
+            bl_numbers,
+            departure_time_start,
+            departure_time_end,
+        )
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )

+ 248 - 0
tools/query_container_timeliness_list.py

@@ -0,0 +1,248 @@
+from datetime import datetime
+
+
+COLUMNS = (
+    ('ship_company', '船公司'),
+    ('bl_number', '提单号'),
+    ('container_code', '柜号'),
+    ('container_type', '柜型'),
+    ('is_direct_send_name', '是否直送'),
+    ('shipping_method', '运输方式'),
+    ('route', '航线'),
+    ('vessel_name', '航名航次'),
+    ('wharf_name', '到港码头'),
+    ('providers_name', '拖车行'),
+    ('dt_status_name', '柜子状态'),
+    ('warehouse_name', '交货仓库'),
+    ('order_total', '件数'),
+    ('total_volume', '体积'),
+    ('total_weight', '重量'),
+    ('loading_name', '起运港'),
+    ('destination_name', '目的港'),
+    ('delivery_address', '派送地址'),
+    ('has_throw_off_name', '甩柜'),
+    ('throw_off_time', '甩柜时间'),
+    ('ed_check_name', '报关查验'),
+    ('ed_check_time', '报关查验时间'),
+    ('qg_check_name', '清关查验'),
+    ('check_time', '清关查验时间'),
+    ('in_inspection_site', '进查验场时间'),
+    ('out_inspection_site', '出查验场时间'),
+    ('is_close_area_name', '封闭区'),
+    ('start_close_area', '进封闭区时间'),
+    ('end_close_area', '出封闭区时间'),
+    ('outbound_date', '出库时间'),
+    ('export_release_time', '出口报关放行时间'),
+    ('etd', '干线预计出发时间'),
+    ('eta', '干线预计到达时间'),
+    ('gxyjsx', '干线预计时效'),
+    ('departure_time', '干线实际出发时间'),
+    ('arrival_time', '干线实际到达时间'),
+    ('gxsjsx', '干线实际时效'),
+    ('hcsx', '航程时效'),
+    ('start_clearance_time', '开始清关时间'),
+    ('import_release_time', '海外清关放行时间'),
+    ('wharf_wait_time', '码头等待时间'),
+    ('pickup_time', '码头提柜时间'),
+    ('second_pickup_time', '二次提柜时间'),
+    ('tgsx', '提柜时效'),
+    ('max_appointment_delivery_time', 'APPT时间'),
+    ('delivery_end_time', '卡车实际派送时间'),
+    ('oversea_warehouse_name', '海外仓库名称'),
+    ('inbound_date', '到仓时间'),
+    ('inbound_time', '海外仓入库时间'),
+    ('container_return_date', '还空柜时间'),
+    ('hksx', '还空时效'),
+    ('amazon_wait_time', '亚马逊等待时间'),
+    ('inner_remark', '内部备注'),
+    ('financial_remark', '财务备注'),
+)
+
+
+class QueryContainerTimelinessListTool:
+    name = 'query_container_timeliness_list'
+    route_path = '/mcp/tools/queryContainerTimelinessList'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        date_field = {
+            'type': 'string',
+            'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        number_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要看柜子时效统计报表表格(已出库排舱单一行一柜)时,'
+                '对照后台admin/Report/outboundReport导出列,固定54列,不含序号。'
+                '必须先确认柜号、提单号或最多31个起运港当地日历日的干线实际出发时间闭区间;'
+                '没有号码时干线实际出发时间必填,有号码时时间可选。柜号与提单号可同时传入并按AND收窄。'
+                '号码类型不明确时必须先询问用户:“请确认使用哪种号码查询:柜号还是提单号?”确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '禁止使用:导出文件、排舱列表query_outbound_list、海外提柜列表、后台页面筛选'
+                '(船公司、港口、出库时间、查验勾选等)。看表格用本工具;用户明确要下载文件时改走'
+                'export_container_timeliness_report。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的'
+                '英文参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'container_codes': dict(
+                        number_array,
+                        description=(
+                            '柜号数组。仅当用户明确说柜号时使用;'
+                            '不得放入其他类型号码。'
+                        ),
+                    ),
+                    'bl_numbers': dict(
+                        number_array,
+                        description=(
+                            '提单号数组。仅当用户明确说提单号时使用;'
+                            '不得放入其他类型号码。'
+                        ),
+                    ),
+                    'departure_time_start': dict(
+                        date_field,
+                        description='干线实际出发时间开始日期,起运港当地日历日 YYYY-MM-DD。',
+                    ),
+                    'departure_time_end': dict(
+                        date_field,
+                        description=(
+                            '干线实际出发时间结束日期,起运港当地日历日 YYYY-MM-DD,'
+                            '且与开始日期跨度不超过31个日历日。'
+                        ),
+                    ),
+                    'page': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer',
+                        'minimum': 1,
+                        'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': [],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        container_codes=None,
+        bl_numbers=None,
+        departure_time_start=None,
+        departure_time_end=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_container_timeliness_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_container_timeliness_list'
+            )
+        payload = self._locator_payload(
+            container_codes,
+            bl_numbers,
+            departure_time_start,
+            departure_time_end,
+        )
+        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,
+        )
+
+    @classmethod
+    def _locator_payload(
+        cls,
+        container_codes,
+        bl_numbers,
+        departure_time_start,
+        departure_time_end,
+    ):
+        payload = {}
+        if container_codes is not None:
+            payload['container_codes'] = cls._number_list(
+                'container_codes', container_codes
+            )
+        if bl_numbers is not None:
+            payload['bl_numbers'] = cls._number_list('bl_numbers', bl_numbers)
+        total = len(payload.get('container_codes', [])) + len(
+            payload.get('bl_numbers', [])
+        )
+        if total > 200:
+            raise ValueError('at most 200 container codes and bl numbers combined')
+        has_start = departure_time_start is not None
+        has_end = departure_time_end is not None
+        if has_start != has_end:
+            raise ValueError('departure time range requires both start and end')
+        if has_start:
+            start = cls._date(departure_time_start, 'departure_time_start')
+            end = cls._date(departure_time_end, 'departure_time_end')
+            cls._assert_window(start, end)
+            payload['departure_time_start'] = start
+            payload['departure_time_end'] = end
+        elif 'container_codes' not in payload and 'bl_numbers' not in payload:
+            raise ValueError(
+                'provide container codes, bl numbers, or a departure time range'
+            )
+        return payload
+
+    @staticmethod
+    def _date(value, field):
+        text = str(value or '').strip()
+        try:
+            parsed = datetime.strptime(text, '%Y-%m-%d').date()
+        except ValueError:
+            raise ValueError(field + ' is invalid')
+        return parsed.strftime('%Y-%m-%d')
+
+    @staticmethod
+    def _assert_window(start, end):
+        start_date = datetime.strptime(start, '%Y-%m-%d').date()
+        end_date = datetime.strptime(end, '%Y-%m-%d').date()
+        if end_date < start_date or (end_date - start_date).days > 30:
+            raise ValueError('departure time window is invalid')
+
+    @staticmethod
+    def _number_list(field, values):
+        if not isinstance(values, list) or not values:
+            raise ValueError(field + ' must be a non-empty list')
+        cleaned = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError(field + ' items must be strings')
+            item = value.strip()
+            if not item or len(item) > 100:
+                raise ValueError(field + ' items must be 1 to 100 chars')
+            if item not in cleaned:
+                cleaned.append(item)
+        if len(cleaned) > 200:
+            raise ValueError('at most 200 ' + field)
+        return cleaned
+
+    @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