jackson преди 2 седмици
родител
ревизия
93e0d05b62

Файловите разлики са ограничени, защото са твърде много
+ 25 - 1
CONTEXT.md


+ 4 - 2
README.md

@@ -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`)
 
@@ -102,7 +104,7 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
-- 六个导出提交工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。
+- 导出提交工具只返回 `task_ref + queued + retry_after_seconds`。客户端稍后在新的调用中使用 `query_export_task`;完成后才返回 `files[].label + files[].url`。Gateway 不在一次调用内等待或循环轮询。`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` 和设备失效文案。

+ 28 - 0
app.py

@@ -71,6 +71,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
 
 
@@ -177,6 +179,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 +338,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 +882,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:** 照抄海外提柜页,非直送柜也只给直送模板(和页面按钮一致,但和「非直送入仓单用海外仓模板」相反);让调用方自己声明直送或非直送(实现简单,但用户要求只凭柜号判断)。

+ 8 - 0
public_gateway.py

@@ -57,6 +57,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
 
@@ -144,6 +146,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),
         }
 

+ 86 - 0
services/output_presenter.py

@@ -39,6 +39,7 @@ class OutputPresenter:
     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 +58,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 +72,7 @@ class OutputPresenter:
         | GOODS_INFO_TOOLS
         | GOODS_TAX_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
+        | INBOUND_RECEIPT_TOOLS
         | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
@@ -953,6 +956,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:
@@ -2224,6 +2229,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:
@@ -2359,6 +2396,16 @@ class OutputPresenter:
         if 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 (
@@ -2389,6 +2436,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):

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

+ 2 - 2
tests/test_destination_trailer_tools.py

@@ -396,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(39, len(local))
+        self.assertEqual(38, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_destination_trailer_list', local)
         self.assertIn('list_destination_trailer_filter_options', local)
 

+ 2 - 2
tests/test_export_pallet_data_tool.py

@@ -296,9 +296,9 @@ class ExportPalletDataToolTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(36, len(local))
+        self.assertEqual(39, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(38, 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(39, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(38, 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(39, len(local))
+        self.assertEqual(38, 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(39, 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(38, 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(39, len(local))
+        self.assertEqual(38, 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(39, 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

@@ -96,8 +96,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(39, len(local))
+        self.assertEqual(38, 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(39, len(local))
+        self.assertEqual(38, 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(39, len(local))
+        self.assertEqual(38, 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(39, len(local))
         self.assertIn('trial_goods_tax', OutputPresenter.SAFE_TOOLS)
 
     def test_presenter_renders_amount(self):

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