소스 검색

master_task_7055442374

jackson 3 주 전
부모
커밋
e32b6ac9e7

+ 10 - 6
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 17 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 19 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 17 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 19 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -36,12 +36,14 @@ Gateway 保持“薄网关”边界:
 | `query_customer_list` | 查询当前员工有权查看的启用主客户 | `/mcp/tools/queryCustomerList` | 固定 17 列安全表格 |
 | `query_customer_payment_followup` | 查询客户应收、未出账、逾期、坏账和未回款月份汇总 | `/mcp/tools/queryCustomerPaymentFollowup` | 固定客户字段与三字段月份汇总 |
 | `query_customer_unverified_bill_details` | 分页查询单个授权客户的未核销账单 | `/mcp/tools/queryCustomerUnverifiedBillDetails` | 固定六字段账单明细 |
+| `query_customer_payment_records` | 按客户和可选收款日期查询逐笔收款及核销 | `/mcp/tools/queryCustomerPaymentRecords` | 固定八字段收款记录 |
+| `query_order_receivable_cost_details` | 按明确订单号查询应收费用确认、关账和核销 | `/mcp/tools/queryOrderReceivableCostDetails` | 固定十三字段费用明细 |
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
 | `query_outbound_list` | 按业务阶段和筛选条件查询排舱列表 | `/mcp/tools/queryOutboundList` | 固定 31 列安全表格 |
 | `query_outbound_detail` | 按排舱单号查询排舱汇总与订单明细 | `/mcp/tools/queryOutboundDetail` | 11 项汇总 + 38 项明细 |
 | `list_outbound_filter_options` | 查询七类排舱筛选项 | `/mcp/tools/listOutboundFilterOptions` | 安全筛选项 |
 | `list_order_filter_options` | 查询精准订单筛选项 | `/mcp/tools/listOrderFilterOptions` | 安全筛选项 |
-| `list_customer_filter_options` | 查询客户列表/客户回款跟进共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
+| `list_customer_filter_options` | 查询客户列表/客户回款工具共用的客户、事业部、商务经理和客户经理筛选项 | `/mcp/tools/listCustomerFilterOptions` | 安全筛选项 |
 | `export_pending_outbound_orders` | 提交未排舱订单异步导出 | `/mcp/tools/exportPendingOutboundOrders` | 签名任务引用 |
 | `export_out_of_province_port_data` | 提交省外进港资料异步导出 | `/mcp/tools/exportOutOfProvincePortData` | 签名任务引用 |
 | `query_export_task` | 查询异步导出状态或文件 | `/mcp/tools/queryExportTask` | 安全任务状态/文件链接 |
@@ -53,7 +55,7 @@ Gateway 保持“薄网关”边界:
 2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
 3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
 
-这 17 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 19 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -62,9 +64,11 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 16 个安全工具执行显式白名单展示。
-- `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- `services/output_presenter.py` 对其余 18 个安全工具执行显式白名单展示。
+- `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_order_receivable_cost_details`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
+- 客户逐笔回款记录固定展示客户名称、水单号、原币/实际收款、收款日期、已/未核销和审核状态八列;所有金额必须为有限数值,未知字段关闭失败。
+- 订单应收费用明细固定展示订单号、客户/费用客户、费用项、计费数据、两项 CNY 金额及确认/关账/核销状态 13 列;金额必须为有限数值,未知字段关闭失败。
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
 - `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 四个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。

+ 27 - 0
app.py

@@ -40,6 +40,10 @@ from tools.query_track import QueryTrackTool
 from tools.query_customer_list import QueryCustomerListTool
 from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
 from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
+from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
+from tools.query_order_receivable_cost_details import (
+    QueryOrderReceivableCostDetailsTool,
+)
 
 
 def parse_int_list(value):
@@ -84,6 +88,11 @@ class GatewayApp:
             ),
             'query_customer_unverified_bill_details':
                 QueryCustomerUnverifiedBillDetailsTool(api_client=api_client),
