Forráskód Böngészése

master_task_7062029557

jackson 2 hete
szülő
commit
3b463fa56f

+ 10 - 6
README.md

@@ -17,7 +17,7 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 21 个查询、筛选和导出工具。
+- 本地 stdio 与公网 HTTP 注册同一组 25 个查询、筛选和导出工具。
 - 支持客户、订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持客户、订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
@@ -25,7 +25,7 @@ Gateway 保持“薄网关”边界:
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 21 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 25 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
@@ -39,6 +39,7 @@ Gateway 保持“薄网关”边界:
 | `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 项明细 |
@@ -46,19 +47,21 @@ Gateway 保持“薄网关”边界:
 | `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` 或 `export_receivable_cost_list` 提交任务,返回 `task_ref`
+1. 调用四个导出工具(包括 `export_payable_cost_list`)提交任务,返回 `task_ref`
 2. 等待建议时间(`retry_after_seconds`)后,使用 `query_export_task` 和 `task_ref` 查询状态
 3. 任务完成后从 `query_export_task` 响应获取下载链接(`files[].url`)
 
-这 22 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 25 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -67,16 +70,17 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 21 个安全工具执行显式白名单展示。
+- `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 日日期。列缺失、额外、错序或非有限金额关闭失败。
 - `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`;未知工具或畸形响应关闭失败。

+ 79 - 0
app.py

@@ -49,6 +49,9 @@ 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):
@@ -100,6 +103,8 @@ class GatewayApp:
                 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
             ),
@@ -111,6 +116,8 @@ class GatewayApp:
             ),
             '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
             ),
@@ -119,6 +126,9 @@ class GatewayApp:
             '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),
@@ -251,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='')
@@ -273,7 +284,15 @@ class GatewayApp:
         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)
