ソースを参照

task-7137997996:燃油系数维护MCP(地址库、燃油系数)

chenxj 5 日 前
親
コミット
09cb68de2f

+ 1 - 1
.env.example

@@ -2,7 +2,7 @@
 # FMS_API_BASE=http://chenjiacheng.base.dahuo.fudingri.com
 
 # 当前本地开发推荐:同一个 IP,不同域名由 hosts + Web 服务器 Host 分发到不同项目。
-# base 项目:负责 auth_code / mcp_token 授权换票
+# base 项目:负责 auth_code / mcp_token 授权换票 / 基础配置
 FMS_AUTH_BASE=http://chenjiacheng.base.dahuo.fudingri.com
 
 # fmsoperate 项目:负责 query_order 等工具接口

+ 35 - 0
app.py

@@ -84,6 +84,12 @@ 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 tools.query_remote_address_list import QueryRemoteAddressListTool
+from tools.maintain_remote_address import MaintainRemoteAddressTool
+from tools.save_remote_address import SaveRemoteAddressTool
+from tools.query_fuel_rate_list import QueryFuelRateListTool
+from tools.save_fuel_rate import SaveFuelRateTool
+from tools.enable_fuel_rate import EnableFuelRateTool
 
 
 def parse_int_list(value):
@@ -108,6 +114,7 @@ class GatewayApp:
         api_client=None,
         token_store=None,
         reporter=None,
+        base_api_client=None,
     ):
         self.auth_client = auth_client
         self.api_client = api_client
@@ -216,6 +223,24 @@ class GatewayApp:
             'query_freightower_track': QueryFreightowerTrackTool(
                 api_client=api_client
             ),
+            'query_remote_address_list': QueryRemoteAddressListTool(
+                api_client=base_api_client or api_client
+            ),
+            'maintain_remote_address': MaintainRemoteAddressTool(
+                api_client=base_api_client or api_client
+            ),
+            'save_remote_address': SaveRemoteAddressTool(
+                api_client=base_api_client or api_client
+            ),
+            'query_fuel_rate_list': QueryFuelRateListTool(
+                api_client=base_api_client or api_client
+            ),
+            'save_fuel_rate': SaveFuelRateTool(
+                api_client=base_api_client or api_client
+            ),
+            'enable_fuel_rate': EnableFuelRateTool(
+                api_client=base_api_client or api_client
+            ),
         }
 
     @classmethod
@@ -254,11 +279,17 @@ class GatewayApp:
             token_store=token_store,
             timeout=config.timeout_seconds,
         )
+        base_api_client = ApiClient(
+            base_url=config.auth_base_url,
+            token_store=token_store,
+            timeout=config.timeout_seconds,
+        )
         return cls(
             auth_client=auth_client,
             api_client=api_client,
             token_store=token_store,
             reporter=diagnostic_reporter_from_config(config),
+            base_api_client=base_api_client,
         )
 
     def registered_tool_names(self):
@@ -493,6 +524,10 @@ class GatewayApp:
             public_app = PublicGatewayApp(
                 session_store=session_store,
                 api_client=ScopedApiClient(config.tools_base_url, timeout=config.timeout_seconds),
+                base_api_client=ScopedApiClient(
+                    config.auth_base_url,
+                    timeout=config.timeout_seconds,
+                ),
             )
             try:
                 return serve_public(

+ 20 - 2
public_gateway.py

@@ -70,6 +70,12 @@ 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 tools.query_remote_address_list import QueryRemoteAddressListTool
+from tools.maintain_remote_address import MaintainRemoteAddressTool
+from tools.save_remote_address import SaveRemoteAddressTool
+from tools.query_fuel_rate_list import QueryFuelRateListTool
+from tools.save_fuel_rate import SaveFuelRateTool
+from tools.enable_fuel_rate import EnableFuelRateTool
 from utils.security import hash_gateway_session_id
 
 
@@ -77,9 +83,10 @@ logger = logging.getLogger(__name__)
 
 
 class PublicGatewayApp:
-    def __init__(self, session_store, api_client, auth_client=None):
+    def __init__(self, session_store, api_client, auth_client=None, base_api_client=None):
         self.session_store = session_store
         self.api_client = api_client
+        self.base_api_client = base_api_client
         self._tools = {
             'query_order': QueryOrderTool(api_client=None),
             'query_track': QueryTrackTool(api_client=None),
@@ -179,6 +186,12 @@ class PublicGatewayApp:
                 api_client=None
             ),
             'query_freightower_track': QueryFreightowerTrackTool(api_client=None),
+            'query_remote_address_list': QueryRemoteAddressListTool(api_client=None),
+            'maintain_remote_address': MaintainRemoteAddressTool(api_client=None),
+            'save_remote_address': SaveRemoteAddressTool(api_client=None),
+            'query_fuel_rate_list': QueryFuelRateListTool(api_client=None),
+            'save_fuel_rate': SaveFuelRateTool(api_client=None),
+            'enable_fuel_rate': EnableFuelRateTool(api_client=None),
         }
         from services.headhaul_document_app import HeadhaulSlotMemory
         self._headhaul_slots = HeadhaulSlotMemory()
@@ -212,6 +225,11 @@ class PublicGatewayApp:
             )
         return session
 
+    def _tool_client(self, tool):
+        if getattr(tool, 'backend', '') == 'base' and self.base_api_client is not None:
+            return self.base_api_client
+        return self.api_client
+
     def _enabled_tool_names(self, response):
         if not isinstance(response, dict):
             raise RuntimeError('invalid enabled tool response')
@@ -369,7 +387,7 @@ class PublicGatewayApp:
                     status='started',
                     event_code='BACKEND_CALL_STARTED',
                 )