+            'query_customer_payment_records': QueryCustomerPaymentRecordsTool(
+                api_client=api_client
+            ),
+            'query_order_receivable_cost_details':
+                QueryOrderReceivableCostDetailsTool(api_client=api_client),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=api_client
             ),
@@ -248,6 +257,8 @@ class GatewayApp:
         call_parser.add_argument('--product-ids', default='')
         call_parser.add_argument('--customer-ids', default='')
         call_parser.add_argument('--customer-id', type=int, default=0)
+        call_parser.add_argument('--receive-date-start', default='')
+        call_parser.add_argument('--receive-date-end', default='')
         call_parser.add_argument('--sales-id', type=int, default=0)
         call_parser.add_argument('--merchandiser-id', type=int, default=0)
         call_parser.add_argument(
@@ -420,6 +431,22 @@ class GatewayApp:
                         '--customer-id is required for query_customer_unverified_bill_details'
                     )
                 tool_args['customer_id'] = args.customer_id
+            elif args.tool == 'query_customer_payment_records':
+                if args.customer_id <= 0:
+                    raise ValueError(
+                        '--customer-id is required for query_customer_payment_records'
+                    )
+                tool_args['customer_id'] = args.customer_id
+                if args.receive_date_start:
+                    tool_args['receive_date_start'] = args.receive_date_start
+                if args.receive_date_end:
+                    tool_args['receive_date_end'] = args.receive_date_end
+            elif args.tool == 'query_order_receivable_cost_details':
+                if not args.order_number:
+                    raise ValueError(
+                        '--order-number is required for query_order_receivable_cost_details'
+                    )
+                tool_args['order_number'] = args.order_number
             elif args.tool == 'query_customs_declaration_files':
                 if args.outbound_numbers:
                     tool_args['outbound_numbers'] = parse_string_list(

+ 9 - 0
public_gateway.py

@@ -26,6 +26,10 @@ from tools.query_track import QueryTrackTool
 from tools.query_customer_list import QueryCustomerListTool
 from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
 from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
+from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
+from tools.query_order_receivable_cost_details import (
+    QueryOrderReceivableCostDetailsTool,
+)
 from utils.security import hash_gateway_session_id
 
 
@@ -51,6 +55,11 @@ class PublicGatewayApp:
             ),
             'query_customer_unverified_bill_details':
                 QueryCustomerUnverifiedBillDetailsTool(api_client=None),
+            'query_customer_payment_records': QueryCustomerPaymentRecordsTool(
+                api_client=None
+            ),
+            'query_order_receivable_cost_details':
+                QueryOrderReceivableCostDetailsTool(api_client=None),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=None
             ),

+ 144 - 1
services/output_presenter.py

@@ -22,6 +22,10 @@ class OutputPresenter:
     PAYMENT_DETAIL_TOOLS = frozenset((
         'query_customer_unverified_bill_details',
     ))
+    PAYMENT_RECORD_TOOLS = frozenset(('query_customer_payment_records',))
+    RECEIVABLE_COST_TOOLS = frozenset((
+        'query_order_receivable_cost_details',
+    ))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -37,7 +41,7 @@ class OutputPresenter:
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
         TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS | PAYMENT_FOLLOWUP_TOOLS
-        | PAYMENT_DETAIL_TOOLS
+        | PAYMENT_DETAIL_TOOLS | PAYMENT_RECORD_TOOLS | RECEIVABLE_COST_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -359,6 +363,31 @@ class OutputPresenter:
         'unverified_amount': '未核销金额',
         'customer_receivable_date': '客户应收款日期',
     }