@@ -467,6 +486,56 @@ 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),
@@ -589,6 +658,16 @@ 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(

+ 0 - 988
docs/mcp-api.md

@@ -1,988 +0,0 @@
-# MCP 工具接口文档
-
-> 本文仅描述当前 Gateway 注册的工具接口。实际可用工具是本地注册集合与服务端启用列表的交集;员工、公司、权限和数据范围由当前设备会话决定。
-
-## 订单与轨迹
-
-### query_order(订单兼容搜索)
-
-路由:`POST /mcp/tools/queryOrder`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 搜索关键词 | `keyword` | string | 是 | 非空;仅用户明确接受跨字段搜索时使用 |
-| 页码 | `page` | integer | 否 | 最小 1 |
-| 每页数量 | `limit` | integer | 否 | 1-100 |
-
-### query_order_exact(订单精准筛选)
-
-路由:`POST /mcp/tools/queryOrderExact`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 订单号 / 客户参考号 / 快递单号 / 排舱单号 / 柜号 / SO号 / Shipment ID | `order_number`、`reference_number`、`tracking_number`、`outbound_number`、`container_code`、`so_number`、`shipment_id` | string | 否 | 单值精准条件;号码类型必须明确 |
-| 收货国家 | `receiver_country` | string | 否 | 国家代码,值来自订单筛选项 |
-| 入库开始日期 / 入库结束日期 | `inbound_date_start`、`inbound_date_end` | string | 否 | `YYYY-MM-DD` |
-| 出库开始日期 / 出库结束日期 | `outbound_date_start`、`outbound_date_end` | string | 否 | `YYYY-MM-DD` |
-| 产品ID / 客户ID / 仓库ID | `product_ids`、`customer_ids`、`warehouse_ids` | integer[] | 否 | ID 数组,值来自订单筛选项 |
-| 订单号数组 / 客户参考号数组 / 快递单号数组 / 排舱单号数组 / 柜号数组 / SO号数组 | `order_numbers`、`reference_numbers`、`tracking_numbers`、`outbound_numbers`、`container_codes`、`so_numbers` | string[] | 否 | 同类号码批量查询;不能与对应单值字段混用 |
-| 销售人员ID / 事业部ID | `sales_id`、`department_id` | integer | 否 | 最小 1,值来自订单筛选项 |
-| 页码 | `page` | integer | 否 | 1-100 |
-| 每页数量 | `limit` | integer | 否 | 1-100 |
-
-至少提供一个业务筛选条件。
-
-### query_order_detail(订单详情)
-
-路由:`POST /mcp/tools/queryOrderDetail`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 订单号 | `order_number` | string | 是 | 非空,最长 100;只接受订单号 |
-| 详情模块 | `section` | string | 否 | 默认“全部”;可选订单概览、箱单信息、箱单商品、DW授权信息、附件信息、入库信息、查验信息、订单轨迹、操作日志、应收与结算日志、派送信息、全部 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_track(物流轨迹)
-
-路由:`POST /mcp/tools/queryTrack`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 内部订单ID | `order_id` | integer | 否 | 最小 1;仅可信系统上下文可用 |
-| 订单号 | `order_number` | string | 否 | 用户明确提供订单号时使用 |
-| 快递/物流跟踪号 | `tracking_number` | string | 否 | 用户明确提供跟踪号时使用 |
-| 页码 | `page` | integer | 否 | 默认 1,最小 1 |
-| 每页数量 | `limit` | integer | 否 | 默认 5,1-100 |
-
-三种定位参数应选择一种,不能猜测号码类型。
-
-### query_order_receivable_cost_details(订单应收费用明细)
-
-路由:`POST /mcp/tools/queryOrderReceivableCostDetails`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 订单号 | `order_number` | string | 是 | 非空,最长 100 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_receivable_cost_list(应收费用单列表)
-
-路由:`POST /mcp/tools/queryReceivableCostList`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 参考号 / 跟踪号 / 订单号 / 账单号数组 | `reference_numbers`、`tracking_numbers`、`order_numbers`、`bill_numbers` | string[] | 条件必填 | 同类 OR、不同类 AND;合计最多 200 项,每项最长 100;号码类型必须明确 |
-| 业务日期开始 / 结束 | `business_date_start`、`business_date_end` | string | 条件必填 | 无号码数组时必须成对提供;员工时区 `YYYY-MM-DD` 闭区间,最多 31 天 |
-| 主客户 / 子客户 | `customer_id`、`sub_customer_id` | integer | 否 | 严格正整数;子客户要求同时提供主客户 |
-| 出账状态 | `billing_status` | integer | 否 | `0` 未出账(DB 0/1)、`1` 已出账(DB 1/2) |
-| 核销状态 | `verification_status` | integer | 否 | `-1`、`0`、`1` |
-| 单据类型 / 费用项 | `document_type`、`cost_type_id` | integer | 否 | 单据类型可为 0;费用项为正整数;值来自筛选工具 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-## 客户与回款
-
-### query_customer_list(客户列表)
-
-路由:`POST /mcp/tools/queryCustomerList`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 客户ID / 事业部ID / 商务经理ID / 客户经理ID | `customer_id`、`department_id`、`sales_id`、`merchandiser_id` | integer | 否 | 最小 1;ID 来自客户筛选项 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_customer_payment_followup(客户回款跟进)
-
-路由:`POST /mcp/tools/queryCustomerPaymentFollowup`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 客户ID / 事业部ID / 商务经理ID / 客户经理ID | `customer_id`、`department_id`、`sales_id`、`merchandiser_id` | integer | 否 | 最小 1,值来自客户筛选项 |
-| 仅看有未核销应收 | `has_unverified_receivable_only` | boolean | 否 | 默认 `true` |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_customer_unverified_bill_details(未核销账单明细)
-
-路由:`POST /mcp/tools/queryCustomerUnverifiedBillDetails`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 客户ID | `customer_id` | integer | 是 | 最小 1,先从客户筛选项取得 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_customer_payment_records(客户逐笔回款记录)
-
-路由:`POST /mcp/tools/queryCustomerPaymentRecords`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 客户ID | `customer_id` | integer | 是 | 最小 1,先从客户名称筛选取得 |
-| 收款开始日期 / 收款结束日期 | `receive_date_start`、`receive_date_end` | string | 否 | `YYYY-MM-DD`;闭区间最多 366 天 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### list_customer_filter_options(客户筛选项)
-
-路由:`POST /mcp/tools/listCustomerFilterOptions`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 筛选类型 | `filter_type` | string | 是 | 客户名称、事业部、商务经理、客户经理 |
-| 关键词 | `keyword` | string | 否 | 最长 100 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### list_receivable_cost_filter_options(应收费用单筛选项)
-
-路由:`POST /mcp/tools/listReceivableCostFilterOptions`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 筛选类型 | `filter_type` | string | 是 | 主客户、子客户、出账状态、核销状态、单据类型、费用项 |
-| 主客户ID | `customer_id` | integer | 条件必填 | `filter_type=子客户` 时必填,其他类型禁止传入 |
-| 关键词 | `keyword` | string | 否 | 最长 100 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-## 排舱与报关
-
-### query_outbound_list(排舱列表)
-
-路由:`POST /mcp/tools/queryOutboundList`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 排舱阶段 | `outbound_status` | integer | 是 | 枚举 20、30、60、70、80、90、100、110、120;必须明确阶段 |
-| 排舱单号数组 / 订单号数组 / 柜号数组 / SO号数组 / 提单号数组 | `outbound_numbers`、`order_numbers`、`container_codes`、`so_numbers`、`bl_numbers` | string[] | 否 | 各 1-100 项;号码类型必须明确 |
-| 运输方式 | `shipping_method` | integer | 否 | 枚举 1、2、3 |
-| 集货仓库 | `warehouse_id` | integer/string | 否 | 值来自排舱筛选项 |
-| 是否直送柜 | `is_direct_send` | integer | 否 | 枚举 0、1 |
-| 拖车方式 / 报关方式 / 清关方式 | `trailer_types`、`declaration_types`、`clearance_types` | integer[] | 否 | 每项 1-2 个 |
-| 截关时间、预计装柜时间、创建时间、装柜时间(开始/结束) | `closing_time_start`、`closing_time_end`、`est_loading_time_start`、`est_loading_time_end`、`create_date_start`、`create_date_end`、`loading_time_start`、`loading_time_end` | string | 否 | 时间字符串最长 19 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### query_outbound_detail(排舱详情)
-
-路由:`POST /mcp/tools/queryOutboundDetail`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 排舱单号 | `outbound_number` | string | 是 | 非空,最长 100 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 10,1-20 |
-
-### query_customs_declaration_files(报关资料文件)
-
-路由:`POST /mcp/tools/queryCustomsDeclarationFiles`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 订单号数组 | `order_numbers` | string[] | 条件必填 | 与排舱单号数组二选一;1-100 项 |
-| 排舱单号数组 | `outbound_numbers` | string[] | 条件必填 | 与订单号数组二选一;1-100 项 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### list_outbound_filter_options(排舱筛选项)
-
-路由:`POST /mcp/tools/listOutboundFilterOptions`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 筛选类型 | `filter_type` | string | 是 | 排舱阶段、运输方式、集货仓库、是否直送柜、拖车方式、报关方式、清关方式 |
-| 关键词 | `keyword` | string | 否 | 最长 100 |
-| 页码 | `page` | integer | 否 | 默认 1,1-100 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-### list_order_filter_options(订单筛选项)
-
-路由:`POST /mcp/tools/listOrderFilterOptions`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 筛选类型 | `filter_type` | string | 是 | `country`、`product`、`customer`、`sales`、`warehouse`、`department` |
-| 关键词 | `keyword` | string | 否 | 关键词 |
-| 页码 | `page` | integer | 否 | 最小 1 |
-| 每页数量 | `limit` | integer | 否 | 1-100 |
-
-## 导出
-
-### export_pending_outbound_orders(未排舱订单异步导出)
-
-路由:`POST /mcp/tools/exportPendingOutboundOrders`
-
-所有字段均为非必填;筛选名称或 ID 必须先通过 `list_pending_outbound_export_filter_options` 获取。
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 订单号或客户参考号 | `number` | string | 否 | 多值用空格、英文逗号或换行分隔 |
-| 产品分类ID / 集货仓库ID / 进口商ID | `product_type_id`、`order_warehouse_id`、`importer_id` | integer | 否 | 筛选 ID |
-| 物流产品ID数组 / 订单状态数组 | `product_id`、`status` | integer[] | 否 | 筛选数组 |
-| 目的国 / 派送地址 / 入库时间范围 / 合并报关单号 / 货物类型 | `receiver_country`、`address`、`inbound_date`、`merge_declare_number`、`packing_type` | string | 否 | 入库时间格式 `YYYY-MM-DD - YYYY-MM-DD` |
-| 是否装柜剔除 | `is_remove` | integer | 否 | 枚举 0、1 |
-| 商品属性(带电、带磁、带木、其它、FDA、玩具、超长超重、敏感货、食品、无属性) | `is_battery`、`is_magnetic`、`is_wood`、`is_other`、`is_fda`、`is_toy`、`is_ultra_limit`、`is_sensitive`、`is_food`、`no_property` | string | 否 | 选中时传 `Y` |
-
-### export_out_of_province_port_data(省外进港资料异步导出)
-
-路由:`POST /mcp/tools/exportOutOfProvincePortData`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 资料类型 | `file_type` | string | 是 | `NB` 宁波、`SH` 上海、`MS` 美森 |
-| 排舱单号数组 / 柜号数组 / 提单号数组 / SO号数组 | `outbound_numbers`、`container_codes`、`bl_numbers`、`so_numbers` | string[] | 条件必填 | 四选一;每项 1-100 个;号码类型必须明确 |
-
-### export_receivable_cost_list(应收费用单列表异步导出)
-
-路由:`POST /mcp/tools/exportReceivableCostList`
-
-筛选合同与 `query_receivable_cost_list` 相同,但不接受 `page/limit/ids`。成功只返回 `task_ref + queued + retry_after_seconds`;稍后单独调用 `query_export_task`,禁止一次调用内轮询。
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 参考号 / 跟踪号 / 订单号 / 账单编号 | `reference_numbers`、`tracking_numbers`、`order_numbers`、`bill_numbers` | string[] | 条件必填 | 四类合计最多 200;或改用业务日期 |
-| 业务发生开始/结束日期 | `business_date_start`、`business_date_end` | date | 条件必填 | 无号码时必填;闭区间最多 31 天 |
-| 主客户 / 子客户 / 出账状态 / 核销状态 / 单据类型 / 费用项 | `customer_id`、`sub_customer_id`、`billing_status`、`verification_status`、`document_type`、`cost_type_id` | integer | 否 | 子客户必须带主客户;筛选值来自列表筛选项工具 |
-
-### query_export_task(查询导出任务)
-
-路由:`POST /mcp/tools/queryExportTask`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 导出任务引用 | `task_ref` | string | 是 | 只能使用导出工具返回值,1-512 字符 |
-
-### list_pending_outbound_export_filter_options(未排舱导出筛选项)
-
-路由:`POST /mcp/tools/listPendingOutboundExportFilterOptions`
-
-| 中文参数名 | 参数名 | 类型 | 必填 | 默认/约束 |
-|---|---|---|---|---|
-| 筛选项类型 | `filter_type` | string | 是 | `product_type`、`product`、`warehouse`、`country`、`importer`、`packing_type`、`order_status`、`goods_attribute`、`is_remove` |
-| 产品分类ID | `product_type_id` | integer | 否 | 查询物流产品时使用 |
-| 关键词 | `keyword` | string | 否 | 名称或编码关键词 |
-| 页码 | `page` | integer | 否 | 默认 1,最小 1 |
-| 每页数量 | `limit` | integer | 否 | 默认 20,1-100 |
-
-## 返回参数
-
-展示字段以 `services/output_presenter.py` 白名单为准:除 `query_order` 外,其余工具只暴露白名单中文键;未知字段关闭失败。成功结果位于 `tools/call` 的 `result.structuredContent`(另有文本 `content`),并带 `_meta.request_id`。
-
-通用分页(多数表格工具):
-
-| 展示名 | 字段 | 类型 | 说明 |
-|---|---|---|---|
-| 页码 | `page` | integer | 当前页,1-100 |
-| 每页数量 | `limit` | integer | 当前页大小 |
-| 是否还有更多 | `has_more` | boolean | 是否还有下一页 |
-
-订单详情工具内的分页键为中文:`页码`、`每页数量`、`是否还有更多`。
-
-### query_order
-
-兼容旧格式,不经 Presenter 白名单:`columns`(列名数组)、`records`(记录数组);具体列由后端旧接口决定。
-
-### query_order_exact
-
-结构:`summary`(可选文本)、`headers`、`rows`、`pagination`、`tips`(可选)。`rows` 与 `headers` 列序一一对应;后端可返回白名单子集。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 订单号 | `order_number` | |
-| 客户参考号 | `reference_number` | |
-| 状态 | `status_txt_name` | |
-| 是否已查验 | `check_status_txt_name` | |
-| 客户名称 | `customer_name` | |
-| 客户属性 | `customer_account_type_name` | |
-| 入库时间 | `inbound_date` | |
-| 未完成工单 | `wo_num` | |
-| 物流产品 | `product_name` | |
-| 件数 | `inbound_pieces` | |
-| 体积(CBM) | `inbound_volume` | |
-| 重量(KG) | `inbound_weight` | |
-| 品名 | `pro_cn_name` | |
-| 报关方式 | `export_declaration_type` | |
-| 合并报关单号 | `merge_declare_number` | |
-| 派送地址 | `delivery_address` | |
-| 柜号 | `container_code` | |
-| 排舱单状态 | `out_status_txt` | |
-| 目的港 | `hinge_of_destination` | |
-| ETD | `etd` | |
-| ATD | `atd` | |
-| ETA | `eta` | |
-| ATA | `ata` | |
-| 清关放行时间 | `release_time` | |
-| 海外入库时间 | `oversea_inbound_date` | |
-| APPT时间 | `appt_time` | |
-| 预计装柜时间 | `est_loading_time` | |
-| 海外提柜时间 | `pickup_time` | |
-| 派送方式 | `delivery_way_title` | |
-| 快递单号 | `tracking_number` | header 可带 description:承运商跟踪号码 |
-| Shipment ID | `shipment_id` | |
-| 商品属性 | `goods_attribute` | |
-| SKU | `sku` | |
-| 商务经理 | `sales_user` | |
-| 客户经理 | `service_user` | |
-| 事业部 | `department_name` | |
-| 订单备注 | `remark` | |
-| 进口商 | `importer_name` | |
-| 交货仓库 | `warehouse_name` | |
-| 付款状态 | `paid_status_name` | |
-
-### query_track
-
-结构:`headers`、`rows`、`pagination`、`tips`(可选)。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 轨迹节点 | `status` | 轨迹状态,如开始集港、离港放行等 |
-| 轨迹地点 | `location` | 发生地点 |
-| 时间 | `time` | 已转换为用户时区 |
-| 轨迹内容 | `content` | 详细描述 |
-| 跟踪号 | `tracking_number` | 快递单号 |
-| Shipment ID | `shipment_id` | 包裹 ID |
-
-### query_customs_declaration_files
-
-结构:`headers`、`rows`、`pagination`、`tips`(可选)。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 排舱单号 | `outbound_number` | |
-| 订单号 | `order_number` | |
-| 文件名 | `file_name` | |
-| 文件类型 | `file_type` | |
-| 文件链接 | `file_url` | 安全下载/预览链接 |
-
-### query_outbound_list
-
-结构:`headers`、`rows`、`pagination`、`tips`(可选)。固定白名单 31 列(后端返回子集时按实际列展示)。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 排舱单号 | `outbound_number` | |
-| 直送柜 | `direct_send` | |
-| 备注 | `remark` | |
-| 超长超重 | `cargo_type` | |
-| 集货仓库 | `warehouse_name` | |
-| 状态 | `status` | |
-| SO号 | `so_number` | |
-| 柜号 | `container_code` | |
-| 封条号 | `seal_number` | |
-| 柜型 | `container_type` | |
-| 运输方式 | `shipping_method` | |
-| 体积(CBM) | `total_volume` | |
-| 重量(KG) | `total_weight` | |
-| 船期 | `ship_schedule` | |
-| 截关时间 | `closing_time` | |
-| 预计装柜时间 | `est_loading_time` | |
-| 操作人 | `operator` | |
-| 拖报清方式 | `operation_modes` | |
-| 操作时间 | `operation_time` | |
-| 船司 | `ship_company` | |
-| 船名航次 | `vessel_name` | |
-| 截SI时间 | `cutoff_time_si` | |
-| 清关口岸 | `clearance_port` | |
-| 起运港 | `loading_port` | |
-| 目的港 | `destination_port` | |
-| 中转港 | `transit_port` | |
-| ETD | `etd` | |
-| ETA | `eta` | |
-| 是否含FDA认证商品 | `has_fda` | |
-| 是否含CPSC商品 | `has_cpsc` | |
-| 是否含食品 | `has_food` | |
-
-### query_outbound_detail
-
-结构:
-
-| 顶层键 | 说明 |
-|---|---|
-| `summary.headers` / `summary.row` | 排舱汇总:11 项,headers 与 row 一一对应 |
-| `details.headers` / `details.rows` | 订单明细表 |
-| `details.pagination` | 明细分页(英文键) |
-| `details.tips` | 可选提示 |
-
-汇总字段:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 提单号 | `bl_number` |
-| 柜号 | `container_code` |
-| 柜型 | `container_type` |
-| 总体积 | `total_volume` |
-| 总重量 | `total_weight` |
-| 总件数 | `total_pieces` |
-| SKU | `sku` |
-| 买单报关数量 | `buy_declaration_count` |
-| 一般贸易报关数量 | `general_declaration_count` |
-| 必装单量/体积 | `must_load` |
-| 备装单量/体积 | `backup_load` |
-
-明细字段:
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 订单号 | `order_number` | |
-| 超长超重 | `cargo_type` | |
-| 报关资料 | `customs_files` | 数组,每项含 `文件名称`、`文件类型`、`文件链接` |
-| 客户参考号 | `reference_number` | |
-| 客户名称 | `customer_name` | |
-| 报关方式 | `declaration_type` | |
-| 合并报关单号 | `merge_declare_number` | |
-| 订单备注 | `order_remark` | |
-| 提单号 | `bl_number` | |
-| 柜号 | `container_code` | |
-| 客户经理 | `customer_service` | |
-| 预计入库时间 | `est_inbound_date` | |
-| 实际入库时间 | `inbound_date` | |
-| 状态 | `status` | |
-| 预报件数 / 入库件数 | `pieces` | |
-| 预报实重(KG) / 入库实重(KG) | `weight` | |
-| 预报体积(m³) / 入库体积(m³) | `volume` | |
-| 交货地 | `pickup_place` | |
-| 物流产品 | `product_name` | |
-| 品名 | `goods_name` | |
-| 截关时间 | `closing_time` | |
-| 报关备注 | `clearance_remark` | |
-| 清关备注 | `import_clearance_remark` | |
-| 派送地址 | `delivery_address` | |
-| 派送类型 | `delivery_type` | |
-| 派送方式 | `delivery_way` | |
-| 派送渠道 | `channel` | |
-| 目的国 | `country_name` | |
-| 进口商 | `importer_name` | |
-| 清关税号 | `vat_type` | |
-| 货物类型 | `packing_type` | |
-| 是否问题件 | `is_abnormal` | |
-| 包装类型 | `package_method` | |
-| 到货回复 | `order_reply` | |
-| 订单是否必装 | `is_must_load` | |
-| 是否含FDA认证商品 | `has_fda` | |
-| 是否含CPSC商品 | `has_cpsc` | |
-| 是否含食品 | `has_food` | |
-
-### query_order_detail
-
-顶层固定:`订单号`、`详情模块`;再按 `section` 展开中文模块。分页键为 `页码` / `每页数量` / `是否还有更多`。
-
-| section 入参 | 详情模块展示名 | 主要返回块 |
-|---|---|---|
-| `overview` | 订单概览 | `状态节点`、`订单信息`、`货运信息`、`箱单汇总`、`进出口商` |
-| `packages` | 箱单信息 | `明细`、`分页` |
-| `package_items` | 箱单商品 | `明细`、`分页` |
-| `dw_auth` | DW授权信息 | `明细`、`分页` |
-| `attachments` | 附件信息 | `明细`、`分页` |
-| `inbound` | 入库信息 | `入库概况`、`明细`、`分页` |
-| `checks` | 查验信息 | `明细`、`分页` |
-| `tracks` | 订单轨迹 | `明细`、`分页` |
-| `operation_logs` | 操作日志 | `明细`、`分页` |
-| `cost_logs` | 应收与结算日志 | `明细`、`分页` |
-| `delivery` | 派送信息 | `派送汇总`、`明细`、`分页` |
-| `all` | 全部 | `订单概览` + 上表其余各模块 |
-
-状态节点字段:
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 阶段 | `stage` | |
-| 节点 | `label` | |
-| 状态 | `state` | 映射为:已完成 / 进行中 / 待完成 / 不展示 |
-| 时间 | `occurred_at` | |
-| 时间类型 | `time_kind` | 映射为:实际 / 预计 / 暂无 |
-
-订单概览 — 订单信息:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 订单号 | `order_number` |
-| 客户参考号 | `reference_number` |
-| 客户名称 | `customer_name` |
-| 起运国 | `departure_country` |
-| 目的国 | `destination_country` |
-| 包装类型 | `package_method` |
-| 物流产品 | `product_name` |
-| 预报件重体 | `forecast_pwv` |
-| 入库件重体 | `inbound_pwv` |
-| 应收计费重/体积 | `receivable_charge_weight` |
-| 结算计费重/体积 | `settlement_charge_weight` |
-| 报关类型 | `declaration_type` |
-| 是否需要预录单 | `pre_recording` |
-| 企业/个人姓名 | `idcard_name` |
-| 社会信用代码/身份证号 | `idcard_number` |
-| 排舱单号 | `outbound_number` |
-| 直送柜/拼柜 | `container_mode` |
-| 是否可查看轨迹地图 | `track_map_available` |
-| 购买保险 | `insurance` |
-| 派送方式 | `delivery_way` |
-| 退运附加 | `return_shipping_surcharge` |
-| 工单数量 | `work_order_count` |
-| 账单备注 | `bill_remark` |
-| 订单备注 | `remark` |
-
-订单概览 — 货运信息:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 预计入库时间 | `estimated_inbound_time` |
-| 货物类型 | `cargo_type` |
-| 交货方式 | `pickup_way` |
-| 交货仓库 | `warehouse` |
-| 提货地址 | `pickup_address` |
-| 提货联系人 | `pickup_contact` |
-| 提货联系电话 | `pickup_phone` |
-| 派送类型 | `delivery_type` |
-| 派送地址 | `delivery_address` |
-| 派送备注 | `delivery_remark` |
-| 海外仓 | `oversea_warehouse` |
-
-订单概览 — 箱单汇总:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 箱数 | `box_count` |
-| 重量 | `total_weight` |
-| 重量单位 | `weight_unit` |
-| 体积 | `total_volume` |
-| SKU数量 | `sku_count` |
-| 清关总价 | `clearance_total_price` |
-| 清关币种 | `clearance_currency` |
-| 采购总价 | `purchase_total_price` |
-| 采购币种 | `purchase_currency` |
-
-订单概览 — 进出口商:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 出口商 | `exporter` |
-| 进口商 | `importer` |
-| 是否做产地证 | `coo_required` |
-
-箱单信息明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| SHIPMENT ID | `shipment_id` |
-| REFERENCE ID | `reference_id` |
-| SKU | `sku_summary` |
-| 商品名 | `product_name_summary` |
-| 箱数 | `box_quantity` |
-| 单箱长(CM) | `box_length_cm` |
-| 单箱宽(CM) | `box_width_cm` |
-| 单箱高(CM) | `box_height_cm` |
-| 单箱毛重量(KG) | `gross_weight_kg` |
-| 单箱净重量(KG) | `net_weight_kg` |
-| 长度是否超限 | `is_over_length` |
-| 宽度是否超限 | `is_over_width` |
-| 高度是否超限 | `is_over_height` |
-| 重量是否超限 | `is_over_weight` |
-
-箱单商品明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| SHIPMENT ID | `shipment_id` |
-| REFERENCE ID | `reference_id` |
-| 箱规 | `box_specification` |
-| 箱规单位 | `box_specification_unit` |
-| 箱单单箱毛重(KG) | `package_gross_weight_kg` |
-| 箱单单箱净重(KG) | `package_net_weight_kg` |
-| 查验结果 | `inspection_result` |
-| 异常原因 | `exception_reason` |
-| 查验图片数量 | `inspection_photo_count` |
-| SKU | `sku` |
-| 单箱个数 | `units_per_box` |
-| 报关类型 | `declaration_type` |
-| 箱号 | `box_number` |
-| 商品单箱毛重量(KG) | `item_gross_weight_kg` |
-| 商品单箱净重量(KG) | `item_net_weight_kg` |
-| 中文品名 | `chinese_name` |
-| 英文名称 | `english_name` |
-| 品牌类型 | `brand_type` |
-| 品牌 | `brand` |
-| 型号 | `model` |
-| 材质(CN) | `material_cn` |
-| 材质(EN) | `material_en` |
-| 用途 | `purpose_cn` |
-| 商品属性 | `goods_attributes` |
-| 报关编码 | `declaration_hs_code` |
-| 清关编码 | `clearance_hs_code` |
-| 材质占比 | `material_ratio` |
-| 清关单价 | `clearance_unit_price` |
-| 清关币种 | `clearance_currency` |
-| 采购单价 | `purchase_unit_price` |
-| 采购币种 | `purchase_currency` |
-| 商品图片数量 | `product_image_count` |
-| 备注 | `remark` |
-
-DW授权信息明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| FBAID | `fba_id` |
-| DW开始时间 | `dw_start` |
-| DW结束时间 | `dw_end` |
-| DW | `dw_display` |
-| 客户授权状态 | `authorization_status` |
-
-附件信息明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 附件分类 | `category` |
-| 文件名称 | `file_name` |
-| 文件类型 | `file_extension` |
-| 是否图片 | `is_image` |
-| 预览链接 | `preview_url` |
-| 下载链接 | `download_url` |
-| 收费项目 | `cost_name` |
-
-入库概况:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 入库状态 | `inbound_status` |
-| 入库时间 | `inbound_time` |
-| 操作人 | `inbound_operator` |
-| 仓库 | `warehouse` |
-| 异常原因 | `abnormal_reason` |
-| 入库图片数量 | `inbound_photo_count` |
-| 预报件重体 | `forecast_pwv_summary` |
-| 入库件重体 | `inbound_pwv_summary` |
-
-入库明细:
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 是否贴标 | `has_label` | |
-| SHIPMENT ID | `shipment_id` | |
-| 入库件数 | `inbound_quantity` | |
-| 测量图片数量 | `measurement_photo_count` | |
-| 入库箱规 | `box_specification` | |
-| 单箱入库重量 / 总重量KG | `weight` | 由 `weight_mode`=`single_box`/`total` 决定展示键名 |
-| 重量单位 | `weight_unit` | |
-| 长度是否超限 | `is_over_length` | |
-| 宽度是否超限 | `is_over_width` | |
-| 高度是否超限 | `is_over_height` | |
-| 重量是否超限 | `is_over_weight` | |
-| 操作时间 | `operated_at` | |
-
-查验信息明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 货件编号 | `shipment_id` |
-| SKU | `sku` |
-| 箱号 | `box_number` |
-| 箱数 | `box_quantity` |
-| 箱规 | `box_specification` |
-| 查验结果 | `inspection_result` |
-| 查验图片数量 | `inspection_photo_count` |
-| 备注 | `remark` |
-| 查验人 | `inspector` |
-| 查验时间 | `inspected_at` |
-
-订单轨迹明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 轨迹类型 | `track_type` |
-| 轨迹节点 | `status` |
-| 轨迹地点 | `location` |
-| 时间 | `occurred_at` |
-| 跟踪号 | `tracking_number` |
-| SHIPMENT ID | `shipment_id` |
-| 轨迹内容 | `content` |
-
-操作日志 / 应收与结算日志明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 操作时间 | `operated_at` |
-| 操作内容 | `content` |
-| 操作人 | `operator` |
-
-派送汇总:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 主跟踪号 | `master_tracking_number` |
-| 子单数量 | `sub_order_count` |
-
-派送明细:
-
-| 展示名 | 后端字段 |
-|---|---|
-| 子跟踪号 | `sub_tracking_number` |
-
-### query_order_receivable_cost_details
-
-结构:`headers`、`rows`、`pagination`。固定 13 列。
-
-| 展示名 | 后端字段 |
-|---|---|
-| 订单号 | `order_number` |
-| 客户名称 | `customer_name` |
-| 费用项 | `cost_name` |
-| 费用客户 | `cost_customer_name` |
-| 计费重 | `charge_weight` |
-| 计费单价 | `unit_price` |
-| 原币币种 | `quote_currency` |
-| 应收原币 | `receivable_original_amount` |
-| 应收金额(CNY) | `receivable_amount_cny` |
-| 结算金额(CNY) | `settlement_amount_cny` |
-| 费用确认状态 | `cost_confirmation_status` |
-| 关账状态 | `closing_status` |
-| 核销状态 | `verification_status` |
-
-### query_receivable_cost_list
-
-结构:`headers`、`rows`、`pagination`。固定 24 列;四个金额字段为 `{"amount": number, "currency": string}`。
-
-| 展示名 | 后端字段 |
-|---|---|
-| 主客户 | `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` |
-
-### query_customer_list
-
-结构:`summary`、`headers`、`rows`、`pagination`、`display_rules`。固定 17 列,须完整展示。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 客户名称 | `customer_name` | |
-| 客户代码 | `customer_code` | |
-| 开户时间 | `create_time` | |
-| 业务类型 | `business_type` | |
-| 客户属性 | `customer_attribute` | |
-| 客户来源 | `customer_source` | |
-| 首次成交时间 | `first_inbound_date` | |
-| 最后一次走货时间 | `last_inbound_date` | |
-| 活跃状态 | `active_status` | |
-| 合同状态 | `contract_status` | |
-| 合同有效期 | `contract_validity` | |
-| 信用额度 | `credit_limit` | |
-| 结算币种 | `currency_code` | |
-| 结算模式(分业务类型) | `billing_modes` | 数组,每项含 `业务类型`、`结算模式` |
-| 商务经理 | `sales_name` | |
-| 客户经理 | `merchandiser_name` | |
-| 事业部 | `department_name` | |
-
-### query_customer_payment_followup
-
-结构:`headers`、`rows`、`pagination`、`display_rules`。固定 14 列。
-
-| 展示名 | 后端字段 | 说明 |
-|---|---|---|
-| 客户名称 | `customer_name` | |
-| 结算币种 | `settlement_currency` | |
-| 已出账未核销金额 | `billed_unverified_amount` | number |
-| 未出账金额 | `unbilled_amount` | number |
-| 逾期未回款金额 | `overdue_unpaid_amount` | number |
-| 已出账未核销金额(人民币) | `billed_unverified_amount_cny` | number |
-| 未出账金额(人民币) | `unbilled_amount_cny` | number |
-| 逾期未回款金额(人民币) | `overdue_unpaid_amount_cny` | number |
-| 未回款月份汇总 | `unverified_receivable_monthly_summary` | 见下表 |
-| 收款单未核销金额 | `receipt_unverified_amount` | number |
-| 当前余额 | `current_balance` | number |
-| 信用额度 | `credit_limit` | number |
-| 坏账合计 | `bad_debt_total` | number |
-| 合同状态 | `contract_status` | |
-
-未回款月份汇总子项:
-
-| 展示名 | 后端字段 | 类型 |
-|---|---|---|
-| 应收月份 | `receivable_month` | string |
-| 未核销金额 | `unverified_amount` | number |
-| 是否逾期 | `is_overdue` | boolean |
-
-### query_customer_unverified_bill_details
-
-结构:`headers`、`rows`、`pagination`。固定 6 列。
-
-| 展示名 | 后端字段 |
-|---|---|
-| 账单月份 | `bill_month` |
-| 账单号 | `bill_no` |
-| 业务类型 | `business_type` |
-| 结算模式 | `settlement_mode` |
-| 未核销金额 | `unverified_amount` |
-| 客户应收款日期 | `customer_receivable_date` |
-
-### query_customer_payment_records
-
-结构:`headers`、`rows`、`pagination`。固定 8 列。
-
-| 展示名 | 后端字段 |
-|---|---|
-| 客户名称 | `customer_name` |
-| 收款水单号 | `payment_reference` |
-| 原币到账金额 | `original_received_amount` |
-| 实际收款金额 | `actual_received_amount` |
-| 收款日期 | `receive_date` |
-| 已核销金额 | `verified_amount` |
-| 未核销金额 | `unverified_amount` |
-| 收款审核状态 | `payment_approval_status` |
-
-### 筛选项工具
-
-`list_customer_filter_options`、`list_order_filter_options`、`list_outbound_filter_options`、`list_pending_outbound_export_filter_options`、`list_receivable_cost_filter_options` 结构相同:`headers`、`rows`、`pagination`。应收费用单筛选的 `value` 可合法为 `0` 或 `-1`。
-
-| 展示名 | 说明 |
-|---|---|
-| 可传值 | 后续业务工具可直接传入的 value |
-| 显示名称 | 给人看的名称 |
-| 业务编码 | 辅助识别编码,可能为空 |
-
-### export_pending_outbound_orders / export_out_of_province_port_data / export_receivable_cost_list
-
-只提交异步任务,不返回文件。结构:
-
-| 展示名 | 字段 | 说明 |
-|---|---|---|
-| 提示文案 | `message` | 固定为「导出任务已提交」 |
-| 任务 | `task.task_ref` | 不透明任务引用,供 `query_export_task` 使用 |
-| 状态 | `task.status` | 固定 `queued` |
-| 建议等待秒数 | `task.retry_after_seconds` | 正整数 |
-
-### query_export_task
-
-| 状态 | 返回结构 |
-|---|---|
-| `queued` / `running` | `message`、`task.task_ref`、`task.status`、`task.retry_after_seconds` |
-| `failed` | `message`(导出任务失败,请重新提交)、`task.task_ref`、`task.status` |
-| `completed` | `message`(文件已生成)、`task`、`files[]` |
-
-`files` 每项:
-
-| 展示名 | 字段 | 说明 |
-|---|---|---|
-| 文件名称 | `label` | |
-| 安全下载链接 | `url` | 仅 http/https |
-
-## 返回结果示例
-
-示例值均为虚构数据;业务数据在 `structuredContent`。
-
-### 表格工具
-
-```json
-{
-  "content": [{"type": "text", "text": "订单查询结果"}],
-  "structuredContent": {
-    "headers": [{"label": "订单号"}, {"label": "客户名称"}],
-    "rows": [["ORD202607290001", "示例客户"]],
-    "pagination": {"page": 1, "limit": 20, "has_more": false}
-  },
-  "_meta": {"request_id": "rq_demo_001"}
-}
-```
-
-### query_outbound_detail
-
-```json
-{
-  "summary": {
-    "headers": [{"label": "提单号"}, {"label": "柜号"}, {"label": "总体积"}],
-    "row": ["BL001", "MSCU1234567", 12.5]
-  },
-  "details": {
-    "headers": [{"label": "订单号"}, {"label": "客户名称"}],
-    "rows": [["ORD202607290001", "示例客户"]],
-    "pagination": {"page": 1, "limit": 10, "has_more": false}
-  }
-}
-```
-
-### query_order_detail
-
-```json
-{
-  "订单号": "ORD202607290001",
-  "详情模块": "全部",
-  "订单概览": {
-    "状态节点": [{"阶段": "入库", "节点": "已入库", "状态": "已完成", "时间": "2026-07-29 10:00:00", "时间类型": "实际"}],
-    "订单信息": {"订单号": "ORD202607290001", "客户名称": "示例客户"}
-  },
-  "订单轨迹": {
-    "明细": [{"轨迹节点": "已入库", "时间": "2026-07-29 10:00:00"}],
-    "分页": {"页码": 1, "每页数量": 20, "是否还有更多": false}
-  }
-}
-```
-
-### 筛选项
-
-```json
-{
-  "headers": [
-    {"label": "可传值"},
-    {"label": "显示名称"},
-    {"label": "业务编码"}
-  ],
-  "rows": [[101, "示例仓库", "WH001"]],
-  "pagination": {"page": 1, "limit": 20, "has_more": false}
-}
-```
-
-### 异步导出
-
-提交:
-
-```json
-{
-  "message": "导出任务已提交",
-  "task": {
-    "task_ref": "TASK202607290001",
-    "status": "queued",
-    "retry_after_seconds": 5
-  }
-}
-```
-
-查询完成:
-
-```json
-{
-  "message": "文件已生成",
-  "task": {"task_ref": "TASK202607290001", "status": "completed"},
-  "files": [
-    {"label": "未排舱订单.xlsx", "url": "https://download.example.com/signed-url"}
-  ]
-}
-```
-
-## 通用返回规则
-
-- 业务成功数据位于 JSON-RPC `result.structuredContent`;表格工具为 `headers` + `rows` + `pagination`。
-- 展示名来自 Presenter 白名单;`rows` 单元格按 `headers` 列序排列,不是对象字典。
-- 异步导出提交返回 `message` + `task`,稍后调用 `query_export_task` 获取状态和下载链接。
-- 后端业务失败使用 `result.isError=true`;协议、参数、未知工具错误使用 JSON-RPC `error`,参数错误码为 `-32602`。
-- 不向调用方返回数据库、Redis、Token、内部 ID 或异常堆栈等敏感内部信息。

+ 10 - 0
public_gateway.py

@@ -35,6 +35,9 @@ 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
 
 
@@ -67,6 +70,8 @@ class PublicGatewayApp:
                 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
             ),
@@ -78,6 +83,8 @@ class PublicGatewayApp:
             ),
             '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
             ),
@@ -86,6 +93,9 @@ class PublicGatewayApp:
             '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),

+ 138 - 0
services/output_presenter.py

@@ -30,6 +30,9 @@ class OutputPresenter:
     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((
@@ -48,6 +51,7 @@ class OutputPresenter:
         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
     )
 
@@ -420,6 +424,70 @@ class OutputPresenter:
         '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': '未核销金额',
@@ -683,6 +751,14 @@ class OutputPresenter:
             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,
@@ -698,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):
@@ -1352,6 +1430,66 @@ class OutputPresenter:
         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'}:

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

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

+ 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

+ 209 - 0
tools/query_payable_cost_list.py

@@ -0,0 +1,209 @@
+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;禁止混合单号猜测、跨字段试查或使用未确认的费用项。'
+                '不传号码时至少提供一组不超过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