Переглянути джерело

Merge remote-tracking branch 'remotes/origin/master_task_7057493101' into master

zhongjr 1 тиждень тому
батько
коміт
fb09de0668

Різницю між файлами не показано, бо вона завелика
+ 25 - 1
CONTEXT.md


+ 6 - 4
README.md

@@ -44,7 +44,7 @@ Gateway 保持“薄网关”边界:
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
 | `query_outbound_list` | 按业务阶段和筛选条件查询排舱列表 | `/mcp/tools/queryOutboundList` | 固定 40 列安全表格 |
 | `query_outbound_detail` | 按排舱单号查询排舱汇总与订单明细 | `/mcp/tools/queryOutboundDetail` | 11 项汇总 + 38 项明细 |
-| `query_destination_trailer_list` | 按六个类型查询海外提柜进度或 DO 制作列表 | `/mcp/tools/queryDestinationTrailerList` | 进度 30 列或 DO 11 列安全表格 |
+| `query_destination_trailer_list` | 按六个类型查询海外提柜进度或 DO 制作列表 | `/mcp/tools/queryDestinationTrailerList` | 进度 32 列或 DO 11 列安全表格 |
 | `list_destination_trailer_filter_options` | 查询海外提柜七类筛选项 | `/mcp/tools/listDestinationTrailerFilterOptions` | 安全筛选项 |
 | `query_order_abnormal_list` | 按六个页签查询头程问题件列表 | `/mcp/tools/queryOrderAbnormalList` | 固定 9 列安全表格 |
 | `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
@@ -54,6 +54,8 @@ Gateway 保持“薄网关”边界:
 | `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
 | `query_container_timeliness_list` | 按柜号、提单号或干线实际出发时间查询柜子时效 | `/mcp/tools/queryContainerTimelinessList` | 固定 54 列安全表格 |
 | `export_container_timeliness_report` | 提交柜子时效统计报表异步导出 | `/mcp/tools/exportContainerTimelinessReport` | 签名任务引用 |