+    PAYMENT_RECORD_COLUMNS = {
+        'customer_name': '客户名称',
+        'payment_reference': '收款水单号',
+        'original_received_amount': '原币到账金额',
+        'actual_received_amount': '实际收款金额',
+        'receive_date': '收款日期',
+        'verified_amount': '已核销金额',
+        'unverified_amount': '未核销金额',
+        'payment_approval_status': '收款审核状态',
+    }
+    RECEIVABLE_COST_COLUMNS = {
+        'order_number': '订单号',
+        'customer_name': '客户名称',
+        'cost_name': '费用项',
+        'cost_customer_name': '费用客户',
+        'charge_weight': '计费重',
+        'unit_price': '计费单价',
+        'quote_currency': '原币币种',
+        'receivable_original_amount': '应收原币',
+        'receivable_amount_cny': '应收金额(CNY)',
+        'settlement_amount_cny': '结算金额(CNY)',
+        'cost_confirmation_status': '费用确认状态',
+        'closing_status': '关账状态',
+        'verification_status': '核销状态',
+    }
     PAYMENT_FOLLOWUP_MONTHLY_COLUMNS = {
         'receivable_month': '应收月份',
         'unverified_amount': '未核销金额',
@@ -592,6 +621,14 @@ class OutputPresenter:
             return self._present_customer_unverified_bill_details(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.PAYMENT_RECORD_TOOLS:
+            return self._present_customer_payment_records(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.RECEIVABLE_COST_TOOLS:
+            return self._present_order_receivable_cost_details(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1110,6 +1147,112 @@ class OutputPresenter:
             translated.append(item)
         return translated
 
+    def _present_customer_payment_records(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        expected_keys = list(self.PAYMENT_RECORD_COLUMNS)
+        if not isinstance(columns, list) or not isinstance(records, list):
+            return self._format_error(meta)
+        actual_keys = []
+        for column in columns:
+            if (
+                not isinstance(column, dict)
+                or set(column) != {'key', 'name'}
+                or not isinstance(column.get('key'), str)
+                or not isinstance(column.get('name'), str)
+                or not column.get('name').strip()
+            ):
+                return self._format_error(meta)
+            actual_keys.append(column['key'])
+        if actual_keys != expected_keys or pagination is None:
+            return self._format_error(meta)
+
+        amount_keys = {
+            'original_received_amount', 'actual_received_amount',
+            'verified_amount', 'unverified_amount',
+        }
+        rows = []
+        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 key in amount_keys:
+                    if (
+                        isinstance(value, bool)
+                        or not isinstance(value, (int, float))
+                        or not math.isfinite(float(value))
+                    ):
+                        return self._format_error(meta)
+                elif not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+
+        headers = [
+            {'label': self.PAYMENT_RECORD_COLUMNS[key]}
+            for key in expected_keys
+        ]
+        content = {'headers': headers, 'rows': rows, 'pagination': pagination}
+        return self._success_result(content, self._render_table(content), meta)
+
+    def _present_order_receivable_cost_details(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        expected_keys = list(self.RECEIVABLE_COST_COLUMNS)
+        if not isinstance(columns, list) or not isinstance(records, list):
+            return self._format_error(meta)
+        actual_keys = []
+        for column in columns:
+            if (
+                not isinstance(column, dict)
+                or set(column) != {'key', 'name'}
+                or not isinstance(column.get('key'), str)
+                or not isinstance(column.get('name'), str)
+                or not column.get('name').strip()
+            ):
+                return self._format_error(meta)
+            actual_keys.append(column['key'])
+        if actual_keys != expected_keys or pagination is None:
+            return self._format_error(meta)
+
+        numeric_keys = {
+            'charge_weight', 'unit_price', 'receivable_original_amount',
+            'receivable_amount_cny', 'settlement_amount_cny',
+        }
+        rows = []
+        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 key in numeric_keys:
+                    if (
+                        isinstance(value, bool)
+                        or not isinstance(value, (int, float))
+                        or not math.isfinite(float(value))
+                    ):
+                        return self._format_error(meta)
+                elif not isinstance(value, str):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+
+        headers = [
+            {'label': self.RECEIVABLE_COST_COLUMNS[key]}
+            for key in expected_keys
+        ]
+        content = {'headers': headers, 'rows': rows, 'pagination': pagination}
+        return self._success_result(content, self._render_table(content), meta)
+
     @staticmethod
     def _customer_billing_modes(value):
         if not isinstance(value, list):

+ 2 - 2
tests/test_customer_payment_followup_tool.py

@@ -108,11 +108,11 @@ class CustomerPaymentFollowupToolTest(unittest.TestCase):
         with self.assertRaises(RuntimeError):
             tool_class().call()
 
-    def test_local_public_registries_and_cli_have_seventeen_tools(self):
+    def test_local_public_registries_and_cli_have_eighteen_tools(self):
         local = GatewayApp(api_client=RecordingApiClient())
         public = PublicGatewayApp(None, None)
         self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
-        self.assertEqual(17, len(local.registered_tool_names()))
+        self.assertEqual(19, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_followup', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 254 - 0
tests/test_customer_payment_records_tool.py

@@ -0,0 +1,254 @@
+import copy
+import importlib.util
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_payment_records']},
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class CustomerPaymentRecordsToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customer_payment_records.py',
+        )
+        self.assertTrue(os.path.isfile(path), '客户回款记录 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('payment_records_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryCustomerPaymentRecordsTool
+
+    def test_closed_schema_requires_customer_and_forwards_dates(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        metadata = tool.metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_customer_payment_records', metadata['name'])
+        self.assertEqual(['customer_id'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({
+            'customer_id', 'receive_date_start', 'receive_date_end',
+            'page', 'limit',
+        }, set(schema['properties']))
+        self.assertEqual('date', schema['properties']['receive_date_start']['format'])
+        self.assertIn('list_customer_filter_options', metadata['description'])
+        self.assertIn('不得根据名称猜测', metadata['description'])
+
+        result = tool.call(
+            customer_id=7,
+            receive_date_start='2026-01-01',
+            receive_date_end='2026-12-31',
+            page=2,
+            limit=30,
+            request_id='rq_records',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_payment_records',
+            '/mcp/tools/queryCustomerPaymentRecords',
+            {
+                'customer_id': 7,
+                'receive_date_start': '2026-01-01',
+                'receive_date_end': '2026-12-31',
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_records',
+        ), client.calls[0])
+
+    def test_invalid_arguments_and_oversized_date_range_fail(self):
+        tool_class = self.load_tool_class()
+        tool = tool_class(RecordingApiClient())
+        invalid = (
+            {'customer_id': True}, {'customer_id': 0}, {'customer_id': '7'},
+            {'customer_id': 7, 'receive_date_start': '2026-02-30'},
+            {'customer_id': 7, 'receive_date_start': 20260101},
+            {'customer_id': 7, 'receive_date_start': '20260101'},
+            {'customer_id': 7, 'receive_date_start': '2026-02-02', 'receive_date_end': '2026-02-01'},
+            {'customer_id': 7, 'receive_date_start': '2025-01-01', 'receive_date_end': '2026-01-02'},
+            {'customer_id': 7, 'page': 0}, {'customer_id': 7, 'page': 101},
+            {'customer_id': 7, 'limit': False}, {'customer_id': 7, 'limit': 101},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+        with self.assertRaises(RuntimeError):
+            tool_class().call(customer_id=7)
+
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call(customer_id=7, receive_date_start='2026-01-01')
+        self.assertEqual('2026-01-01', client.calls[-1][2]['receive_date_start'])
+        self.assertNotIn('receive_date_end', client.calls[-1][2])
+        tool.call(customer_id=7, receive_date_end='2026-12-31')
+        self.assertEqual('2026-12-31', client.calls[-1][2]['receive_date_end'])
+        self.assertNotIn('receive_date_start', client.calls[-1][2])
+        tool.call(
+            customer_id=7,
+            receive_date_start='2025-01-01',
+            receive_date_end='2026-01-01',
+        )
+        self.assertEqual('2026-01-01', client.calls[-1][2]['receive_date_end'])
+
+    def test_registries_and_cli_have_eighteen_tools(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(19, len(local.registered_tool_names()))
+        self.assertIn('query_customer_payment_records', local.registered_tool_names())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-start', '2026-01-01',
+            '--receive-date-end', '2026-12-31', '--page', '2', '--limit', '30',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual({
+            'customer_id': 7,
+            'receive_date_start': '2026-01-01',
+            'receive_date_end': '2026-12-31',
+            'page': 2,
+            'limit': 30,
+        }, client.calls[-1][2])
+
+        start_only = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-start', '2026-01-01',
+        ], stdout=start_only)
+        self.assertIn('receive_date_start', client.calls[-1][2])
+        self.assertNotIn('receive_date_end', client.calls[-1][2])
+
+        end_only = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '7', '--receive-date-end', '2026-12-31',
+        ], stdout=end_only)
+        self.assertIn('receive_date_end', client.calls[-1][2])
+        self.assertNotIn('receive_date_start', client.calls[-1][2])
+
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_customer_payment_records',
+            ], stdout=io.StringIO())
+
+
+class CustomerPaymentRecordsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'customer_name', 'payment_reference', 'original_received_amount',
+        'actual_received_amount', 'receive_date', 'verified_amount',
+        'unverified_amount', 'payment_approval_status',
+    ]
+    HEADERS = [
+        '客户名称', '收款水单号', '原币到账金额', '实际收款金额',
+        '收款日期', '已核销金额', '未核销金额', '收款审核状态',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'customer_name': '甲客户',
+                    'payment_reference': 'BANK-260001',
+                    'original_received_amount': 100.25,
+                    'actual_received_amount': 700.5,
+                    'receive_date': '2026-07-20 16:30:00',
+                    'verified_amount': 500.25,
+                    'unverified_amount': 200.25,
+                    'payment_approval_status': '审核通过',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_presenter_translates_exact_eight_columns(self):
+        result = OutputPresenter().present(
+            'query_customer_payment_records', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': header} for header in self.HEADERS],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(8, len(result['structured_content']['rows'][0]))
+        serialized = json.dumps(result, ensure_ascii=False)
+        for key in self.KEYS:
+            self.assertNotIn(key, serialized)
+
+    def test_unknown_missing_malformed_and_nonfinite_fields_fail_closed(self):
+        cases = []
+        unknown = self.payload()
+        unknown['data']['records'][0]['secret'] = 'hidden'
+        cases.append(unknown)
+        missing = self.payload()
+        del missing['data']['records'][0]['payment_reference']
+        cases.append(missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+        bad_records = self.payload()
+        bad_records['data']['records'] = 'bad'
+        cases.append(bad_records)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['customer_name'] = []
+        cases.append(bad_text)
+        bool_amount = self.payload()
+        bool_amount['data']['records'][0]['verified_amount'] = True
+        cases.append(bool_amount)
+        for amount in (float('inf'), float('-inf'), float('nan'), '100.00'):
+            bad_amount = self.payload()
+            bad_amount['data']['records'][0]['actual_received_amount'] = amount
+            cases.append(bad_amount)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        bad_meta = self.payload()
+        bad_meta['meta']['total'] = 1
+        cases.append(bad_meta)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_customer_payment_records', payload
+                )['is_error'])
+
+    def test_payload_factory_does_not_share_nested_state(self):
+        self.assertEqual(self.payload(), copy.deepcopy(self.payload()))
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 2 - 2
tests/test_customer_query_tools.py

@@ -64,11 +64,11 @@ class CustomerQueryToolTest(unittest.TestCase):
         with self.assertRaisesRegex(RuntimeError, 'api client is required'):
             QueryCustomerListTool().call()
 
-    def test_local_and_public_registries_are_identical_and_have_17_tools(self):
+    def test_local_and_public_registries_are_identical_and_have_18_tools(self):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(17, len(local))
+        self.assertEqual(19, 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(17, len(local.registered_tool_names()))
+        self.assertEqual(19, len(local.registered_tool_names()))
         self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
 
         output = StringIO()

+ 195 - 0
tests/test_order_receivable_cost_details_tool.py

@@ -0,0 +1,195 @@
+import copy
+import importlib.util
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_order_receivable_cost_details']},
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class OrderReceivableCostDetailsToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_order_receivable_cost_details.py',
+        )
+        self.assertTrue(os.path.isfile(path), '订单应收费用 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('receivable_cost_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryOrderReceivableCostDetailsTool
+
+    def test_closed_schema_requires_explicit_order_number_and_forwards_exactly(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        metadata = tool_class(client).metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_order_receivable_cost_details', metadata['name'])
+        self.assertEqual(['order_number'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({'order_number', 'page', 'limit'}, set(schema['properties']))
+        self.assertIn('明确订单号', metadata['description'])
+        self.assertIn('不得使用参考号', metadata['description'])
+        self.assertIn('不得模糊', metadata['description'])
+
+        result = tool_class(client).call(
+            order_number='ORD-260001', page=2, limit=30, request_id='rq_cost'
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_order_receivable_cost_details',
+            '/mcp/tools/queryOrderReceivableCostDetails',
+            {'order_number': 'ORD-260001', 'page': 2, 'limit': 30},
+            'rq_cost',
+        ), client.calls[-1])
+
+    def test_invalid_arguments_fail_before_backend_call(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        for arguments in (
+            {}, {'order_number': ''}, {'order_number': '   '},
+            {'order_number': 123}, {'order_number': True},
+            {'order_number': 'x' * 101}, {'order_number': 'ORD-1', 'page': 0},
+            {'order_number': 'ORD-1', 'page': 101},
+            {'order_number': 'ORD-1', 'limit': False},
+            {'order_number': 'ORD-1', 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual([], client.calls)
+        with self.assertRaises(RuntimeError):
+            tool_class().call(order_number='ORD-1')
+
+    def test_registries_cli_and_payment_records_remain_available(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(19, 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())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_order_receivable_cost_details',
+            '--order-number', 'ORD-260001', '--page', '2', '--limit', '30',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual('ORD-260001', client.calls[-1][2]['order_number'])
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_order_receivable_cost_details',
+            ], stdout=io.StringIO())
+
+
+class OrderReceivableCostDetailsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'order_number', 'customer_name', 'cost_name', 'cost_customer_name',
+        'charge_weight', 'unit_price', 'quote_currency',
+        'receivable_original_amount', 'receivable_amount_cny',
+        'settlement_amount_cny', 'cost_confirmation_status',
+        'closing_status', 'verification_status',
+    ]
+    HEADERS = [
+        '订单号', '客户名称', '费用项', '费用客户', '计费重', '计费单价',
+        '原币币种', '应收原币', '应收金额(CNY)', '结算金额(CNY)',
+        '费用确认状态', '关账状态', '核销状态',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'order_number': 'ORD-260001', 'customer_name': '主客户',
+                    'cost_name': '海运费', 'cost_customer_name': '子客户(SC01)',
+                    'charge_weight': 12.35, 'unit_price': 8.5,
+                    'quote_currency': 'USD', 'receivable_original_amount': 10.0,
+                    'receivable_amount_cny': 72.0, 'settlement_amount_cny': 39.0,
+                    'cost_confirmation_status': '已确认', 'closing_status': '部分关账',
+                    'verification_status': '是',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_presenter_translates_exact_thirteen_columns(self):
+        result = OutputPresenter().present(
+            'query_order_receivable_cost_details', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': header} for header in self.HEADERS],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(13, len(result['structured_content']['rows'][0]))
+        serialized = json.dumps(result, ensure_ascii=False)
+        for key in self.KEYS:
+            self.assertNotIn(key, serialized)
+
+    def test_unknown_missing_reordered_and_bad_values_fail_closed(self):
+        cases = []
+        unknown = self.payload()
+        unknown['data']['records'][0]['secret'] = 'hidden'
+        cases.append(unknown)
+        missing = self.payload()
+        del missing['data']['records'][0]['cost_name']
+        cases.append(missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        bad_meta = self.payload()
+        bad_meta['meta']['total'] = 1
+        cases.append(bad_meta)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['order_number'] = []
+        cases.append(bad_text)
+        for amount in (True, '10.00', float('inf'), float('-inf'), float('nan')):
+            bad_amount = self.payload()
+            bad_amount['data']['records'][0]['receivable_amount_cny'] = amount
+            cases.append(bad_amount)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_order_receivable_cost_details', payload
+                )['is_error'])
+
+    def test_payload_factory_does_not_share_nested_state(self):
+        self.assertEqual(self.payload(), copy.deepcopy(self.payload()))
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 1 - 1
tests/test_output_presenter.py

@@ -203,7 +203,7 @@ class OutputPresenterTest(unittest.TestCase):
     def test_customer_tools_are_safe_and_total_is_16(self):
         self.assertTrue(self.presenter.handles(QueryCustomerListTool.name))
         self.assertTrue(self.presenter.handles(ListCustomerFilterOptionsTool.name))
-        self.assertEqual(16, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(18, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(

+ 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(17, len(local))
+        self.assertEqual(19, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 104 - 0
tools/query_customer_payment_records.py

@@ -0,0 +1,104 @@
+from datetime import date
+
+
+class QueryCustomerPaymentRecordsTool:
+    name = 'query_customer_payment_records'
+    route_path = '/mcp/tools/queryCustomerPaymentRecords'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '分页查询单个客户的逐笔收款记录、已核销金额和未核销金额。必须先调用'
+                'list_customer_filter_options并使用客户名称筛选取得customer_id;不得根据名称猜测'
+                'customer_id,不得模糊查询或改用其他工具试查。收款日期可按闭区间筛选,'
+                '起止日期同时提供时闭区间最多包含366个日历日。公司、员工、菜单权限和客户数据范围由'
+                '当前设备会话确定,调用方不得覆盖。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'customer_id': {'type': 'integer', 'minimum': 1},
+                    'receive_date_start': {
+                        'type': 'string', 'format': 'date',
+                        'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+                    },
+                    'receive_date_end': {
+                        'type': 'string', 'format': 'date',
+                        'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['customer_id'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        customer_id,
+        receive_date_start=None,
+        receive_date_end=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_customer_payment_records',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_customer_payment_records'
+            )
+        start = self._optional_date(receive_date_start, 'receive_date_start')
+        end = self._optional_date(receive_date_end, 'receive_date_end')
+        if start and end:
+            span = (date.fromisoformat(end) - date.fromisoformat(start)).days
+            if span < 0 or span > 365:
+                raise ValueError('receive date range is invalid')
+        payload = {
+            'customer_id': self._positive_integer(customer_id, 'customer_id'),
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        if start:
+            payload['receive_date_start'] = start
+        if end:
+            payload['receive_date_end'] = end
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _positive_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._positive_integer(value, field)
+        if value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @staticmethod
+    def _optional_date(value, field):
+        if value is None or value == '':
+            return ''
+        if not isinstance(value, str):
+            raise ValueError('{0} is invalid'.format(field))
+        try:
+            parsed = date.fromisoformat(value)
+        except ValueError:
+            raise ValueError('{0} is invalid'.format(field))
+        if parsed.isoformat() != value:
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 76 - 0
tools/query_order_receivable_cost_details.py

@@ -0,0 +1,76 @@
+class QueryOrderReceivableCostDetailsTool:
+    name = 'query_order_receivable_cost_details'
+    route_path = '/mcp/tools/queryOrderReceivableCostDetails'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '按明确订单号分页查询应收费用行、费用确认、关账和核销状态。调用前必须'
+                '确认用户提供的是订单号;不得使用参考号、物流单号或其他号码猜单,不得模糊'
+                '查询、跨字段试查或改用其他工具重试。公司、员工、菜单权限和客户数据范围由'
+                '当前设备会话确定,调用方不得覆盖。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'order_number': {
+                        'type': 'string', 'minLength': 1, 'maxLength': 100,
+                        'pattern': '.*\\S.*',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['order_number'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        order_number,
+        page=1,
+        limit=20,
+        request_id='rq_query_order_receivable_cost_details',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_order_receivable_cost_details'
+            )
+        payload = {
+            'order_number': self._order_number(order_number),
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _order_number(value):
+        if not isinstance(value, str):
+            raise ValueError('order_number is invalid')
+        value = value.strip()
+        if not value or len(value) > 100:
+            raise ValueError('order_number is invalid')
+        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('{0} is invalid'.format(field))
+        return value