소스 검색

Merge branch 'master' into uat

jackson 1 주 전
부모
커밋
2e96062c4a

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 25 - 1
CONTEXT.md


+ 7 - 5
README.md

@@ -31,7 +31,7 @@ Gateway 保持“薄网关”边界:
 |---|---|---|---|
 | `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
 | `query_track` | 按订单号/排舱单号或跟踪号/柜号/快递单号查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
-| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期和已发生节点 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、轨迹 |
+| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期、港口、箱子、轨迹和港区计划 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、箱子、轨迹、港区计划 |
 | `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
 | `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
 | `query_customer_list` | 查询当前员工有权查看的启用主客户 | `/mcp/tools/queryCustomerList` | 固定 17 列安全表格 |
@@ -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` | 安全筛选项 |
@@ -59,6 +59,8 @@ Gateway 保持“薄网关”边界:
 | `prepare_headhaul_document_upload` | 签发一次性上传槽位,不接收文件字节 | `/mcp/tools/prepareHeadhaulDocumentUpload` | 上传令牌与地址 |
 | `save_headhaul_document` | 按 ticket_ref 或外链保存头程文档 | `/mcp/tools/saveHeadhaulDocument` | 文档身份 |
 | `delete_headhaul_document` | 按 document_ref 删除一份头程文档 | `/mcp/tools/deleteHeadhaulDocument` | 固定已删除 |
