ソースを参照

task_7130794664

jackson 2 週間 前
親
コミット
fcec9619f3

+ 4 - 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,
 )
@@ -160,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(

+ 4 - 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,
 )
@@ -127,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(

+ 69 - 1
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',))
@@ -67,6 +68,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
@@ -938,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
@@ -1735,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)
@@ -2393,7 +2414,11 @@ 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 (
@@ -2424,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, {})

+ 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(39, len(local))
-        self.assertEqual(38, 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(39, 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(39, 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(39, 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(39, 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()

+ 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(39, len(local))
-        self.assertEqual(38, 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)
 

+ 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(39, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(38, 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(39, len(local))
+        self.assertEqual(40, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(38, 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_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(39, len(local))
-        self.assertEqual(38, 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(39, 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(38, 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(39, len(local))
-        self.assertEqual(38, 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(39, 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

@@ -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(39, len(local))
-        self.assertEqual(38, 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(39, len(local))
-        self.assertEqual(38, 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(39, len(local))
-        self.assertEqual(38, 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(39, 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