Parcourir la source

Merge branch 'master' into master_task_710395169

jackson il y a 2 semaines
Parent
commit
0254fb905c

+ 4 - 1
README.md

@@ -31,6 +31,7 @@ Gateway 保持“薄网关”边界:
 |---|---|---|---|
 | `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
 | `query_track` | 按订单号/排舱单号或跟踪号/柜号/快递单号查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
+| `query_freightower_track` | 按本公司排舱单的提单号或柜号查询飞驼现场船期和已发生节点 | `/mcp/tools/queryFreightowerTrack` | 船期、港口、轨迹 |
 | `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
 | `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
 | `query_customer_list` | 查询当前员工有权查看的启用主客户 | `/mcp/tools/queryCustomerList` | 固定 17 列安全表格 |
@@ -48,6 +49,8 @@ Gateway 保持“薄网关”边界:
 | `query_order_abnormal_list` | 按六个页签查询头程问题件列表 | `/mcp/tools/queryOrderAbnormalList` | 固定 9 列安全表格 |
 | `list_order_abnormal_filter_options` | 查询头程问题件六个中文页签 | `/mcp/tools/listOrderAbnormalFilterOptions` | 安全筛选项 |
 | `query_receive_volume_list` | 按入库时间查询收货量已入库订单明细 | `/mcp/tools/queryReceiveVolumeList` | 固定 9 列安全表格 |
+| `query_goods_info` | 按 SKU 或中文品名查询已审核商品资料 | `/mcp/tools/queryGoodsInfo` | 固定 24 列安全表格 |
+| `trial_goods_tax` | 按 SKU、起运国代码和目的国代码试算已审核商品的预计关税 | `/mcp/tools/trialGoodsTax` | 预计关税金额 |
 | `list_receive_volume_filter_options` | 查询收货量五类筛选项 | `/mcp/tools/listReceiveVolumeFilterOptions` | 安全筛选项 |
 | `query_container_timeliness_list` | 按柜号、提单号或干线实际出发时间查询柜子时效 | `/mcp/tools/queryContainerTimelinessList` | 固定 54 列安全表格 |
 | `export_container_timeliness_report` | 提交柜子时效统计报表异步导出 | `/mcp/tools/exportContainerTimelinessReport` | 签名任务引用 |
@@ -493,7 +496,7 @@ Gateway 会调用以下路径:
 
 - Auth:本地兼容会话只使用 `/mcp/auth/refresh`、`/mcp/auth/revoke`;正式公网设备配置由 base 的登录态设备接口创建,不调用已退役的 `/mcp/auth/exchange`。
 - 动态工具列表:`/mcp/tools/listEnabledTools`。
-- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
+- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryFreightowerTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
 - 筛选项:`/mcp/tools/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
 - 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`、`/mcp/tools/queryExportTask`。
 

+ 46 - 0
app.py

@@ -65,6 +65,8 @@ from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
@@ -78,6 +80,7 @@ from tools.prepare_headhaul_document_upload import (
 )
 from tools.save_headhaul_document import SaveHeadhaulDocumentTool
 from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
 
 
 def parse_int_list(value):
@@ -176,6 +179,8 @@ class GatewayApp:
             'query_receive_volume_list': QueryReceiveVolumeListTool(
                 api_client=api_client
             ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=api_client),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=api_client),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=api_client),
             'query_container_timeliness_list': QueryContainerTimelinessListTool(
@@ -196,6 +201,9 @@ class GatewayApp:
             'delete_headhaul_document': DeleteHeadhaulDocumentTool(
                 api_client=api_client
             ),
+            'query_freightower_track': QueryFreightowerTrackTool(
+                api_client=api_client
+            ),
         }
 
     @classmethod
@@ -340,6 +348,14 @@ class GatewayApp:
         call_parser = subparsers.add_parser('call')
         call_parser.add_argument('--tool', required=True)
         call_parser.add_argument('--keyword', default='')
+        call_parser.add_argument('--sku', default='')
+        call_parser.add_argument('--cn-name', default='')
+        call_parser.add_argument('--departure-country', default='')
+        call_parser.add_argument('--destination-country', default='')
+        call_parser.add_argument('--price', type=float, default=0)
+        call_parser.add_argument('--qty', type=float, default=0)
+        call_parser.add_argument('--gross-weight', type=float, default=0)
+        call_parser.add_argument('--box', type=float, default=0)
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
         call_parser.add_argument('--business-type', type=int, default=0)
@@ -351,6 +367,7 @@ class GatewayApp:
         call_parser.add_argument('--reference-numbers', default='')
         call_parser.add_argument('--outbound-number', default='')
         call_parser.add_argument('--outbound-numbers', default='')
+        call_parser.add_argument('--bl-number', default='')
         call_parser.add_argument('--bl-numbers', default='')
         call_parser.add_argument('--container-code', default='')
         call_parser.add_argument('--container-codes', default='')
@@ -841,6 +858,23 @@ class GatewayApp:
                     tool_args['order_numbers'] = parse_string_list(
                         args.order_numbers
                     )
+            elif args.tool == 'trial_goods_tax':
+                tool_args['sku'] = args.sku
+                tool_args['departure_country'] = args.departure_country
+                tool_args['destination_country'] = args.destination_country
+                tool_args['price'] = args.price
+                tool_args['qty'] = args.qty
+                tool_args['gross_weight'] = args.gross_weight
+                tool_args['box'] = args.box
+            elif args.tool == 'query_goods_info':
+                if args.sku:
+                    tool_args['sku'] = args.sku
+                if args.cn_name:
+                    tool_args['cn_name'] = args.cn_name
+                if not args.sku and not args.cn_name:
+                    raise ValueError(
+                        '--sku or --cn-name is required for query_goods_info'
+                    )
             elif args.tool == 'query_receive_volume_list':
                 if not args.inbound_date_start or not args.inbound_date_end:
                     raise ValueError(
@@ -880,6 +914,18 @@ class GatewayApp:
                 if args.tool == 'export_container_timeliness_report':
                     tool_args.pop('page', None)
                     tool_args.pop('limit', None)
+            elif args.tool == 'query_freightower_track':
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
+                if args.bl_number:
+                    tool_args['bl_number'] = args.bl_number
+                if args.container_code:
+                    tool_args['container_code'] = args.container_code
+                if not tool_args.get('bl_number') and not tool_args.get('container_code'):
+                    raise ValueError(
+                        '--bl-number or --container-code is required for '
+                        'query_freightower_track'
+                    )
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
                 'list_customer_filter_options',

+ 6 - 0
public_gateway.py

@@ -47,6 +47,8 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
@@ -64,6 +66,7 @@ from tools.prepare_headhaul_document_upload import (
 )
 from tools.save_headhaul_document import SaveHeadhaulDocumentTool
 from tools.delete_headhaul_document import DeleteHeadhaulDocumentTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
 from utils.security import hash_gateway_session_id
 
 
@@ -141,6 +144,8 @@ class PublicGatewayApp:
             'query_receive_volume_list': QueryReceiveVolumeListTool(
                 api_client=None
             ),
+            'query_goods_info': QueryGoodsInfoTool(api_client=None),
+            'trial_goods_tax': TrialGoodsTaxTool(api_client=None),
             'list_receive_volume_filter_options':
                 ListReceiveVolumeFilterOptionsTool(api_client=None),
             'query_container_timeliness_list': QueryContainerTimelinessListTool(
@@ -148,6 +153,7 @@ class PublicGatewayApp:
             ),
             'export_container_timeliness_report':
                 ExportContainerTimelinessReportTool(api_client=None),
+            'query_freightower_track': QueryFreightowerTrackTool(api_client=None),
             'query_headhaul_document_list': QueryHeadhaulDocumentListTool(
                 api_client=None
             ),

+ 221 - 0
services/output_presenter.py

@@ -36,11 +36,14 @@ class OutputPresenter:
     DESTINATION_TRAILER_TOOLS = frozenset(('query_destination_trailer_list',))
     ORDER_ABNORMAL_TOOLS = frozenset(('query_order_abnormal_list',))
     RECEIVE_VOLUME_TOOLS = frozenset(('query_receive_volume_list',))
+    GOODS_INFO_TOOLS = frozenset(('query_goods_info',))
+    GOODS_TAX_TOOLS = frozenset(('trial_goods_tax',))
     CONTAINER_TIMELINESS_TOOLS = frozenset(('query_container_timeliness_list',))
     HEADHAUL_DOCUMENT_TOOLS = frozenset(('query_headhaul_document_list',))
     HEADHAUL_PREPARE_TOOLS = frozenset(('prepare_headhaul_document_upload',))
     HEADHAUL_SAVE_TOOLS = frozenset(('save_headhaul_document',))
     HEADHAUL_DELETE_TOOLS = frozenset(('delete_headhaul_document',))
+    FREIGHTOWER_TRACK_TOOLS = frozenset(('query_freightower_track',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -69,11 +72,14 @@ class OutputPresenter:
         | DESTINATION_TRAILER_TOOLS
         | ORDER_ABNORMAL_TOOLS
         | RECEIVE_VOLUME_TOOLS
+        | GOODS_INFO_TOOLS
+        | GOODS_TAX_TOOLS
         | CONTAINER_TIMELINESS_TOOLS
         | HEADHAUL_DOCUMENT_TOOLS
         | HEADHAUL_PREPARE_TOOLS
         | HEADHAUL_SAVE_TOOLS
         | HEADHAUL_DELETE_TOOLS
+        | FREIGHTOWER_TRACK_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -575,6 +581,20 @@ class OutputPresenter:
     ORDER_ABNORMAL_STATUSES = frozenset((
         '待处理', '放行待审核', '包裹放行', '退件待审核', '退件驳回', '已退件',
     ))
+    GOODS_INFO_COLUMNS = (
+        ('sku', 'SKU'), ('departure_country', '起运国'),
+        ('destination_country', '目的国'), ('goods_attr', '商品属性'),
+        ('cn_name', '中文名称'), ('en_name', '英文名称'),
+        ('declaration_hscode', '报关编码'), ('clearance_hscode', '清关编码'),
+        ('tax_rate', '税率'), ('is_inspection_fee', '木制/竹制品商检费'),
+        ('is_fda_declaration', 'FDA申报'), ('product_surcharge', '产品附加费'),
+        ('tax_surcharge', '关税附加费'), ('is_epa_declaration', 'EPA申报'),
+        ('is_act_declaration', 'LACY ACT申报'), ('is_dot_declaration', 'DOT申报'),
+        ('is_cpsc_declaration', 'CPSC申报'), ('brand_type', '品牌类型'),
+        ('brand', '品牌'), ('goods_type', '型号'),
+        ('material_cn', '材质(CN)'), ('material_en', '材质(EN)'),
+        ('purpose_cn', '用途(CN)'), ('purpose_en', '用途(EN)'),
+    )
     RECEIVE_VOLUME_COLUMNS = (
         ('order_number', '订单号'), ('customer_name', '客户名称'),
         ('packing_type', '货物类型'), ('inbound_volume', '入库体积'),
@@ -852,6 +872,10 @@ class OutputPresenter:
         'query_export_task': {
             'task_ref': '导出任务引用',
         },
+        'query_freightower_track': {
+            'bl_number': '提单号',
+            'container_code': '柜号',
+        },
     }
 
     ERROR_MESSAGES = {
@@ -867,6 +891,12 @@ class OutputPresenter:
         'MCP_1601': '没有可导出的数据',
         'MCP_9001': '工具调用失败,请稍后重试',
     }
+    TAX_TRIAL_MESSAGES = frozenset((
+        '未找到已审核商品',
+        '匹配到多条商品,请核对起运国和目的国',
+        '该商品没有应收清关编码,无法试算',
+        '关税试算失败,请稍后重试',
+    ))
 
     NON_RETRYABLE_CODES = frozenset((
         'MCP_1101',
@@ -957,6 +987,12 @@ class OutputPresenter:
             return self._present_receive_volume_list(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.GOODS_INFO_TOOLS:
+            return self._present_goods_info(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.GOODS_TAX_TOOLS:
+            return self._present_goods_tax(data, meta)
         if tool_name in self.CONTAINER_TIMELINESS_TOOLS:
             return self._present_container_timeliness_list(
                 data, tool_result.get('meta'), meta
@@ -971,6 +1007,8 @@ class OutputPresenter:
             return self._present_headhaul_save(data, meta)
         if tool_name in self.HEADHAUL_DELETE_TOOLS:
             return self._present_headhaul_delete(data, meta)
+        if tool_name in self.FREIGHTOWER_TRACK_TOOLS:
+            return self._present_freightower_track(data, meta)
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1834,6 +1872,79 @@ class OutputPresenter:
         }
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_goods_tax(self, data, meta):
+        expected = {
+            'estimated_tax', 'sku', 'departure_country', 'destination_country',
+        }
+        if set(data) != expected:
+            return self._format_error(meta)
+        amount = data.get('estimated_tax')
+        sku = data.get('sku')
+        departure = data.get('departure_country')
+        destination = data.get('destination_country')
+        if (
+            isinstance(amount, bool) or not isinstance(amount, (int, float))
+            or not math.isfinite(float(amount))
+            or not isinstance(sku, str) or not sku.strip()
+            or not self._country_code(departure)
+            or not self._country_code(destination)
+        ):
+            return self._format_error(meta)
+        content = {
+            'estimated_tax': amount,
+            'sku': sku,
+            'departure_country': departure,
+            'destination_country': destination,
+        }
+        text = '预计关税金额: {0}'.format(amount)
+        return self._success_result(content, text, meta)
+
+    @staticmethod
+    def _country_code(value):
+        return (
+            isinstance(value, str)
+            and value.isalpha()
+            and value.isupper()
+            and 2 <= len(value) <= 3
+        )
+
+    def _present_goods_info(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        expected = list(self.GOODS_INFO_COLUMNS)
+        pagination = self._customer_pagination(raw_meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, (key, name) in zip(columns, expected):
+            if column != {'key': key, 'name': name}:
+                return self._format_error(meta)
+        rows = []
+        expected_keys = [key for key, _ in expected]
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+        headers = [{'label': name} for _, name in expected]
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(content, self._render_table(content), meta)
+
     def _present_receive_volume_list(self, data, raw_meta, meta):
         if set(data) != {'columns', 'records'}:
             return self._format_error(meta)
@@ -2389,6 +2500,11 @@ class OutputPresenter:
             return False
 
     def _business_error(self, tool_name, code, raw_message, meta):
+        message = str(raw_message or '').strip()
+        if tool_name == 'trial_goods_tax' and message in self.TAX_TRIAL_MESSAGES:
+            safe_code = code if code in self.ERROR_MESSAGES else 'MCP_9001'
+            retryable = safe_code not in self.NON_RETRYABLE_CODES
+            return self._error_result(safe_code, message, retryable, meta)
         if code not in self.ERROR_MESSAGES and code != 'MCP_1401':
             logger.warning(
                 'Unknown MCP business error code',
@@ -2404,6 +2520,19 @@ class OutputPresenter:
         if code == 'MCP_1401':
             label = self._find_field_label(tool_name, raw_message)
             message = self._parameter_message(label)
+        elif tool_name in self.FREIGHTOWER_TRACK_TOOLS:
+            allowed = str(raw_message or '').strip()
+            if allowed not in (
+                '暂时没有飞驼轨迹',
+                '未找到本公司排舱单',
+                '匹配到多票排舱单,请同时提供提单号和柜号',
+                '未找到飞驼轨迹订单',
+                '飞驼查询超时',
+                '飞驼查询失败',
+            ):
+                message = self.ERROR_MESSAGES.get(code, self.ERROR_MESSAGES['MCP_9001'])
+            else:
+                message = allowed
         else:
             message = self.ERROR_MESSAGES[code]
         retryable = code not in self.NON_RETRYABLE_CODES
@@ -2487,6 +2616,98 @@ class OutputPresenter:
             )
         return '\n'.join(lines)
 
+        return self._success_result(content, '\n'.join(lines), meta)
+
+    def _present_freightower_track(self, data, meta):
+        if set(data) != {'schedule', 'ports', 'tracks'}:
+            return self._format_error(meta)
+        schedule = data.get('schedule')
+        ports = data.get('ports')
+        tracks = data.get('tracks')
+        schedule_keys = (
+            'bl_number', 'container_code', 'vessel', 'voyage', 'etd', 'eta',
+        )
+        if (
+            not isinstance(schedule, dict)
+            or set(schedule) != set(schedule_keys)
+            or not isinstance(ports, list)
+            or not isinstance(tracks, list)
+        ):
+            return self._format_error(meta)
+        labels = {
+            'bl_number': '提单号',
+            'container_code': '柜号',
+            'vessel': '船名',
+            'voyage': '航次',
+            'etd': '预计离港',
+            'eta': '预计到达',
+        }
+        safe_schedule = {}
+        for key in schedule_keys:
+            value = schedule.get(key)
+            if not isinstance(value, str):
+                return self._format_error(meta)
+            safe_schedule[labels[key]] = value
+        safe_ports = []
+        for port in ports:
+            if not isinstance(port, dict) or set(port) != {
+                'place_type', 'port_name', 'eta', 'ata',
+            }:
+                return self._format_error(meta)
+            if port['place_type'] not in ('中转港', '目的港', '交货地'):
+                return self._format_error(meta)
+            for key in ('port_name', 'eta', 'ata'):
+                if not isinstance(port[key], str):
+                    return self._format_error(meta)
+            safe_ports.append({
+                '地点类型': port['place_type'],
+                '港口': port['port_name'],
+                '预计到达': port['eta'],
+                '实际到达': port['ata'],
+            })
+        safe_tracks = []
+        for track in tracks:
+            if not isinstance(track, dict) or set(track) != {
+                'time', 'location', 'content', 'event_code',
+            }:
+                return self._format_error(meta)
+            for key in ('time', 'location', 'content', 'event_code'):
+                if not isinstance(track[key], str):
+                    return self._format_error(meta)
+            safe_tracks.append({
+                '时间': track['time'],
+                '地点': track['location'],
+                '内容': track['content'],
+                '状态代码': track['event_code'],
+            })
+        content = {
+            '船期': safe_schedule,
+            '港口': safe_ports,
+            '轨迹': safe_tracks,
+        }
+        lines = ['飞驼船期']
+        for label, value in safe_schedule.items():
+            lines.append('- {0}: {1}'.format(label, value))
+        lines.append('港口')
+        if not safe_ports:
+            lines.append('- 无')
+        for port in safe_ports:
+            lines.append(
+                '- {0} {1} 预计到达 {2} 实际到达 {3}'.format(
+                    port['地点类型'], port['港口'], port['预计到达'], port['实际到达'],
+                )
+            )
+        lines.append('轨迹')
+        if not safe_tracks:
+            lines.append('- 无')
+        for track in safe_tracks:
+            lines.append(
+                '- {0} {1} {2} {3}'.format(
+                    track['时间'], track['地点'], track['内容'], track['状态代码'],
+                )
+            )
+        return self._success_result(content, '\n'.join(lines), meta)
+
     @staticmethod
     def _success_result(content, text, meta):
         return {

+ 194 - 0
tests/test_query_freightower_track_tool.py

@@ -0,0 +1,194 @@
+import io
+import unittest
+from unittest.mock import patch
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_freightower_track import QueryFreightowerTrackTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.call = (tool_code, route_path, payload, request_id)
+        return {'code': 'MCP_0000', 'data': {'ok': True}}
+
+
+def sample_data():
+    return {
+        'schedule': {
+            'bl_number': 'BL1',
+            'container_code': 'BOX1',
+            'vessel': 'VESSEL',
+            'voyage': '001E',
+            'etd': '2026-09-01 00:00:00',
+            'eta': '2026-09-20 00:00:00',
+        },
+        'ports': [{
+            'place_type': '目的港',
+            'port_name': '洛杉矶',
+            'eta': '2026-09-20',
+            'ata': '',
+        }],
+        'tracks': [{
+            'time': '2026-09-20 01:00:00',
+            'location': 'LOS ANGELES',
+            'content': '抵港',
+            'event_code': 'BDAR',
+        }],
+    }
+
+
+class QueryFreightowerTrackToolTest(unittest.TestCase):
+    def test_metadata_requires_explicit_number_type(self):
+        metadata = QueryFreightowerTrackTool().metadata()
+        description = metadata['description']
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_freightower_track', metadata['name'])
+        self.assertNotIn('required', schema)
+        self.assertFalse(schema['additionalProperties'])
+        self.assertIn('使用场景:', description)
+        self.assertIn('禁止使用:', description)
+        self.assertIn('号码类型不明确时必须先询问用户', description)
+        self.assertIn('不得根据号码格式猜测', description)
+        self.assertIn('不得跨字段或跨工具试查', description)
+        self.assertIn('query_track', description)
+        self.assertIn('仅当用户明确', schema['properties']['bl_number']['description'])
+        self.assertIn('不得放入其他类型号码', schema['properties']['container_code']['description'])
+
+    def test_call_forwards_only_provided_numbers(self):
+        api = RecordingApiClient()
+        tool = QueryFreightowerTrackTool(api_client=api)
+
+        tool.call(bl_number=' BL1 ', container_code=' BOX1 ', request_id='rq_1')
+
+        self.assertEqual((
+            'query_freightower_track',
+            '/mcp/tools/queryFreightowerTrack',
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            'rq_1',
+        ), api.call)
+
+    def test_call_accepts_one_number(self):
+        api = RecordingApiClient()
+        QueryFreightowerTrackTool(api_client=api).call(container_code='BOX1')
+        self.assertEqual({'container_code': 'BOX1'}, api.call[2])
+
+    def test_invalid_numbers_are_rejected(self):
+        tool = QueryFreightowerTrackTool(api_client=RecordingApiClient())
+        with self.assertRaises(ValueError):
+            tool.call()
+        with self.assertRaises(ValueError):
+            tool.call(bl_number=123)
+        with self.assertRaises(ValueError):
+            tool.call(container_code='B' * 101)
+
+    def test_api_client_is_required(self):
+        with self.assertRaises(RuntimeError):
+            QueryFreightowerTrackTool().call(bl_number='BL1')
+
+    def test_registries_include_the_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('query_freightower_track', local)
+        self.assertEqual(35, len(local))
+        self.assertEqual(34, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_cli_forwards_bill_and_container(self):
+        app = GatewayApp()
+        stdout = io.StringIO()
+        with patch.object(app, 'call_tool', return_value={'code': 'MCP_0000'}) as call_tool:
+            code = app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+                '--bl-number', 'BL1',
+                '--container-code', 'BOX1',
+            ], stdout=stdout)
+        self.assertEqual(0, code)
+        self.assertEqual('query_freightower_track', call_tool.call_args[0][0])
+        self.assertEqual(
+            {'bl_number': 'BL1', 'container_code': 'BOX1'},
+            call_tool.call_args[0][1],
+        )
+
+    def test_cli_requires_a_number(self):
+        app = GatewayApp()
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call',
+                '--tool', 'query_freightower_track',
+            ], stdout=io.StringIO())
+
+    def test_presenter_renders_schedule_ports_and_arrival(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': sample_data(),
+            'meta': {'request_id': 'rq_ft'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual('洛杉矶', result['structured_content']['港口'][0]['港口'])
+        self.assertEqual('BDAR', result['structured_content']['轨迹'][0]['状态代码'])
+        self.assertIn('抵港', result['text'])
+        self.assertNotIn('bl_number', result['text'])
+        self.assertEqual({'request_id': 'rq_ft'}, result['meta'])
+
+    def test_presenter_accepts_empty_ports_and_tracks(self):
+        data = sample_data()
+        data['ports'] = []
+        data['tracks'] = []
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_0000',
+            'data': data,
+        })
+        self.assertFalse(result['is_error'])
+        self.assertIn('港口\n- 无', result['text'])
+        self.assertIn('轨迹\n- 无', result['text'])
+
+    def test_presenter_rejects_unknown_shape(self):
+        cases = []
+        extra = sample_data()
+        extra['schedule']['secret'] = 'x'
+        cases.append(extra)
+        bad_type = sample_data()
+        bad_type['ports'][0]['place_type'] = '起运港'
+        cases.append(bad_type)
+        bad_value = sample_data()
+        bad_value['tracks'][0]['time'] = 1
+        cases.append(bad_value)
+        cases.append({'schedule': {}})
+        for data in cases:
+            with self.subTest(data=data):
+                result = OutputPresenter().present('query_freightower_track', {
+                    'code': 'MCP_0000',
+                    'data': data,
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret', result['text'])
+
+    def test_presenter_keeps_allowlisted_business_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': '暂时没有飞驼轨迹',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertIn('暂时没有飞驼轨迹', result['text'])
+
+    def test_presenter_hides_unknown_upstream_message(self):
+        result = OutputPresenter().present('query_freightower_track', {
+            'code': 'MCP_1501',
+            'msg': 'carrier token leaked',
+            'data': [],
+        })
+        self.assertTrue(result['is_error'])
+        self.assertNotIn('leaked', result['text'])
+        self.assertIn('目标数据不可用', result['text'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 11 - 0
tests/test_tool_description_boundaries.py

@@ -23,11 +23,14 @@ from tools.query_order_abnormal_list import QueryOrderAbnormalListTool
 from tools.list_order_abnormal_filter_options import (
     ListOrderAbnormalFilterOptionsTool,
 )
+from tools.query_goods_info import QueryGoodsInfoTool
+from tools.trial_goods_tax import TrialGoodsTaxTool
 from tools.query_receive_volume_list import QueryReceiveVolumeListTool
 from tools.list_receive_volume_filter_options import (
     ListReceiveVolumeFilterOptionsTool,
 )
 from tools.query_container_timeliness_list import QueryContainerTimelinessListTool
+from tools.query_freightower_track import QueryFreightowerTrackTool
 from tools.export_container_timeliness_report import (
     ExportContainerTimelinessReportTool,
 )
@@ -62,6 +65,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryOrderAbnormalListTool(),
             ListOrderAbnormalFilterOptionsTool(),
             QueryReceiveVolumeListTool(),
+            QueryGoodsInfoTool(),
+            TrialGoodsTaxTool(),
             ListReceiveVolumeFilterOptionsTool(),
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
@@ -70,6 +75,7 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             PrepareHeadhaulDocumentUploadTool(),
             SaveHeadhaulDocumentTool(),
             DeleteHeadhaulDocumentTool(),
+            QueryFreightowerTrackTool(),
         )
 
         for tool in tools:
@@ -93,6 +99,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             QueryContainerTimelinessListTool(),
             ExportContainerTimelinessReportTool(),
             QueryHeadhaulDocumentListTool(),
+            TrialGoodsTaxTool(),
+            QueryFreightowerTrackTool(),
         )
 
         for tool in tools:
@@ -157,6 +165,9 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
             DeleteHeadhaulDocumentTool(): (
                 'document_ref', '盲删', '批量删除',
             ),
+            QueryFreightowerTrackTool(): (
+                '飞驼', 'query_track', '提单号',
+            ),
         }
 
         for tool, phrases in expected.items():

+ 85 - 0
tests/test_trial_goods_tax_tool.py

@@ -0,0 +1,85 @@
+import unittest
+from unittest.mock import Mock
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.trial_goods_tax import TrialGoodsTaxTool
+
+
+class TrialGoodsTaxToolTest(unittest.TestCase):
+    def test_call_forwards_required_fields(self):
+        client = Mock()
+        client.call_tool.return_value = {'code': 'MCP_0000'}
+        TrialGoodsTaxTool(client).call(
+            sku=' SKU-1 ',
+            departure_country=' CN ',
+            destination_country='us',
+            price=10,
+            qty=2,
+            gross_weight=1.5,
+            box=1,
+        )
+        client.call_tool.assert_called_once_with(
+            'trial_goods_tax',
+            '/mcp/tools/trialGoodsTax',
+            {
+                'sku': 'SKU-1',
+                'departure_country': 'CN',
+                'destination_country': 'US',
+                'price': 10,
+                'qty': 2,
+                'gross_weight': 1.5,
+                'box': 1,
+            },
+            'rq_trial_goods_tax',
+        )
+
+    def test_call_rejects_country_name(self):
+        tool = TrialGoodsTaxTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call(
+                sku='SKU-1', departure_country='中国', destination_country='US',
+                price=10, qty=1, gross_weight=1, box=1,
+            )
+
+    def test_call_rejects_zero_price(self):
+        tool = TrialGoodsTaxTool(api_client=Mock())
+        with self.assertRaises(ValueError):
+            tool.call(
+                sku='SKU-1', departure_country='CN', destination_country='US',
+                price=0, qty=1, gross_weight=1, box=1,
+            )
+
+    def test_registries_match(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertIn('trial_goods_tax', local)
+        self.assertEqual(36, len(local))
+        self.assertIn('trial_goods_tax', OutputPresenter.SAFE_TOOLS)
+
+    def test_presenter_renders_amount(self):
+        result = OutputPresenter().present('trial_goods_tax', {
+            'code': 'MCP_0000',
+            'data': {
+                'estimated_tax': 12.5,
+                'sku': 'SKU-1',
+                'departure_country': 'CN',
+                'destination_country': 'US',
+            },
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(12.5, result['structured_content']['estimated_tax'])
+        self.assertIn('预计关税金额', result['text'])
+
+    def test_presenter_keeps_safe_failure_message(self):
+        result = OutputPresenter().present('trial_goods_tax', {
+            'code': 'MCP_1501',
+            'msg': '未找到已审核商品',
+            'data': [],
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertTrue(result['is_error'])
+        self.assertEqual('未找到已审核商品', result['structured_content']['message'])

+ 77 - 0
tools/query_freightower_track.py

@@ -0,0 +1,77 @@
+class QueryFreightowerTrackTool:
+    name = 'query_freightower_track'
+    route_path = '/mcp/tools/queryFreightowerTrack'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number = {
+            'type': 'string',
+            'minLength': 1,
+            'maxLength': 100,
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查看飞驼现场船期和集装箱节点时使用,'
+                '返回船名、航次、预计离港、预计到达、中转港/目的港/交货地,'
+                '以及已经发生的轨迹节点。提单号、柜号至少提供一个;'
+                '号码类型不明确时必须先询问用户是提单号还是柜号,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '禁止使用:系统里已经保存的轨迹用 query_track;'
+                '本工具不导出文件,不修改干线到达或提柜状态。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'bl_number': dict(number, description=(
+                        '提单号。仅当用户明确说明是提单号时使用,'
+                        '不得放入其他类型号码。'
+                    )),
+                    'container_code': dict(number, description=(
+                        '柜号。仅当用户明确说明是柜号时使用,'
+                        '不得放入其他类型号码。'
+                    )),
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        bl_number='',
+        container_code='',
+        request_id='rq_query_freightower_track',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_freightower_track'
+            )
+        payload = {}
+        bl_number = self._number(bl_number, 'bl_number')
+        container_code = self._number(container_code, 'container_code')
+        if bl_number:
+            payload['bl_number'] = bl_number
+        if container_code:
+            payload['container_code'] = container_code
+        if not payload:
+            raise ValueError('bl_number or container_code is required')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            payload,
+            request_id,
+        )
+
+    @staticmethod
+    def _number(value, field):
+        if value in (None, ''):
+            return ''
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if len(value) > 100:
+            raise ValueError(field + ' must contain at most 100 characters')
+        return value

+ 95 - 0
tools/query_goods_info.py

@@ -0,0 +1,95 @@
+class QueryGoodsInfoTool:
+    name = 'query_goods_info'
+    route_path = '/mcp/tools/queryGoodsInfo'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要查已审核商品资料时使用,可按 SKU 或中文品名查询,'
+                '返回与后台编码查询页相同的列表字段,包括起运国、目的国、属性、'
+                '报关编码、清关编码和税率。SKU 与中文品名至少提供一个;'
+                '用户没说明是 SKU 还是中文品名时必须先询问,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '同一 SKU 按起运国和目的国会返回多行。'
+                '禁止使用:不查未审核商品,不导出文件,不修改商品或清关资料。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'sku': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 2000,
+                        'description': (
+                            'SKU。仅当用户明确说明是 SKU 时使用。'
+                            '多个 SKU 用逗号或换行分隔,最多 50 个,每个最多 100 个字符。'
+                        ),
+                    },
+                    'cn_name': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': (
+                            '中文品名。仅当用户明确说明是中文品名时使用,按包含关系查询。'
+                        ),
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        sku='',
+        cn_name='',
+        page=1,
+        limit=20,
+        request_id='rq_query_goods_info',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_goods_info')
+        payload = {
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        sku = self._text(sku, 'sku', 2000)
+        cn_name = self._text(cn_name, 'cn_name', 100)
+        if sku:
+            payload['sku'] = sku
+        if cn_name:
+            payload['cn_name'] = cn_name
+        if 'sku' not in payload and 'cn_name' not in payload:
+            raise ValueError('sku or cn_name is required')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _text(value, field, max_length):
+        if value in (None, ''):
+            return ''
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if len(value) > max_length:
+            raise ValueError(field + ' is too long')
+        return value
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError(field + ' is invalid')
+        return value

+ 109 - 0
tools/trial_goods_tax.py

@@ -0,0 +1,109 @@
+import math
+
+
+class TrialGoodsTaxTool:
+    name = 'trial_goods_tax'
+    route_path = '/mcp/tools/trialGoodsTax'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number = {'type': 'number', 'exclusiveMinimum': 0, 'maximum': 1000000000}
+        country = {
+            'type': 'string',
+            'minLength': 2,
+            'maxLength': 3,
+            'pattern': '^[A-Za-z]{2,3}$',
+            'description': '国家代码,例如 CN、US。不得传中文国名或“国家名-代码”。',
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确要对某一条已审核商品做关税试算时使用。'
+                '必须同时提供 SKU、起运国国家代码、目的国国家代码、采购单价、SKU数量、总毛重和箱数。'
+                '起运国和目的国只能填国家代码,例如 CN、US。'
+                '同一 SKU 有多条时,号码类型不明确时必须先询问用户,国家不明确也必须先询问,确认前不得调用。'
+                '不得根据号码格式猜测,不得跨字段或跨工具试查。'
+                '返回预计关税金额,不保存试算结果。'
+                '禁止使用:不查询商品列表,不修改商品或清关资料,不创建订单,不导出文件。'
+                '查商品资料用 query_goods_info。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'sku': {
+                        'type': 'string',
+                        'minLength': 1,
+                        'maxLength': 100,
+                        'description': 'SKU。仅当用户明确说明是 SKU 时使用。',
+                    },
+                    'departure_country': dict(country, description='起运国。'),
+                    'destination_country': dict(country, description='目的国。'),
+                    'price': dict(number, description='采购单价,必须大于 0。'),
+                    'qty': dict(number, description='SKU数量,必须大于 0。'),
+                    'gross_weight': dict(number, description='总毛重,必须大于 0。'),
+                    'box': dict(number, description='箱数,必须大于 0。'),
+                },
+                'required': [
+                    'sku', 'departure_country', 'destination_country',
+                    'price', 'qty', 'gross_weight', 'box',
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        sku='',
+        departure_country='',
+        destination_country='',
+        price=None,
+        qty=None,
+        gross_weight=None,
+        box=None,
+        request_id='rq_trial_goods_tax',
+        **_ignored,
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for trial_goods_tax')
+        payload = {
+            'sku': self._text(sku, 'sku', 100),
+            'departure_country': self._country(departure_country, 'departure_country'),
+            'destination_country': self._country(destination_country, 'destination_country'),
+            'price': self._positive(price, 'price'),
+            'qty': self._positive(qty, 'qty'),
+            'gross_weight': self._positive(gross_weight, 'gross_weight'),
+            'box': self._positive(box, 'box'),
+        }
+        if not payload['sku'] or not payload['departure_country'] or not payload['destination_country']:
+            raise ValueError('sku, departure_country and destination_country are required')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _country(value, field):
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a country code')
+        value = value.strip().upper()
+        if len(value) < 2 or len(value) > 3 or not value.isalpha():
+            raise ValueError(field + ' must be a country code')
+        return value
+
+    @staticmethod
+    def _text(value, field, max_length):
+        if not isinstance(value, str):
+            raise ValueError(field + ' must be a string')
+        value = value.strip()
+        if not value or len(value) > max_length:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @staticmethod
+    def _positive(value, field):
+        if isinstance(value, bool) or not isinstance(value, (int, float)):
+            raise ValueError(field + ' is invalid')
+        if not math.isfinite(value) or value <= 0 or value > 1000000000:
+            raise ValueError(field + ' is invalid')
+        return value