-            result = self.api_client.call_tool(
+            result = self._tool_client(tool).call_tool(
                 token=session['mcp_token'],
                 tool_code=tool.name,
                 route_path=tool.route_path,

+ 257 - 0
services/output_presenter.py

@@ -17,6 +17,13 @@ class OutputPresenter:
         'query_customs_declaration_files',
         'query_outbound_list',
         'query_customer_list',
+        'query_remote_address_list',
+        'query_fuel_rate_list',
+        'save_fuel_rate',
+        'enable_fuel_rate',
+    ))
+    FUEL_RATE_TOOLS = frozenset((
+        'query_fuel_rate_list', 'save_fuel_rate', 'enable_fuel_rate',
     ))
     PAYMENT_FOLLOWUP_TOOLS = frozenset(('query_customer_payment_followup',))
     PAYMENT_DETAIL_TOOLS = frozenset((
@@ -41,6 +48,8 @@ class OutputPresenter:
     GOODS_TAX_TOOLS = frozenset(('trial_goods_tax',))
     CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
     HEADHAUL_DOCUMENT_TOOLS = frozenset(('query_headhaul_document_list',))
+    REMOTE_ADDRESS_MAINTAIN_TOOLS = frozenset(('maintain_remote_address',))
+    REMOTE_ADDRESS_SAVE_TOOLS = frozenset(('save_remote_address',))
     HEADHAUL_PREPARE_TOOLS = frozenset(('prepare_headhaul_document_upload',))
     HEADHAUL_SAVE_TOOLS = frozenset(('save_headhaul_document',))
     HEADHAUL_DELETE_TOOLS = frozenset(('delete_headhaul_document',))
@@ -80,6 +89,8 @@ class OutputPresenter:
         | GOODS_TAX_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
         | HEADHAUL_DOCUMENT_TOOLS
+        | REMOTE_ADDRESS_MAINTAIN_TOOLS
+        | REMOTE_ADDRESS_SAVE_TOOLS
         | HEADHAUL_PREPARE_TOOLS
         | HEADHAUL_SAVE_TOOLS
         | HEADHAUL_DELETE_TOOLS
@@ -389,6 +400,44 @@ class OutputPresenter:
             'merchandiser_name': ('客户经理',),
             'department_name': ('事业部',),
         },
+        'query_remote_address_list': {
+            'address_no': ('偏远地址库编号',),
+            'remote_name': ('偏远地址库名称',),
+            'status_text': ('状态',),
+            'create_time': ('添加时间',),
+            'create_user': ('添加人',),
+        },
+        'query_fuel_rate_list': {
+            'fuel_no': ('燃油代码',),
+            'price_type_name': ('燃油类型',),
+            'setting_type_text': ('配置方式',),
+            'fuel_ratio': ('燃油系数',),
+            'start_date': ('开始生效时间',),
+            'end_date': ('截止生效时间',),
+            'conditions_text': ('条件',),
+            'product_type_name': ('产品分类',),
+            'channel_type_name': ('渠道分类',),
+            'product_name': ('产品',),
+            'channel_name': ('渠道',),
+            'status_name': ('状态',),
+            'source': ('来源',),
+            'user_name': ('创建人',),
+            'create_date': ('创建时间',),
+        },
+        'save_fuel_rate': {
+            'fuel_no': ('燃油代码',),
+            'price_type_name': ('燃油类型',),
+            'fuel_ratio': ('燃油系数',),
+            'status_text': ('状态',),
+            'source': ('来源',),
+            'start_date': ('开始生效时间',),
+            'end_date': ('截止生效时间',),
+            'conditions_text': ('条件',),
+        },
+        'enable_fuel_rate': {
+            'fuel_no': ('燃油代码',),
+            'status_text': ('状态',),
+        },
     }
 
     PAYMENT_FOLLOWUP_COLUMNS = {
@@ -624,6 +673,20 @@ class OutputPresenter:
         ('operator_name', '操作人'),
         ('operate_time', '操作时间'),
     )
+    REMOTE_ADDRESS_DETAIL_COLUMNS = (
+        ('shipper_country', '发件人国家'),
+        ('shipper_province', '发件人州/省'),
+        ('shipper_city', '发件人城市'),
+        ('shipper_start_postcode', '发件人开始邮编'),
+        ('shipper_end_postcode', '发件人结束邮编'),
+        ('country', '收件人国家'),
+        ('province', '收件人州/省'),
+        ('city', '收件人城市'),
+        ('start_postcode', '收件人开始邮编'),
+        ('end_postcode', '收件人结束邮编'),
+        ('create_time', '添加时间'),
+        ('create_user', '添加人'),
+    )
     CONTAINER_TIMELINESS_COLUMNS = (
         ('ship_company', '船公司'), ('bl_number', '提单号'),
         ('container_code', '柜号'), ('container_type', '柜型'),
@@ -762,6 +825,55 @@ class OutputPresenter:
             'page': '页码',
             'limit': '每页数量',
         },