+| `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` | 安全筛选项 |
@@ -74,7 +76,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`)
 
@@ -105,11 +107,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,
 )
@@ -80,6 +81,8 @@ from tools.prepare_headhaul_document_upload import (
 )
 from tools.save_headhaul_document import SaveHeadhaulDocumentTool
 from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.list_inbound_receipt_templates import ListInboundReceiptTemplatesTool
+from tools.export_inbound_receipt import ExportInboundReceiptTool
 from tools.query_freightower_track import QueryFreightowerTrackTool
 
 
@@ -169,6 +172,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(
@@ -201,6 +207,12 @@ class GatewayApp:
             'delete_headhaul_document': DeleteHeadhaulDocumentTool(
                 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
             ),
@@ -371,6 +383,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='')
@@ -914,6 +927,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,
 )
@@ -66,6 +67,8 @@ from tools.prepare_headhaul_document_upload import (
 )
 from tools.save_headhaul_document import SaveHeadhaulDocumentTool
 from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+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
 
@@ -134,6 +137,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(
@@ -166,6 +172,12 @@ class PublicGatewayApp:
             'delete_headhaul_document': DeleteHeadhaulDocumentTool(
                 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),
         }
         from services.headhaul_document_app import HeadhaulSlotMemory

+ 280 - 83
services/output_presenter.py

@@ -34,6 +34,7 @@ class OutputPresenter:
     PAYABLE_FILTER_TOOLS = frozenset(('list_payable_cost_filter_options',))
     PAYABLE_EXPORT_TOOLS = frozenset(('export_payable_cost_list',))
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
+    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',))
@@ -43,6 +44,7 @@ class OutputPresenter:
     HEADHAUL_PREPARE_TOOLS = frozenset(('prepare_headhaul_document_upload',))
     HEADHAUL_SAVE_TOOLS = frozenset(('save_headhaul_document',))
     HEADHAUL_DELETE_TOOLS = frozenset(('delete_headhaul_document',))
+    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',))
@@ -62,6 +64,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 = (
@@ -70,6 +73,7 @@ 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
@@ -79,6 +83,7 @@ class OutputPresenter:
         | HEADHAUL_PREPARE_TOOLS
         | HEADHAUL_SAVE_TOOLS
         | HEADHAUL_DELETE_TOOLS
+        | INBOUND_RECEIPT_TOOLS
         | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
@@ -557,6 +562,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', '出库单号'),
@@ -979,6 +993,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
@@ -1007,6 +1023,8 @@ class OutputPresenter:
             return self._present_headhaul_save(data, meta)
         if tool_name in self.HEADHAUL_DELETE_TOOLS:
             return self._present_headhaul_delete(data, 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:
@@ -1784,6 +1802,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)
@@ -2385,6 +2420,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:
@@ -2517,9 +2584,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 (
@@ -2538,6 +2619,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, {})
@@ -2550,6 +2674,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):
@@ -2616,98 +2779,132 @@ class OutputPresenter:
             )
         return '\n'.join(lines)
 
-        return self._success_result(content, '\n'.join(lines), meta)
-
     def _present_freightower_track(self, data, meta):
-        if set(data) != {'schedule', 'ports', 'tracks'}:
+        expected = {'schedule', 'ports', 'containers', 'tracks', 'terminal_plan'}
+        if set(data) != expected:
             return self._format_error(meta)
-        schedule = data.get('schedule')
-        ports = data.get('ports')
-        tracks = data.get('tracks')
-        schedule_keys = (
-            'bl_number', 'container_code', 'vessel', 'voyage', 'etd', 'eta',
-        )
-        if (
-            not isinstance(schedule, dict)
-            or set(schedule) != set(schedule_keys)
-            or not isinstance(ports, list)
-            or not isinstance(tracks, list)
-        ):
+        schedule = self._freightower_object(data.get('schedule'), (
+            ('bl_number', '提单号'),
+            ('container_code', '柜号'),
+            ('carrier_name', '船公司'),
+            ('vessel', '船名'),
+            ('voyage', '航次'),
+            ('route_code', '航线'),
+            ('current_status', '当前状态'),
+            ('current_status_time', '当前状态时间'),
+            ('current_status_place', '当前状态地点'),
+            ('update_time', '数据更新时间'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('eta', '预计到达'),
+        ))
+        if schedule is None:
+            return self._format_error(meta)
+        ports = self._freightower_rows(data.get('ports'), (
+            ('place_type', '地点类型'),
+            ('port_name', '港口'),
+            ('terminal_name', '码头'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('eta', '预计到达'),
+            ('ata', '实际到达'),
+        ), {'place_type': ('起运港', '中转港', '目的港', '交货地')})
+        containers = self._freightower_rows(data.get('containers'), (
+            ('container_no', '箱号'),
+            ('container_type', '箱型'),
+            ('seal_no', '铅封号'),
+            ('current_status', '当前状态'),
+            ('off_load', '是否甩柜'),
+        ), {'off_load': ('', '是', '否')})
+        tracks = self._freightower_rows(data.get('tracks'), (
+            ('time', '时间'),
+            ('location', '地点'),
+            ('content', '内容'),
+            ('event_code', '状态代码'),
+            ('estimate_flag', '实际/预计'),
+            ('vessel_voyage', '船名航次'),
+            ('terminal_name', '码头'),
+        ), {'estimate_flag': ('实际', '预计')})
+        terminal_plan = self._freightower_object(data.get('terminal_plan'), (
+            ('port_name', '港口'),
+            ('terminal_name', '码头'),
+            ('open_time', '开港'),
+            ('close_time', '截港'),
+            ('etd', '预计离港'),
+            ('atd', '实际离港'),
+            ('shipping_agent', '船代'),
+        ))
+        if None in (ports, containers, tracks, terminal_plan):
             return self._format_error(meta)
-        labels = {
-            'bl_number': '提单号',
-            'container_code': '柜号',
-            'vessel': '船名',
-            'voyage': '航次',
-            'etd': '预计离港',
-            'eta': '预计到达',
-        }
-        safe_schedule = {}
-        for key in schedule_keys:
-            value = schedule.get(key)
-            if not isinstance(value, str):
-                return self._format_error(meta)
-            safe_schedule[labels[key]] = value
-        safe_ports = []
-        for port in ports:
-            if not isinstance(port, dict) or set(port) != {
-                'place_type', 'port_name', 'eta', 'ata',
-            }:
-                return self._format_error(meta)
-            if port['place_type'] not in ('中转港', '目的港', '交货地'):
-                return self._format_error(meta)
-            for key in ('port_name', 'eta', 'ata'):
-                if not isinstance(port[key], str):
-                    return self._format_error(meta)
-            safe_ports.append({
-                '地点类型': port['place_type'],
-                '港口': port['port_name'],
-                '预计到达': port['eta'],
-                '实际到达': port['ata'],
-            })
-        safe_tracks = []
-        for track in tracks:
-            if not isinstance(track, dict) or set(track) != {
-                'time', 'location', 'content', 'event_code',
-            }:
-                return self._format_error(meta)
-            for key in ('time', 'location', 'content', 'event_code'):
-                if not isinstance(track[key], str):
-                    return self._format_error(meta)
-            safe_tracks.append({
-                '时间': track['time'],
-                '地点': track['location'],
-                '内容': track['content'],
-                '状态代码': track['event_code'],
-            })
         content = {
-            '船期': safe_schedule,
-            '港口': safe_ports,
-            '轨迹': safe_tracks,
+            '船期': schedule,
+            '港口': ports,
+            '箱子': containers,
+            '轨迹': tracks,
+            '港区计划': terminal_plan,
         }
         lines = ['飞驼船期']
-        for label, value in safe_schedule.items():
+        for label, value in schedule.items():
             lines.append('- {0}: {1}'.format(label, value))
-        lines.append('港口')
-        if not safe_ports:
-            lines.append('- 无')
-        for port in safe_ports:
-            lines.append(
-                '- {0} {1} 预计到达 {2} 实际到达 {3}'.format(
-                    port['地点类型'], port['港口'], port['预计到达'], port['实际到达'],
-                )
-            )
-        lines.append('轨迹')
-        if not safe_tracks:
+        lines.extend(self._freightower_section('港口', ports, (
+            '地点类型', '港口', '码头', '预计离港', '实际离港', '预计到达', '实际到达',
+        )))
+        lines.extend(self._freightower_section('箱子', containers, (
+            '箱号', '箱型', '铅封号', '当前状态', '是否甩柜',
+        )))
+        lines.extend(self._freightower_section('轨迹', tracks, (
+            '时间', '地点', '内容', '状态代码', '实际/预计', '船名航次', '码头',
+        )))
+        if any(terminal_plan.values()):
+            lines.append('港区计划')
+            for label, value in terminal_plan.items():
+                lines.append('- {0}: {1}'.format(label, value))
+        else:
+            lines.append('港区计划')
             lines.append('- 无')
-        for track in safe_tracks:
-            lines.append(
-                '- {0} {1} {2} {3}'.format(
-                    track['时间'], track['地点'], track['内容'], track['状态代码'],
-                )
-            )
         return self._success_result(content, '\n'.join(lines), meta)
 
+    def _freightower_object(self, value, fields):
+        if not isinstance(value, dict) or set(value) != {key for key, _label in fields}:
+            return None
+        result = {}
+        for key, label in fields:
+            item = value.get(key)
+            if not isinstance(item, str):
+                return None
+            result[label] = item
+        return result
+
+    def _freightower_rows(self, rows, fields, choices):
+        if not isinstance(rows, list):
+            return None
+        keys = {key for key, _label in fields}
+        result = []
+        for row in rows:
+            if not isinstance(row, dict) or set(row) != keys:
+                return None
+            item = {}
+            for key, label in fields:
+                cell = row.get(key)
+                if not isinstance(cell, str):
+                    return None
+                allowed = choices.get(key)
+                if allowed is not None and cell not in allowed:
+                    return None
+                item[label] = cell
+            result.append(item)
+        return result
+
+    @staticmethod
+    def _freightower_section(title, rows, labels):
+        lines = [title]
+        if not rows:
+            lines.append('- 无')
+            return lines
+        for row in rows:
+            lines.append('- ' + ' '.join(row[label] for label in labels))
+        return lines
+
     @staticmethod
     def _success_result(content, text, meta):
         return {

+ 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()

+ 11 - 2
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', '出库单号'),
@@ -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}

+ 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()

+ 36 - 1
tests/test_query_freightower_track_tool.py

@@ -22,23 +22,52 @@ def sample_data():
         'schedule': {
             'bl_number': 'BL1',
             'container_code': 'BOX1',
+            'carrier_name': '马士基',
             'vessel': 'VESSEL',
             'voyage': '001E',
+            'route_code': 'CVT',
+            'current_status': '抵港',
+            'current_status_time': '2026-09-20 01:00:00',
+            'current_status_place': '洛杉矶',
+            'update_time': '2026-09-20 02:00:00',
             'etd': '2026-09-01 00:00:00',
+            'atd': '2026-09-02 00:00:00',
             'eta': '2026-09-20 00:00:00',
         },
         'ports': [{
             'place_type': '目的港',
             'port_name': '洛杉矶',
+            'terminal_name': 'TCCL',
+            'etd': '',
+            'atd': '',
             'eta': '2026-09-20',
             'ata': '',
         }],
+        'containers': [{
+            'container_no': 'BOX1',
+            'container_type': '40HC',
+            'seal_no': 'SEAL1',
+            'current_status': '抵港',
+            'off_load': '否',
+        }],
         'tracks': [{
             'time': '2026-09-20 01:00:00',
             'location': 'LOS ANGELES',
             'content': '抵港',
             'event_code': 'BDAR',
+            'estimate_flag': '实际',
+            'vessel_voyage': 'VESSEL 001E',
+            'terminal_name': 'TCCL',
         }],
+        'terminal_plan': {
+            'port_name': '上海',
+            'terminal_name': '外高桥',
+            'open_time': '2026-08-28 00:00:00',
+            'close_time': '2026-09-01 03:00:00',
+            'etd': '2026-09-01 00:00:00',
+            'atd': '',
+            'shipping_agent': '中外运',
+        },
     }
 
 
@@ -133,6 +162,8 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         self.assertFalse(result['is_error'])
         self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
         self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
+        self.assertEqual('马士基', result['structured_content']['船期']['船公司'])
+        self.assertEqual('中外运', result['structured_content']['港区计划']['船代'])
         self.assertIn('抵港', result['text'])
         self.assertNotIn('bl_number', result['text'])
         self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
@@ -140,14 +171,18 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
     def test_presenter_accepts_empty_ports_and_tracks(self):
         data = sample_data()
         data['ports'] = []
+        data['containers'] = []
         data['tracks'] = []
+        data['terminal_plan'] = {key: '' for key in data['terminal_plan']}
         result = OutputPresenter().present('query_freightower_track', {
             'code': 'MCP_0000',
             'data': data,
         })
         self.assertFalse(result['is_error'])
         self.assertIn('港口\n- 无', result['text'])
+        self.assertIn('箱子\n- 无', result['text'])
         self.assertIn('轨迹\n- 无', result['text'])
+        self.assertIn('港区计划\n- 无', result['text'])
 
     def test_presenter_rejects_unknown_shape(self):
         cases = []
@@ -155,7 +190,7 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         extra['schedule']['secret'] = 'x'
         cases.append(extra)
         bad_type = sample_data()
-        bad_type['ports'][0]['place_type'] = '起运港'
+        bad_type['ports'][0]['place_type'] = '接货地'
         cases.append(bad_type)
         bad_value = sample_data()
         bad_value['tracks'][0]['time'] = 1

+ 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,
+        )

+ 3 - 2
tools/query_freightower_track.py

@@ -15,8 +15,9 @@ class QueryFreightowerTrackTool:
             'name': self.name,
             'description': (
                 '使用场景:用户明确要查看飞驼现场船期和集装箱节点时使用,'
-                '返回船名、航次、预计离港、预计到达、中转港/目的港/交货地,'
-                '以及已经发生的轨迹节点。提单号、柜号至少提供一个;'
+                '返回船公司、船名航次、航线、当前状态、预计和实际离到港、'
+                '起运港到交货地、箱型铅封、实际和预计轨迹,以及港区开截港。'
+                '提单号、柜号至少提供一个;'
                 '号码类型不明确时必须先询问用户是提单号还是柜号,确认前不得调用。'
                 '不得根据号码格式猜测,不得跨字段或跨工具试查。'
                 '禁止使用:系统里已经保存的轨迹用 query_track;'