Procházet zdrojové kódy

master_task_7062029557

jackson před 3 týdny
rodič
revize
bafd09c035

+ 12 - 8
README.md

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

+ 90 - 0
app.py

@@ -19,11 +19,15 @@ from services.scoped_api_client import ScopedApiClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
+from tools.list_receivable_cost_filter_options import (
+    ListReceivableCostFilterOptionsTool,
+)
 from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
     ExportOutOfProvincePortDataTool,
 )
+from tools.export_receivable_cost_list import ExportReceivableCostListTool
 from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
@@ -44,6 +48,7 @@ from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
 from tools.query_order_receivable_cost_details import (
     QueryOrderReceivableCostDetailsTool,
 )
+from tools.query_receivable_cost_list import QueryReceivableCostListTool
 
 
 def parse_int_list(value):
@@ -93,6 +98,8 @@ class GatewayApp:
             ),
             'query_order_receivable_cost_details':
                 QueryOrderReceivableCostDetailsTool(api_client=api_client),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=api_client),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=api_client
             ),
@@ -102,11 +109,16 @@ class GatewayApp:
             'list_customer_filter_options': ListCustomerFilterOptionsTool(
                 api_client=api_client
             ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=api_client),
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=api_client
             ),
             'export_out_of_province_port_data':
                 ExportOutOfProvincePortDataTool(api_client=api_client),
+            'export_receivable_cost_list': ExportReceivableCostListTool(
+                api_client=api_client
+            ),
             'query_export_task': QueryExportTaskTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
@@ -257,6 +269,14 @@ 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('--sub-customer-id', type=int, default=0)
+        call_parser.add_argument('--bill-numbers', default='')
+        call_parser.add_argument('--business-date-start', default='')
+        call_parser.add_argument('--business-date-end', default='')
+        call_parser.add_argument('--billing-status', type=int, default=None)
+        call_parser.add_argument('--verification-status', type=int, default=None)
+        call_parser.add_argument('--document-type', type=int, default=None)
+        call_parser.add_argument('--cost-type-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)
@@ -447,6 +467,66 @@ class GatewayApp:
                         '--order-number is required for query_order_receivable_cost_details'
                     )
                 tool_args['order_number'] = args.order_number
+            elif args.tool == 'query_receivable_cost_list':
+                for field, value in (
+                    ('reference_numbers', args.reference_numbers),
+                    ('tracking_numbers', args.tracking_numbers),
+                    ('order_numbers', args.order_numbers),
+                    ('bill_numbers', args.bill_numbers),
+                ):
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                for field, value in (
+                    ('business_date_start', args.business_date_start),
+                    ('business_date_end', args.business_date_end),
+                ):
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('sub_customer_id', args.sub_customer_id),
+                    ('cost_type_id', args.cost_type_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+            elif args.tool == 'export_receivable_cost_list':
+                for field, value in (
+                    ('reference_numbers', args.reference_numbers),
+                    ('tracking_numbers', args.tracking_numbers),
+                    ('order_numbers', args.order_numbers),
+                    ('bill_numbers', args.bill_numbers),
+                ):
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                for field, value in (
+                    ('business_date_start', args.business_date_start),
+                    ('business_date_end', args.business_date_end),
+                ):
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('sub_customer_id', args.sub_customer_id),
+                    ('cost_type_id', args.cost_type_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+                tool_args.pop('page', None)
+                tool_args.pop('limit', None)
             elif args.tool == 'query_customs_declaration_files':
                 if args.outbound_numbers:
                     tool_args['outbound_numbers'] = parse_string_list(
@@ -509,6 +589,16 @@ class GatewayApp:
                         '--task-ref is required for query_export_task'
                     )
                 tool_args = {'task_ref': args.task_ref}
+            elif args.tool == 'list_receivable_cost_filter_options':
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        'list_receivable_cost_filter_options'
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
+                if args.customer_id > 0:
+                    tool_args['customer_id'] = args.customer_id
             elif args.tool in (
                 'list_order_filter_options', 'list_outbound_filter_options',
                 'list_customer_filter_options'

+ 13 - 1
docs/mcp-api.md

@@ -236,6 +236,18 @@
 | 资料类型 | `file_type` | string | 是 | `NB` 宁波、`SH` 上海、`MS` 美森 |
 | 排舱单号数组 / 柜号数组 / 提单号数组 / SO号数组 | `outbound_numbers`、`container_codes`、`bl_numbers`、`so_numbers` | string[] | 条件必填 | 四选一;每项 1-100 个;号码类型必须明确 |
 
+### export_receivable_cost_list(应收费用单列表异步导出)
+
+路由:`POST /mcp/tools/exportReceivableCostList`
+
+筛选合同与 `query_receivable_cost_list` 相同,但不接受 `page/limit/ids`。成功只返回 `task_ref + queued + retry_after_seconds`;稍后单独调用 `query_export_task`,禁止一次调用内轮询。
+
+| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
+|---|---|---|---|---|
+| 参考号 / 跟踪号 / 订单号 / 账单编号 | `reference_numbers`、`tracking_numbers`、`order_numbers`、`bill_numbers` | string[] | 条件必填 | 四类合计最多 200;或改用业务日期 |
+| 业务发生开始/结束日期 | `business_date_start`、`business_date_end` | date | 条件必填 | 无号码时必填;闭区间最多 31 天 |
+| 主客户 / 子客户 / 出账状态 / 核销状态 / 单据类型 / 费用项 | `customer_id`、`sub_customer_id`、`billing_status`、`verification_status`、`document_type`、`cost_type_id` | integer | 否 | 子客户必须带主客户;筛选值来自列表筛选项工具 |
+
 ### query_export_task(查询导出任务)
 
 路由:`POST /mcp/tools/queryExportTask`
@@ -849,7 +861,7 @@ DW授权信息明细:
 | 显示名称 | 给人看的名称 |
 | 业务编码 | 辅助识别编码,可能为空 |
 
-### export_pending_outbound_orders / export_out_of_province_port_data
+### export_pending_outbound_orders / export_out_of_province_port_data / export_receivable_cost_list
 
 只提交异步任务,不返回文件。结构:
 

+ 12 - 0
public_gateway.py

@@ -5,11 +5,15 @@ import uuid
 from constants import DEVICE_INVALID_MESSAGE
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
 from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
+from tools.list_receivable_cost_filter_options import (
+    ListReceivableCostFilterOptionsTool,
+)
 from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
     ExportOutOfProvincePortDataTool,
 )
+from tools.export_receivable_cost_list import ExportReceivableCostListTool
 from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
@@ -30,6 +34,7 @@ from tools.query_customer_payment_records import QueryCustomerPaymentRecordsTool
 from tools.query_order_receivable_cost_details import (
     QueryOrderReceivableCostDetailsTool,
 )
+from tools.query_receivable_cost_list import QueryReceivableCostListTool
 from utils.security import hash_gateway_session_id
 
 
@@ -60,6 +65,8 @@ class PublicGatewayApp:
             ),
             'query_order_receivable_cost_details':
                 QueryOrderReceivableCostDetailsTool(api_client=None),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=None),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=None
             ),
@@ -69,11 +76,16 @@ class PublicGatewayApp:
             'list_customer_filter_options': ListCustomerFilterOptionsTool(
                 api_client=None
             ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=None),
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=None
             ),
             'export_out_of_province_port_data':
                 ExportOutOfProvincePortDataTool(api_client=None),
+            'export_receivable_cost_list': ExportReceivableCostListTool(
+                api_client=None
+            ),
             'query_export_task': QueryExportTaskTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),

+ 143 - 0
services/output_presenter.py

@@ -26,6 +26,10 @@ class OutputPresenter:
     RECEIVABLE_COST_TOOLS = frozenset((
         'query_order_receivable_cost_details',
     ))
+    RECEIVABLE_LIST_TOOLS = frozenset(('query_receivable_cost_list',))
+    RECEIVABLE_FILTER_TOOLS = frozenset((
+        'list_receivable_cost_filter_options',
+    ))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -37,11 +41,13 @@ class OutputPresenter:
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
         'export_out_of_province_port_data',
+        'export_receivable_cost_list',
     ))
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
         TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS | PAYMENT_FOLLOWUP_TOOLS
         | PAYMENT_DETAIL_TOOLS | PAYMENT_RECORD_TOOLS | RECEIVABLE_COST_TOOLS
+        | RECEIVABLE_LIST_TOOLS | RECEIVABLE_FILTER_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -388,6 +394,32 @@ class OutputPresenter:
         'closing_status': '关账状态',
         'verification_status': '核销状态',
     }
+    RECEIVABLE_LIST_COLUMNS = {
+        'main_customer_name': '主客户',
+        'sub_customer_name': '子客户',
+        'customer_attribute': '客户属性',
+        'department_name': '事业部',
+        'document_type': '单据类型',
+        'warehouse_name': '仓库',
+        'business_number': '业务单号',
+        'reference_number': '参考号',
+        'cost_name': '费用项',
+        'original_amount': '原币金额',
+        'total_amount': '总金额',
+        'billed_amount': '已出账金额',
+        'unbilled_amount': '未出账金额',
+        'bill_number': '账单编号',
+        'verification_status': '核销状态',
+        'receipt_number': '收款水单号',
+        'receipt_date': '收款日期',
+        'sales_name': '商务经理',
+        'merchandiser_name': '客户经理',
+        'drainage_user_name': '引流人',
+        'first_leg_order_status': '头程订单状态',
+        'settlement_mode': '结算模式',
+        'cost_occurred_at': '费用发生时间',
+        'business_occurred_at': '业务发生时间',
+    }
     PAYMENT_FOLLOWUP_MONTHLY_COLUMNS = {
         'receivable_month': '应收月份',
         'unverified_amount': '未核销金额',
@@ -549,6 +581,20 @@ class OutputPresenter:
             'so_numbers': 'SO号',
             'file_type': '资料类型',
         },
+        'export_receivable_cost_list': {
+            'reference_numbers': '参考号',
+            'tracking_numbers': '跟踪号',
+            'order_numbers': '订单号',
+            'bill_numbers': '账单编号',
+            'business_date_start': '业务发生开始日期',
+            'business_date_end': '业务发生结束日期',
+            'customer_id': '主客户',
+            'sub_customer_id': '子客户',
+            'billing_status': '出账状态',
+            'verification_status': '核销状态',
+            'document_type': '单据类型',
+            'cost_type_id': '费用项',
+        },
         'query_export_task': {
             'task_ref': '导出任务引用',
         },
@@ -629,6 +675,14 @@ class OutputPresenter:
             return self._present_order_receivable_cost_details(
                 data, tool_result.get('meta'), meta
             )
+        if tool_name in self.RECEIVABLE_LIST_TOOLS:
+            return self._present_receivable_cost_list(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.RECEIVABLE_FILTER_TOOLS:
+            return self._present_receivable_cost_filter_options(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -1253,6 +1307,95 @@ class OutputPresenter:
         content = {'headers': headers, 'rows': rows, 'pagination': pagination}
         return self._success_result(content, self._render_table(content), meta)
 
+    def _present_receivable_cost_list(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_LIST_COLUMNS)
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected_keys)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, key in zip(columns, expected_keys):
+            if column != {
+                'key': key,
+                'name': self.RECEIVABLE_LIST_COLUMNS[key],
+            }:
+                return self._format_error(meta)
+
+        amount_keys = {
+            'original_amount', 'total_amount', 'billed_amount', 'unbilled_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 not self._valid_amount_cell(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_LIST_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 _valid_amount_cell(value):
+        if not isinstance(value, dict) or set(value) != {'amount', 'currency'}:
+            return False
+        amount = value.get('amount')
+        return (
+            not isinstance(amount, bool)
+            and isinstance(amount, (int, float))
+            and math.isfinite(float(amount))
+            and isinstance(value.get('currency'), str)
+        )
+
+    def _present_receivable_cost_filter_options(self, data, raw_meta, meta):
+        if set(data) != {'records'}:
+            return self._format_error(meta)
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        if not isinstance(records, list) or pagination is None:
+            return self._format_error(meta)
+        rows = []
+        for record in records:
+            if not isinstance(record, dict) or set(record) != {
+                'value', 'label', 'code',
+            }:
+                return self._format_error(meta)
+            value = record.get('value')
+            label = record.get('label')
+            code = record.get('code')
+            if (
+                isinstance(value, bool) or not isinstance(value, int)
+                or not isinstance(label, str) or not label.strip()
+                or not isinstance(code, str)
+            ):
+                return self._format_error(meta)
+            rows.append([value, label.strip(), code.strip()])
+        content = {
+            'headers': [
+                {'label': '可传值'}, {'label': '显示名称'}, {'label': '业务编码'},
+            ],
+            '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):

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

+ 209 - 0
tests/test_export_receivable_cost_list_tool.py

@@ -0,0 +1,209 @@
+import io
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.export_receivable_cost_list import ExportReceivableCostListTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['export_receivable_cost_list']},
+        }
+
+    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': {
+                'task_ref': 'mexp_test',
+                'status': 'queued',
+                'retry_after_seconds': 10,
+            },
+        }
+
+
+class ExportReceivableCostListToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_matches_list_filters_without_pagination(self):
+        metadata = ExportReceivableCostListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_receivable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({
+            'reference_numbers', 'tracking_numbers', 'order_numbers',
+            'bill_numbers', 'business_date_start', 'business_date_end',
+            'customer_id', 'sub_customer_id', 'billing_status',
+            'verification_status', 'document_type', 'cost_type_id',
+        }, set(schema['properties']))
+        self.assertNotIn('page', schema['properties'])
+        self.assertNotIn('limit', schema['properties'])
+        self.assertNotIn('ids', schema['properties'])
+        self.assertIn('明确要求导出应收费用单', metadata['description'])
+        self.assertIn('query_receivable_cost_list', metadata['description'])
+        self.assertIn('query_export_task', metadata['description'])
+        self.assertIn('禁止在一次调用内轮询', metadata['description'])
+
+    def test_call_forwards_exact_filters(self):
+        client = RecordingApiClient()
+        result = ExportReceivableCostListTool(client).call(
+            order_numbers=[' ORDER-1 '],
+            billing_status=0,
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual(
+            (
+                'export_receivable_cost_list',
+                '/mcp/tools/exportReceivableCostList',
+                {
+                    'order_numbers': ['ORDER-1'],
+                    'billing_status': 0,
+                },
+                'rq_export_receivable_cost_list',
+            ),
+            client.calls[0],
+        )
+
+    def test_call_validation_boundaries(self):
+        tool = ExportReceivableCostListTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool.call(order_numbers=['O1'])
+
+        client = RecordingApiClient()
+        tool = ExportReceivableCostListTool(client)
+        with self.assertRaisesRegex(ValueError, 'non-empty list'):
+            tool.call(order_numbers='O1')
+        with self.assertRaisesRegex(ValueError, 'must be strings'):
+            tool.call(order_numbers=[1])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(order_numbers=[''])
+        with self.assertRaisesRegex(ValueError, '1 to 100 chars'):
+            tool.call(order_numbers=['x' * 101])
+        with self.assertRaisesRegex(ValueError, 'at most 200'):
+            tool.call(order_numbers=['n{0}'.format(i) for i in range(201)])
+        with self.assertRaisesRegex(ValueError, 'both start and end'):
+            tool.call(business_date_start='2026-07-01')
+        with self.assertRaisesRegex(ValueError, 'within 31 days'):
+            tool.call(
+                business_date_start='2026-07-01',
+                business_date_end='2026-08-01',
+            )
+        with self.assertRaisesRegex(ValueError, 'number filters or a business'):
+            tool.call()
+        with self.assertRaisesRegex(ValueError, 'requires customer_id'):
+            tool.call(order_numbers=['O1'], sub_customer_id=9)
+
+        ExportReceivableCostListTool(client).call(
+            order_numbers=['ORDER-1', ' ORDER-1 '],
+        )
+        self.assertEqual({'order_numbers': ['ORDER-1']}, client.calls[-1][2])
+
+        ExportReceivableCostListTool(client).call(
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            customer_id=7,
+            sub_customer_id=9,
+            verification_status=-1,
+            document_type=8,
+            cost_type_id=12,
+        )
+        self.assertEqual(
+            {
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'verification_status': -1,
+                'document_type': 8,
+                'cost_type_id': 12,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_cli_forwards_export_filters(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        code = app.run_cli([
+            'call', '--tool', 'export_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual({'order_numbers': ['ORDER-1']}, client.calls[-1][2])
+
+        code = app.run_cli([
+            'call', '--tool', 'export_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+            '--business-date-start', '2026-07-01',
+            '--business-date-end', '2026-07-31',
+            '--customer-id', '7',
+            '--sub-customer-id', '9',
+            '--billing-status', '0',
+            '--verification-status', '-1',
+            '--document-type', '8',
+            '--cost-type-id', '12',
+        ], stdout=io.StringIO())
+        self.assertEqual(0, code)
+        self.assertEqual('export_receivable_cost_list', client.calls[-1][0])
+        self.assertEqual(
+            {
+                'order_numbers': ['ORDER-1'],
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'billing_status': 0,
+                'verification_status': -1,
+                'document_type': 8,
+                'cost_type_id': 12,
+            },
+            client.calls[-1][2],
+        )
+
+    def test_local_and_public_registries_include_export_tool(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(22, len(local))
+        self.assertIn('export_receivable_cost_list', local)
+        self.assertEqual(21, len(OutputPresenter.SAFE_TOOLS))
+
+    def test_presenter_reuses_queued_export_contract(self):
+        presented = OutputPresenter().present(
+            'export_receivable_cost_list',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_test',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(presented['is_error'])
+        task = presented['structured_content']['task']
+        self.assertEqual('queued', task['status'])
+        self.assertEqual('mexp_test', task['task_ref'])
+        self.assertEqual(10, task['retry_after_seconds'])
+
+    def test_wrong_route_path_fails_and_restore_passes(self):
+        tool = ExportReceivableCostListTool()
+        original = tool.route_path
+        tool.route_path = '/mcp/tools/exportReceivableCostListWrong'
+        self.assertNotEqual(
+            '/mcp/tools/exportReceivableCostList',
+            tool.route_path,
+        )
+        tool.route_path = original
+        self.assertEqual(
+            '/mcp/tools/exportReceivableCostList',
+            tool.route_path,
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 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(19, len(local.registered_tool_names()))
+        self.assertEqual(22, 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())
 

+ 2 - 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(18, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(21, len(self.presenter.SAFE_TOOLS))
 
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(
@@ -440,6 +440,7 @@ class OutputPresenterTest(unittest.TestCase):
         for tool_name in (
             'export_pending_outbound_orders',
             'export_out_of_province_port_data',
+            'export_receivable_cost_list',
         ):
             with self.subTest(tool_name=tool_name):
                 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(19, len(local))
+        self.assertEqual(22, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 480 - 0
tests/test_receivable_cost_list_tools.py

@@ -0,0 +1,480 @@
+import copy
+import importlib.util
+import io
+import json
+import math
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+ROOT = os.path.dirname(os.path.dirname(__file__))
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'tool_codes': [
+                    'query_receivable_cost_list',
+                    'list_receivable_cost_filter_options',
+                ],
+            },
+        }
+
+    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 ToolLoaderMixin:
+    def load_tool(self, filename, class_name):
+        path = os.path.join(ROOT, 'tools', filename)
+        self.assertTrue(os.path.isfile(path), filename + ' is not implemented')
+        spec = importlib.util.spec_from_file_location(class_name, path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return getattr(module, class_name)
+
+
+class ReceivableCostListToolTest(ToolLoaderMixin, unittest.TestCase):
+    NUMBER_FIELDS = (
+        'reference_numbers',
+        'tracking_numbers',
+        'order_numbers',
+        'bill_numbers',
+    )
+
+    def tool_class(self):
+        return self.load_tool(
+            'query_receivable_cost_list.py',
+            'QueryReceivableCostListTool',
+        )
+
+    def test_schema_is_closed_exact_and_zero_inference(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_receivable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertNotIn('number', schema['properties'])
+        self.assertEqual({
+            'reference_numbers', 'tracking_numbers', 'order_numbers',
+            'bill_numbers', 'business_date_start', 'business_date_end',
+            'customer_id', 'sub_customer_id', 'billing_status',
+            'verification_status', 'document_type', 'cost_type_id',
+            'page', 'limit',
+        }, set(schema['properties']))
+        for field in self.NUMBER_FIELDS:
+            definition = schema['properties'][field]
+            self.assertEqual('array', definition['type'])
+            self.assertEqual(1, definition['minItems'])
+            self.assertEqual(200, definition['maxItems'])
+            self.assertEqual({
+                'type': 'string', 'minLength': 1, 'maxLength': 100,
+                'pattern': '.*\\S.*',
+            }, definition['items'])
+        self.assertEqual([0, 1], schema['properties']['billing_status']['enum'])
+        self.assertEqual(
+            [-1, 0, 1],
+            schema['properties']['verification_status']['enum'],
+        )
+        self.assertEqual(0, schema['properties']['document_type']['minimum'])
+        self.assertEqual(1, schema['properties']['cost_type_id']['minimum'])
+        self.assertIn('禁止根据格式猜测', metadata['description'])
+        self.assertIn(
+            'list_receivable_cost_filter_options',
+            metadata['description'],
+        )
+
+    def test_call_forwards_exact_filters_and_date_pair(self):
+        tool_class = self.tool_class()
+        client = RecordingApiClient()
+        result = tool_class(client).call(
+            reference_numbers=[' REF-1 '],
+            tracking_numbers=['TRACK-1'],
+            order_numbers=['ORDER-1'],
+            bill_numbers=['BILL-1'],
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            customer_id=7,
+            sub_customer_id=9,
+            billing_status=0,
+            verification_status=-1,
+            document_type=0,
+            cost_type_id=12,
+            page=2,
+            limit=30,
+            request_id='rq_receivable',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_receivable_cost_list',
+            '/mcp/tools/queryReceivableCostList',
+            {
+                'reference_numbers': ['REF-1'],
+                'tracking_numbers': ['TRACK-1'],
+                'order_numbers': ['ORDER-1'],
+                'bill_numbers': ['BILL-1'],
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-07-31',
+                'customer_id': 7,
+                'sub_customer_id': 9,
+                'billing_status': 0,
+                'verification_status': -1,
+                'document_type': 0,
+                'cost_type_id': 12,
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_receivable',
+        ), client.calls[-1])
+
+    def test_call_enforces_cross_field_and_strict_boundaries(self):
+        tool = self.tool_class()(RecordingApiClient())
+        invalid = [
+            {},
+            {'business_date_start': '2026-07-01'},
+            {'business_date_end': '2026-07-31'},
+            {
+                'business_date_start': '2026-07-01',
+                'business_date_end': '2026-08-01',
+            },
+            {'reference_numbers': []},
+            {'reference_numbers': [7]},
+            {'reference_numbers': [' ']},
+            {'reference_numbers': ['x' * 101]},
+            {'reference_numbers': ['x'] * 201},
+            {
+                'reference_numbers': ['x'] * 101,
+                'tracking_numbers': ['y'] * 100,
+            },
+            {'reference_numbers': ['x'], 'sub_customer_id': 9},
+            {'reference_numbers': ['x'], 'customer_id': True},
+            {'reference_numbers': ['x'], 'billing_status': 2},
+            {'reference_numbers': ['x'], 'verification_status': 2},
+            {'reference_numbers': ['x'], 'document_type': -1},
+            {'reference_numbers': ['x'], 'cost_type_id': 0},
+            {'reference_numbers': ['x'], 'page': 101},
+            {'reference_numbers': ['x'], 'limit': 1.5},
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': 20260701,
+                'business_date_end': '2026-07-31',
+            },
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': 'not-a-date',
+                'business_date_end': '2026-07-31',
+            },
+            {
+                'reference_numbers': ['x'],
+                'business_date_start': '20260701',
+                'business_date_end': '2026-07-31',
+            },
+        ]
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual([], tool.api_client.calls)
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            self.tool_class()().call(reference_numbers=['REF-1'])
+
+
+class ReceivableCostFilterOptionsToolTest(ToolLoaderMixin, unittest.TestCase):
+    FILTER_TYPES = ['主客户', '子客户', '出账状态', '核销状态', '单据类型', '费用项']
+
+    def tool_class(self):
+        return self.load_tool(
+            'list_receivable_cost_filter_options.py',
+            'ListReceivableCostFilterOptionsTool',
+        )
+
+    def test_schema_has_exact_types_and_customer_dependency(self):
+        metadata = self.tool_class()().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            self.FILTER_TYPES,
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertEqual(
+            {'type': 'integer', 'minimum': 1},
+            schema['properties']['customer_id'],
+        )
+        self.assertIn('子客户', json.dumps(schema, ensure_ascii=False))
+        self.assertIn('禁止猜测', metadata['description'])
+
+    def test_call_forwards_and_enforces_customer_linkage(self):
+        tool_class = self.tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call(
+            filter_type=' 子客户 ',
+            customer_id=7,
+            keyword=' 子 ',
+            page=2,
+            limit=30,
+            request_id='rq_filters',
+        )
+        self.assertEqual((
+            'list_receivable_cost_filter_options',
+            '/mcp/tools/listReceivableCostFilterOptions',
+            {
+                'filter_type': '子客户',
+                'customer_id': 7,
+                'keyword': '子',
+                'page': 2,
+                'limit': 30,
+            },
+            'rq_filters',
+        ), client.calls[-1])
+        tool.call(filter_type='主客户')
+        for arguments in (
+            {'filter_type': '子客户'},
+            {'filter_type': 7},
+            {'filter_type': '子客户', 'customer_id': 0},
+            {'filter_type': '主客户', 'customer_id': 7},
+            {'filter_type': '其他'},
+            {'filter_type': '主客户', 'keyword': 7},
+            {'filter_type': '主客户', 'keyword': 'x' * 101},
+            {'filter_type': '主客户', 'page': 0},
+            {'filter_type': '主客户', 'limit': True},
+            {'filter_type': '主客户', 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+        self.assertEqual(2, len(client.calls))
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            tool_class().call('主客户')
+
+
+class ReceivableCostIntegrationTest(unittest.TestCase):
+    def test_registries_are_identical_ordered_and_counts_are_current(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(22, len(local))
+        self.assertEqual(21, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(
+            (
+                'query_receivable_cost_list',
+                'list_receivable_cost_filter_options',
+            ),
+            tuple(
+                name for name in local
+                if name in {
+                    'query_receivable_cost_list',
+                    'list_receivable_cost_filter_options',
+                }
+            ),
+        )
+
+    def test_cli_forwards_both_tools(self):
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_receivable_cost_list',
+            '--reference-numbers', ' REF-1,REF-2 ',
+            '--bill-numbers', ' BILL-1 ',
+            '--business-date-start', '2026-07-01',
+            '--business-date-end', '2026-07-31',
+            '--customer-id', '7', '--sub-customer-id', '9',
+            '--billing-status', '0', '--verification-status', '-1',
+            '--document-type', '0', '--cost-type-id', '12',
+            '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'reference_numbers': ['REF-1', 'REF-2'],
+            'bill_numbers': ['BILL-1'],
+            'business_date_start': '2026-07-01',
+            'business_date_end': '2026-07-31',
+            'customer_id': 7,
+            'sub_customer_id': 9,
+            'billing_status': 0,
+            'verification_status': -1,
+            'document_type': 0,
+            'cost_type_id': 12,
+            'page': 2,
+            'limit': 30,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'query_receivable_cost_list',
+            '--order-numbers', 'ORDER-1',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'order_numbers': ['ORDER-1'], 'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'list_receivable_cost_filter_options',
+            '--filter-type', '子客户', '--customer-id', '7',
+            '--keyword', '子',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'filter_type': '子客户', 'customer_id': 7,
+            'keyword': '子', 'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        app.run_cli([
+            'call', '--tool', 'list_receivable_cost_filter_options',
+            '--filter-type', '主客户',
+        ], stdout=io.StringIO())
+        self.assertEqual({
+            'filter_type': '主客户', 'keyword': '',
+            'page': 1, 'limit': 20,
+        }, client.calls[-1][2])
+        with self.assertRaisesRegex(ValueError, '--filter-type is required'):
+            app.run_cli([
+                'call', '--tool', 'list_receivable_cost_filter_options',
+            ], stdout=io.StringIO())
+
+
+class ReceivableCostPresenterTest(unittest.TestCase):
+    KEYS = [
+        'main_customer_name', 'sub_customer_name', 'customer_attribute',
+        'department_name', 'document_type', 'warehouse_name', 'business_number',
+        'reference_number', 'cost_name', 'original_amount', 'total_amount',
+        'billed_amount', 'unbilled_amount', 'bill_number', 'verification_status',
+        'receipt_number', 'receipt_date', 'sales_name', 'merchandiser_name',
+        'drainage_user_name', 'first_leg_order_status', 'settlement_mode',
+        'cost_occurred_at', 'business_occurred_at',
+    ]
+    NAMES = [
+        '主客户', '子客户', '客户属性', '事业部', '单据类型', '仓库', '业务单号',
+        '参考号', '费用项', '原币金额', '总金额', '已出账金额', '未出账金额',
+        '账单编号', '核销状态', '收款水单号', '收款日期', '商务经理',
+        '客户经理', '引流人', '头程订单状态', '结算模式', '费用发生时间',
+        '业务发生时间',
+    ]
+    AMOUNT_KEYS = {
+        'original_amount', 'total_amount', 'billed_amount', 'unbilled_amount',
+    }
+
+    def payload(self):
+        record = {key: '值' for key in self.KEYS}
+        record.update({
+            'original_amount': {'amount': 10.25, 'currency': 'USD'},
+            'total_amount': {'amount': 70.5, 'currency': 'CNY'},
+            'billed_amount': {'amount': 20.0, 'currency': 'CNY'},
+            'unbilled_amount': {'amount': 50.5, 'currency': 'CNY'},
+        })
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [
+                    {'key': key, 'name': name}
+                    for key, name in zip(self.KEYS, self.NAMES)
+                ],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_receivable',
+            },
+        }
+
+    def test_query_presenter_requires_exact_contract(self):
+        result = OutputPresenter().present(
+            'query_receivable_cost_list',
+            self.payload(),
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': name} for name in self.NAMES],
+            result['structured_content']['headers'],
+        )
+        self.assertEqual(24, len(result['structured_content']['rows'][0]))
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+
+    def test_query_presenter_fails_closed_on_malformed_contract(self):
+        cases = []
+        for mutation in (
+            lambda value: value['data']['columns'].reverse(),
+            lambda value: value['data']['columns'][0].update({'secret': True}),
+            lambda value: value['data']['columns'][0].update({'name': '错误名称'}),
+            lambda value: value['data']['records'][0].update({'secret': 'hidden'}),
+            lambda value: value['data']['records'][0].pop('cost_name'),
+            lambda value: value['data']['records'][0].update({'cost_name': []}),
+            lambda value: value['data'].update({'secret': True}),
+            lambda value: value['meta'].update({'total': 1}),
+        ):
+            payload = self.payload()
+            mutation(payload)
+            cases.append(payload)
+        for key in self.AMOUNT_KEYS:
+            for amount in (True, '10', float('inf'), float('-inf'), float('nan')):
+                payload = self.payload()
+                payload['data']['records'][0][key]['amount'] = amount
+                cases.append(payload)
+            for malformed in (
+                {'amount': 1},
+                {'amount': 1, 'currency': 'CNY', 'secret': True},
+                {'amount': 1, 'currency': 7},
+            ):
+                payload = self.payload()
+                payload['data']['records'][0][key] = malformed
+                cases.append(payload)
+        for meta in (
+            {'page': 0, 'limit': 20, 'has_more': False},
+            {'page': 1, 'limit': 101, 'has_more': False},
+            {'page': 1, 'limit': 20, 'has_more': 0},
+        ):
+            payload = self.payload()
+            payload['meta'] = meta
+            cases.append(payload)
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_receivable_cost_list', payload
+                )['is_error'])
+
+    def test_filter_presenter_preserves_zero_and_negative_values(self):
+        payload = {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [
+                    {'value': 0, 'label': '未出账', 'code': 'unbilled'},
+                    {'value': -1, 'label': '未核销', 'code': 'unverified'},
+                ],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+        result = OutputPresenter().present(
+            'list_receivable_cost_filter_options',
+            payload,
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [[0, '未出账', 'unbilled'], [-1, '未核销', 'unverified']],
+            result['structured_content']['rows'],
+        )
+        for mutation in (
+            lambda value: value['data'].update({'secret': True}),
+            lambda value: value['data']['records'][0].update({'secret': True}),
+            lambda value: value['data']['records'][0].update({'value': True}),
+            lambda value: value['data']['records'][0].update({'label': ''}),
+            lambda value: value['data']['records'][0].update({'code': 1}),
+            lambda value: value['meta'].update({'total': 2}),
+        ):
+            malformed = copy.deepcopy(payload)
+            mutation(malformed)
+            self.assertTrue(OutputPresenter().present(
+                'list_receivable_cost_filter_options',
+                malformed,
+            )['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 169 - 0
tools/export_receivable_cost_list.py

@@ -0,0 +1,169 @@
+from datetime import date
+
+
+class ExportReceivableCostListTool:
+    name = 'export_receivable_cost_list'
+    route_path = '/mcp/tools/exportReceivableCostList'
+    NUMBER_FIELDS = (
+        'reference_numbers',
+        'tracking_numbers',
+        'order_numbers',
+        'bill_numbers',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+        date_field = {
+            'type': 'string',
+            'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '本工具只提交异步导出任务,不会在本次调用中等待文件生成,也禁止在一次调用内'
+                '轮询任务状态。成功后返回任务引用(task_ref)和建议等待时间'
+                '(retry_after_seconds)。稍后单独使用 query_export_task 查询任务状态或下载链接。'
+                '使用场景:只有用户明确要求导出应收费用单并需要下载文件时才可调用。'
+                '筛选值必须与 query_receivable_cost_list / list_receivable_cost_filter_options'
+                '已确认的号码类型、日期区间和筛选项 value 完全一致;禁止根据格式猜测号码类型,'
+                '禁止勾选 ID,禁止一次调用内轮询 query_export_task。'
+                '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'reference_numbers': dict(number_array),
+                    'tracking_numbers': dict(number_array),
+                    'order_numbers': dict(number_array),
+                    'bill_numbers': dict(number_array),
+                    'business_date_start': dict(date_field),
+                    'business_date_end': dict(date_field),
+                    'customer_id': {'type': 'integer', 'minimum': 1},
+                    'sub_customer_id': {'type': 'integer', 'minimum': 1},
+                    'billing_status': {'type': 'integer', 'enum': [0, 1]},
+                    'verification_status': {
+                        'type': 'integer', 'enum': [-1, 0, 1],
+                    },
+                    'document_type': {'type': 'integer', 'minimum': 0},
+                    'cost_type_id': {'type': 'integer', 'minimum': 1},
+                },
+                'required': [],
+                'allOf': [
+                    {
+                        'anyOf': [
+                            {'required': ['reference_numbers']},
+                            {'required': ['tracking_numbers']},
+                            {'required': ['order_numbers']},
+                            {'required': ['bill_numbers']},
+                            {
+                                'required': [
+                                    'business_date_start',
+                                    'business_date_end',
+                                ],
+                            },
+                        ],
+                    },
+                    {
+                        'if': {'required': ['sub_customer_id']},
+                        'then': {'required': ['customer_id']},
+                    },
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        reference_numbers=None,
+        tracking_numbers=None,
+        order_numbers=None,
+        bill_numbers=None,
+        business_date_start=None,
+        business_date_end=None,
+        customer_id=None,
+        sub_customer_id=None,
+        billing_status=None,
+        verification_status=None,
+        document_type=None,
+        cost_type_id=None,
+        request_id='rq_export_receivable_cost_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for export_receivable_cost_list'
+            )
+
+        supplied_numbers = {}
+        total_numbers = 0
+        for field, values in (
+            ('reference_numbers', reference_numbers),
+            ('tracking_numbers', tracking_numbers),
+            ('order_numbers', order_numbers),
+            ('bill_numbers', bill_numbers),
+        ):
+            if values is None:
+                continue
+            if not isinstance(values, list) or not values:
+                raise ValueError(field + ' must be a non-empty list')
+            cleaned = []
+            for value in values:
+                if not isinstance(value, str):
+                    raise ValueError(field + ' items must be strings')
+                item = value.strip()
+                if not item or len(item) > 100:
+                    raise ValueError(field + ' items must be 1 to 100 chars')
+                if item not in cleaned:
+                    cleaned.append(item)
+            supplied_numbers[field] = cleaned
+            total_numbers += len(cleaned)
+        if total_numbers > 200:
+            raise ValueError('at most 200 numbers across all number fields')
+
+        has_start = business_date_start is not None
+        has_end = business_date_end is not None
+        if has_start != has_end:
+            raise ValueError('business date range requires both start and end')
+        if has_start:
+            start = date.fromisoformat(str(business_date_start))
+            end = date.fromisoformat(str(business_date_end))
+            if end < start or (end - start).days > 30:
+                raise ValueError('business date range must be within 31 days')
+
+        if not supplied_numbers and not has_start:
+            raise ValueError(
+                'provide number filters or a business date range'
+            )
+        if sub_customer_id is not None and customer_id is None:
+            raise ValueError('sub_customer_id requires customer_id')
+
+        payload = dict(supplied_numbers)
+        if has_start:
+            payload['business_date_start'] = str(business_date_start)
+            payload['business_date_end'] = str(business_date_end)
+        for field, value in (
+            ('customer_id', customer_id),
+            ('sub_customer_id', sub_customer_id),
+            ('billing_status', billing_status),
+            ('verification_status', verification_status),
+            ('document_type', document_type),
+            ('cost_type_id', cost_type_id),
+        ):
+            if value is not None:
+                payload[field] = value
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )

+ 100 - 0
tools/list_receivable_cost_filter_options.py

@@ -0,0 +1,100 @@
+class ListReceivableCostFilterOptionsTool:
+    name = 'list_receivable_cost_filter_options'
+    route_path = '/mcp/tools/listReceivableCostFilterOptions'
+    FILTER_TYPES = ('主客户', '子客户', '出账状态', '核销状态', '单据类型', '费用项')
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '为query_receivable_cost_list取得当前员工授权范围内的主客户、子客户、'
+                '出账状态、核销状态、单据类型或费用项筛选值。调用查询工具时必须使用本工具'
+                '返回的value,禁止猜测内部ID或状态值。查询子客户时必须提供已选择的主客户'
+                'customer_id;其他筛选类型不得传customer_id。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string', 'enum': list(self.FILTER_TYPES),
+                    },
+                    'customer_id': {'type': 'integer', 'minimum': 1},
+                    'keyword': {'type': 'string', 'maxLength': 100},
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['filter_type'],
+                'allOf': [{
+                    'if': {
+                        'properties': {'filter_type': {'const': '子客户'}},
+                        'required': ['filter_type'],
+                    },
+                    'then': {'required': ['customer_id']},
+                    'else': {'not': {'required': ['customer_id']}},
+                }],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        filter_type,
+        customer_id=None,
+        keyword='',
+        page=1,
+        limit=20,
+        request_id='rq_list_receivable_cost_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_receivable_cost_filter_options'
+            )
+        if not isinstance(filter_type, str):
+            raise ValueError('filter_type is invalid')
+        filter_type = filter_type.strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('filter_type is invalid')
+        if filter_type == '子客户':
+            if customer_id is None:
+                raise ValueError('customer_id is required for 子客户')
+            customer_id = self._positive_integer(customer_id, 'customer_id')
+        elif customer_id is not None:
+            raise ValueError('customer_id is only valid for 子客户')
+        if not isinstance(keyword, str):
+            raise ValueError('keyword is invalid')
+        keyword = keyword.strip()
+        if len(keyword) > 100:
+            raise ValueError('keyword is invalid')
+        payload = {
+            'filter_type': filter_type,
+            'keyword': keyword,
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        if customer_id is not None:
+            payload['customer_id'] = customer_id
+        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

+ 214 - 0
tools/query_receivable_cost_list.py

@@ -0,0 +1,214 @@
+from datetime import date
+
+
+class QueryReceivableCostListTool:
+    name = 'query_receivable_cost_list'
+    route_path = '/mcp/tools/queryReceivableCostList'
+    NUMBER_FIELDS = (
+        'reference_numbers',
+        'tracking_numbers',
+        'order_numbers',
+        'bill_numbers',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        number_array = {
+            'type': 'array',
+            'minItems': 1,
+            'maxItems': 200,
+            'items': {
+                'type': 'string',
+                'minLength': 1,
+                'maxLength': 100,
+                'pattern': '.*\\S.*',
+            },
+        }
+        date_field = {
+            'type': 'string',
+            'format': 'date',
+            'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '分页查询当前员工有权查看的应收费用列表。仅可按已明确业务类型的参考号、'
+                '跟踪号、订单号或账单编号精确查询;禁止根据格式猜测号码类型,禁止跨字段'
+                '试查、并行试查或失败后改用其他号码字段。没有明确号码时必须提供不超过31个'
+                '日历日的业务日期闭区间。客户、出账状态、核销状态、单据类型和费用项筛选值'
+                '必须先调用list_receivable_cost_filter_options选择并使用其返回的value,'
+                '不得猜测内部ID。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'reference_numbers': dict(number_array),
+                    'tracking_numbers': dict(number_array),
+                    'order_numbers': dict(number_array),
+                    'bill_numbers': dict(number_array),
+                    'business_date_start': dict(date_field),
+                    'business_date_end': dict(date_field),
+                    'customer_id': {'type': 'integer', 'minimum': 1},
+                    'sub_customer_id': {'type': 'integer', 'minimum': 1},
+                    'billing_status': {'type': 'integer', 'enum': [0, 1]},
+                    'verification_status': {
+                        'type': 'integer', 'enum': [-1, 0, 1],
+                    },
+                    'document_type': {'type': 'integer', 'minimum': 0},
+                    'cost_type_id': {'type': 'integer', 'minimum': 1},
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': [],
+                'allOf': [
+                    {
+                        'anyOf': [
+                            {'required': ['reference_numbers']},
+                            {'required': ['tracking_numbers']},
+                            {'required': ['order_numbers']},
+                            {'required': ['bill_numbers']},
+                            {
+                                'required': [
+                                    'business_date_start',
+                                    'business_date_end',
+                                ],
+                            },
+                        ],
+                    },
+                    {
+                        'if': {'required': ['sub_customer_id']},
+                        'then': {'required': ['customer_id']},
+                    },
+                ],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        reference_numbers=None,
+        tracking_numbers=None,
+        order_numbers=None,
+        bill_numbers=None,
+        business_date_start=None,
+        business_date_end=None,
+        customer_id=None,
+        sub_customer_id=None,
+        billing_status=None,
+        verification_status=None,
+        document_type=None,
+        cost_type_id=None,
+        page=1,
+        limit=20,
+        request_id='rq_query_receivable_cost_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_receivable_cost_list'
+            )
+
+        supplied_numbers = {}
+        total_numbers = 0
+        for field, values in (
+            ('reference_numbers', reference_numbers),
+            ('tracking_numbers', tracking_numbers),
+            ('order_numbers', order_numbers),
+            ('bill_numbers', bill_numbers),
+        ):
+            if values is not None:
+                normalized = self._number_list(values, field)
+                supplied_numbers[field] = normalized
+                total_numbers += len(normalized)
+        if total_numbers > 200:
+            raise ValueError('combined exact number count is invalid')
+
+        start = self._optional_date(business_date_start, 'business_date_start')
+        end = self._optional_date(business_date_end, 'business_date_end')
+        if bool(start) != bool(end):
+            raise ValueError('business date range is invalid')
+        if start and end:
+            span = (date.fromisoformat(end) - date.fromisoformat(start)).days
+            if span < 0 or span > 30:
+                raise ValueError('business date range is invalid')
+        if not supplied_numbers and not (start and end):
+            raise ValueError('exact numbers or business date range is required')
+        if sub_customer_id is not None and customer_id is None:
+            raise ValueError('customer_id is required with sub_customer_id')
+
+        payload = dict(supplied_numbers)
+        if start:
+            payload['business_date_start'] = start
+            payload['business_date_end'] = end
+        for field, value, minimum in (
+            ('customer_id', customer_id, 1),
+            ('sub_customer_id', sub_customer_id, 1),
+            ('document_type', document_type, 0),
+            ('cost_type_id', cost_type_id, 1),
+        ):
+            if value is not None:
+                payload[field] = self._integer(value, field, minimum)
+        for field, value, allowed in (
+            ('billing_status', billing_status, (0, 1)),
+            ('verification_status', verification_status, (-1, 0, 1)),
+        ):
+            if value is not None:
+                value = self._integer(value, field)
+                if value not in allowed:
+                    raise ValueError('{0} is invalid'.format(field))
+                payload[field] = value
+        payload['page'] = self._bounded_integer(page, 'page')
+        payload['limit'] = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _number_list(values, field):
+        if not isinstance(values, list) or not 1 <= len(values) <= 200:
+            raise ValueError('{0} is invalid'.format(field))
+        result = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError('{0} is invalid'.format(field))
+            value = value.strip()
+            if not value or len(value) > 100:
+                raise ValueError('{0} is invalid'.format(field))
+            result.append(value)
+        return result
+
+    @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
+
+    @staticmethod
+    def _integer(value, field, minimum=None):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError('{0} is invalid'.format(field))
+        if minimum is not None and value < minimum:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._integer(value, field, 1)
+        if value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value