+        'query_remote_address_list': {
+            'address_no': '偏远地址库编号',
+            'remote_name': '偏远地址库名称',
+            'status': '状态',
+            'country': '国家',
+            'province': '州/省',
+            'city': '城市',
+            'postcode': '邮编',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'maintain_remote_address': {
+            'address_no': '偏远地址库编号',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'save_remote_address': {
+            'address_no': '偏远地址库编号',
+            'remote_name': '偏远地址库名称',
+            'status': '状态',
+            'details': '地址明细',
+        },
+        'query_fuel_rate_list': {
+            'fuel_no': '燃油代码',
+            'product': '产品',
+            'channel': '渠道',
+            'product_type': '产品分类',
+            'channel_type': '渠道分类',
+            'price_type': '燃油类型',
+            'status': '状态',
+            'start_date': '生效时间',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'save_fuel_rate': {
+            'price_type': '燃油类型',
+            'start_date': '开始生效时间',
+            'end_date': '结束生效时间',
+            'items': '燃油系数',
+            'fuel_ratio': '燃油费率',
+            'conditions': '计费条件',
+            'condition': '计费条件',
+            'condition_value': '条件值',
+            'condition_relation': '条件关系',
+            'group': '条件组',
+        },
+        'enable_fuel_rate': {
+            'fuel_no': '燃油代码',
+        },
         'query_customer_payment_followup': {
             'customer_id': '客户名称',
             'department_id': '事业部',
@@ -1027,6 +1139,12 @@ class OutputPresenter:
             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.REMOTE_ADDRESS_MAINTAIN_TOOLS:
+            return self._present_remote_address_maintain(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.REMOTE_ADDRESS_SAVE_TOOLS:
+            return self._present_remote_address_save(data, meta)
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1382,6 +1500,133 @@ class OutputPresenter:
             meta,
         )
 
+    def _present_remote_address_maintain(self, data, raw_meta, meta):
+        required = {
+            'summary', 'mode_text', 'address_no', 'remote_name',
+            'status_text', 'temp_key', 'columns', 'records',
+        }
+        if not isinstance(data, dict) or not required.issubset(set(data)):
+            return self._format_error(meta)
+        if set(data) - required - {'tips'}:
+            return self._format_error(meta)
+        if data.get('mode_text') not in ('新增', '编辑'):
+            return self._format_error(meta)
+        for key in ('summary', 'address_no', 'remote_name', 'status_text', 'temp_key'):
+            if not isinstance(data.get(key), str):
+                return self._format_error(meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        expected = list(self.REMOTE_ADDRESS_DETAIL_COLUMNS)
+        if not isinstance(columns, list) or len(columns) != len(expected):
+            return self._format_error(meta)
+        if not isinstance(records, list):
+            return self._format_error(meta)
+        for index, column in enumerate(columns):
+            key, label = expected[index]
+            if (
+                not isinstance(column, dict)
+                or column.get('key') != key
+                or column.get('name') != label
+            ):
+                return self._format_error(meta)
+        keys = [item[0] for item in expected]
+        rows = []
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(keys):
+                return self._format_error(meta)
+            rows.append(['' if record.get(key) is None else record.get(key) for key in keys])
+        headers = [{'label': label} for _, label in expected]
+        table = {
+            'summary': self._safe_text(data.get('summary')),
+            'headers': headers,
+            'rows': rows,
+        }
+        tips = self._safe_tips(data.get('tips'))
+        if tips:
+            table['tips'] = tips
+        content = {
+            'summary': table['summary'],
+            'mode_text': data['mode_text'],
+            'address_no': data['address_no'],
+            'remote_name': data['remote_name'],
+            'status_text': data['status_text'],
+            'headers': headers,
+            'rows': rows,
+        }
+        if tips:
+            content['tips'] = tips
+        pagination = self._build_pagination(raw_meta)
+        if pagination:
+            content['pagination'] = pagination
+        return self._success_result(content, self._render_table(table), meta)
+
+    def _present_remote_address_save(self, data, meta):
+        required = {'summary', 'mode_text', 'address_no', 'remote_name', 'status_text'}
+        if not isinstance(data, dict) or set(data) != required:
+            return self._format_error(meta)
+        if data.get('mode_text') not in ('新增', '编辑'):
+            return self._format_error(meta)
+        if data.get('status_text') not in ('启用', '禁用'):
+            return self._format_error(meta)
+        address_no = data.get('address_no')
+        remote_name = data.get('remote_name')
+        summary = data.get('summary')
+        if (
+            not isinstance(address_no, str)
+            or not address_no.startswith('PY')
+            or not address_no[2:].isdigit()
+            or not isinstance(remote_name, str)
+            or not remote_name.strip()
+            or not isinstance(summary, str)
+            or not summary.strip()
+        ):
+            return self._format_error(meta)
+        content = {
+            '结果': summary.strip(),
+            '操作': data['mode_text'],
+            '偏远地址库编号': address_no,
+            '偏远地址库名称': remote_name.strip(),
+            '状态': data['status_text'],
+        }
+        return self._success_result(content, summary.strip(), meta)
+
+    @staticmethod
+    def _remote_address_save_message(message):
+        if message in (
+            '偏远地址库名称不能为空',
+            '偏远地址库名称过长',
+            '状态只能是启用或禁用',
+            '偏远地址库编号不正确',
+            '地址明细格式不正确',
+            '偏远地址库没有详情,请导入',
+            '编辑只修改名称和状态,不能同时提交地址明细',
+        ):
+            return True
+        if message.startswith('一次最多保存') and message.endswith('条地址明细'):
+            return True
+        if (
+            message.startswith('第')
+            and '条' in message
+            and len(message) <= 80
+        ):
+            return True
+        return False
+
+    @staticmethod
+    def _fuel_rate_message(message):
+        if not message or len(message) > 400:
+            return False
+        lowered = message.lower()
+        if 'sql' in lowered or 'exception' in lowered or '\\' in message:
+            return False
+        markers = (
+            '不正确', '不存在', '不能为空', '只能', '请添加', '请完善',
+            '对应多条', '不能大于', '不能同时', '重复', '过多', '未启用',
+            '草稿', '失败', '只适用于', '日期', '只新增', '最多新增', '不能修改', '不支持',
+            '已启用', '最多启用', '请选择',
+        )
+        return any(marker in message for marker in markers)
+
     def _present_customer_payment_followup(self, data, raw_meta, meta):
         if set(data) != {'columns', 'records'}:
             return self._format_error(meta)
@@ -2588,6 +2833,14 @@ class OutputPresenter:
             if not self._arrange_trailer_message(message):
                 message = self._parameter_message('')
             return self._error_result(code, message, False, meta)
+        elif code == 'MCP_1401' and tool_name in self.REMOTE_ADDRESS_SAVE_TOOLS:
+            if not self._remote_address_save_message(message):
+                message = self._parameter_message('')
+            return self._error_result(code, message, False, meta)
+        elif code == 'MCP_1401' and tool_name in self.FUEL_RATE_TOOLS:
+            if not self._fuel_rate_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)
@@ -2614,6 +2867,10 @@ class OutputPresenter:
                 message = self.ERROR_MESSAGES.get(code, self.ERROR_MESSAGES['MCP_9001'])
             else:
                 message = allowed
+        elif tool_name in self.REMOTE_ADDRESS_MAINTAIN_TOOLS and message == '偏远地址库不存在':
+            message = '偏远地址库不存在'
+        elif tool_name in self.REMOTE_ADDRESS_SAVE_TOOLS and message == '偏远地址库不存在':
+            message = '偏远地址库不存在'
         else:
             message = self.ERROR_MESSAGES[code]
         retryable = code not in self.NON_RETRYABLE_CODES

+ 1 - 1
tests/test_arrange_destination_trailer_tool.py

@@ -179,7 +179,7 @@ class ArrangeDestinationTrailerToolTest(unittest.TestCase):
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
         self.assertIn('arrange_destination_trailer', local)
-        self.assertEqual(40, len(local))
+        self.assertEqual(51, len(local))
         self.assertIn('arrange_destination_trailer', OutputPresenter.SAFE_TOOLS)
 
 

+ 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(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, 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(42, len(local.registered_tool_names()))
+        self.assertEqual(51, 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(42, len(local.registered_tool_names()))
+        self.assertEqual(51, 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(42, len(local))
+        self.assertEqual(51, 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(42, len(local.registered_tool_names()))
+        self.assertEqual(51, 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(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, 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(42, len(local))
+        self.assertEqual(51, len(local))
         self.assertIn('export_pallet_data', local)
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(50, 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(42, len(local))
+        self.assertEqual(51, len(local))
         self.assertIn('export_receivable_cost_list', local)
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(50, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_reuses_queued_export_contract(self):
         presented = OutputPresenter().present(

+ 202 - 0
tests/test_fuel_rate_tools.py

@@ -0,0 +1,202 @@
+import unittest
+
+from tools.query_fuel_rate_list import QueryFuelRateListTool
+from tools.save_fuel_rate import SaveFuelRateTool
+from tools.enable_fuel_rate import EnableFuelRateTool
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class FuelRateToolTest(unittest.TestCase):
+    def test_tools_are_registered_on_base_backend(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('query_fuel_rate_list', local)
+        self.assertIn('save_fuel_rate', local)
+        self.assertIn('enable_fuel_rate', local)
+        self.assertEqual('base', QueryFuelRateListTool.backend)
+        self.assertEqual('base', SaveFuelRateTool.backend)
+        self.assertEqual('base', EnableFuelRateTool.backend)
+
+    def test_query_posts_exact_filters(self):
+        client = FakeClient()
+        QueryFuelRateListTool(api_client=client).call(
+            fuel_no=' RY12 ',
+            product='美国专线',
+            price_type='销售燃油',
+            status=3,
+            start_date='2026-10-21',
+            page=2,
+            limit=20,
+            request_id='rq_fuel_list',
+        )
+        self.assertEqual('query_fuel_rate_list', client.calls[0][0])
+        self.assertEqual('/mcp/tools/queryFuelRateList', client.calls[0][1])
+        self.assertEqual({
+            'page': 2,
+            'limit': 20,
+            'fuel_no': 'RY12',
+            'product': '美国专线',
+            'price_type': '销售燃油',
+            'status': '3',
+            'start_date': '2026-10-21',
+        }, client.calls[0][2])
+
+    def test_save_posts_condition_item(self):
+        client = FakeClient()
+        SaveFuelRateTool(api_client=client).call(
+            price_type=1,
+            start_date='2026-10-21',
+            items=[{
+                'fuel_ratio': 0.15,
+                'conditions': [{
+                    'condition': '物流产品',
+                    'group': '1',
+                    'condition_value': '美国专线',
+                }],
+            }],
+            request_id='rq_fuel_save',
+        )
+        self.assertEqual('/mcp/tools/saveFuelRate', client.calls[0][1])
+        self.assertEqual({
+            'price_type': '1',
+            'start_date': '2026-10-21',
+            'items': [{
+                'fuel_ratio': '0.1500',
+                'conditions': [{
+                    'condition': '物流产品',
+                    'group': '1',
+                    'condition_value': '美国专线',
+                }],
+            }],
+        }, client.calls[0][2])
+
+    def test_save_edit_posts_fuel_no(self):
+        client = FakeClient()
+        SaveFuelRateTool(api_client=client).call(
+            fuel_no=' RY12 ',
+            price_type='销售燃油',
+            start_date='2026-10-22',
+            items=[{
+                'fuel_ratio': '0.2000',
+                'conditions': [{
+                    'condition': '物流产品',
+                    'group': '1',
+                    'condition_value': '美国专线',
+                }],
+            }],
+        )
+        self.assertEqual('RY12', client.calls[0][2]['fuel_no'])
+        self.assertEqual('销售燃油', client.calls[0][2]['price_type'])
+        self.assertEqual(1, len(client.calls[0][2]['items']))
+
+    def test_save_edit_rejects_multiple_items(self):
+        tool = SaveFuelRateTool(api_client=FakeClient())
+        item = {
+            'fuel_ratio': '0.2000',
+            'conditions': [{
+                'condition': '物流产品',
+                'group': '1',
+                'condition_value': '美国专线',
+            }],
+        }
+        with self.assertRaises(ValueError):
+            tool.call(
+                fuel_no='RY12',
+                price_type=1,
+                start_date='2026-10-22',
+                items=[item, item],
+            )
+
+    def test_presenter_keeps_missing_condition_message(self):
+        result = OutputPresenter().present('save_fuel_rate', {
+            'code': 'MCP_1401',
+            'msg': '第1条物流产品条件值[美国专线]不存在或未启用',
+            'data': {},
+            'meta': {'request_id': 'rq_fuel_save'},
+        })
+        self.assertIn('不存在或未启用', str(result))
+
+    def test_presenter_shows_fuel_list(self):
+        columns = [
+            {'key': key, 'name': name}
+            for key, name in (
+                ('fuel_no', '燃油代码'),
+                ('price_type_name', '燃油类型'),
+                ('status_name', '状态'),
+                ('source', '来源'),
+            )
+        ]
+        result = OutputPresenter().present('query_fuel_rate_list', {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': '共查询到 1 条燃油系数',
+                'columns': columns,
+                'records': [{
+                    'fuel_no': 'RY12',
+                    'price_type_name': '销售燃油',
+                    'status_name': '启用',
+                    'source': 'MCP',
+                }],
+                'tips': [],
+            },
+            'meta': {'request_id': 'rq_fuel_list', 'page': 1, 'limit': 20, 'total': 1},
+        })
+        rendered = str(result)
+        self.assertIn('RY12', rendered)
+        self.assertIn('MCP', rendered)
+
+    def test_enable_posts_fuel_numbers(self):
+        client = FakeClient()
+        EnableFuelRateTool(api_client=client).call(fuel_no=[' RY12 ', 'RY13', 'RY12'])
+        self.assertEqual('enable_fuel_rate', client.calls[0][0])
+        self.assertEqual('/mcp/tools/enableFuelRate', client.calls[0][1])
+        self.assertEqual(['RY12', 'RY13'], client.calls[0][2]['fuel_no'])
+
+    def test_enable_rejects_empty_list(self):
+        with self.assertRaises(ValueError):
+            EnableFuelRateTool(api_client=FakeClient()).call(fuel_no=[])
+
+    def test_presenter_keeps_already_enabled_message(self):
+        result = OutputPresenter().present('enable_fuel_rate', {
+            'code': 'MCP_1401',
+            'msg': '燃油系数RY12已启用',
+            'data': {},
+            'meta': {'request_id': 'rq_fuel_enable'},
+        })
+        self.assertIn('已启用', str(result))
+
+    def test_presenter_shows_enabled_fuel(self):
+        result = OutputPresenter().present('enable_fuel_rate', {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': '已启用 1 条燃油系数',
+                'columns': [
+                    {'key': 'fuel_no', 'name': '燃油代码'},
+                    {'key': 'status_text', 'name': '状态'},
+                ],
+                'records': [{'fuel_no': 'RY12', 'status_text': '启用'}],
+                'tips': [],
+            },
+            'meta': {'request_id': 'rq_fuel_enable'},
+        })
+        rendered = str(result)
+        self.assertIn('RY12', rendered)
+        self.assertIn('启用', rendered)
+
+
+class FakeClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, name, route, payload, request_id):
+        self.calls.append((name, route, payload, request_id))
+        return {'ok': True}
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 2 - 2
tests/test_headhaul_document_tools.py

@@ -161,7 +161,7 @@ class HeadhaulDocumentToolContractTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(42, len(local))
+        self.assertEqual(51, len(local))
         for name in (
             'query_headhaul_document_list',
             'list_headhaul_document_filter_options',
@@ -170,7 +170,7 @@ class HeadhaulDocumentToolContractTest(unittest.TestCase):
             'delete_headhaul_document',
         ):
             self.assertIn(name, local)
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(50, len(OutputPresenter.SAFE_TOOLS))
 
     def test_presenter_list_keeps_six_columns_and_document_ref(self):
         presenter = OutputPresenter()

+ 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(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, 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(42, len(local.registered_tool_names()))
+        self.assertEqual(51, 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(41, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(50, 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(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, 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(42, len(local))
+        self.assertEqual(51, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 2 - 2
tests/test_query_freightower_track_tool.py

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

+ 2 - 2
tests/test_receivable_cost_list_tools.py

@@ -279,8 +279,8 @@ class ReceivableCostIntegrationTest(unittest.TestCase):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, 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(42, len(local))
-        self.assertEqual(41, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(51, len(local))
+        self.assertEqual(50, len(OutputPresenter.SAFE_TOOLS))
         self.assertIn('query_receive_volume_list', local)
         self.assertIn('list_receive_volume_filter_options', local)

+ 152 - 0
tests/test_remote_address_tools.py

@@ -0,0 +1,152 @@
+import unittest
+
+from tools.maintain_remote_address import MaintainRemoteAddressTool
+from tools.query_remote_address_list import QueryRemoteAddressListTool
+from tools.save_remote_address import SaveRemoteAddressTool
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RemoteAddressToolTest(unittest.TestCase):
+    def test_tools_are_registered_on_base_backend(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('query_remote_address_list', local)
+        self.assertIn('maintain_remote_address', local)
+        self.assertIn('save_remote_address', local)
+        self.assertEqual('base', QueryRemoteAddressListTool.backend)
+        self.assertEqual('base', MaintainRemoteAddressTool.backend)
+        self.assertEqual('base', SaveRemoteAddressTool.backend)
+
+    def test_query_posts_filters_to_base_route(self):
+        client = FakeClient()
+        result = QueryRemoteAddressListTool(api_client=client).call(
+            address_no=' PY12 ',
+            remote_name='美国',
+            status=1,
+            postcode='10001',
+            page=2,
+            limit=20,
+            request_id='rq_remote_list',
+        )
+        self.assertEqual({'ok': True}, result)
+        self.assertEqual('query_remote_address_list', client.calls[0][0])
+        self.assertEqual('/mcp/tools/queryRemoteAddressList', client.calls[0][1])
+        self.assertEqual({
+            'page': 2,
+            'limit': 20,
+            'address_no': 'PY12',
+            'remote_name': '美国',
+            'postcode': '10001',
+            'status': 1,
+        }, client.calls[0][2])
+
+    def test_maintain_without_number_prepares_create(self):
+        client = FakeClient()
+        MaintainRemoteAddressTool(api_client=client).call(request_id='rq_maintain')
+        self.assertEqual('/mcp/tools/maintainRemoteAddress', client.calls[0][1])
+        self.assertEqual({'page': 1, 'limit': 20}, client.calls[0][2])
+
+    def test_presenter_hides_temp_key(self):
+        columns = [
+            {'key': key, 'name': name}
+            for key, name in OutputPresenter.REMOTE_ADDRESS_DETAIL_COLUMNS
+        ]
+        record = {key: '' for key, _name in OutputPresenter.REMOTE_ADDRESS_DETAIL_COLUMNS}
+        result = OutputPresenter().present('maintain_remote_address', {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': '准备新增偏远地址库',
+                'mode_text': '新增',
+                'address_no': '',
+                'remote_name': '',
+                'status_text': '',
+                'temp_key': 'secret-temp-key',
+                'columns': columns,
+                'records': [record],
+                'tips': [],
+            },
+            'meta': {'request_id': 'rq_maintain', 'page': 1, 'limit': 20, 'total': 0},
+        })
+        rendered = str(result)
+        self.assertNotIn('secret-temp-key', rendered)
+        self.assertNotIn('temp_key', rendered)
+
+    def test_save_create_posts_details(self):
+        client = FakeClient()
+        SaveRemoteAddressTool(api_client=client).call(
+            remote_name=' 美西偏远 ',
+            status=1,
+            details=[{
+                'country': 'US',
+                'province': 'CA',
+                'start_postcode': '90001',
+                'end_postcode': '90099',
+            }],
+            request_id='rq_save_remote',
+        )
+        self.assertEqual('save_remote_address', client.calls[0][0])
+        self.assertEqual('/mcp/tools/saveRemoteAddress', client.calls[0][1])
+        self.assertEqual({
+            'remote_name': '美西偏远',
+            'status': 1,
+            'details': [{
+                'country': 'US',
+                'province': 'CA',
+                'start_postcode': '90001',
+                'end_postcode': '90099',
+            }],
+        }, client.calls[0][2])
+
+    def test_save_edit_rejects_details(self):
+        tool = SaveRemoteAddressTool(api_client=FakeClient())
+        with self.assertRaises(ValueError):
+            tool.call(
+                address_no='PY12',
+                remote_name='美西偏远',
+                status=0,
+                details=[{'country': 'US'}],
+            )
+
+    def test_presenter_shows_saved_address(self):
+        result = OutputPresenter().present('save_remote_address', {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': {
+                'summary': '已新增偏远地址库 PY12 美西偏远,状态启用',
+                'mode_text': '新增',
+                'address_no': 'PY12',
+                'remote_name': '美西偏远',
+                'status_text': '启用',
+            },
+            'meta': {'request_id': 'rq_save_remote'},
+        })
+        rendered = str(result)
+        self.assertIn('PY12', rendered)
+        self.assertIn('美西偏远', rendered)
+        self.assertNotIn('temp_key', rendered)
+
+    def test_presenter_keeps_missing_detail_message(self):
+        result = OutputPresenter().present('save_remote_address', {
+            'code': 'MCP_1401',
+            'msg': '偏远地址库没有详情,请导入',
+            'data': {},
+            'meta': {'request_id': 'rq_save_remote'},
+        })
+        self.assertIn('偏远地址库没有详情,请导入', str(result))
+
+
+class FakeClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, name, route, payload, request_id):
+        self.calls.append((name, route, payload, request_id))
+        return {'ok': True}
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 1 - 1
tests/test_trial_goods_tax_tool.py

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

+ 67 - 0
tools/enable_fuel_rate.py

@@ -0,0 +1,67 @@
+class EnableFuelRateTool:
+    name = 'enable_fuel_rate'
+    route_path = '/mcp/tools/enableFuelRate'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:把燃油系数生效或启用。燃油代码必须来自query_fuel_rate_list,格式为RY加数字。'
+                '可以一次启用多条,最多20条。草稿和失效都可以启用。'
+                '已经启用的不能再次启用。'
+                '禁止使用:新增、编辑、失效、删除、手填不存在的燃油代码。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和燃油代码。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'fuel_no': {
+                        'type': 'array',
+                        'minItems': 1,
+                        'maxItems': 20,
+                        'items': {
+                            'type': 'string',
+                            'minLength': 1,
+                            'maxLength': 20,
+                        },
+                        'description': '要启用的燃油代码,例如["RY12"]。',
+                    },
+                },
+                'required': ['fuel_no'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(self, fuel_no, request_id='rq_enable_fuel_rate'):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for enable_fuel_rate')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {'fuel_no': _fuel_numbers(fuel_no)},
+            request_id,
+        )
+
+
+def _fuel_numbers(value):
+    if isinstance(value, str):
+        value = [value]
+    if not isinstance(value, list) or not value or len(value) > 20:
+        raise ValueError('fuel_no is invalid')
+    numbers = []
+    seen = set()
+    for item in value:
+        if not isinstance(item, str) or not item.strip() or len(item.strip()) > 20:
+            raise ValueError('fuel_no is invalid')
+        number = item.strip()
+        if number in seen:
+            continue
+        seen.add(number)
+        numbers.append(number)
+    if not numbers:
+        raise ValueError('fuel_no is invalid')
+    return numbers

+ 77 - 0
tools/maintain_remote_address.py

@@ -0,0 +1,77 @@
+class MaintainRemoteAddressTool:
+    name = 'maintain_remote_address'
+    route_path = '/mcp/tools/maintainRemoteAddress'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:查看某个已有偏远地址库的名称、状态和地址明细。'
+                '编号格式为PY加数字,例如PY12,必须来自query_remote_address_list的结果。'
+                '新增地址库,或修改名称和状态,请使用save_remote_address。'
+                '禁止使用:手填不存在的编号、导入文件、删除明细、保存名称。'
+                '临时键是内部字段,不要向用户展示。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和偏远地址库编号。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'address_no': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '要维护的偏远地址库编号。不传表示准备新增。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20,
+                    },
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        address_no=None,
+        page=1,
+        limit=20,
+        request_id='rq_maintain_remote_address',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for maintain_remote_address'
+            )
+        payload = {
+            'page': QueryPage.page(page),
+            'limit': QueryPage.limit(limit),
+        }
+        if address_no is not None:
+            if not isinstance(address_no, str):
+                raise ValueError('address_no is invalid')
+            address_no = address_no.strip()
+            if address_no:
+                payload['address_no'] = address_no
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+
+class QueryPage:
+    @staticmethod
+    def page(value):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError('page is invalid')
+        return value
+
+    @staticmethod
+    def limit(value):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError('limit is invalid')
+        return value

+ 147 - 0
tools/query_fuel_rate_list.py

@@ -0,0 +1,147 @@
+class QueryFuelRateListTool:
+    name = 'query_fuel_rate_list'
+    route_path = '/mcp/tools/queryFuelRateList'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要查询燃油系数配置时调用。可按燃油代码、生效时间、'
+                '燃油类型、状态、产品、渠道、产品分类、渠道分类筛选。'
+                '产品、渠道、产品分类、渠道分类可以填代码或名称,必须完整匹配,不能按包含搜索。'
+                '燃油代码格式为RY加数字,例如RY12。燃油类型为销售燃油或成本燃油。'
+                '状态为草稿、失效或启用。生效时间筛选的是该时间点正在生效的配置。'
+                '不传筛选条件时返回当前公司的燃油系数。'
+                '编辑草稿请使用save_fuel_rate并传入这里返回的燃油代码。'
+                '生效或启用请使用enable_fuel_rate。'
+                '禁止使用:失效、删除燃油系数。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和燃油代码。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'fuel_no': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '燃油代码,格式为RY加数字,例如RY12。',
+                    },
+                    'start_date': {
+                        'type': 'string',
+                        'minLength': 8,
+                        'maxLength': 30,
+                        'description': '生效时间,筛选该时间点生效的配置,格式YYYY-MM-DD。',
+                    },
+                    'product': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '产品代码或产品名称,必须完整匹配。',
+                    },
+                    'channel': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '渠道代码或渠道名称,必须完整匹配。',
+                    },
+                    'product_type': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '产品分类代码或名称,必须完整匹配。只适用于销售燃油。',
+                    },
+                    'channel_type': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '渠道分类代码或名称,必须完整匹配。只适用于成本燃油。',
+                    },
+                    'price_type': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '燃油类型。1或销售燃油,2或成本燃油。',
+                    },
+                    'status': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 10,
+                        'description': '状态。1或草稿,2或失效,3或启用。',
+                    },
+                    'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
+                    'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        fuel_no=None,
+        start_date=None,
+        product=None,
+        channel=None,
+        product_type=None,
+        channel_type=None,
+        price_type=None,
+        status=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_fuel_rate',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_fuel_rate_list')
+        payload = {
+            'page': _page(page),
+            'limit': _limit(limit),
+        }
+        _put_text(payload, 'fuel_no', fuel_no, 20)
+        _put_text(payload, 'start_date', start_date, 30)
+        _put_text(payload, 'product', product, 100)
+        _put_text(payload, 'channel', channel, 100)
+        _put_text(payload, 'product_type', product_type, 100)
+        _put_text(payload, 'channel_type', channel_type, 100)
+        _put_choice(payload, 'price_type', price_type, ('1', '2', '销售燃油', '成本燃油'))
+        _put_choice(payload, 'status', status, ('1', '2', '3', '草稿', '失效', '启用'))
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
+
+
+def _page(value):
+    if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+        raise ValueError('page is invalid')
+    return value
+
+
+def _limit(value):
+    if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+        raise ValueError('limit is invalid')
+    return value
+
+
+def _put_text(payload, key, value, max_length):
+    if value is None:
+        return
+    if not isinstance(value, str):
+        raise ValueError(key + ' is invalid')
+    value = value.strip()
+    if not value:
+        return
+    if len(value) > max_length:
+        raise ValueError(key + ' is invalid')
+    payload[key] = value
+
+
+def _put_choice(payload, key, value, choices):
+    if value is None:
+        return
+    if isinstance(value, bool):
+        raise ValueError(key + ' is invalid')
+    if isinstance(value, int):
+        value = str(value)
+    if not isinstance(value, str) or value.strip() not in choices:
+        raise ValueError(key + ' is invalid')
+    payload[key] = value.strip()

+ 129 - 0
tools/query_remote_address_list.py

@@ -0,0 +1,129 @@
+class QueryRemoteAddressListTool:
+    name = 'query_remote_address_list'
+    route_path = '/mcp/tools/queryRemoteAddressList'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户要搜索或查看偏远地址库列表时调用。可按偏远地址库编号、'
+                '名称、启用状态、国家、州/省、城市、邮编筛选,返回当前页。编号格式为'
+                'PY加数字,例如PY12。不传筛选条件时返回当前公司的地址库列表。'
+                '禁止使用:修改地址库、导入地址、删除明细、把内部ID当成编号展示。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和偏远地址库编号。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'address_no': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '偏远地址库编号,格式为PY加数字,例如PY12。',
+                    },
+                    'remote_name': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '偏远地址库名称,按名称包含匹配。',
+                    },
+                    'status': {
+                        'type': 'integer',
+                        'enum': [0, 1],
+                        'description': '状态。1启用,0禁用。',
+                    },
+                    'country': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 10,
+                        'description': '国家代码,精确匹配地址明细。',
+                    },
+                    'province': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 50,
+                        'description': '州/省,精确匹配地址明细。',
+                    },
+                    'city': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 50,
+                        'description': '城市,精确匹配地址明细。',
+                    },
+                    'postcode': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '邮编,落在地址明细的开始邮编和结束邮编之间。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20,
+                    },
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        address_no=None,
+        remote_name=None,
+        status=None,
+        country=None,
+        province=None,
+        city=None,
+        postcode=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_remote_address_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_remote_address_list'
+            )
+        payload = {
+            'page': self._page(page),
+            'limit': self._limit(limit),
+        }
+        text_fields = {
+            'address_no': address_no,
+            'remote_name': remote_name,
+            'country': country,
+            'province': province,
+            'city': city,
+            'postcode': postcode,
+        }
+        for key, value in text_fields.items():
+            if value is None:
+                continue
+            if not isinstance(value, str):
+                raise ValueError(key + ' is invalid')
+            value = value.strip()
+            if value:
+                payload[key] = value
+        if status is not None:
+            if isinstance(status, bool) or status not in (0, 1):
+                raise ValueError('status is invalid')
+            payload['status'] = status
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+    @staticmethod
+    def _page(value):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError('page is invalid')
+        return value
+
+    @staticmethod
+    def _limit(value):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError('limit is invalid')
+        return value