+| `list_inbound_receipt_templates` | 按柜号返回直送或非直送入仓单模板 | `/mcp/tools/listInboundReceiptTemplates` | 种类、模板名称和编码 |
+| `export_inbound_receipt` | 按柜号和已选模板提交入仓单异步导出 | `/mcp/tools/exportInboundReceipt` | 签名任务引用 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
 | `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
@@ -69,7 +71,7 @@ Gateway 保持“薄网关”边界:
 
 **异步导出流程:**
 
-1. 调用六个导出提交工具(包括 `export_pallet_data` 与 `export_container_timeliness_report`)提交任务,返回 `task_ref`
+1. 调用导出提交工具(包括 `export_pallet_data`、`export_container_timeliness_report` 与 `export_inbound_receipt`)提交任务,返回 `task_ref`
 2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
 3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
 
@@ -98,11 +100,11 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 - 订单应收费用明细固定展示确认页同源的四项 CNY 汇总,以及订单号、客户/费用客户、费用项、计费数据、两项 CNY 金额及确认/关账/核销状态 13 列;金额必须为有限数值,未知字段关闭失败。
 - 应收费用单列表固定展示后台 24 个业务列;四个金额单元格必须包含有限数值和币种。配套筛选项保留整数 `0/-1` 状态值;未知字段、列重排或畸形分页关闭失败。
 - 应付费用列表按六个业务类型严格返回后台页签列集(17/17/19/15/15/18 列);五类号码字段分别 `IN` 且彼此与其他条件 `AND`,无号码时要求一组最多 31 日日期。头程 `order_numbers` 同时精确匹配单号与关联订单号(`sub_number`)。列缺失、额外、错序或非有限金额关闭失败。
-- 海外提柜列表前五类固定 30 列,DO单制作固定 11 列;DO 原/新文件只接受空串或安全 HTTP(S) URL。类型必选,提单号、柜号、日期和其他筛选均为可选。
+- 海外提柜列表前五类固定 32 列,DO单制作固定 11 列;DO 原/新文件只接受空串或安全 HTTP(S) URL。类型必选,提单号、柜号、日期和其他筛选均为可选。
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
-- 六个导出提交工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
+- 导出提交工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。`export_inbound_receipt` 同样只提交任务;模板先由 `list_inbound_receipt_templates` 按柜号返回。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
 - stdio 与公网 `tools/call` 缺少有效工具名时返回 JSON-RPC `-32602 Invalid params`,不包装为业务 `isError`。
 - 公网 `tools/list` 在设备会话缺失或 Redis 映射不存在时返回 JSON-RPC `-32001` 和设备失效文案。

+ 32 - 0
app.py

@@ -54,6 +54,7 @@ from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsT
 from tools.export_payable_cost_list import ExportPayableCostListTool
 from tools.export_pallet_data import ExportPalletDataTool
 from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
 from tools.list_destination_trailer_filter_options import (
     ListDestinationTrailerFilterOptionsTool,
 )
@@ -71,6 +72,8 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+from tools.export_inbound_receipt import ExportInboundReceiptTool
 from tools.query_freightower_track import QueryFreightowerTrackTool
 
 
@@ -158,6 +161,9 @@ class GatewayApp:
             'query_destination_trailer_list': QueryDestinationTrailerListTool(
                 api_client=api_client
             ),
+            'arrange_destination_trailer': ArrangeDestinationTrailerTool(
+                api_client=api_client
+            ),
             'list_destination_trailer_filter_options':
                 ListDestinationTrailerFilterOptionsTool(api_client=api_client),
             'query_order_abnormal_list': QueryOrderAbnormalListTool(
@@ -177,6 +183,12 @@ class GatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=api_client),
+            'list_inbound_receipt_templates': ListInboundReceiptTemplatesTool(
+                api_client=api_client
+            ),
+            'export_inbound_receipt': ExportInboundReceiptTool(
+                api_client=api_client
+            ),
             'query_freightower_track': QueryFreightowerTrackTool(
                 api_client=api_client
             ),
@@ -330,6 +342,7 @@ class GatewayApp:
         call_parser.add_argument('--bl-numbers', default='')
         call_parser.add_argument('--container-code', default='')
         call_parser.add_argument('--container-codes', default='')
+        call_parser.add_argument('--template-code', default='')
         call_parser.add_argument('--so-number', default='')
         call_parser.add_argument('--so-numbers', default='')
         call_parser.add_argument('--shipment-id', default='')
@@ -873,6 +886,25 @@ class GatewayApp:
                 if args.tool == 'export_container_timeliness_report':
                     tool_args.pop('page', None)
                     tool_args.pop('limit', None)
+            elif args.tool in (
+                'list_inbound_receipt_templates',
+                'export_inbound_receipt',
+            ):
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+                if not args.container_codes:
+                    raise ValueError(
+                        '--container-codes is required for ' + args.tool
+                    )
+                tool_args['container_codes'] = parse_string_list(
+                    args.container_codes
+                )
+                if args.tool == 'export_inbound_receipt':
+                    if not args.template_code:
+                        raise ValueError(
+                            '--template-code is required for export_inbound_receipt'
+                        )
+                    tool_args['template_code'] = args.template_code
             elif args.tool == 'query_freightower_track':
                 tool_args.pop('page', None)
                 tool_args.pop('limit', None)

+ 9 - 0
docs/adr/0002-inbound-receipt-template-follows-direct-send.md

@@ -0,0 +1,9 @@
+# 入仓单模板按柜上的是否直送划分
+
+MCP 查入仓单模板和导出入仓单时,只看这一柜排舱单当前是不是直送柜。直送柜用海外提柜页那两张直送模板里已启用的;非直送柜用海外仓页入仓单模板里去掉这两张之后、已启用的全部。调用方只传柜号,不声明种类。
+
+海外提柜页的「入仓单导出」不是这样:不管勾中的柜是不是直送,弹窗都只给那两张直送模板。若 MCP 照抄页面,非直送柜会拿到直送模板。按柜上的标记划分,同一次才能只返回一套模板。
+
+**Status:** accepted
+
+**Considered Options:** 照抄海外提柜页,非直送柜也只给直送模板(和页面按钮一致,但和「非直送入仓单用海外仓模板」相反);让调用方自己声明直送或非直送(实现简单,但用户要求只凭柜号判断)。

+ 12 - 0
public_gateway.py

@@ -40,6 +40,7 @@ from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsT
 from tools.export_payable_cost_list import ExportPayableCostListTool
 from tools.export_pallet_data import ExportPalletDataTool
 from tools.query_destination_trailer_list import QueryDestinationTrailerListTool
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
 from tools.list_destination_trailer_filter_options import (
     ListDestinationTrailerFilterOptionsTool,
 )
@@ -57,6 +58,8 @@ from tools.query_container_timeliness_list import QueryContainerTimelinessListTo
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+from tools.export_inbound_receipt import ExportInboundReceiptTool
 from tools.query_freightower_track import QueryFreightowerTrackTool
 from utils.security import hash_gateway_session_id
 
@@ -125,6 +128,9 @@ class PublicGatewayApp:
             'query_destination_trailer_list': QueryDestinationTrailerListTool(
                 api_client=None
             ),
+            'arrange_destination_trailer': ArrangeDestinationTrailerTool(
+                api_client=None
+            ),
             'list_destination_trailer_filter_options':
                 ListDestinationTrailerFilterOptionsTool(api_client=None),
             'query_order_abnormal_list': QueryOrderAbnormalListTool(
@@ -144,6 +150,12 @@ class PublicGatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=None),
+            'list_inbound_receipt_templates': ListInboundReceiptTemplatesTool(
+                api_client=None
+            ),
+            'export_inbound_receipt': ExportInboundReceiptTool(
+                api_client=None
+            ),
             'query_freightower_track': QueryFreightowerTrackTool(api_client=None),
         }
 

+ 164 - 1
services/output_presenter.py

@@ -34,11 +34,13 @@ class OutputPresenter:
     PAYABLE_FILTER_TOOLS = frozenset(('list_payable_cost_filter_options',))
     PAYABLE_EXPORT_TOOLS = frozenset(('export_payable_cost_list',))
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
+    ARRANGE_TRAILER_TOOLS = frozenset(('arrange_destination_trailer',))
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
     GOODS_INFO_TOOLS = frozenset(('query_goods_info',))
     GOODS_TAX_TOOLS = frozenset(('trial_goods_tax',))
     CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
+    INBOUND_RECEIPT_TOOLS = frozenset(('list_inbound_receipt_templates',))
     FREIGHTOWER_TRACK_TOOLS = frozenset(('query_freightower_track',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
@@ -57,6 +59,7 @@ class OutputPresenter:
         'export_receivable_cost_list',
         'export_pallet_data',
         'export_container_timeliness_report',
+        'export_inbound_receipt',
     ))
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
@@ -65,11 +68,13 @@ class OutputPresenter:
         | RECEIVABLE_LIST_TOOLS | RECEIVABLE_FILTER_TOOLS
         | PAYABLE_COST_TOOLS | PAYABLE_FILTER_TOOLS | PAYABLE_EXPORT_TOOLS
         | DESTINATION_TRAILER_TOOLS
+        | ARRANGE_TRAILER_TOOLS
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
         | GOODS_INFO_TOOLS
         | GOODS_TAX_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
+        | INBOUND_RECEIPT_TOOLS
         | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
@@ -548,6 +553,15 @@ class OutputPresenter:
             ('wharf_wait_time', '码头等待时间'),
             ('amazon_wait_time', '亚马逊等待时间'),
             ('bill_status', '应付费用状态'),
+            ('issue_way', '出单方式'),
+            ('release_status', '放货状态'),
+            ('freight_collect', '海运费到付'),
+            ('is_dg', 'DG'),
+            ('is_soc', 'SOC'),
+            ('is_oneway', 'ONEWAY'),
+            ('appointment_method', '预约方式'),
+            ('need_appointment_code', '是否需要预约码'),
+            ('warehouse_special_remark', '仓库特殊备注'),
         ),
         'do': (
             ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
@@ -926,6 +940,8 @@ class OutputPresenter:
             return self._present_destination_trailer_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.ARRANGE_TRAILER_TOOLS:
+            return self._present_arrange_destination_trailer(data, meta)
         if tool_name in self.ORDER_ABNORMAL_TOOLS:
             return self._present_order_abnormal_list(
                 data, tool_result.get('meta'), meta
@@ -944,6 +960,8 @@ class OutputPresenter:
             return self._present_container_timeliness_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.INBOUND_RECEIPT_TOOLS:
+            return self._present_inbound_receipt_templates(data, meta)
         if tool_name in self.FREIGHTOWER_TRACK_TOOLS:
             return self._present_freightower_track(data, meta)
         if tool_name in self.TABLE_TOOLS:
@@ -1721,6 +1739,23 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_arrange_destination_trailer(self, data, meta):
+        expected = ('trailer_number', 'bl_number', 'container_code')
+        if set(data) != set(expected):
+            return self._format_error(meta)
+        for key in expected:
+            if not isinstance(data.get(key), str):
+                return self._format_error(meta)
+        content = {
+            '拖车单号': data['trailer_number'],
+            '提单号': data['bl_number'],
+            '柜号': data['container_code'],
+        }
+        text = '拖车单号:{0};提单号:{1};柜号:{2}'.format(
+            data['trailer_number'], data['bl_number'], data['container_code']
+        )
+        return self._success_result(content, text, meta)
+
     def _present_destination_trailer_list(self, data, raw_meta, meta):
         if set(data) != {'trailer_stage', 'columns', 'records'}:
             return self._format_error(meta)
@@ -2215,6 +2250,38 @@ class OutputPresenter:
             content['pagination'] = pagination
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_inbound_receipt_templates(self, data, meta):
+        if set(data) != {'receipt_kind', 'templates'}:
+            return self._format_error(meta)
+        kind = data.get('receipt_kind')
+        templates = data.get('templates')
+        if kind not in ('直送入仓单', '非直送入仓单') or not isinstance(templates, list):
+            return self._format_error(meta)
+        if not templates:
+            return self._format_error(meta)
+        rows = []
+        for item in templates:
+            if not isinstance(item, dict) or set(item) != {'name', 'template_code'}:
+                return self._format_error(meta)
+            name = item.get('name')
+            code = item.get('template_code')
+            if (
+                not isinstance(name, str) or not name.strip()
+                or not isinstance(code, str) or not code.strip()
+            ):
+                return self._format_error(meta)
+            rows.append([name.strip(), code.strip()])
+        content = {
+            'receipt_kind': kind,
+            'summary': kind,
+            'headers': [
+                {'label': '模板名称'},
+                {'label': '模板编码'},
+            ],
+            'rows': rows,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
     def _present_export_submission(self, data, meta):
         expected = {'task_ref', 'status', 'retry_after_seconds'}
         if set(data) != expected:
@@ -2347,9 +2414,23 @@ class OutputPresenter:
                 },
             )
             code = 'MCP_9001'
-        if code == 'MCP_1401':
+        if code == 'MCP_1401' and tool_name in self.ARRANGE_TRAILER_TOOLS:
+            if not self._arrange_trailer_message(message):
+                message = self._parameter_message('')
+            return self._error_result(code, message, False, meta)
+        elif code == 'MCP_1401':
             label = self._find_field_label(tool_name, raw_message)
             message = self._parameter_message(label)
+        elif tool_name in self.INBOUND_RECEIPT_TOOLS | self.EXPORT_TOOLS and (
+            tool_name in (
+                'list_inbound_receipt_templates',
+                'export_inbound_receipt',
+            )
+        ):
+            allowed = self._inbound_receipt_message(message)
+            message = allowed or self.ERROR_MESSAGES.get(
+                code, self.ERROR_MESSAGES['MCP_9001']
+            )
         elif tool_name in self.FREIGHTOWER_TRACK_TOOLS:
             allowed = str(raw_message or '').strip()
             if allowed not in (
@@ -2368,6 +2449,49 @@ class OutputPresenter:
         retryable = code not in self.NON_RETRYABLE_CODES
         return self._error_result(code, message, retryable, meta)
 
+    @staticmethod
+    def _arrange_trailer_message(message):
+        if message in (
+            '请提供提单号或柜号',
+            '存在不能填写的内容',
+            '号码不正确',
+            '请填写拖车行',
+            '找不到可安排的柜子',
+            '匹配到不止一柜,请改说另一个号码',
+            '当前柜子不能安排拖车行',
+            '当前不需要填写海外仓',
+            '当前不需要填写直送改拆',
+            '当前不需要填写改拆原因',
+            '直送柜只能选择是或否',
+            '拖车行请说完整的名称-代码',
+            '拖车行不能填',
+            '港后跟进人不能填',
+            '预计提柜时间不正确',
+            '请选择直送改拆',
+            '直送改拆只能选择是或否',
+            '改拆原因不正确',
+            '请填写海外仓',
+            '海外仓不能填',
+            '请填写仓库联系人',
+            '请填写仓库联系电话',
+            '当前不需要填写重量体积',
+            '该柜子需要在页面上传BOL后再安排',
+            '重量体积不正确',
+            '订单不在这一柜',
+            '请填写重量体积',
+            '干线未出发不可安排拖车',
+            '当前柜子已安排拖车行',
+            '仓库不存在',
+            '仓库联系人不能为空',
+            '仓库联系电话不能为空',
+            '拖车行不存在',
+            '拖车行类型不是海外拖车',
+            '排舱单不存在',
+            '安排拖车行失败',
+        ):
+            return True
+        return message.startswith('请填写订单') and message.endswith('的重量和体积')
+
     def _find_field_label(self, tool_name, raw_message):
         message = str(raw_message or '')
         fields = self.FIELD_LABELS.get(tool_name, {})
@@ -2380,6 +2504,45 @@ class OutputPresenter:
     def _parameter_message(label):
         return '{0}参数不正确'.format(label) if label else '工具参数不正确,请检查后重试'
 
+    @classmethod
+    def _inbound_receipt_message(cls, message):
+        text = str(message or '').strip()
+        if text in (
+            '当前没有可用的入仓单模板',
+            '所选模板不属于这次柜号对应的入仓单',
+        ):
+            return text
+        missing_prefix = '这些柜号没有对应的排舱单:'
+        if text.startswith(missing_prefix) and cls._container_code_list(
+            text[len(missing_prefix):]
+        ):
+            return text
+        direct_prefix = '这些柜号是直送柜:'
+        indirect_mark = '。这些柜号是非直送柜:'
+        if text.startswith(direct_prefix) and text.endswith('。') and indirect_mark in text:
+            body = text[len(direct_prefix):-1]
+            direct, indirect = body.split(indirect_mark, 1)
+            if cls._container_code_list(direct) and cls._container_code_list(indirect):
+                return text
+        return ''
+
+    @staticmethod
+    def _container_code_list(text):
+        if not text or len(text) > 200 * 101:
+            return False
+        parts = text.split('、')
+        if not parts:
+            return False
+        for part in parts:
+            if (
+                not part
+                or part != part.strip()
+                or len(part) > 100
+                or any(ord(char) < 32 for char in part)
+            ):
+                return False
+        return True
+
     @staticmethod
     def _build_meta(raw_meta):
         if not isinstance(raw_meta, dict):

+ 187 - 0
tests/test_arrange_destination_trailer_tool.py

@@ -0,0 +1,187 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.arrange_destination_trailer import ArrangeDestinationTrailerTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, name, route_path, payload, request_id):
+        self.calls.append((name, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {'request_id': request_id}}
+
+
+class ArrangeDestinationTrailerToolTest(unittest.TestCase):
+    def test_metadata_rejects_unknown_fields(self):
+        schema = ArrangeDestinationTrailerTool().metadata()['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['trailer_provider'], schema['required'])
+        self.assertNotIn('outbound_id', schema['properties'])
+
+    def test_call_requires_a_number_and_posts_the_route(self):
+        client = RecordingApiClient()
+        tool = ArrangeDestinationTrailerTool(api_client=client)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A')
+        tool.call(trailer_provider='甲拖车-A', container_code='CONT1')
+        self.assertEqual('arrange_destination_trailer', client.calls[0][0])
+        self.assertEqual('/mcp/tools/arrangeDestinationTrailer', client.calls[0][1])
+        self.assertEqual(
+            {'trailer_provider': '甲拖车-A', 'container_code': 'CONT1'},
+            client.calls[0][2],
+        )
+
+    def test_presenter_returns_only_three_labels(self):
+        presenter = OutputPresenter()
+        result = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 'DET1',
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+            },
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual({
+            '拖车单号': 'DET1',
+            '提单号': 'BL1',
+            '柜号': 'CONT1',
+        }, result['structured_content'])
+        self.assertFalse(presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 'DET1',
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+                'providers_id': 1,
+            },
+            'meta': {'request_id': 'rq1'},
+        })['is_error'] is False)
+        self.assertTrue(presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_0000',
+            'data': {
+                'trailer_number': 1,
+                'bl_number': 'BL1',
+                'container_code': 'CONT1',
+            },
+            'meta': {'request_id': 'rq1'},
+        })['is_error'])
+
+    def test_call_rejects_bad_values_and_forwards_spoken_fields(self):
+        tool = ArrangeDestinationTrailerTool()
+        with self.assertRaises(RuntimeError):
+            tool.call(trailer_provider='甲拖车-A', container_code='CONT1')
+
+        client = RecordingApiClient()
+        tool = ArrangeDestinationTrailerTool(api_client=client)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='  ', container_code='CONT1')
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A', container_code=1)
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲拖车-A', bl_number='BL1', is_direct_send='未知')
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                est_pickup_time='2026-10-12',
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[{'weight': '1'}],
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures='1',
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[1],
+            )
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                order_measures=[{'order_number': 'SO1'}] * 501,
+            )
+        with self.assertRaises(ValueError):
+            tool.call(trailer_provider='甲' * 201, container_code='CONT1')
+        with self.assertRaises(ValueError):
+            tool.call(
+                trailer_provider='甲拖车-A',
+                bl_number='BL1',
+                direct_change_reason='其他',
+            )
+
+        tool.call(
+            trailer_provider=' 甲拖车-A ',
+            bl_number=' BL1 ',
+            container_code=' CONT1 ',
+            is_direct_send='否',
+            warehouse_code='LAX1',
+            warehouse_contact='张三',
+            warehouse_tel='10086',
+            follow_up_user='空',
+            est_pickup_time='2026-10-12 08:00:00',
+            is_direct_change='是',
+            direct_change_reason='直送爆仓被拒',
+            order_measures=[{
+                'order_number': 'SO1',
+                'weight': '1.5',
+                'volume': None,
+            }],
+            request_id='rq_case',
+        )
+        self.assertEqual({
+            'trailer_provider': '甲拖车-A',
+            'bl_number': 'BL1',
+            'container_code': 'CONT1',
+            'is_direct_send': '否',
+            'warehouse_code': 'LAX1',
+            'warehouse_contact': '张三',
+            'warehouse_tel': '10086',
+            'follow_up_user': '空',
+            'est_pickup_time': '2026-10-12 08:00:00',
+            'is_direct_change': '是',
+            'direct_change_reason': '直送爆仓被拒',
+            'order_measures': [{'order_number': 'SO1', 'weight': '1.5'}],
+        }, client.calls[-1][2])
+        self.assertEqual('rq_case', client.calls[-1][3])
+
+    def test_presenter_keeps_known_arrange_errors(self):
+        presenter = OutputPresenter()
+        result = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_1401',
+            'msg': '请填写订单O1的重量和体积',
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual('请填写订单O1的重量和体积', result['structured_content']['message'])
+        self.assertFalse(result['structured_content']['retryable'])
+        hidden = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_1401',
+            'msg': 'SQLSTATE[42S02]',
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual('工具参数不正确,请检查后重试', hidden['structured_content']['message'])
+
+    def test_registered_on_both_gateways(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('arrange_destination_trailer', local)
+        self.assertEqual(40, len(local))
+        self.assertIn('arrange_destination_trailer', OutputPresenter.SAFE_TOOLS)
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 2 - 2
tests/test_container_timeliness_tools.py

@@ -296,8 +296,8 @@ class ContainerTimelinessToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_container_timeliness_list', local)
         self.assertIn('export_container_timeliness_report', local)
 

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

+ 13 - 4
tests/test_destination_trailer_tools.py

@@ -21,8 +21,17 @@ PROGRESS_COLUMNS = [
     ('est_pickup_time', '预计提柜时间'), ('pickup_time', '实际提柜时间'),
     ('inbound_date', '到仓时间'), ('container_return_time', '还柜时间'),
     ('pickup_prescription', '提柜时效'), ('return_prescription', '还柜时效'),
-    ('wharf_wait_time', '码头等待时间'), ('amazon_wait_time', '亚马逊等待时间'),
+    ('wharf_wait_time', '码头等待时间'),     ('amazon_wait_time', '亚马逊等待时间'),
     ('bill_status', '应付费用状态'),
+    ('issue_way', '出单方式'),
+    ('release_status', '放货状态'),
+    ('freight_collect', '海运费到付'),
+    ('is_dg', 'DG'),
+    ('is_soc', 'SOC'),
+    ('is_oneway', 'ONEWAY'),
+    ('appointment_method', '预约方式'),
+    ('need_appointment_code', '是否需要预约码'),
+    ('warehouse_special_remark', '仓库特殊备注'),
 ]
 DO_COLUMNS = [
     ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
@@ -387,8 +396,8 @@ class DestinationTrailerToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_destination_trailer_list', local)
         self.assertIn('list_destination_trailer_filter_options', local)
 
@@ -439,7 +448,7 @@ class DestinationTrailerToolContractTest(unittest.TestCase):
             },
         })
         self.assertFalse(result['is_error'])
-        self.assertEqual(30, len(result['structured_content']['headers']))
+        self.assertEqual(39, len(result['structured_content']['headers']))
         self.assertEqual('待提柜', result['structured_content']['trailer_stage'])
 
         do_record = {key: 'd' for key, _ in DO_COLUMNS}

+ 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(36, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(39, 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(36, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 85 - 0
tests/test_inbound_receipt_tools.py

@@ -0,0 +1,85 @@
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_inbound_receipt import ExportInboundReceiptTool
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+
+
+class _Api:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_name, route_path, payload, request_id):
+        self.calls.append((tool_name, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class InboundReceiptToolTest(unittest.TestCase):
+    def test_local_and_public_registries_match(self):
+        local = set(GatewayApp(api_client=object()).registered_tool_names())
+        public = set(PublicGatewayApp(object(), object()).registered_tool_names())
+        self.assertEqual(local, public)
+        self.assertIn('list_inbound_receipt_templates', local)
+        self.assertIn('export_inbound_receipt', local)
+
+    def test_list_tool_strips_and_dedupes_container_codes(self):
+        api = _Api()
+        tool = ListInboundReceiptTemplatesTool(api)
+        tool.call([' CONT-1 ', 'CONT-1'], request_id='rq_list')
+        self.assertEqual(
+            ('list_inbound_receipt_templates', '/mcp/tools/listInboundReceiptTemplates',
+             {'container_codes': ['CONT-1']}, 'rq_list'),
+            api.calls[0]
+        )
+        self.assertIn('container_codes', tool.metadata()['input_schema']['required'])
+
+    def test_export_requires_selected_template_and_does_not_wait(self):
+        api = _Api()
+        tool = ExportInboundReceiptTool(api)
+        description = tool.metadata()['description']
+        self.assertIn('query_export_task', description)
+        self.assertIn('不会在本次调用中等待文件生成', description)
+        tool.call(['CONT-1'], ' FMS-INBOUND-LAX ', request_id='rq_export')
+        self.assertEqual('FMS-INBOUND-LAX', api.calls[0][2]['template_code'])
+        with self.assertRaises(ValueError):
+            tool.call(['CONT-1'], '   ')
+
+    def test_presenter_shows_kind_and_passes_container_rejection(self):
+        presenter = OutputPresenter()
+        shown = presenter.present('list_inbound_receipt_templates', {
+            'code': 'MCP_0000',
+            'data': {
+                'receipt_kind': '直送入仓单',
+                'templates': [
+                    {'name': '直送柜', 'template_code': 'FMS-INBOUND-ZSG'},
+                ],
+            },
+            'meta': {'request_id': 'rq_ok'},
+        })
+        self.assertFalse(shown['is_error'])
+        self.assertEqual('直送入仓单', shown['structured_content']['receipt_kind'])
+        self.assertNotIn('id', shown['structured_content']['rows'][0])
+
+        rejected = presenter.present('list_inbound_receipt_templates', {
+            'code': 'MCP_1601',
+            'msg': '这些柜号没有对应的排舱单:NONE-1',
+            'data': {},
+            'meta': {'request_id': 'rq_missing'},
+        })
+        self.assertTrue(rejected['is_error'])
+        self.assertIn('NONE-1', rejected['text'])
+
+        hidden = presenter.present('export_inbound_receipt', {
+            'code': 'MCP_1601',
+            'msg': 'SQLSTATE select * from fms_outbound',
+            'data': {},
+            'meta': {'request_id': 'rq_sql'},
+        })
+        self.assertNotIn('SQLSTATE', hidden['text'])
+        self.assertIn('没有可导出的数据', hidden['text'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, 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(36, len(local.registered_tool_names()))
+        self.assertEqual(40, len(local.registered_tool_names()))
         self.assertIn('query_order_receivable_cost_details', local.registered_tool_names())
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 

+ 1 - 1
tests/test_output_presenter.py

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

+ 2 - 2
tests/test_payable_cost_tools.py

@@ -231,8 +231,8 @@ class PayableCostToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, 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(36, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 2 - 2
tests/test_query_freightower_track_tool.py

@@ -125,8 +125,8 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
         self.assertIn('query_freightower_track', local)
-        self.assertEqual(35, len(local))
-        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
 
     def test_cli_forwards_bill_and_container(self):
         app = GatewayApp()

+ 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(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, 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(36, len(local))
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(40, len(local))
+        self.assertEqual(39, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_receive_volume_list', local)
         self.assertIn('list_receive_volume_filter_options', local)

+ 1 - 1
tests/test_trial_goods_tax_tool.py

@@ -56,7 +56,7 @@ class TrialGoodsTaxToolTest(unittest.TestCase):
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
         self.assertIn('trial_goods_tax', local)
-        self.assertEqual(36, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('trial_goods_tax', OutputPresenter.SAFE_TOOLS)
 
     def test_presenter_renders_amount(self):

+ 187 - 0
tools/arrange_destination_trailer.py

@@ -0,0 +1,187 @@
+class ArrangeDestinationTrailerTool:
+    name = 'arrange_destination_trailer'
+    route_path = '/mcp/tools/arrangeDestinationTrailer'
+    CHANGE_REASONS = (
+        '直送爆仓被拒',
+        '客户HOLD货',
+        '拿不到APPT',
+        '集装箱装箱不当,亚马逊无法卸货',
+        '货物不符合直送标准',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        text = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 200,
+            'pattern': '.*\\S.*',
+        }
+        number = dict(text, maxLength=100)
+        return {
+            'name': self.name,
+            'description': (
+                '首次安排海外拖车行,对齐后台待安排拖车页的「安排拖车行」。'
+                '只接受提单号或柜号,可以只给一个,也可以两个都给并且同时满足;'
+                '必须正好命中当前员工能看到的一柜。不得传内部编号,不得修改已有提柜单。'
+                '拖车行可以说「名称-代码」、名称或代码。海外仓只说仓库代码。'
+                '港后跟进人说员工姓名;不说则保持现在的人,说「空」才清空。'
+                '直送柜不说则保持现状,只能说是或否。'
+                '选否时要说海外仓;联系人和电话不说则用仓库主数据。'
+                '选是时不能再说海外仓、联系人或电话。'
+                '本来就是直送、预选海外仓有外部仓库代码时,仍要按订单给重量和体积;'
+                '重量体积不合法时整次拒绝,不会先生成拖车单。'
+                '本来是直送、这次改为否时,必须说直送改拆;选是还要说页面上的改拆原因原文。'
+                '预计提柜时间可选,格式 YYYY-MM-DD HH:MM:SS,按目的港当地时间。'
+                '需要上传 BOL 的柜子不要调用,请到页面安排。'
+                '有外部仓库代码但不要 BOL 时,可以按订单号改重量或体积。'
+                '不该出现的字段不要传。失败时不会返回可选列表。'
+                '成功只返回拖车单号、提单号、柜号。'
+                '参数名仅用于工具调用;向用户回答时只用中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'bl_number': dict(number, description='提单号。与柜号至少给一个。'),
+                    'container_code': dict(number, description='柜号。与提单号至少给一个。'),
+                    'trailer_provider': dict(
+                        text,
+                        description='拖车行。可说名称-代码、名称或代码,必须正好命中一家。',
+                    ),
+                    'is_direct_send': {
+                        'type': 'string',
+                        'enum': ['是', '否'],
+                        'description': '直送柜。不传则保持这一柜现在的值。',
+                    },
+                    'warehouse_code': dict(number, description='海外仓仓库代码。直送为否时必填。'),
+                    'warehouse_contact': dict(text, description='仓库联系人。不传则用海外仓主数据。'),
+                    'warehouse_tel': dict(text, description='仓库联系电话。不传则用海外仓主数据。'),
+                    'follow_up_user': dict(
+                        text,
+                        description='港后跟进人姓名。说「空」表示清空。不传则保持现在的人。',
+                    ),
+                    'est_pickup_time': {
+                        'type': 'string',
+                        'pattern': '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$',
+                        'description': '预计提柜时间,目的港当地时间。',
+                    },
+                    'is_direct_change': {
+                        'type': 'string',
+                        'enum': ['是', '否'],
+                        'description': '直送改拆。仅本来是直送、这次改为否时填写。',
+                    },
+                    'direct_change_reason': {
+                        'type': 'string',
+                        'enum': list(self.CHANGE_REASONS),
+                        'description': '改拆原因,必须是页面上的整句原文。',
+                    },
+                    'order_measures': {
+                        'type': 'array',
+                        'maxItems': 500,
+                        'items': {
+                            'type': 'object',
+                            'properties': {
+                                'order_number': dict(number, description='这一柜上的订单号。'),
+                                'weight': {
+                                    'type': 'string',
+                                    'minLength': 1,
+                                    'maxLength': 20,
+                                    'description': '重量,千克,大于 0 小于 50000,最多三位小数。',
+                                },
+                                'volume': {
+                                    'type': 'string',
+                                    'minLength': 1,
+                                    'maxLength': 20,
+                                    'description': '体积,CBM,大于 0 小于 10000,最多三位小数。',
+                                },
+                            },
+                            'required': ['order_number'],
+                            'additionalProperties': False,
+                        },
+                    },
+                },
+                'required': ['trailer_provider'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        trailer_provider,
+        bl_number=None,
+        container_code=None,
+        is_direct_send=None,
+        warehouse_code=None,
+        warehouse_contact=None,
+        warehouse_tel=None,
+        follow_up_user=None,
+        est_pickup_time=None,
+        is_direct_change=None,
+        direct_change_reason=None,
+        order_measures=None,
+        request_id='rq_arrange_destination_trailer',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for arrange_destination_trailer')
+        payload = {
+            'trailer_provider': self._text(trailer_provider, 'trailer_provider', 200),
+        }
+        if bl_number is not None:
+            payload['bl_number'] = self._text(bl_number, 'bl_number', 100)
+        if container_code is not None:
+            payload['container_code'] = self._text(container_code, 'container_code', 100)
+        if bl_number is None and container_code is None:
+            raise ValueError('bl number or container code is required')
+        self._choice(payload, 'is_direct_send', is_direct_send, ('是', '否'))
+        self._optional(payload, 'warehouse_code', warehouse_code, 100)
+        self._optional(payload, 'warehouse_contact', warehouse_contact, 200)
+        self._optional(payload, 'warehouse_tel', warehouse_tel, 200)
+        self._optional(payload, 'follow_up_user', follow_up_user, 200)
+        if est_pickup_time is not None:
+            text = self._text(est_pickup_time, 'est_pickup_time', 19)
+            if len(text) != 19:
+                raise ValueError('est_pickup_time is invalid')
+            payload['est_pickup_time'] = text
+        self._choice(payload, 'is_direct_change', is_direct_change, ('是', '否'))
+        self._choice(payload, 'direct_change_reason', direct_change_reason, self.CHANGE_REASONS)
+        if order_measures is not None:
+            payload['order_measures'] = self._measures(order_measures)
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+    def _optional(self, payload, field, value, limit):
+        if value is not None:
+            payload[field] = self._text(value, field, limit)
+
+    def _choice(self, payload, field, value, allowed):
+        if value is None:
+            return
+        if value not in allowed:
+            raise ValueError(field + ' is invalid')
+        payload[field] = value
+
+    @staticmethod
+    def _text(value, field, limit):
+        if not isinstance(value, str):
+            raise ValueError(field + ' is invalid')
+        text = value.strip()
+        if not text or len(text) > limit:
+            raise ValueError(field + ' is invalid')
+        return text
+
+    def _measures(self, rows):
+        if not isinstance(rows, list) or len(rows) > 500:
+            raise ValueError('order_measures is invalid')
+        result = []
+        for row in rows:
+            if not isinstance(row, dict) or 'order_number' not in row:
+                raise ValueError('order_measures is invalid')
+            item = {'order_number': self._text(row['order_number'], 'order_number', 100)}
+            for key in ('weight', 'volume'):
+                if key in row and row[key] is not None:
+                    item[key] = self._text(row[key], key, 20)
+            result.append(item)
+        return result

+ 60 - 0
tools/export_inbound_receipt.py

@@ -0,0 +1,60 @@
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+
+
+class ExportInboundReceiptTool:
+    name = 'export_inbound_receipt'
+    route_path = '/mcp/tools/exportInboundReceipt'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成,也禁止在一次调用内'
+                '轮询任务状态。成功后返回任务引用(task_ref)和建议等待时间'
+                '(retry_after_seconds)。稍后单独使用 query_export_task 查询任务状态或下载链接。'
+                '使用场景:用户已经从 list_inbound_receipt_templates 的结果里选定一张入仓单模板,'
+                '并明确要求导出文件。必须传同一批柜号和用户选中的模板编码。'
+                '不得改用直送或非直送以外的模板,不得在没有查到模板时自行挑选编码。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'container_codes': ListInboundReceiptTemplatesTool.number_array(
+                        '与查模板时相同的柜号数组。'
+                    ),
+                    'template_code': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '用户从查模板结果里选定的模板编码。',
+                    },
+                },
+                'required': ['container_codes', 'template_code'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        container_codes=None,
+        template_code=None,
+        request_id='rq_export_inbound_receipt',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for export_inbound_receipt')
+        code = template_code.strip() if isinstance(template_code, str) else ''
+        if not code or len(code) > 100:
+            raise ValueError('template_code is required')
+        payload = {
+            'container_codes': ListInboundReceiptTemplatesTool.clean_container_codes(
+                container_codes
+            ),
+            'template_code': code,
+        }
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )

+ 74 - 0
tools/list_inbound_receipt_templates.py

@@ -0,0 +1,74 @@
+class ListInboundReceiptTemplatesTool:
+    name = 'list_inbound_receipt_templates'
+    route_path = '/mcp/tools/listInboundReceiptTemplates'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '按柜号返回这一次对应的入仓单模板。不要让用户声明直送或非直送,'
+                '系统会根据柜号自己判断。用户选定模板名称后,把同一批柜号和返回的模板编码'
+                '交给 export_inbound_receipt。没有柜号时先询问柜号,不得猜测。'
+                '直送和非直送混在一起,或有柜号对不上时,整次失败,按返回说明拆开再查。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'container_codes': self.number_array(
+                        '柜号数组。只放用户明确说出的柜号。'
+                    ),
+                },
+                'required': ['container_codes'],
+                'additionalProperties': False,
+            },
+        }
+
+    @staticmethod
+    def number_array(description):
+        return {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'description': description,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+
+    @staticmethod
+    def clean_container_codes(container_codes):
+        if not isinstance(container_codes, list) or not container_codes:
+            raise ValueError('container_codes is required')
+        if len(container_codes) > 200:
+            raise ValueError('container_codes exceeds 200 items')
+        cleaned = []
+        for item in container_codes:
+            if not isinstance(item, str):
+                raise ValueError('container code must be a string')
+            text = item.strip()
+            if not text or len(text) > 100:
+                raise ValueError('container code length is invalid')
+            if text not in cleaned:
+                cleaned.append(text)
+        if not cleaned or len(cleaned) > 200:
+            raise ValueError('container_codes is required')
+        return cleaned
+
+    def call(self, container_codes=None, request_id='rq_list_inbound_receipt_templates'):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_inbound_receipt_templates'
+            )
+        payload = {
+            'container_codes': self.clean_container_codes(container_codes),
+        }
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )