6 Commit-ok e32b6ac9e7 ... 16d67f249d

Szerző SHA1 Üzenet Dátum
  jackson 16d67f249d master_task_7062029557 1 hónapja
  jackson 1174877300 master_task_7062029557 1 hónapja
  jackson c89361ac6c master_task_7062029557 1 hónapja
  jackson 3b463fa56f master_task_7062029557 1 hónapja
  jackson bafd09c035 master_task_7062029557 1 hónapja
  jackson 172be8ac81 api文档 1 hónapja

+ 16 - 8
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 19 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 25 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 19 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 25 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -38,24 +38,30 @@ 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_payable_cost_list` | 按业务类型、五类号码或最多 31 天日期查询应付费用列表 | `/mcp/tools/queryPayableCostList` | 按页签严格动态列安全表格 |
 | `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` | 安全筛选项 |
+| `list_payable_cost_filter_options` | 查询应付费用八类筛选项,费用项按业务类型联动 | `/mcp/tools/listPayableCostFilterOptions` | 安全筛选项 |
 | `export_pending_outbound_orders` | 提交未排舱订单异步导出 | `/mcp/tools/exportPendingOutboundOrders` | 签名任务引用 |
 | `export_out_of_province_port_data` | 提交省外进港资料异步导出 | `/mcp/tools/exportOutOfProvincePortData` | 签名任务引用 |
+| `export_receivable_cost_list` | 提交应收费用单列表异步导出 | `/mcp/tools/exportReceivableCostList` | 签名任务引用 |
+| `export_payable_cost_list` | 提交单页签应付费用列表异步导出 | `/mcp/tools/exportPayableCostList` | 签名任务引用 |
 | `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_payable_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 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 25 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -64,15 +70,17 @@ 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` 对其余 24 个安全工具执行显式白名单展示。
+- `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` 状态值;未知字段、列重排或畸形分页关闭失败。
+- 应付费用列表按六个业务类型严格返回后台页签列集(17/17/19/15/15/18 列);五类号码字段分别 `IN` 且彼此与其他条件 `AND`,无号码时要求一组最多 31 日日期。头程 `order_numbers` 同时精确匹配单号与关联订单号(`sub_number`)。列缺失、额外、错序或非有限金额关闭失败。
 - `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`;未知工具或畸形响应关闭失败。

+ 169 - 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,10 @@ 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 tools.query_payable_cost_list import QueryPayableCostListTool
+from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
+from tools.export_payable_cost_list import ExportPayableCostListTool
 
 
 def parse_int_list(value):
@@ -93,6 +101,10 @@ class GatewayApp:
             ),
             'query_order_receivable_cost_details':
                 QueryOrderReceivableCostDetailsTool(api_client=api_client),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=api_client),
+            'query_payable_cost_list':
+                QueryPayableCostListTool(api_client=api_client),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=api_client
             ),
@@ -102,11 +114,21 @@ class GatewayApp:
             'list_customer_filter_options': ListCustomerFilterOptionsTool(
                 api_client=api_client
             ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=api_client),
+            'list_payable_cost_filter_options':
+                ListPayableCostFilterOptionsTool(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
+            ),
+            'export_payable_cost_list': ExportPayableCostListTool(
+                api_client=api_client
+            ),
             'query_export_task': QueryExportTaskTool(api_client=api_client),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=api_client),
@@ -239,6 +261,7 @@ class GatewayApp:
         call_parser.add_argument('--keyword', default='')
         call_parser.add_argument('--order-id', type=int, default=0)
         call_parser.add_argument('--order-number', default='')
+        call_parser.add_argument('--business-type', type=int, default=0)
         call_parser.add_argument('--section', default='全部')
         call_parser.add_argument('--order-numbers', default='')
         call_parser.add_argument('--tracking-number', default='')
@@ -257,6 +280,22 @@ 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('--cost-date-start', default='')
+        call_parser.add_argument('--cost-date-end', default='')
+        call_parser.add_argument('--operation-date-start', default='')
+        call_parser.add_argument('--operation-date-end', default='')
+        call_parser.add_argument('--business-node-id', type=int, default=0)
+        call_parser.add_argument('--provider-id', type=int, default=0)
+        call_parser.add_argument('--cost-type-ids', default='')
+        call_parser.add_argument('--billing-status', type=int, default=None)
+        call_parser.add_argument('--payment-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 +486,116 @@ class GatewayApp:
                         '--order-number is required for query_order_receivable_cost_details'
                     )
                 tool_args['order_number'] = args.order_number
+            elif args.tool in (
+                'query_payable_cost_list', 'export_payable_cost_list'
+            ):
+                if args.business_type <= 0:
+                    raise ValueError(
+                        '--business-type is required for ' + args.tool
+                    )
+                tool_args['business_type'] = args.business_type
+                payable_number_lists = {
+                    'order_numbers': args.order_numbers,
+                    'tracking_numbers': args.tracking_numbers,
+                    'container_codes': args.container_codes,
+                    'bl_numbers': args.bl_numbers,
+                    'so_numbers': args.so_numbers,
+                }
+                for field, value in payable_number_lists.items():
+                    if value:
+                        tool_args[field] = parse_string_list(value)
+                payable_dates = {
+                    'business_date_start': args.business_date_start,
+                    'business_date_end': args.business_date_end,
+                    'cost_date_start': args.cost_date_start,
+                    'cost_date_end': args.cost_date_end,
+                    'operation_date_start': args.operation_date_start,
+                    'operation_date_end': args.operation_date_end,
+                }
+                for field, value in payable_dates.items():
+                    if value:
+                        tool_args[field] = value
+                for field, value in (
+                    ('business_node_id', args.business_node_id),
+                    ('provider_id', args.provider_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                if args.cost_type_ids:
+                    tool_args['cost_type_ids'] = parse_int_list(
+                        args.cost_type_ids
+                    )
+                for field, value in (
+                    ('billing_status', args.billing_status),
+                    ('payment_status', args.payment_status),
+                    ('verification_status', args.verification_status),
+                    ('document_type', args.document_type),
+                ):
+                    if value is not None:
+                        tool_args[field] = value
+                if args.tool == 'export_payable_cost_list':
+                    tool_args.pop('page', None)
+                    tool_args.pop('limit', None)
+            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 +658,26 @@ class GatewayApp:
                         '--task-ref is required for query_export_task'
                     )
                 tool_args = {'task_ref': args.task_ref}
+            elif args.tool == 'list_payable_cost_filter_options':
+                if not args.filter_type:
+                    raise ValueError(
+                        '--filter-type is required for '
+                        'list_payable_cost_filter_options'
+                    )
+                tool_args['filter_type'] = args.filter_type
+                tool_args['keyword'] = args.keyword
+                if args.business_type > 0:
+                    tool_args['business_type'] = args.business_type
+            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'

+ 22 - 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,10 @@ 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 tools.query_payable_cost_list import QueryPayableCostListTool
+from tools.list_payable_cost_filter_options import ListPayableCostFilterOptionsTool
+from tools.export_payable_cost_list import ExportPayableCostListTool
 from utils.security import hash_gateway_session_id
 
 
@@ -60,6 +68,10 @@ class PublicGatewayApp:
             ),
             'query_order_receivable_cost_details':
                 QueryOrderReceivableCostDetailsTool(api_client=None),
+            'query_receivable_cost_list':
+                QueryReceivableCostListTool(api_client=None),
+            'query_payable_cost_list':
+                QueryPayableCostListTool(api_client=None),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=None
             ),
@@ -69,11 +81,21 @@ class PublicGatewayApp:
             'list_customer_filter_options': ListCustomerFilterOptionsTool(
                 api_client=None
             ),
+            'list_receivable_cost_filter_options':
+                ListReceivableCostFilterOptionsTool(api_client=None),
+            'list_payable_cost_filter_options':
+                ListPayableCostFilterOptionsTool(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
+            ),
+            'export_payable_cost_list': ExportPayableCostListTool(
+                api_client=None
+            ),
             'query_export_task': QueryExportTaskTool(api_client=None),
             'list_pending_outbound_export_filter_options':
                 ListPendingOutboundExportFilterOptionsTool(api_client=None),

+ 281 - 0
services/output_presenter.py

@@ -26,6 +26,13 @@ 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',
+    ))
+    PAYABLE_COST_TOOLS = frozenset(('query_payable_cost_list',))
+    PAYABLE_FILTER_TOOLS = frozenset(('list_payable_cost_filter_options',))
+    PAYABLE_EXPORT_TOOLS = frozenset(('export_payable_cost_list',))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
@@ -37,11 +44,14 @@ 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
+        | PAYABLE_COST_TOOLS | PAYABLE_FILTER_TOOLS | PAYABLE_EXPORT_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -388,6 +398,96 @@ 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': '业务发生时间',
+    }
+    PAYABLE_COST_COLUMNS = {
+        1: (
+            ('number', '单号'), ('so_number', 'SO号'), ('container_code', '柜号'),
+            ('bl_number', '提单号'), ('sub_number', '订单号'),
+            ('business_node_name', '业务节点'), ('providers_name', '物流商'),
+            ('cost_name', '费用名称'), ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'), ('verify_status_text', '核销状态'),
+            ('remark', '备注'), ('bill_no', '账单编号'), ('payment_order_number', '付款单号'),
+        ),
+        2: (
+            ('number', '单号'), ('sub_number', '跟踪单号'), ('workorder_number', '工单号'),
+            ('order_type_name', '单据类型'), ('providers_name', '物流商'),
+            ('cost_name', '费用名称'), ('payable_cost_q', '应付原币金额'),
+            ('currency', '应付原币币种'), ('company_money_q', '本位币金额%s'),
+            ('yf_lock', '应付锁定'), ('business_date', '业务发生时间'),
+            ('trade_date', '费用发生时间'), ('create_date', '操作时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'), ('payment_order_number', '付款单号'),
+        ),
+        3: (
+            ('number', '单号'), ('order_status_text', '包裹状态'),
+            ('providers_name', '物流商'), ('business_node_name', '费用业务节点'),
+            ('cost_name', '费用名称'), ('detail_price', '计费单价'),
+            ('charge_weight', '计费重'), ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'), ('quote_currency', '原币币种'),
+            ('company_money_q', '计费本位币金额'), ('yf_lock', '应付锁定'),
+            ('bill_no', '账单编号'), ('payment_order_number', '付款单号'),
+            ('trade_date', '费用发生时间'), ('verify_status_text', '核销状态'),
+            ('remark', '财务备注'), ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ),
+        4: (
+            ('number', '订单号'), ('business_bill_no', '货代账单号'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('department_name', '事业部'),
+            ('yf_lock', '应付锁定'), ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('business_node_name', '业务节点'),
+            ('remark', '备注'), ('bill_no', '应付账单编号'),
+            ('payment_order_number', '付款单号'),
+        ),
+        5: (
+            ('order_type_name', '单据类型'), ('number', '订单号'), ('warehouse_name', '仓库'),
+            ('business_node_name', '业务节点'), ('providers_name', '物流商'),
+            ('cost_name', '费用名称'), ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'), ('verify_status_text', '核销状态'),
+            ('remark', '备注'), ('bill_no', '账单编号'), ('payment_order_number', '付款单号'),
+        ),
+        7: (
+            ('number', '订单号'), ('ep_order_status_text', '订单状态'),
+            ('business_node_name', '业务节点'), ('providers_name', '物流商'),
+            ('cost_name', '费用名称'), ('detail_price', '计费单价'),
+            ('charge_weight', '计费重'), ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'), ('quote_currency', '原币币种'),
+            ('company_money_q', '本位币金额'), ('trade_date', '费用发生时间'),
+            ('remark', '备注'), ('verify_status_text', '核销状态'),
+            ('bill_no', '账单编号'), ('payment_order_number', '付款单号'),
+            ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ),
+    }
+    PAYABLE_AMOUNT_KEYS = frozenset((
+        'detail_price', 'charge_weight', 'quote_cost', 'payable_cost_q', 'company_money_q',
+    ))
     PAYMENT_FOLLOWUP_MONTHLY_COLUMNS = {
         'receivable_month': '应收月份',
         'unverified_amount': '未核销金额',
@@ -549,6 +649,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 +743,22 @@ 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.PAYABLE_COST_TOOLS:
+            return self._present_payable_cost_list(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.PAYABLE_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,
@@ -644,6 +774,8 @@ class OutputPresenter:
             return self._present_options(data, tool_result.get('meta'), meta)
         if tool_name in self.EXPORT_TOOLS:
             return self._present_export_submission(data, meta)
+        if tool_name in self.PAYABLE_EXPORT_TOOLS:
+            return self._present_export_submission(data, meta)
         return self._present_export_task(data, meta)
 
     def _present_order_detail(self, data, raw_meta, meta):
@@ -1253,6 +1385,155 @@ 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)
+
+    def _present_payable_cost_list(self, data, raw_meta, meta):
+        if set(data) != {'business_type', 'company_currency', 'columns', 'records'}:
+            return self._format_error(meta)
+        business_type = data.get('business_type')
+        company_currency = data.get('company_currency')
+        if (
+            isinstance(business_type, bool)
+            or not isinstance(business_type, int)
+            or business_type not in self.PAYABLE_COST_COLUMNS
+            or not isinstance(company_currency, str)
+            or not company_currency.strip()
+        ):
+            return self._format_error(meta)
+        pagination = self._customer_pagination(raw_meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        expected = list(self.PAYABLE_COST_COLUMNS[business_type])
+        if business_type == 2:
+            expected = [
+                (key, name.replace('%s', company_currency.strip().upper()))
+                for key, name in expected
+            ]
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or len(columns) != len(expected)
+            or pagination is None
+        ):
+            return self._format_error(meta)
+        for column, (key, name) in zip(columns, expected):
+            if column != {'key': key, 'name': name}:
+                return self._format_error(meta)
+        rows = []
+        expected_keys = [key for key, _ in expected]
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if key in self.PAYABLE_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': name} for _, name in expected]
+        content = {
+            'business_type': business_type,
+            '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(25, 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(25, len(local.registered_tool_names()))
         self.assertIn('query_customer_payment_records', local.registered_tool_names())
 
         stdout = io.StringIO()

+ 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_18_tools(self):
+    def test_local_and_public_registries_are_identical_and_have_25_tools(self):
         local = GatewayApp().registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
         self.assertEqual(local, public)
-        self.assertEqual(19, len(local))
+        self.assertEqual(25, 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(25, 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(25, len(local))
+        self.assertIn('export_receivable_cost_list', local)
+        self.assertEqual(24, 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(25, 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())
 

+ 3 - 2
tests/test_output_presenter.py

@@ -200,10 +200,10 @@ class OutputPresenterTest(unittest.TestCase):
             with self.subTest(meta=meta):
                 self.assertTrue(present([], meta)['is_error'])
 
-    def test_customer_tools_are_safe_and_total_is_16(self):
+    def test_customer_tools_are_safe_and_total_is_24(self):
         self.assertTrue(self.presenter.handles(QueryCustomerListTool.name))
         self.assertTrue(self.presenter.handles(ListCustomerFilterOptionsTool.name))
-        self.assertEqual(18, len(self.presenter.SAFE_TOOLS))
+        self.assertEqual(24, 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(

+ 654 - 0
tests/test_payable_cost_tools.py

@@ -0,0 +1,654 @@
+import copy
+import importlib
+import io
+import math
+from pathlib import Path
+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_payable_cost_list',
+                    'list_payable_cost_filter_options',
+                    'export_payable_cost_list',
+                ],
+            },
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        if tool_code == 'export_payable_cost_list':
+            return {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_payable',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            }
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+
+
+class PayableCostToolContractTest(unittest.TestCase):
+    FILTER_TYPES = (
+        '业务类型', '业务节点', '物流商', '费用项',
+        '是否生成账单', '是否付款', '核销状态', '单据类型',
+    )
+    QUERY_PROPERTIES = {
+        'business_type', 'order_numbers', 'tracking_numbers',
+        'container_codes', 'bl_numbers', 'so_numbers',
+        'business_date_start', 'business_date_end',
+        'cost_date_start', 'cost_date_end',
+        'operation_date_start', 'operation_date_end',
+        'business_node_id', 'provider_id', 'cost_type_ids',
+        'billing_status', 'payment_status', 'verification_status',
+        'document_type', 'page', 'limit',
+    }
+
+    def tool_class(self, module_name, class_name):
+        path = Path(__file__).parents[1] / 'tools' / (module_name + '.py')
+        self.assertTrue(path.is_file(), str(path))
+        return getattr(importlib.import_module('tools.' + module_name), class_name)
+
+    def test_query_schema_is_closed_and_locks_required_business_type(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('query_payable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(self.QUERY_PROPERTIES, set(schema['properties']))
+        self.assertIn('business_type', schema['required'])
+        self.assertEqual(
+            [1, 2, 3, 4, 5, 7],
+            schema['properties']['business_type']['enum'],
+        )
+        self.assertIn('list_payable_cost_filter_options', metadata['description'])
+        self.assertIn('禁止', metadata['description'])
+
+    def test_query_call_forwards_five_number_fields_and_three_dates(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            business_type=1,
+            order_numbers=[' O-1 '],
+            tracking_numbers=[' T-1 '],
+            container_codes=[' C-1 '],
+            bl_numbers=[' B-1 '],
+            so_numbers=[' S-1 '],
+            business_date_start='2026-07-01',
+            business_date_end='2026-07-31',
+            cost_date_start='2026-07-02',
+            cost_date_end='2026-07-30',
+            operation_date_start='2026-07-03',
+            operation_date_end='2026-07-29',
+            business_node_id=11,
+            provider_id=12,
+            cost_type_ids=[13, 14],
+            billing_status=0,
+            payment_status=-1,
+            verification_status=1,
+            document_type=0,
+            page=2,
+            limit=100,
+            request_id='rq_payable',
+        )
+        self.assertEqual(
+            (
+                'query_payable_cost_list',
+                '/mcp/tools/queryPayableCostList',
+                {
+                    'business_type': 1,
+                    'order_numbers': ['O-1'],
+                    'tracking_numbers': ['T-1'],
+                    'container_codes': ['C-1'],
+                    'bl_numbers': ['B-1'],
+                    'so_numbers': ['S-1'],
+                    'business_date_start': '2026-07-01',
+                    'business_date_end': '2026-07-31',
+                    'cost_date_start': '2026-07-02',
+                    'cost_date_end': '2026-07-30',
+                    'operation_date_start': '2026-07-03',
+                    'operation_date_end': '2026-07-29',
+                    'business_node_id': 11,
+                    'provider_id': 12,
+                    'cost_type_ids': [13, 14],
+                    'billing_status': 0,
+                    'payment_status': -1,
+                    'verification_status': 1,
+                    'document_type': 0,
+                    'page': 2,
+                    'limit': 100,
+                },
+                'rq_payable',
+            ),
+            client.calls[-1],
+        )
+
+    def test_query_call_rejects_number_date_and_filter_boundaries(self):
+        cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        tool = cls(RecordingApiClient())
+        invalid = [
+            {},
+            {'business_type': 6, 'order_numbers': ['O']},
+            {'business_type': 1},
+            {'business_type': 1, 'order_numbers': 'O'},
+            {'business_type': 1, 'order_numbers': [1]},
+            {'business_type': 1, 'order_numbers': ['']},
+            {'business_type': 1, 'order_numbers': ['x' * 101]},
+            {'business_type': 1, 'order_numbers': [str(i) for i in range(201)]},
+            {'business_type': 1, 'order_numbers': ['O'] * 201},
+            {'business_type': 1, 'business_date_start': '2026-07-01'},
+            {
+                'business_type': 1,
+                'cost_date_start': '2026-07-01',
+                'cost_date_end': '2026-08-01',
+            },
+            {'business_type': 1, 'operation_date_end': '2026-07-01'},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': []},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': [True]},
+            {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': list(range(1, 202))},
+            {'business_type': 1, 'order_numbers': ['O'], 'page': 101},
+            {'business_type': 1, 'order_numbers': ['O'], 'limit': 0},
+        ]
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    tool.call(**arguments)
+
+    def test_filter_schema_and_call_lock_eight_types_and_cost_linkage(self):
+        cls = self.tool_class(
+            'list_payable_cost_filter_options',
+            'ListPayableCostFilterOptionsTool',
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            {'filter_type', 'business_type', 'keyword', 'page', 'limit'},
+            set(schema['properties']),
+        )
+        self.assertEqual(
+            list(self.FILTER_TYPES),
+            schema['properties']['filter_type']['enum'],
+        )
+        client = RecordingApiClient()
+        cls(client).call(
+            filter_type=' 费用项 ', business_type=3,
+            keyword=' 运 ', page=2, limit=30,
+        )
+        self.assertEqual(
+            {
+                'filter_type': '费用项', 'business_type': 3,
+                'keyword': '运', 'page': 2, 'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        for arguments in (
+            {'filter_type': '费用项'},
+            {'filter_type': '业务节点', 'business_type': 6},
+            {'filter_type': '未知'},
+            {'filter_type': '业务类型', 'keyword': 1},
+            {'filter_type': '业务类型', 'page': 0},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    cls(client).call(**arguments)
+
+    def test_export_schema_matches_query_without_pagination(self):
+        cls = self.tool_class(
+            'export_payable_cost_list', 'ExportPayableCostListTool'
+        )
+        metadata = cls().metadata()
+        schema = metadata['input_schema']
+        self.assertEqual('export_payable_cost_list', metadata['name'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(
+            self.QUERY_PROPERTIES - {'page', 'limit'},
+            set(schema['properties']),
+        )
+        self.assertIn('business_type', schema['required'])
+        self.assertIn('query_export_task', metadata['description'])
+        self.assertIn('单页签', metadata['description'])
+
+    def test_local_public_registry_cli_and_safe_counts_are_current(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(25, len(local))
+        self.assertEqual(24, len(OutputPresenter.SAFE_TOOLS))
+        self.assertEqual(
+            (
+                'query_payable_cost_list',
+                'list_payable_cost_filter_options',
+                'export_payable_cost_list',
+            ),
+            tuple(name for name in local if 'payable_cost' in name),
+        )
+
+        client = RecordingApiClient()
+        app = GatewayApp(api_client=client)
+        app.run_cli([
+            'call', '--tool', 'query_payable_cost_list',
+            '--business-type', '1', '--order-numbers', ' O-1,O-2 ',
+            '--cost-type-ids', '13,14', '--page', '2', '--limit', '30',
+        ], stdout=io.StringIO())
+        self.assertEqual(
+            {
+                'business_type': 1, 'order_numbers': ['O-1', 'O-2'],
+                'cost_type_ids': [13, 14], 'page': 2, 'limit': 30,
+            },
+            client.calls[-1][2],
+        )
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '费用项', '--business-type', '3',
+        ], stdout=io.StringIO())
+        self.assertEqual('list_payable_cost_filter_options', client.calls[-1][0])
+        app.run_cli([
+            'call', '--tool', 'export_payable_cost_list',
+            '--business-type', '7', '--order-numbers', 'E-1',
+        ], stdout=io.StringIO())
+        self.assertEqual('export_payable_cost_list', client.calls[-1][0])
+
+    def test_new_tool_error_paths_and_cli_forwarding_are_covered(self):
+        query_cls = self.tool_class(
+            'query_payable_cost_list', 'QueryPayableCostListTool'
+        )
+        with self.assertRaises(RuntimeError):
+            query_cls().call(1, order_numbers=['O-1'])
+        query = query_cls(RecordingApiClient())
+        query.call(business_type=1, order_numbers=['O', 'O'])
+        invalid = (
+            {
+                'business_type': 1, 'business_date_start': '2026-07-02',
+                'business_date_end': '2026-07-01',
+            },
+            {
+                'business_type': 1, 'business_date_start': 1,
+                'business_date_end': 1,
+            },
+            {
+                'business_type': 1, 'business_date_start': '2026-99-01',
+                'business_date_end': '2026-99-02',
+            },
+            {
+                'business_type': 1, 'business_date_start': '20260101',
+                'business_date_end': '20260102',
+            },
+            {'business_type': 1, 'order_numbers': ['O'], 'billing_status': 2},
+        )
+        for arguments in invalid:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises((TypeError, ValueError)):
+                    query.call(**arguments)
+
+        export_cls = self.tool_class(
+            'export_payable_cost_list', 'ExportPayableCostListTool'
+        )
+        with self.assertRaises(RuntimeError):
+            export_cls().call(1, order_numbers=['O-1'])
+        with self.assertRaises(ValueError):
+            export_cls(RecordingApiClient()).call(6, order_numbers=['O-1'])
+
+        filter_cls = self.tool_class(
+            'list_payable_cost_filter_options',
+            'ListPayableCostFilterOptionsTool',
+        )
+        with self.assertRaises(RuntimeError):
+            filter_cls().call('业务类型')
+        filter_tool = filter_cls(RecordingApiClient())
+        with self.assertRaises(ValueError):
+            filter_tool.call(1)
+        with self.assertRaises(ValueError):
+            filter_tool.call('业务类型', business_type=True)
+        with self.assertRaises(ValueError):
+            filter_tool.call('业务类型', keyword='x' * 101)
+        filter_tool.call('业务类型')
+
+        calls = []
+        app = GatewayApp()
+        app.call_tool = lambda name, arguments, request_id='': (
+            calls.append((name, arguments, request_id))
+            or {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+        )
+        output = io.StringIO()
+        app.run_cli([
+            'call', '--tool', 'query_payable_cost_list', '--business-type', '1',
+            '--order-numbers', 'O-1', '--tracking-numbers', 'T-1',
+            '--container-codes', 'C-1', '--bl-numbers', 'B-1', '--so-numbers', 'S-1',
+            '--business-date-start', '2026-07-01', '--business-date-end', '2026-07-02',
+            '--cost-date-start', '2026-07-03', '--cost-date-end', '2026-07-04',
+            '--operation-date-start', '2026-07-05', '--operation-date-end', '2026-07-06',
+            '--business-node-id', '11', '--provider-id', '12', '--cost-type-ids', '13,14',
+            '--billing-status', '1', '--payment-status', '-1',
+            '--verification-status', '2', '--document-type', '0',
+        ], stdout=output)
+        self.assertEqual('query_payable_cost_list', calls[-1][0])
+        app.run_cli([
+            'call', '--tool', 'export_payable_cost_list', '--business-type', '1',
+            '--order-numbers', 'O-1',
+        ], stdout=io.StringIO())
+        self.assertEqual('export_payable_cost_list', calls[-1][0])
+        with self.assertRaises(ValueError):
+            app.run_cli(['call', '--tool', 'query_payable_cost_list'], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '业务类型',
+        ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'list_payable_cost_filter_options',
+            '--filter-type', '业务类型', '--business-type', '3',
+        ], stdout=io.StringIO())
+        with self.assertRaises(ValueError):
+            app.run_cli([
+                'call', '--tool', 'list_payable_cost_filter_options'
+            ], stdout=io.StringIO())
+
+        for argv in (
+            [
+                'call', '--tool', 'query_customer_payment_followup',
+                '--customer-id', '1', '--department-id', '2', '--sales-id', '3',
+                '--merchandiser-id', '4', '--has-unverified-receivable-only', 'false',
+            ],
+            [
+                'call', '--tool', 'query_customer_unverified_bill_details',
+                '--customer-id', '1',
+            ],
+            [
+                'call', '--tool', 'query_customer_payment_records',
+                '--customer-id', '1', '--receive-date-start', '2026-07-01',
+                '--receive-date-end', '2026-07-02',
+            ],
+            [
+                'call', '--tool', 'query_order_receivable_cost_details',
+                '--order-number', 'O-1',
+            ],
+        ):
+            app.run_cli(argv, stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_followup',
+        ], stdout=io.StringIO())
+        for argv in (
+            ['call', '--tool', 'query_customer_unverified_bill_details'],
+            ['call', '--tool', 'query_customer_payment_records'],
+            ['call', '--tool', 'query_order_receivable_cost_details'],
+        ):
+            with self.assertRaises(ValueError):
+                app.run_cli(argv, stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '1', '--receive-date-start', '2026-07-01',
+        ], stdout=io.StringIO())
+        app.run_cli([
+            'call', '--tool', 'query_customer_payment_records',
+            '--customer-id', '1', '--receive-date-end', '2026-07-02',
+        ], stdout=io.StringIO())
+
+
+
+class PayableCostPresenterContractTest(unittest.TestCase):
+    COLUMN_MAP = {
+        1: [
+            ('number', '单号'), ('so_number', 'SO号'),
+            ('container_code', '柜号'), ('bl_number', '提单号'),
+            ('sub_number', '订单号'), ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        2: [
+            ('number', '单号'), ('sub_number', '跟踪单号'),
+            ('workorder_number', '工单号'), ('order_type_name', '单据类型'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '应付原币金额'), ('currency', '应付原币币种'),
+            ('company_money_q', '本位币金额CNY'), ('yf_lock', '应付锁定'),
+            ('business_date', '业务发生时间'),
+            ('trade_date', '费用发生时间'), ('create_date', '操作时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        3: [
+            ('number', '单号'), ('order_status_text', '包裹状态'),
+            ('providers_name', '物流商'),
+            ('business_node_name', '费用业务节点'),
+            ('cost_name', '费用名称'), ('detail_price', '计费单价'),
+            ('charge_weight', '计费重'), ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'),
+            ('quote_currency', '原币币种'),
+            ('company_money_q', '计费本位币金额'),
+            ('yf_lock', '应付锁定'), ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '财务备注'),
+            ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ],
+        4: [
+            ('number', '订单号'), ('business_bill_no', '货代账单号'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'),
+            ('department_name', '事业部'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'),
+            ('business_node_name', '业务节点'), ('remark', '备注'),
+            ('bill_no', '应付账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        5: [
+            ('order_type_name', '单据类型'), ('number', '订单号'),
+            ('warehouse_name', '仓库'),
+            ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('payable_cost_q', '金额'), ('currency', '币种'),
+            ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
+            ('trade_date', '费用发生时间'),
+            ('verify_status_text', '核销状态'), ('remark', '备注'),
+            ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+        ],
+        7: [
+            ('number', '订单号'), ('ep_order_status_text', '订单状态'),
+            ('business_node_name', '业务节点'),
+            ('providers_name', '物流商'), ('cost_name', '费用名称'),
+            ('detail_price', '计费单价'), ('charge_weight', '计费重'),
+            ('quote_cost', '计费金额'),
+            ('payable_cost_q', '实际应付金额'),
+            ('quote_currency', '原币币种'),
+            ('company_money_q', '本位币金额'),
+            ('trade_date', '费用发生时间'), ('remark', '备注'),
+            ('verify_status_text', '核销状态'), ('bill_no', '账单编号'),
+            ('payment_order_number', '付款单号'),
+            ('create_user_name', '操作人'), ('create_date', '录入时间'),
+        ],
+    }
+    AMOUNT_KEYS = {
+        'detail_price', 'charge_weight', 'quote_cost',
+        'payable_cost_q', 'company_money_q',
+    }
+
+    def payload(self, business_type):
+        columns = self.COLUMN_MAP[business_type]
+        record = {}
+        for key, _ in columns:
+            record[key] = 12.5 if key in self.AMOUNT_KEYS else key + '-value'
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'business_type': business_type,
+                'company_currency': 'CNY',
+                'columns': [
+                    {'key': key, 'name': name} for key, name in columns
+                ],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_payable',
+            },
+        }
+
+    def test_six_business_types_have_exact_dynamic_columns(self):
+        presenter = OutputPresenter()
+        for business_type, columns in self.COLUMN_MAP.items():
+            with self.subTest(business_type=business_type):
+                result = presenter.present(
+                    'query_payable_cost_list', self.payload(business_type)
+                )
+                self.assertFalse(result['is_error'])
+                self.assertEqual(
+                    [{'label': name} for _, name in columns],
+                    result['structured_content']['headers'],
+                )
+                self.assertEqual(
+                    len(columns),
+                    len(result['structured_content']['rows'][0]),
+                )
+                self.assertEqual(
+                    business_type,
+                    result['structured_content']['business_type'],
+                )
+
+    def test_unknown_missing_wrong_order_and_non_finite_values_fail_closed(self):
+        presenter = OutputPresenter()
+        self.assertTrue(presenter.handles('query_payable_cost_list'))
+        cases = []
+        payload = self.payload(1)
+        payload['data']['columns'].append({'key': 'secret', 'name': '秘密'})
+        payload['data']['records'][0]['secret'] = 'hidden'
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['columns'].reverse()
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['records'][0].pop('cost_name')
+        cases.append(payload)
+        payload = self.payload(1)
+        payload['data']['records'][0]['secret'] = 'hidden'
+        cases.append(payload)
+        payload = self.payload(6) if 6 in self.COLUMN_MAP else self.payload(1)
+        payload['data']['business_type'] = 6
+        cases.append(payload)
+        for business_type in self.COLUMN_MAP:
+            for key, _ in self.COLUMN_MAP[business_type]:
+                if key not in self.AMOUNT_KEYS:
+                    continue
+                for value in (True, '12.5', math.inf, -math.inf, math.nan):
+                    payload = self.payload(business_type)
+                    payload['data']['records'][0][key] = value
+                    cases.append(payload)
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_payable_cost_list', payload
+                )['is_error'])
+
+    def test_filter_and_export_presenters_use_safe_contracts(self):
+        presenter = OutputPresenter()
+        filtered = presenter.present(
+            'list_payable_cost_filter_options',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'records': [
+                        {'value': 0, 'label': '否', 'code': 'no'},
+                        {'value': -1, 'label': '未付款', 'code': 'unpaid'},
+                    ],
+                },
+                'meta': {'page': 1, 'limit': 20, 'has_more': False},
+            },
+        )
+        self.assertFalse(filtered['is_error'])
+        self.assertEqual(
+            [[0, '否', 'no'], [-1, '未付款', 'unpaid']],
+            filtered['structured_content']['rows'],
+        )
+        exported = presenter.present(
+            'export_payable_cost_list',
+            {
+                'code': 'MCP_0000',
+                'data': {
+                    'task_ref': 'mexp_payable',
+                    'status': 'queued',
+                    'retry_after_seconds': 10,
+                },
+            },
+        )
+        self.assertFalse(exported['is_error'])
+        self.assertEqual(
+            'mexp_payable', exported['structured_content']['task']['task_ref']
+        )
+
+    def test_payable_filter_presenter_rejects_malformed_records_and_meta(self):
+        presenter = OutputPresenter()
+        valid = {
+            'code': 'MCP_0000',
+            'data': {
+                'records': [{'value': 0, 'label': 'ok', 'code': 'no'}],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+        cases = []
+        malformed = copy.deepcopy(valid)
+        malformed['data']['unexpected'] = True
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0].pop('code')
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0]['value'] = '0'
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['data']['records'][0]['label'] = ''
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['meta'].pop('has_more')
+        cases.append(malformed)
+        malformed = copy.deepcopy(valid)
+        malformed['meta']['unexpected'] = True
+        cases.append(malformed)
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(
+                    presenter.present(
+                        'list_payable_cost_filter_options', payload
+                    )['is_error']
+                )
+
+    def test_presenter_rejects_unknown_top_level_and_non_string_text_cells(self):
+        presenter = OutputPresenter()
+        payload = self.payload(1)
+        payload['data']['unexpected'] = True
+        self.assertTrue(
+            presenter.present('query_payable_cost_list', payload)['is_error']
+        )
+        payload = self.payload(1)
+        payload['data']['records'][0]['remark'] = 7
+        self.assertTrue(
+            presenter.present('query_payable_cost_list', payload)['is_error']
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()

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

+ 53 - 0
tools/export_payable_cost_list.py

@@ -0,0 +1,53 @@
+from .query_payable_cost_list import QueryPayableCostListTool
+
+
+class ExportPayableCostListTool:
+    name = 'export_payable_cost_list'
+    route_path = '/mcp/tools/exportPayableCostList'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        query_schema = QueryPayableCostListTool().metadata()['input_schema']
+        properties = dict(query_schema['properties'])
+        properties.pop('page')
+        properties.pop('limit')
+        return {
+            'name': self.name,
+            'description': (
+                '只提交一个应付费用列表单页签的异步导出任务,不在本次调用内等待或轮询。'
+                '成功后返回task_ref、状态和建议等待时间,稍后单独调用query_export_task查询。'
+                '筛选值必须与query_payable_cost_list及list_payable_cost_filter_options一致,'
+                '禁止混合单号猜测或勾选ID。'
+            ),
+            'input_schema': {
+                'type': 'object', 'properties': properties,
+                'required': ['business_type'], 'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, business_type, order_numbers=None, tracking_numbers=None,
+        container_codes=None, bl_numbers=None, so_numbers=None,
+        business_date_start=None, business_date_end=None,
+        cost_date_start=None, cost_date_end=None,
+        operation_date_start=None, operation_date_end=None,
+        business_node_id=None, provider_id=None, cost_type_ids=None,
+        billing_status=None, payment_status=None, verification_status=None,
+        document_type=None, request_id='rq_export_payable_cost_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for export_payable_cost_list')
+        payload = QueryPayableCostListTool._filters(
+            QueryPayableCostListTool._integer(business_type, 'business_type'),
+            order_numbers, tracking_numbers, container_codes, bl_numbers, so_numbers,
+            business_date_start, business_date_end, cost_date_start, cost_date_end,
+            operation_date_start, operation_date_end, business_node_id, provider_id,
+            cost_type_ids, billing_status, payment_status, verification_status,
+            document_type,
+        )
+        payload['business_type'] = business_type
+        if business_type not in QueryPayableCostListTool.BUSINESS_TYPES:
+            raise ValueError('business_type is invalid')
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)

+ 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
+        )

+ 71 - 0
tools/list_payable_cost_filter_options.py

@@ -0,0 +1,71 @@
+class ListPayableCostFilterOptionsTool:
+    name = 'list_payable_cost_filter_options'
+    route_path = '/mcp/tools/listPayableCostFilterOptions'
+    FILTER_TYPES = (
+        '业务类型', '业务节点', '物流商', '费用项',
+        '是否生成账单', '是否付款', '核销状态', '单据类型',
+    )
+    BUSINESS_TYPES = (1, 2, 3, 4, 5, 7)
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '取得新版应付费用列表的八类筛选值。费用项必须按已选择的业务类型联动查询,'
+                '调用列表时只能使用本工具返回的value,禁止猜测内部ID。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {'type': 'string', 'enum': list(self.FILTER_TYPES)},
+                    'business_type': {'type': 'integer', 'enum': list(self.BUSINESS_TYPES)},
+                    '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'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, filter_type, business_type=None, keyword='', page=1, limit=20,
+        request_id='rq_list_payable_cost_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for list_payable_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 == '费用项' and business_type is None:
+            raise ValueError('business_type is required for 费用项')
+        if business_type is not None:
+            if isinstance(business_type, bool) or not isinstance(business_type, int):
+                raise ValueError('business_type is invalid')
+            if business_type not in self.BUSINESS_TYPES:
+                raise ValueError('business_type is invalid')
+        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 business_type is not None:
+            payload['business_type'] = business_type
+        return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1 or value > 100:
+            raise ValueError(field + ' is invalid')
+        return value

+ 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

+ 210 - 0
tools/query_payable_cost_list.py

@@ -0,0 +1,210 @@
+from datetime import date
+
+
+class QueryPayableCostListTool:
+    name = 'query_payable_cost_list'
+    route_path = '/mcp/tools/queryPayableCostList'
+    NUMBER_FIELDS = (
+        'order_numbers', 'tracking_numbers', 'container_codes',
+        'bl_numbers', 'so_numbers',
+    )
+    BUSINESS_TYPES = (1, 2, 3, 4, 5, 7)
+
+    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}$',
+        }
+        properties = {
+            'business_type': {'type': 'integer', 'enum': list(self.BUSINESS_TYPES)},
+            'order_numbers': dict(number_array),
+            'tracking_numbers': dict(number_array),
+            'container_codes': dict(number_array),
+            'bl_numbers': dict(number_array),
+            'so_numbers': dict(number_array),
+            'business_date_start': dict(date_field),
+            'business_date_end': dict(date_field),
+            'cost_date_start': dict(date_field),
+            'cost_date_end': dict(date_field),
+            'operation_date_start': dict(date_field),
+            'operation_date_end': dict(date_field),
+            'business_node_id': {'type': 'integer', 'minimum': 1},
+            'provider_id': {'type': 'integer', 'minimum': 1},
+            'cost_type_ids': {
+                'type': 'array', 'minItems': 1, 'maxItems': 200,
+                'items': {'type': 'integer', 'minimum': 1},
+            },
+            'billing_status': {'type': 'integer', 'enum': [0, 1]},
+            'payment_status': {'type': 'integer', 'enum': [-1, 1]},
+            'verification_status': {'type': 'integer', 'enum': [0, 1, 2]},
+            'document_type': {'type': 'integer', 'minimum': 0},
+            'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
+            'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
+        }
+        return {
+            'name': self.name,
+            'description': (
+                '按结算新版页签查询当前员工公司内的应付费用列表。五类号码字段分别精确筛选,'
+                '不同字段与其他条件均为AND;禁止混合单号猜测、跨字段试查或使用未确认的费用项。'
+                '头程(business_type=1)的order_numbers同时匹配单号与展示订单号(sub_number)。'
+                '不传号码时至少提供一组不超过31日的业务、费用或操作日期闭区间。'
+                '筛选值必须先调用list_payable_cost_filter_options取得,禁止猜测内部ID。'
+            ),
+            'input_schema': {
+                'type': 'object', 'properties': properties,
+                'required': ['business_type'], 'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, business_type, order_numbers=None, tracking_numbers=None,
+        container_codes=None, bl_numbers=None, so_numbers=None,
+        business_date_start=None, business_date_end=None,
+        cost_date_start=None, cost_date_end=None,
+        operation_date_start=None, operation_date_end=None,
+        business_node_id=None, provider_id=None, cost_type_ids=None,
+        billing_status=None, payment_status=None, verification_status=None,
+        document_type=None, page=1, limit=20,
+        request_id='rq_query_payable_cost_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_payable_cost_list')
+        business_type = self._integer(business_type, 'business_type')
+        if business_type not in self.BUSINESS_TYPES:
+            raise ValueError('business_type is invalid')
+        payload = self._filters(
+            business_type, order_numbers, tracking_numbers, container_codes,
+            bl_numbers, so_numbers, business_date_start, business_date_end,
+            cost_date_start, cost_date_end, operation_date_start,
+            operation_date_end, business_node_id, provider_id, cost_type_ids,
+            billing_status, payment_status, verification_status, document_type,
+        )
+        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)
+
+    @classmethod
+    def _filters(
+        cls, business_type, order_numbers, tracking_numbers, container_codes,
+        bl_numbers, so_numbers, business_date_start, business_date_end,
+        cost_date_start, cost_date_end, operation_date_start, operation_date_end,
+        business_node_id, provider_id, cost_type_ids, billing_status,
+        payment_status, verification_status, document_type,
+    ):
+        values = {
+            'order_numbers': order_numbers, 'tracking_numbers': tracking_numbers,
+            'container_codes': container_codes, 'bl_numbers': bl_numbers,
+            'so_numbers': so_numbers,
+        }
+        payload = {}
+        payload['business_type'] = business_type
+        total = 0
+        for field in cls.NUMBER_FIELDS:
+            if values[field] is None:
+                continue
+            cleaned = cls._number_list(values[field], field)
+            payload[field] = cleaned
+            total += len(values[field])
+        if total > 200:
+            raise ValueError('at most 200 numbers are allowed')
+
+        date_pairs = (
+            ('business_date_start', business_date_start, business_date_end),
+            ('cost_date_start', cost_date_start, cost_date_end),
+            ('operation_date_start', operation_date_start, operation_date_end),
+        )
+        complete_dates = False
+        for field, start_value, end_value in date_pairs:
+            start = cls._optional_date(start_value, field)
+            end = cls._optional_date(end_value, field.replace('_start', '_end'))
+            if bool(start) != bool(end):
+                raise ValueError(field + ' range is invalid')
+            if start:
+                if (date.fromisoformat(end) - date.fromisoformat(start)).days > 30:
+                    raise ValueError(field + ' range is invalid')
+                if end < start:
+                    raise ValueError(field + ' range is invalid')
+                payload[field] = start
+                payload[field.replace('_start', '_end')] = end
+                complete_dates = True
+        if total == 0 and not complete_dates:
+            raise ValueError('number or date filters are required')
+
+        for field, value, minimum in (
+            ('business_node_id', business_node_id, 1),
+            ('provider_id', provider_id, 1),
+            ('document_type', document_type, 0),
+        ):
+            if value is not None:
+                payload[field] = cls._integer(value, field, minimum)
+        if cost_type_ids is not None:
+            if not isinstance(cost_type_ids, list) or not cost_type_ids:
+                raise ValueError('cost_type_ids is invalid')
+            if len(cost_type_ids) > 200:
+                raise ValueError('cost_type_ids is invalid')
+            payload['cost_type_ids'] = [cls._integer(item, 'cost_type_ids', 1) for item in cost_type_ids]
+        for field, value, allowed in (
+            ('billing_status', billing_status, (0, 1)),
+            ('payment_status', payment_status, (-1, 1)),
+            ('verification_status', verification_status, (0, 1, 2)),
+        ):
+            if value is not None:
+                checked = cls._integer(value, field)
+                if checked not in allowed:
+                    raise ValueError(field + ' is invalid')
+                payload[field] = checked
+        return payload
+
+    @staticmethod
+    def _number_list(values, field):
+        if not isinstance(values, list) or not values:
+            raise ValueError(field + ' is invalid')
+        result = []
+        for value in values:
+            if not isinstance(value, str):
+                raise ValueError(field + ' is invalid')
+            value = value.strip()
+            if not value or len(value) > 100:
+                raise ValueError(field + ' is invalid')
+            if value not in result:
+                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(field + ' is invalid')
+        try:
+            parsed = date.fromisoformat(value)
+        except ValueError:
+            raise ValueError(field + ' is invalid')
+        if parsed.isoformat() != value:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @staticmethod
+    def _integer(value, field, minimum=None):
+        if isinstance(value, bool) or not isinstance(value, int):
+            raise ValueError(field + ' is invalid')
+        if minimum is not None and value < minimum:
+            raise ValueError(field + ' is invalid')
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._integer(value, field, 1)
+        if value > 100:
+            raise ValueError(field + ' is invalid')
+        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