+ 202 - 0
tools/save_fuel_rate.py

@@ -0,0 +1,202 @@
+class SaveFuelRateTool:
+    name = 'save_fuel_rate'
+    route_path = '/mcp/tools/saveFuelRate'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:按计费条件新增燃油系数,或编辑一条草稿燃油系数。'
+                '新增时不要传燃油代码。编辑时必须传query_fuel_rate_list返回的燃油代码,并且只能提交一条费率。'
+                '必须带燃油类型、开始生效时间,以及至少一条费率和计费条件。'
+                '结束生效时间不传时按9999-12-31长期有效。'
+                '燃油类型为1或销售燃油、2或成本燃油。编辑时不能修改燃油类型。'
+                '只能编辑草稿,且只能编辑计费条件配置。'
+                '计费条件和条件值都可以填代码或名称,必须完整匹配,不能按包含搜索。'
+                '销售燃油可选物流产品、物流产品分类、仓库、偏远地址库。'
+                '成本燃油可选物流渠道、物流渠道分类、仓库、偏远地址库。'
+                '偏远地址库可以填PY编号或地址库名称。'
+                '同一条件组第一条不要填关系;后面的关系不传时按and。同一组不能同时使用and和or。'
+                '新增和编辑后都是草稿,不会立即参与计费。生效请使用enable_fuel_rate。'
+                '禁止使用:失效、删除、复制、手填不存在的燃油代码。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和燃油代码。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'price_type': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '燃油类型。1或销售燃油,2或成本燃油。',
+                    },
+                    'start_date': {
+                        'type': 'string',
+                        'minLength': 8,
+                        'maxLength': 30,
+                        'description': '开始生效时间,格式YYYY-MM-DD。',
+                    },
+                    'end_date': {
+                        'type': 'string',
+                        'minLength': 8,
+                        'maxLength': 30,
+                        'description': '结束生效时间,格式YYYY-MM-DD。不传表示9999-12-31。',
+                    },
+                    'fuel_no': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '要编辑的燃油代码,格式为RY加数字。新增时不要传。',
+                    },
+                    'items': {
+                        'type': 'array',
+                        'minItems': 1,
+                        'maxItems': 20,
+                        'items': {
+                            'type': 'object',
+                            'properties': {
+                                'fuel_ratio': {
+                                    'type': 'string',
+                                    'minLength': 1,
+                                    'maxLength': 10,
+                                    'description': '燃油费率,0到1,最多4位小数。',
+                                },
+                                'conditions': {
+                                    'type': 'array',
+                                    'minItems': 1,
+                                    'maxItems': 30,
+                                    'items': {
+                                        'type': 'object',
+                                        'properties': {
+                                            'condition': {
+                                                'type': 'string',
+                                                'minLength': 1,
+                                                'maxLength': 50,
+                                                'description': '计费条件代码或名称,必须完整匹配。',
+                                            },
+                                            'group': {
+                                                'type': 'string',
+                                                'minLength': 1,
+                                                'maxLength': 20,
+                                                'description': '条件组编号,同组条件写同一个组号。',
+                                            },
+                                            'condition_value': {
+                                                'type': 'string',
+                                                'minLength': 1,
+                                                'maxLength': 100,
+                                                'description': '条件值代码或名称,必须完整匹配。多选用英文逗号分隔。',
+                                            },
+                                            'condition_relation': {
+                                                'type': 'string',
+                                                'enum': ['and', 'or'],
+                                                'description': '与同组上一条的关系。同组第一条不要传。',
+                                            },
+                                        },
+                                        'required': ['condition', 'group', 'condition_value'],
+                                        'additionalProperties': False,
+                                    },
+                                },
+                            },
+                            'required': ['fuel_ratio', 'conditions'],
+                            'additionalProperties': False,
+                        },
+                    },
+                },
+                'required': ['price_type', 'start_date', 'items'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        price_type,
+        start_date,
+        items,
+        end_date=None,
+        fuel_no=None,
+        request_id='rq_save_fuel_rate',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for save_fuel_rate')
+        built_items = _items(items)
+        payload = {
+            'price_type': _choice(price_type, ('1', '2', '销售燃油', '成本燃油'), 'price_type'),
+            'start_date': _date(start_date, 'start_date'),
+            'items': built_items,
+        }
+        if end_date is not None:
+            payload['end_date'] = _date(end_date, 'end_date')
+        if fuel_no is not None:
+            if not isinstance(fuel_no, str) or not fuel_no.strip() or len(fuel_no.strip()) > 20:
+                raise ValueError('fuel_no is invalid')
+            if len(built_items) != 1:
+                raise ValueError('items is invalid')
+            payload['fuel_no'] = fuel_no.strip()
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
+
+
+def _choice(value, choices, key):
+    if isinstance(value, bool):
+        raise ValueError(key + ' is invalid')
+    if isinstance(value, int):
+        value = str(value)
+    if not isinstance(value, str) or value.strip() not in choices:
+        raise ValueError(key + ' is invalid')
+    return value.strip()
+
+
+def _date(value, key):
+    if not isinstance(value, str) or not value.strip() or len(value.strip()) > 30:
+        raise ValueError(key + ' is invalid')
+    return value.strip()
+
+
+def _items(items):
+    if not isinstance(items, list) or not items or len(items) > 20:
+        raise ValueError('items is invalid')
+    rows = []
+    for item in items:
+        if not isinstance(item, dict) or set(item) - {'fuel_ratio', 'conditions'}:
+            raise ValueError('items is invalid')
+        ratio = item.get('fuel_ratio')
+        if isinstance(ratio, bool) or isinstance(ratio, int) or isinstance(ratio, float):
+            ratio = '{0:.4f}'.format(ratio)
+        if not isinstance(ratio, str) or not ratio.strip() or len(ratio.strip()) > 10:
+            raise ValueError('fuel_ratio is invalid')
+        rows.append({
+            'fuel_ratio': ratio.strip(),
+            'conditions': _conditions(item.get('conditions')),
+        })
+    return rows
+
+
+def _conditions(conditions):
+    if not isinstance(conditions, list) or not conditions or len(conditions) > 30:
+        raise ValueError('conditions is invalid')
+    rows = []
+    allowed = {'condition', 'group', 'condition_value', 'condition_relation'}
+    for condition in conditions:
+        if not isinstance(condition, dict) or set(condition) - allowed:
+            raise ValueError('conditions is invalid')
+        row = {
+            'condition': _text(condition.get('condition'), 50, 'condition'),
+            'group': _text(condition.get('group'), 20, 'group'),
+            'condition_value': _text(condition.get('condition_value'), 100, 'condition_value'),
+        }
+        relation = condition.get('condition_relation')
+        if relation is not None:
+            if relation not in ('and', 'or'):
+                raise ValueError('condition_relation is invalid')
+            row['condition_relation'] = relation
+        rows.append(row)
+    return rows
+
+
+def _text(value, max_length, key):
+    if not isinstance(value, str) or not value.strip() or len(value.strip()) > max_length:
+        raise ValueError(key + ' is invalid')
+    return value.strip()

+ 149 - 0
tools/save_remote_address.py

@@ -0,0 +1,149 @@
+class SaveRemoteAddressTool:
+    name = 'save_remote_address'
+    route_path = '/mcp/tools/saveRemoteAddress'
+    backend = 'base'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:新增偏远地址库,或修改已有偏远地址库的名称和启用状态。'
+                '新增时不要传编号,必须带名称、状态,以及至少一条地址明细。'
+                '编辑时必须传query_remote_address_list返回的编号,只修改名称和状态,不能提交明细。'
+                '没有地址明细的地址库不能保存。'
+                '状态用1表示启用,用0表示禁用。'
+                '国家可以填国家二字码或中文名称。发件人国家和收件人国家至少填一个。'
+                '结束邮编不能单独填写,必须同时有开始邮编。'
+                '禁止使用:手填不存在的编号、导入文件、删除明细、修改已有明细。'
+                '参数只用于工具内部调用;最终回答只能展示中文业务名称和偏远地址库编号。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'address_no': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 20,
+                        'description': '要编辑的偏远地址库编号,格式为PY加数字。新增时不要传。',
+                    },
+                    'remote_name': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': '偏远地址库名称。',
+                    },
+                    'status': {
+                        'type': 'integer',
+                        'enum': [0, 1],
+                        'description': '1启用,0禁用。',
+                    },
+                    'details': {
+                        'type': 'array',
+                        'maxItems': 200,
+                        'description': '新增时的地址明细。编辑时不要传。',
+                        'items': {
+                            'type': 'object',
+                            'properties': {
+                                'shipper_country': {'type': 'string', 'maxLength': 50},
+                                'shipper_province': {'type': 'string', 'maxLength': 20},
+                                'shipper_city': {'type': 'string', 'maxLength': 20},
+                                'shipper_start_postcode': {'type': 'string', 'maxLength': 10},
+                                'shipper_end_postcode': {'type': 'string', 'maxLength': 10},
+                                'country': {'type': 'string', 'maxLength': 50},
+                                'province': {'type': 'string', 'maxLength': 20},
+                                'city': {'type': 'string', 'maxLength': 20},
+                                'start_postcode': {'type': 'string', 'maxLength': 10},
+                                'end_postcode': {'type': 'string', 'maxLength': 10},
+                            },
+                            'additionalProperties': False,
+                        },
+                    },
+                },
+                'required': ['remote_name', 'status'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        remote_name,
+        status,
+        address_no=None,
+        details=None,
+        request_id='rq_save_remote_address',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for save_remote_address')
+        if not isinstance(remote_name, str) or not remote_name.strip() or len(remote_name.strip()) > 100:
+            raise ValueError('remote_name is invalid')
+        if isinstance(status, bool) or status not in (0, 1):
+            raise ValueError('status is invalid')
+        payload = {
+            'remote_name': remote_name.strip(),
+            'status': status,
+        }
+        if address_no is not None:
+            if not isinstance(address_no, str):
+                raise ValueError('address_no is invalid')
+            address_no = address_no.strip()
+            if address_no:
+                if len(address_no) > 20:
+                    raise ValueError('address_no is invalid')
+                payload['address_no'] = address_no
+        if details is not None:
+            payload['details'] = self._details(details)
+        if payload.get('address_no') and payload.get('details'):
+            raise ValueError('details is invalid')
+        if not payload.get('address_no') and not payload.get('details'):
+            raise ValueError('details is invalid')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id,
+        )
+
+    @staticmethod
+    def _details(details):
+        if not isinstance(details, list) or not details or len(details) > 200:
+            raise ValueError('details is invalid')
+        fields = (
+            'shipper_country', 'shipper_province', 'shipper_city',
+            'shipper_start_postcode', 'shipper_end_postcode',
+            'country', 'province', 'city', 'start_postcode', 'end_postcode',
+        )
+        postcode_fields = {
+            'shipper_start_postcode', 'shipper_end_postcode',
+            'start_postcode', 'end_postcode',
+        }
+        rows = []
+        for detail in details:
+            if not isinstance(detail, dict) or set(detail) - set(fields):
+                raise ValueError('details is invalid')
+            row = {}
+            for field in fields:
+                if field not in detail:
+                    continue
+                value = detail[field]
+                if not isinstance(value, str):
+                    raise ValueError('details is invalid')
+                value = value.strip()
+                limit = 10 if field in postcode_fields else (50 if field in ('shipper_country', 'country') else 20)
+                if len(value) > limit:
+                    raise ValueError('details is invalid')
+                if field in postcode_fields and value and not _postcode(value):
+                    raise ValueError('details is invalid')
+                if value:
+                    row[field] = value
+            if not row.get('shipper_country') and not row.get('country'):
+                raise ValueError('details is invalid')
+            rows.append(row)
+        return rows
+
+
+def _postcode(value):
+    for character in value:
+        if character.isalnum() or character in ' -':
+            continue
+        return False
+    return True