Prechádzať zdrojové kódy

mcp接口增加订单详情,优化接口并发数

jackson 6 dní pred
rodič
commit
b91e33ddd1

+ 1 - 0
.env.example

@@ -10,6 +10,7 @@ FMS_TOOLS_BASE=http://chenjiacheng.fmsoperate.dahuo.fudingri.com
 
 FMS_CLIENT_TYPE=workbuddy
 FMS_TIMEOUT_SECONDS=10
+FMS_MAX_IN_FLIGHT_PER_TOOL=5
 FMS_REFRESH_SKEW_SECONDS=120
 FMS_LOG_LEVEL=info
 

+ 10 - 8
README.md

@@ -17,21 +17,22 @@ Gateway 保持“薄网关”边界:
 - 支持 Redis token/session 存储。
 - 支持文件 token store 作为开发排障兜底。
 - 不再支持授权码绑定工具;正式接入只使用后台生成的 `GWS_xxx` 设备配置。
-- 本地 stdio 与公网 HTTP 注册同一组 11 个查询、筛选和导出工具。
-- 支持订单、轨迹、报关资料、排舱列表与详情查询。
+- 本地 stdio 与公网 HTTP 注册同一组 12 个查询、筛选和导出工具。
+- 支持订单、订单详情、轨迹、报关资料、排舱列表与详情查询。
 - 支持订单与排舱筛选项,以及未排舱订单和省外进港资料导出。
 - 支持 MCP `initialize`、`tools/list`、`tools/call`。
 - 公网模式支持 `gateway_session_id` 请求级隔离、Redis Gateway session、审计日志和基础限流。
 
 ## 工具目录
 
-`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 11 个候选工具:
+`GatewayApp` 与 `PublicGatewayApp` 当前注册以下 12 个候选工具:
 
 | MCP 工具 | 用途 | ThinkPHP 路由 | 最终展示 |
 |---|---|---|---|
 | `query_order` | 普通订单列表查询 | `/mcp/tools/queryOrder` | 旧协议兼容 |
 | `query_track` | 按订单或物流号码查询轨迹 | `/mcp/tools/queryTrack` | 安全表格 |
 | `query_order_exact` | 按明确号码类型精准查询订单 | `/mcp/tools/queryOrderExact` | 安全表格 |
+| `query_order_detail` | 按明确订单号查询订单详情,支持“全部”聚合 | `/mcp/tools/queryOrderDetail` | 安全中文详情 |
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
 | `query_outbound_list` | 按业务阶段和筛选条件查询排舱列表 | `/mcp/tools/queryOutboundList` | 安全表格 |
 | `query_outbound_detail` | 按排舱单号查询排舱汇总与订单明细 | `/mcp/tools/queryOutboundDetail` | 安全详情 |
@@ -41,7 +42,7 @@ Gateway 保持“薄网关”边界:
 | `export_out_of_province_port_data` | 导出省外进港资料 | `/mcp/tools/exportOutOfProvincePortData` | 安全文件链接 |
 | `list_pending_outbound_export_filter_options` | 查询未排舱导出筛选项 | `/mcp/tools/listPendingOutboundExportFilterOptions` | 安全筛选项 |
 
-这 11 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
+这 12 个名称只是 Gateway 的本地候选集合。员工在 `tools/list` 中实际看到、在 `tools/call` 中实际可调用的工具,始终是“Gateway 本地注册集合”与 fmsoperate 当前动态启用列表的交集;动态列表缺失、格式错误或查询失败时关闭访问,不回退为全量开放。
 
 MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明或注册集合变化后,必须重启对应 Gateway 进程并让客户端重新连接,客户端才会重新获取工具列表。
 
@@ -50,9 +51,10 @@ MCP 能力声明为 `tools.listChanged=false`。工具名称、Schema、说明
 Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内部接口和工具入参:
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
-- `services/output_presenter.py` 对其余 10 个安全工具执行显式白名单展示。
+- `services/output_presenter.py` 对其余 11 个安全工具执行显式白名单展示。
 - `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
 - `query_outbound_detail` 使用中文 `summary + details + pagination` 两层结构。
+- `query_order_detail` 按中文详情模块返回固定分组或明细;传入“全部”时一次返回概览和十个明细模块,各明细模块独立分页。附件保留文件名、预览与下载链接;后端机器字段和内部 ID 不进入最终展示。
 - 三个筛选项工具保留“可传值、显示名称、业务编码”,确保返回值可继续传给查询或导出工具。
 - 两个导出工具返回 `files[].label + files[].url`,不暴露后端 `file_url` 键。
 - `request_id` 位于 MCP 结果 `_meta`;参数错误使用业务名称,未知异常不透传后端细节。
@@ -246,7 +248,7 @@ Invoke-WebRequest http://127.0.0.1:8765/health -UseBasicParsing
 
 ### 部署状态核对
 
-用户已确认 `Y:\fmsoperate\sql` 中 10 个 MCP SQL 和 `Y:\base\sql` 中 5 个 MCP SQL 均已执行。SQL 已执行不等于工具当前处于启用状态;实际可见工具仍以 fmsoperate 动态注册表的当前启用值为准。
+用户已确认 `Y:\fmsoperate\sql` 原有 10 个 MCP SQL 和 `Y:\base\sql` 中 5 个 MCP SQL 均已执行。订单详情的新注册 SQL 仍待目标环境审核与执行,首次注册默认关闭;实际可见工具始终以 fmsoperate 动态注册表当前启用值为准。
 
 仓库静态内容无法证明运行中的 Gateway 是否已加载当前代码,也无法证明 Workbuddy 是否已重新连接。发布工具或 Schema 变更后,运维交接必须分别确认:
 
@@ -406,7 +408,7 @@ Gateway 会调用以下路径:
 
 - Auth:`/mcp/auth/exchange`、`/mcp/auth/refresh`、`/mcp/auth/revoke`。
 - 动态工具列表:`/mcp/tools/listEnabledTools`。
-- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
+- 查询:`/mcp/tools/queryOrder`、`/mcp/tools/queryTrack`、`/mcp/tools/queryOrderExact`、`/mcp/tools/queryOrderDetail`、`/mcp/tools/queryCustomsDeclarationFiles`、`/mcp/tools/queryOutboundList`、`/mcp/tools/queryOutboundDetail`。
 - 筛选项:`/mcp/tools/listOutboundFilterOptions`、`/mcp/tools/listOrderFilterOptions`、`/mcp/tools/listPendingOutboundExportFilterOptions`。
 - 导出:`/mcp/tools/exportPendingOutboundOrders`、`/mcp/tools/exportOutOfProvincePortData`。
 
@@ -439,6 +441,6 @@ Windows 下 Python 标准输出可能使用本机控制台编码。当前 `mcp_p
 - Gateway 不是业务系统,不直接查 MySQL。
 - Gateway 不替代 ThinkPHP 权限体系。
 - 公网 Gateway 是否能生产放量,取决于 Workbuddy 远程 MCP 是否能稳定传递 `gateway_session_id`。
-- fmsoperate 和 base 的 MCP SQL 已确认执行;工具当前动态启用值仍必须从运行环境核验。
+- fmsoperate 原有 SQL 和 base SQL 已确认执行;订单详情注册 SQL 尚待执行,工具当前动态启用值仍必须从运行环境核验。
 - 仓库无法证明运行中 Gateway 已重启或客户端已重连,发布交接必须显式确认这两项。
 - 写入型 MCP 工具需要单独安全评审后再开放。

+ 9 - 0
app.py

@@ -27,6 +27,7 @@ from tools.query_customs_declaration_files import (
     QueryCustomsDeclarationFilesTool,
 )
 from tools.query_order_exact import QueryOrderExactTool
+from tools.query_order_detail import QueryOrderDetailTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
@@ -56,6 +57,7 @@ class GatewayApp:
             'query_order': QueryOrderTool(api_client=api_client),
             'query_track': QueryTrackTool(api_client=api_client),
             'query_order_exact': QueryOrderExactTool(api_client=api_client),
+            'query_order_detail': QueryOrderDetailTool(api_client=api_client),
             'query_customs_declaration_files':
                 QueryCustomsDeclarationFilesTool(api_client=api_client),
             'query_outbound_list': QueryOutboundListTool(api_client=api_client),
@@ -197,6 +199,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('--section', default='全部')
         call_parser.add_argument('--order-numbers', default='')
         call_parser.add_argument('--tracking-number', default='')
         call_parser.add_argument('--tracking-numbers', default='')
@@ -271,6 +274,7 @@ class GatewayApp:
                 enable_rate_limit=config.rate_limit_enabled,
                 rate_limit_max_requests=config.rate_limit_max_requests,
                 rate_limit_window_seconds=config.rate_limit_window_seconds,
+                max_in_flight_per_tool=config.max_in_flight_per_tool,
             )
         elif args.command == 'call':
             tool_args = {
@@ -332,6 +336,11 @@ class GatewayApp:
                     tool_args['sales_id'] = args.sales_id
                 if args.department_id > 0:
                     tool_args['department_id'] = args.department_id
+            elif args.tool == 'query_order_detail':
+                if not args.order_number:
+                    raise ValueError('--order-number is required for query_order_detail')
+                tool_args['order_number'] = args.order_number
+                tool_args['section'] = args.section
             elif args.tool == 'query_customs_declaration_files':
                 if args.outbound_numbers:
                     tool_args['outbound_numbers'] = parse_string_list(

+ 2 - 0
config.py

@@ -23,6 +23,7 @@ class GatewayConfig:
     rate_limit_enabled: bool = True
     rate_limit_max_requests: int = 60
     rate_limit_window_seconds: int = 60
+    max_in_flight_per_tool: int = 2
 
     @classmethod
     def from_env(cls, env=None, dotenv_path=''):
@@ -73,6 +74,7 @@ class GatewayConfig:
             rate_limit_enabled=cls._parse_bool(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_ENABLED', 'MCP_RATE_LIMIT_ENABLED'), default=True),
             rate_limit_max_requests=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_MAX_REQUESTS', 'MCP_RATE_LIMIT_MAX_REQUESTS'), default=60),
             rate_limit_window_seconds=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_WINDOW_SECONDS', 'MCP_RATE_LIMIT_WINDOW_SECONDS'), default=60),
+            max_in_flight_per_tool=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_MAX_IN_FLIGHT_PER_TOOL', 'MCP_MAX_IN_FLIGHT_PER_TOOL'), default=2),
         )
 
     @staticmethod

+ 65 - 0
docs/superpowers/specs/2026-07-17-order-detail-design.md

@@ -0,0 +1,65 @@
+# MCP 订单详情设计
+
+## 目标
+
+`query_order_detail` 按明确订单号读取 `fmsoperate` 后台订单详情页可见数据,同时保持员工菜单、公司和订单数据范围。Gateway 只定义工具 Schema 和中文展示 DTO,不复制 PHP 权限或业务查询。
+
+## 调用合同
+
+ThinkPHP 路由:`POST /mcp/tools/queryOrderDetail`
+
+输入:
+
+| 字段 | 规则 |
+|---|---|
+| `order_number` | 必填,去空后 1 至 100 字符 |
+| `section` | 可选,默认“订单概览”,仅允许工具 Schema 中的中文枚举 |
+| `page` | 1 至 100,默认 1 |
+| `limit` | 1 至 100,默认 20 |
+
+模块包括:订单概览、箱单信息、箱单商品、DW授权信息、附件信息、入库信息、查验信息、订单轨迹、操作日志、应收与结算日志、派送信息。
+
+完整详情必须先查询订单概览,再逐一查询其他十个模块。分页模块持续查询到“是否还有更多”为否;一个模块为空不代表其他模块为空。
+
+## 权限边界
+
+每次调用都执行以下顺序:
+
+```text
+设备/Token 会话
+  -> 动态工具注册与 route_code
+  -> admin/Order/index 菜单
+  -> query_order_exact 同源父订单数据范围
+  -> 服务端父订单 ID
+  -> 目标 section
+```
+
+调用方不能提供订单 ID、公司/员工/角色、箱单 ID、查验 ID 或其他子资源 ID。不存在、跨公司和数据范围外订单使用相同的目标不可用结果。合作伙伴关联 ID 只能由已授权展示订单在服务端派生。
+
+## PHP 响应
+
+PHP 使用固定机器结构供 Gateway 校验:
+
+- 所有结果包含规范订单号、内部 section 和 payload。
+- 概览 payload 固定包含状态节点、订单信息、货运信息、箱单汇总和进出口商。
+- 普通模块固定包含 records。
+- 入库和派送固定包含 summary 与 records。
+- 分页信息只包含页码、每页数量和是否还有更多,不统计总数。
+
+MySQL 模块在父订单重新授权后读取。Mongo 日志、轨迹接口和 `track_db` 无法加入同一快照,只能在授权成功后使用服务端派生参数访问。
+
+## 中文展示
+
+`OutputPresenter` 对顶层、分组、状态枚举和每行字段执行完整白名单校验。未知、缺失或多余字段均关闭失败。最终结构化结果和文本只使用中文业务键;机器字段、内部参数名及数据库 ID 不进入 AI 展示。
+
+附件展示保留:附件分类、文件名称、文件类型、是否图片、预览链接、下载链接和收费项目。链接由 PHP 校验 HTTP(S) 协议与配置主机。商品图片、入库/测量照片和查验照片不返回链接或内容,只返回数量。Freight Tower 只返回地图是否可用,不返回含密钥的 iframe URL。
+
+## 发布
+
+1. 部署 fmsoperate 与 Gateway 代码。
+2. 审核并执行 `fmsoperate/sql/mcp_add_query_order_detail_tool.sql`;首次注册默认 `status=0`,重复执行保留管理员状态。
+3. 核对注册表 route_code 和预期 status。
+4. 重启 Gateway,并让 Workbuddy 重新连接。
+5. 使用有权、无权、跨公司和合作伙伴员工完成只读冒烟。
+
+代码存在、测试通过或 SQL 文件存在均不表示目标环境已启用该工具。

+ 4 - 4
project-docs/overview.md

@@ -8,18 +8,18 @@ Gateway 保持薄边界:不直连业务数据库,不在 Python 中复制员
 
 ## 当前能力
 
-本地 `GatewayApp` 与公网 `PublicGatewayApp` 注册同一组 11 个候选工具:
+本地 `GatewayApp` 与公网 `PublicGatewayApp` 注册同一组 12 个候选工具:
 
 | 类别 | 工具 |
 |---|---|
-| 订单与轨迹 | `query_order`、`query_order_exact`、`query_track` |
+| 订单与轨迹 | `query_order`、`query_order_exact`、`query_order_detail`、`query_track` |
 | 报关与排舱 | `query_customs_declaration_files`、`query_outbound_list`、`query_outbound_detail` |
 | 筛选项 | `list_outbound_filter_options`、`list_order_filter_options`、`list_pending_outbound_export_filter_options` |
 | 导出 | `export_pending_outbound_orders`、`export_out_of_province_port_data` |
 
 候选注册不代表员工一定可见。实际工具集合是本地注册集合与 fmsoperate 动态启用列表的交集,并继续受员工权限与数据范围约束。动态列表不可用或格式异常时关闭访问。
 
-`query_order` 保持旧响应兼容;其余 10 个工具由 `services/output_presenter.py` 转为中文白名单展示 DTO。所有工具遵守零推断边界:结果意图或号码业务类型不明确时先询问,禁止按格式猜测、并行试查或失败后跨字段、跨工具重试。
+`query_order` 保持旧响应兼容;其余 11 个工具由 `services/output_presenter.py` 转为中文白名单展示 DTO。订单详情按固定中文模块展示,图片附件保留文件名和安全链接,机器字段及内部 ID 关闭失败。所有工具遵守零推断边界:结果意图或号码业务类型不明确时先询问,禁止按格式猜测、并行试查或失败后跨字段、跨工具重试。
 
 ## 跨仓职责
 
@@ -41,4 +41,4 @@ Gateway 保持薄边界:不直连业务数据库,不在 Python 中复制员
 
 ## 当前交接状态
 
-用户已确认 `Y:/fmsoperate/sql`  10 个 MCP SQL 与 `Y:/base/sql` 的 5 个 MCP SQL 均已执行。仓库仍无法静态判断动态注册表当前启用值、运行中 Gateway 是否已重启、Workbuddy 是否已重新连接这些状态必须从部署环境核验。
+用户已确认 `Y:/fmsoperate/sql` 原有 10 个 MCP SQL 与 `Y:/base/sql` 的 5 个 MCP SQL 均已执行。订单详情注册 SQL 尚待目标环境执行;仓库仍无法静态判断动态注册表当前启用值、运行中 Gateway 是否已重启、Workbuddy 是否已重新连接这些状态必须从部署环境核验。

+ 8 - 6
project-docs/requirements.md

@@ -10,7 +10,7 @@
 ## 协议合同
 
 - 支持 MCP `initialize`、`tools/list` 和 `tools/call`;能力声明保持 `tools.listChanged=false`。
-- `tools/list` 返回本地 11 个候选工具与 fmsoperate 动态启用列表的交集。
+- `tools/list` 返回本地 12 个候选工具与 fmsoperate 动态启用列表的交集。
 - `tools/call` 在调用前再次校验工具已注册、设备会话有效且后端仍允许当前员工使用该工具。
 - 动态列表缺失、格式错误或查询失败时关闭访问,不允许回退为本地全量工具。
 - 后端业务失败仍放在 JSON-RPC `result` 中并设置 `isError=true`,不得中断 stdio 或 HTTP 会话;协议、参数或未知方法错误使用 JSON-RPC `error`。
@@ -21,7 +21,8 @@
 | 类别 | 工具 | 核心要求 |
 |---|---|---|
 | 查询 | `query_order` | 保持旧展示协议兼容 |
-| 查询 | `query_order_exact`、`query_track` | 只在结果意图和号码类型明确后调用 |
+| 查询 | `query_order_exact`、`query_track` | `query_order_exact` 只返回订单列表/批量精准筛选结果;只在结果意图和号码类型明确后调用 |
+| 查询 | `query_order_detail` | 只按明确订单号查询单个订单详情和中文模块;多个订单需逐个调用;列表、批量筛选不得使用本工具;完整详情可使用“全部”一次查询概览及十个明细模块 |
 | 查询 | `query_customs_declaration_files` | 订单号数组与排舱单号数组必须二选一 |
 | 查询 | `query_outbound_list` | 排舱阶段必选;运输方式未指定时查询全部 |
 | 查询 | `query_outbound_detail` | 只按明确排舱单号查询,不接受内部排舱 ID |
@@ -42,8 +43,9 @@
 ## 输出与错误安全
 
 - `query_order` 是唯一旧展示例外,继续使用 `columns + records` 和原文本行为。
-- 其余 10 个工具由 `OutputPresenter` 白名单处理:4 个表格工具、1 个排舱详情工具、3 个筛选项工具和 2 个导出工具。
-- 表格使用中文 `headers + rows + pagination`;排舱详情使用 `summary + details + pagination`;导出只返回 `files[].label + files[].url`。
+- 其余 11 个工具由 `OutputPresenter` 白名单处理:4 个表格工具、1 个排舱详情工具、1 个订单详情工具、3 个筛选项工具和 2 个导出工具。
+- 表格使用中文 `headers + rows + pagination`;排舱详情使用 `summary + details + pagination`;订单详情使用固定中文分组、明细和分页;导出只返回 `files[].label + files[].url`。
+- 订单详情未知模块、分组或字段必须关闭失败;“全部”响应必须严格包含概览和十个明细分组,各明细分组独立带分页;附件可展示分类、文件名、类型、图片标识、预览和下载链接,商品/入库/查验图片不展示 URL。
 - 筛选项保留可继续传参的 `value`、用户显示 `label` 和业务 `code`,但不得泄露未列入白名单的后端字段。
 - `request_id` 放在 MCP 结果 `_meta`;安全工具的业务错误使用固定中文消息,不透传后端 `msg`、异常数据、堆栈或原始响应。
 - Gateway 已知错误码与重试策略以 `OutputPresenter.ERROR_MESSAGES`、`NON_RETRYABLE_CODES` 为代码源;未知业务码对外归一为 `MCP_9001`。
@@ -54,12 +56,12 @@
 - 公网请求使用 `GWS_xxx` 查找 Redis Gateway session,并从服务端会话取得 `mcp_token`;不得把 token 返回给客户端。
 - 公网入口生成可信 `rq_http_*` 追踪号;调用方 `X-Request-Id` 只允许记录短哈希,不得作为可信追踪号。
 - 日志不得记录明文 Gateway 凭据、MCP token、Cookie、Authorization、授权码或后端原始响应。
-- `initialize` 与 `tools/list` 不限流;`tools/call` 按已认证的 `gateway_session_id + tool_name` 使用独立滑动窗口配额
+- `initialize` 与 `tools/list` 不限流;`tools/call` 按已认证的 `gateway_session_id + tool_name` 使用可选滑动窗口配额和可释放的并发配额,超限返回 JSON-RPC `-32029`,不得关闭 MCP 连接。窗口配置为 `0` 时关闭累计次数限制;并发名额在请求结束或异常后立即释放
 - 公网必须位于 HTTPS 反向代理后,Redis 只允许内网访问并启用生产密码。
 
 ## 变更与验收
 
 - 工具变更必须同步更新工具 metadata、stdio/public 双注册表、CLI 转发、Presenter、ThinkPHP 路由/验证/Logic/Model、动态注册数据和相关测试。
 - 因 `tools.listChanged=false`,工具或 Schema 变化后必须重启 Gateway 并让客户端重新连接。
-- 用户已确认 fmsoperate  10 个 MCP SQL 与 base 的 5 个 MCP SQL 均已执行;动态启用值仍以运行环境为准。
+- 用户已确认 fmsoperate 原有 10 个 MCP SQL 与 base 的 5 个 MCP SQL 均已执行;订单详情注册 SQL 尚待执行,动态启用值仍以运行环境为准。
 - Python 生产代码变更必须运行全量 unittest 和 `.coveragerc` 要求的语句、分支 100% 严格覆盖率;跨仓 PHP 变更必须运行对应合同测试与语法检查。

+ 10 - 6
project-docs/tech-specs.md

@@ -11,14 +11,14 @@
 
 | 组件 | 职责 |
 |---|---|
-| `app.py` | 本地 `GatewayApp`、stdio/CLI 入口、11 个工具注册与参数转发 |
-| `public_gateway.py` | 公网 `PublicGatewayApp`、设备会话解析后的 scoped 调用、同组 11 个工具注册 |
+| `app.py` | 本地 `GatewayApp`、stdio/CLI 入口、12 个工具注册与参数转发 |
+| `public_gateway.py` | 公网 `PublicGatewayApp`、设备会话解析后的 scoped 调用、同组 12 个工具注册 |
 | `public_server.py` | `/mcp` HTTP JSON-RPC、可信追踪号、审计上下文、限流和 `/health` |
 | `mcp_protocol.py` | MCP 握手、`tools/list`、`tools/call`、旧/新结果分流和协议错误 |
 | `services/api_client.py` | 本地 token 上下文下的 ThinkPHP 工具请求与动态列表请求 |
 | `services/scoped_api_client.py` | 公网请求级 `mcp_token` 转发,避免进程级身份串用 |
 | `services/gateway_session_store.py` | `GWS_xxx` 到服务端员工会话的 Redis 映射 |
-| `services/output_presenter.py` | 10 个安全工具的白名单 DTO、错误映射和文本渲染 |
+| `services/output_presenter.py` | 11 个安全工具的白名单 DTO、错误映射和文本渲染 |
 | `tools/*.py` | 工具名称、说明、输入 Schema、路由和调用封装 |
 
 ## 本地与公网会话
@@ -40,36 +40,40 @@
 
 ## 动态工具可见性
 
-- `GatewayApp` 与 `PublicGatewayApp` 的候选注册顺序和名称必须完全一致,当前各为 11 个。
+- `GatewayApp` 与 `PublicGatewayApp` 的候选注册顺序和名称必须完全一致,当前各为 12 个。
 - `tools/list` 调用 `/mcp/tools/listEnabledTools`,只返回本地注册与后端启用代码的交集。
 - `tools/call` 重新读取启用集合,避免工具被禁用后继续调用。
 - MCP 返回 `tools.listChanged=false`,因此工具或 Schema 变更必须通过 Gateway 重启和客户端重连刷新。
 
 ## Presenter 分类
 
-`query_order` 走旧协议分支,保留 `columns/records/meta` 和既有文本行为。`OutputPresenter.SAFE_TOOLS` 显式处理其余 10 个工具:
+`query_order` 走旧协议分支,保留 `columns/records/meta` 和既有文本行为。`OutputPresenter.SAFE_TOOLS` 显式处理其余 11 个工具:
 
 | 类型 | 工具 | DTO |
 |---|---|---|
 | 表格 | `query_order_exact`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` | `headers + rows + pagination` |
 | 排舱详情 | `query_outbound_detail` | `summary + details`,分页位于 `details.pagination` |
+| 订单详情 | `query_order_detail` | 中文订单号、详情模块、固定概览分组或明细及分页 |
 | 筛选项 | `list_order_filter_options`、`list_outbound_filter_options`、`list_pending_outbound_export_filter_options` | `value + label + code` 三列 |
 | 导出 | `export_pending_outbound_orders`、`export_out_of_province_port_data` | `files[].label + files[].url` |
 
 Presenter 对列定义、记录类型、详情汇总、附件结构和导出 URL 做白名单校验。未知工具、未允许列、畸形响应或未知异常返回安全 `isError=true`;真实异常仅记录在服务端日志。后端业务错误映射为固定消息,不透传原始 `msg/data`。
 
+订单详情 Presenter 不依赖原始请求参数,而是校验后端规范 `section`、固定分组及每行完整键集合。“全部”响应校验概览和十个明细分组,并将每个明细分组的分页信息独立展示。状态和时间类型只接受已知枚举;入库重量根据后端固定模式显示为单箱重量或总重量。
+
 ## 追踪、日志与限流
 
 - stdio 自动生成 `rq_*`;公网入口始终生成可信 `rq_http_*`,不采信调用方 `X-Request-Id`。
 - 调用方 `X-Request-Id` 只记录 SHA-256 短哈希 `client_request_id_hash`,用于关联客户端反馈。
 - 追踪号贯穿动态工具列表、工具调用、MCP `_meta` 和结构化日志。
 - 审计日志只记录必要的 request/jsonrpc ID、协议方法、工具代码、员工/公司标识和脱敏客户端标识;禁止记录凭据与原始响应。
-- `initialize` 与 `tools/list` 不进入限流器;`tools/call` 使用进程内 `SimpleRateLimiter`,已知工具按 `gateway_session_id + tool_name` 分桶。
+- `initialize` 与 `tools/list` 不进入限流器;`tools/call` 使用 `gateway_session_id + tool_name` 分桶,并通过 `FMS_MAX_IN_FLIGHT_PER_TOOL` 控制可释放的同时执行数。`FMS_RATE_LIMIT_MAX_REQUESTS=0` 时关闭累计窗口限制,超限统一返回 JSON-RPC `-32029`
 - 限流配置为 `FMS_RATE_LIMIT_ENABLED`、`FMS_RATE_LIMIT_MAX_REQUESTS`、`FMS_RATE_LIMIT_WINDOW_SECONDS`;生产环境还应在反向代理层限流。
 
 ## 关键业务参数
 
 - `query_outbound_list.outbound_status` 必填,只暴露后台业务阶段;`shipping_method` 无默认值,未传表示全部运输方式。
+- `query_order_detail.order_number` 必填;`section` 接受 11 个中文模块或“全部”,页码和每页数量均为 1 至 100。
 - `query_outbound_list.warehouse_id` 接受正整数或 `-1`(客户仓),拒绝 0 与其他负数。
 - `list_outbound_filter_options.filter_type` 使用七个中文枚举;仓库分支由 fmsoperate 复用 `OrderModel::getSortWarehouse()`,所有分支先校验 `admin/Outbound/index`。
 - 排舱筛选响应保留 `value/label/code` 供跨工具传参,面向用户只展示中文标签。

+ 11 - 4
project-docs/timeline.md

@@ -21,16 +21,23 @@
 
 ## 2026-07-17:工具边界、筛选与限流
 
-- 11 个候选工具统一采用零推断说明:先确认结果意图与号码类型,禁止格式猜测、并行试查和跨字段/跨工具重试。
+- 12 个候选工具统一采用零推断说明:先确认结果意图与号码类型,禁止格式猜测、并行试查和跨字段/跨工具重试。
 - `list_outbound_filter_options` 统一七类排舱筛选项,仓库能力并入该工具,不保留重复入口。
 - 排舱阶段改为必选;运输方式未指定时查询全部运输方式。
 - `initialize` 与 `tools/list` 退出限流,`tools/call` 保持按设备会话与工具分桶。
-- 本地 `GatewayApp` 与公网 `PublicGatewayApp` 对齐为 11 个候选工具;`OutputPresenter` 处理其中 10 个安全工具。
+- 本地 `GatewayApp` 与公网 `PublicGatewayApp` 对齐为 12 个候选工具;`OutputPresenter` 处理其中 11 个安全工具。
 - 用户确认 `Y:/fmsoperate/sql` 的 10 个 MCP SQL 与 `Y:/base/sql` 的 5 个 MCP SQL 均已执行。
 - 文档治理建立 `AGENTS.md`,放行并重写 `project-docs` 五件套;历史计划与旧测试快照经校验备份后从活动工作区清理。
 
+## 2026-07-17:订单详情工具
+
+- 增加 `query_order_detail` 的中文模块 Schema、本地/公网双注册、CLI 转发和严格白名单 Presenter。
+- 支持 `section=全部` 一次返回概览和十个明细模块,各明细模块独立分页并保持中文白名单展示。
+- 概览固定展示状态节点及四组业务信息;明细覆盖箱单、DW、附件、入库、查验、轨迹、日志和派送,附件保留安全链接。
+- fmsoperate 注册 SQL 仍待目标环境执行;Gateway 重启、Workbuddy 重连和实际动态可见性仍需发布确认。
+
 ## 当前交接
 
-- 只读 `.coverage` 报告记录为 `1822 statements / 734 branches / 100.00%`;这是已有产物读数,不替代发布前的重新验证。
-- SQL 执行状态已确认,工具的当前动态启用值仍需通过实际员工会话的 `tools/list` 核验。
+- Python 测试继续执行 `.coveragerc` 的语句与分支 100% 门禁;发布前必须重新运行,不能引用历史报告代替
+- 原有 SQL 执行状态已确认,订单详情注册 SQL尚待执行;工具当前动态启用值仍需通过实际员工会话的 `tools/list` 核验。
 - 运行中 Gateway 是否已加载当前代码、Workbuddy 是否已重新连接,无法由仓库静态判断,发布交接必须显式确认。

+ 4 - 3
project-docs/user-structure.md

@@ -20,12 +20,13 @@
 
 ## AI 工具选择流程
 
-1. 确认结果意图:普通订单、精准订单、轨迹、报关资料、排舱列表、排舱详情、筛选项或导出。
+1. 确认结果意图:普通订单、精准订单、订单详情、轨迹、报关资料、排舱列表、排舱详情、筛选项或导出。
 2. 确认号码类型:订单号、排舱单号、物流单号、柜号、提单号或 SO 号。上下文不明确时先询问。
 3. 只调用一个已确认的目标工具;不得按号码格式猜测、并行试查、跨字段试查或失败后自行换工具。
 4. 需要筛选值时先调用对应筛选工具。唯一命中可传递内部 `value`,多条命中让用户选择。
 5. 排舱列表统一使用 `list_outbound_filter_options` 查询七类选项,包括集货仓库;不另设仓库工具。
-6. 面向用户展示中文标签和完整业务结果,内部值只用于结构化串联。
+6. 完整订单详情先查订单概览,再逐一查询十个明细模块并跟随分页;某模块为空不能跳过其他模块。
+7. 面向用户展示中文标签和完整业务结果,内部值只用于结构化串联。
 
 ## 跨仓开发流程
 
@@ -59,7 +60,7 @@ Workbuddy
 | `app.py` | 本地入口、CLI 和 `GatewayApp` |
 | `public_gateway.py`、`public_server.py` | 公网 Gateway 与 HTTP JSON-RPC 服务 |
 | `mcp_protocol.py` | MCP 协议处理与结果分流 |
-| `tools/` | 11 个工具的 Schema、说明和路由封装 |
+| `tools/` | 12 个工具的 Schema、说明和路由封装 |
 | `services/` | API、认证、session、request context、scoped client 与 Presenter |
 | `utils/` | 限流和安全散列工具 |
 | `tests/` | Python 单元、协议、展示、会话和安全回归测试 |

+ 2 - 0
public_gateway.py

@@ -16,6 +16,7 @@ from tools.query_customs_declaration_files import (
     QueryCustomsDeclarationFilesTool,
 )
 from tools.query_order_exact import QueryOrderExactTool
+from tools.query_order_detail import QueryOrderDetailTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
@@ -33,6 +34,7 @@ class PublicGatewayApp:
             'query_order': QueryOrderTool(api_client=None),
             'query_track': QueryTrackTool(api_client=None),
             'query_order_exact': QueryOrderExactTool(api_client=None),
+            'query_order_detail': QueryOrderDetailTool(api_client=None),
             'query_customs_declaration_files':
                 QueryCustomsDeclarationFilesTool(api_client=None),
             'query_outbound_list': QueryOutboundListTool(api_client=None),

+ 45 - 14
public_server.py

@@ -175,13 +175,38 @@ class PublicMcpHttpHandler:
                 if blocked:
                     return blocked
                 params = message_params
-                result = self.gateway_app.call_tool(
-                    session_id,
-                    params.get('name'),
-                    params.get('arguments') or {},
-                    request_id=trace_request_id,
-                    client_ip=client_ip,
-                )
+                acquired = self.rate_limiter is None \
+                    or self.rate_limiter.try_acquire(rate_key)
+                if not acquired:
+                    logger.warning(
+                        'MCP public concurrency limit exceeded',
+                        extra={
+                            'request_id': trace_request_id,
+                            'jsonrpc_id': request_id,
+                            'protocol_method': method,
+                            'tool_code': tool_name,
+                            'client_identity': client_ip,
+                            'protocol_code': -32029,
+                            'diagnostic_reason': 'CONCURRENCY_LIMIT_EXCEEDED',
+                        },
+                    )
+                    return McpProtocolHandler._error_response(
+                        request_id,
+                        -32029,
+                        'Too many requests in progress. Please try again later.',
+                        trace_request_id,
+                    )
+                try:
+                    result = self.gateway_app.call_tool(
+                        session_id,
+                        params.get('name'),
+                        params.get('arguments') or {},
+                        request_id=trace_request_id,
+                        client_ip=client_ip,
+                    )
+                finally:
+                    if self.rate_limiter is not None:
+                        self.rate_limiter.release(rate_key)
                 return McpProtocolHandler._tool_call_response(
                     request_id,
                     tool_name,
@@ -314,17 +339,22 @@ def create_http_handler(gateway_app, rate_limiter=None):
 
         def _write_json(self, payload):
             raw = json.dumps(payload, ensure_ascii=False).encode('utf-8')
-            self.send_response(200)
-            self.send_header('Content-Type', 'application/json; charset=utf-8')
-            self.send_header('Content-Length', str(len(raw)))
-            self.end_headers()
-            self.wfile.write(raw)
+            try:
+                self.send_response(200)
+                self.send_header('Content-Type', 'application/json; charset=utf-8')
+                self.send_header('Content-Length', str(len(raw)))
+                self.end_headers()
+                self.wfile.write(raw)
+            except (BrokenPipeError, ConnectionResetError):
+                self.close_connection = True
+                logger.info('MCP client disconnected before response was written')
 
     return Handler
 
 
 def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
-                 rate_limit_max_requests=60, rate_limit_window_seconds=60):
+                 rate_limit_max_requests=60, rate_limit_window_seconds=60,
+                 max_in_flight_per_tool=2):
     # Configure logging
     logging.basicConfig(
         level=logging.INFO,
@@ -338,8 +368,9 @@ def serve_public(gateway_app, host='0.0.0.0', port=8765, enable_rate_limit=True,
         rate_limiter = SimpleRateLimiter(
             max_requests=rate_limit_max_requests,
             window_seconds=rate_limit_window_seconds,
+            max_in_flight=max_in_flight_per_tool,
         )
-        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s per session and tool (tools/call only)")
+        logger.info(f"Rate limiting enabled: {rate_limit_max_requests} requests/{rate_limit_window_seconds}s and {max_in_flight_per_tool} in-flight per session and tool (tools/call only)")
 
     logger.info(f"Starting public MCP Gateway on {host}:{port}")
     server = ThreadingHTTPServer((host, int(port)), create_http_handler(gateway_app, rate_limiter))

+ 375 - 1
services/output_presenter.py

@@ -16,6 +16,7 @@ class OutputPresenter:
         'query_outbound_list',
     ))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
+    ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
     OPTION_TOOLS = frozenset((
         'list_order_filter_options',
         'list_outbound_filter_options',
@@ -25,7 +26,152 @@ class OutputPresenter:
         'export_pending_outbound_orders',
         'export_out_of_province_port_data',
     ))
-    SAFE_TOOLS = TABLE_TOOLS | DETAIL_TOOLS | OPTION_TOOLS | EXPORT_TOOLS
+    SAFE_TOOLS = (
+        TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS
+        | OPTION_TOOLS | EXPORT_TOOLS
+    )
+
+    ORDER_DETAIL_SECTIONS = {
+        'overview': '订单概览',
+        'packages': '箱单信息',
+        'package_items': '箱单商品',
+        'dw_auth': 'DW授权信息',
+        'attachments': '附件信息',
+        'inbound': '入库信息',
+        'checks': '查验信息',
+        'tracks': '订单轨迹',
+        'operation_logs': '操作日志',
+        'cost_logs': '应收与结算日志',
+        'delivery': '派送信息',
+        'all': '全部',
+    }
+
+    ORDER_DETAIL_FIELDS = {
+        'overview': {
+            'order_info': {
+                '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': '订单备注',
+            },
+            'freight_info': {
+                'estimated_inbound_time': '预计入库时间', 'cargo_type': '货物类型',
+                'pickup_way': '交货方式', 'warehouse': '交货仓库',
+                'pickup_address': '提货地址', 'pickup_contact': '提货联系人',
+                'pickup_phone': '提货联系电话', 'delivery_type': '派送类型',
+                'delivery_address': '派送地址', 'delivery_remark': '派送备注',
+                'oversea_warehouse': '海外仓',
+            },
+            'package_summary': {
+                'box_count': '箱数', 'total_weight': '重量', 'weight_unit': '重量单位',
+                'total_volume': '体积', 'sku_count': 'SKU数量',
+                'clearance_total_price': '清关总价', 'clearance_currency': '清关币种',
+                'purchase_total_price': '采购总价', 'purchase_currency': '采购币种',
+            },
+            'trader_info': {
+                'exporter': '出口商', 'importer': '进口商',
+                'coo_required': '是否做产地证',
+            },
+        },
+        'packages': {
+            'shipment_id': 'SHIPMENT ID', 'reference_id': 'REFERENCE ID',
+            'sku_summary': 'SKU', 'product_name_summary': '商品名',
+            'box_quantity': '箱数', 'box_length_cm': '单箱长(CM)',
+            'box_width_cm': '单箱宽(CM)', 'box_height_cm': '单箱高(CM)',
+            'gross_weight_kg': '单箱毛重量(KG)', 'net_weight_kg': '单箱净重量(KG)',
+            'is_over_length': '长度是否超限', 'is_over_width': '宽度是否超限',
+            'is_over_height': '高度是否超限', 'is_over_weight': '重量是否超限',
+        },
+        'package_items': {
+            'shipment_id': 'SHIPMENT ID', 'reference_id': 'REFERENCE ID',
+            'box_specification': '箱规', 'box_specification_unit': '箱规单位',
+            'package_gross_weight_kg': '箱单单箱毛重(KG)',
+            'package_net_weight_kg': '箱单单箱净重(KG)',
+            'inspection_result': '查验结果', 'exception_reason': '异常原因',
+            'inspection_photo_count': '查验图片数量', 'sku': 'SKU',
+            'units_per_box': '单箱个数', 'declaration_type': '报关类型',
+            'box_number': '箱号', 'item_gross_weight_kg': '商品单箱毛重量(KG)',
+            'item_net_weight_kg': '商品单箱净重量(KG)', 'chinese_name': '中文品名',
+            'english_name': '英文名称', 'brand_type': '品牌类型', 'brand': '品牌',
+            'model': '型号', 'material_cn': '材质(CN)', 'material_en': '材质(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_auth': {
+            'fba_id': 'FBAID', 'dw_start': 'DW开始时间', 'dw_end': 'DW结束时间',
+            'dw_display': 'DW', 'authorization_status': '客户授权状态',
+        },
+        'attachments': {
+            'category': '附件分类', 'file_name': '文件名称',
+            'file_extension': '文件类型', 'is_image': '是否图片',
+            'preview_url': '预览链接', 'download_url': '下载链接',
+            'cost_name': '收费项目',
+        },
+        'inbound_summary': {
+            'inbound_status': '入库状态', 'inbound_time': '入库时间',
+            'inbound_operator': '操作人', 'warehouse': '仓库',
+            'abnormal_reason': '异常原因', 'inbound_photo_count': '入库图片数量',
+            'forecast_pwv_summary': '预报件重体', 'inbound_pwv_summary': '入库件重体',
+        },
+        'inbound': {
+            'has_label': '是否贴标', 'shipment_id': 'SHIPMENT ID',
+            'inbound_quantity': '入库件数',
+            'measurement_photo_count': '测量图片数量',
+            'box_specification': '入库箱规', 'weight_mode': None, 'weight': None,
+            'weight_unit': '重量单位', 'is_over_length': '长度是否超限',
+            'is_over_width': '宽度是否超限', 'is_over_height': '高度是否超限',
+            'is_over_weight': '重量是否超限', 'operated_at': '操作时间',
+        },
+        'checks': {
+            'shipment_id': '货件编号', 'sku': 'SKU', 'box_number': '箱号',
+            'box_quantity': '箱数', 'box_specification': '箱规',
+            'inspection_result': '查验结果',
+            'inspection_photo_count': '查验图片数量', 'remark': '备注',
+            'inspector': '查验人', 'inspected_at': '查验时间',
+        },
+        'tracks': {
+            'track_type': '轨迹类型', 'status': '轨迹节点', 'location': '轨迹地点',
+            'occurred_at': '时间', 'tracking_number': '跟踪号',
+            'shipment_id': 'SHIPMENT ID', 'content': '轨迹内容',
+        },
+        'operation_logs': {
+            'operated_at': '操作时间', 'content': '操作内容', 'operator': '操作人',
+        },
+        'cost_logs': {
+            'operated_at': '操作时间', 'content': '操作内容', 'operator': '操作人',
+        },
+        'delivery_summary': {
+            'master_tracking_number': '主跟踪号', 'sub_order_count': '子单数量',
+        },
+        'delivery': {'sub_tracking_number': '子跟踪号'},
+    }
+
+    ORDER_DETAIL_STATUS_FIELDS = {
+        'stage': '阶段', 'label': '节点', 'state': '状态',
+        'occurred_at': '时间', 'time_kind': '时间类型',
+    }
+    ORDER_DETAIL_STATES = {
+        'completed': '已完成', 'in_progress': '进行中',
+        'pending': '待完成', 'hidden': '不展示',
+    }
+    ORDER_DETAIL_TIME_KINDS = {
+        'actual': '实际', 'estimated': '预计', 'empty': '暂无',
+    }
 
     TABLE_COLUMNS = {
         'query_order_exact': {
@@ -217,6 +363,10 @@ class OutputPresenter:
             'page': '页码',
             'limit': '每页数量',
         },
+        'query_order_detail': {
+            'order_number': '订单号', 'section': '详情模块',
+            'page': '页码', 'limit': '每页数量',
+        },
         'list_order_filter_options': {
             'filter_type': '筛选项类型',
             'keyword': '关键词',
@@ -326,6 +476,8 @@ class OutputPresenter:
                 tool_result.get('meta'),
                 meta,
             )
+        if tool_name in self.ORDER_DETAIL_TOOLS:
+            return self._present_order_detail(data, tool_result.get('meta'), meta)
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -337,6 +489,228 @@ class OutputPresenter:
             return self._present_options(data, tool_result.get('meta'), meta)
         return self._present_export(data, meta)
 
+    def _present_order_detail(self, data, raw_meta, meta):
+        if set(data) != {'section', 'order_number', 'payload'}:
+            return self._format_error(meta)
+        section = data.get('section')
+        order_number = data.get('order_number')
+        payload = data.get('payload')
+        if (
+            section not in self.ORDER_DETAIL_SECTIONS
+            or not isinstance(order_number, str) or not order_number.strip()
+            or not isinstance(payload, dict)
+        ):
+            return self._format_error(meta)
+        content = {
+            '订单号': order_number.strip(),
+            '详情模块': self.ORDER_DETAIL_SECTIONS[section],
+        }
+        if section == 'all':
+            all_content = self._present_order_detail_all(payload)
+            if all_content is None:
+                return self._format_error(meta)
+            content.update(all_content)
+        elif section == 'overview':
+            expected = {
+                'status_nodes', 'order_info', 'freight_info',
+                'package_summary', 'trader_info',
+            }
+            if set(payload) != expected or not isinstance(payload['status_nodes'], list):
+                return self._format_error(meta)
+            nodes = []
+            for row in payload['status_nodes']:
+                translated = self._translate_exact(row, self.ORDER_DETAIL_STATUS_FIELDS)
+                if translated is None:
+                    return self._format_error(meta)
+                state = row.get('state')
+                time_kind = row.get('time_kind')
+                if state not in self.ORDER_DETAIL_STATES or time_kind not in self.ORDER_DETAIL_TIME_KINDS:
+                    return self._format_error(meta)
+                translated['状态'] = self.ORDER_DETAIL_STATES[state]
+                translated['时间类型'] = self.ORDER_DETAIL_TIME_KINDS[time_kind]
+                nodes.append(translated)
+            content['状态节点'] = nodes
+            for raw_key, chinese_key in (
+                ('order_info', '订单信息'), ('freight_info', '货运信息'),
+                ('package_summary', '箱单汇总'), ('trader_info', '进出口商'),
+            ):
+                translated = self._translate_exact(
+                    payload.get(raw_key),
+                    self.ORDER_DETAIL_FIELDS['overview'][raw_key],
+                )
+                if translated is None:
+                    return self._format_error(meta)
+                content[chinese_key] = translated
+        elif section == 'inbound':
+            if set(payload) != {'summary', 'records'}:
+                return self._format_error(meta)
+            summary = self._translate_exact(
+                payload.get('summary'), self.ORDER_DETAIL_FIELDS['inbound_summary']
+            )
+            records = self._translate_order_detail_rows(section, payload.get('records'))
+            if summary is None or records is None:
+                return self._format_error(meta)
+            content['入库概况'] = summary
+            content['明细'] = records
+            pagination = self._order_detail_pagination(raw_meta)
+            if pagination is None:
+                return self._format_error(meta)
+            content['分页'] = pagination
+        elif section == 'delivery':
+            if set(payload) != {'summary', 'records'}:
+                return self._format_error(meta)
+            summary = self._translate_exact(
+                payload.get('summary'), self.ORDER_DETAIL_FIELDS['delivery_summary']
+            )
+            records = self._translate_order_detail_rows(section, payload.get('records'))
+            if summary is None or records is None:
+                return self._format_error(meta)
+            content['派送汇总'] = summary
+            content['明细'] = records
+            pagination = self._order_detail_pagination(raw_meta)
+            if pagination is None:
+                return self._format_error(meta)
+            content['分页'] = pagination
+        else:
+            if set(payload) != {'records'}:
+                return self._format_error(meta)
+            records = self._translate_order_detail_rows(section, payload.get('records'))
+            pagination = self._order_detail_pagination(raw_meta)
+            if records is None or pagination is None:
+                return self._format_error(meta)
+            content['明细'] = records
+            content['分页'] = pagination
+        text = json.dumps(content, ensure_ascii=False, indent=2)
+        return self._success_result(content, text, meta)
+
+    def _present_order_detail_all(self, payload):
+        expected_sections = set(self.ORDER_DETAIL_SECTIONS) - {'all'}
+        if set(payload) != expected_sections:
+            return None
+
+        overview = payload.get('overview')
+        if not isinstance(overview, dict) or set(overview) != {
+            'status_nodes', 'order_info', 'freight_info', 'package_summary', 'trader_info',
+        } or not isinstance(overview['status_nodes'], list):
+            return None
+        overview_content = {'状态节点': []}
+        for row in overview['status_nodes']:
+            translated = self._translate_exact(row, self.ORDER_DETAIL_STATUS_FIELDS)
+            if translated is None:
+                return None
+            state = row.get('state')
+            time_kind = row.get('time_kind')
+            if state not in self.ORDER_DETAIL_STATES or time_kind not in self.ORDER_DETAIL_TIME_KINDS:
+                return None
+            translated['状态'] = self.ORDER_DETAIL_STATES[state]
+            translated['时间类型'] = self.ORDER_DETAIL_TIME_KINDS[time_kind]
+            overview_content['状态节点'].append(translated)
+        for raw_key, chinese_key in (
+            ('order_info', '订单信息'), ('freight_info', '货运信息'),
+            ('package_summary', '箱单汇总'), ('trader_info', '进出口商'),
+        ):
+            translated = self._translate_exact(
+                overview.get(raw_key), self.ORDER_DETAIL_FIELDS['overview'][raw_key]
+            )
+            if translated is None:
+                return None
+            overview_content[chinese_key] = translated
+
+        result = {'订单概览': overview_content}
+        for section, label in self.ORDER_DETAIL_SECTIONS.items():
+            if section in ('overview', 'all'):
+                continue
+            translated = self._present_order_detail_all_section(section, payload.get(section))
+            if translated is None:
+                return None
+            result[label] = translated
+        return result
+
+    def _present_order_detail_all_section(self, section, payload):
+        if not isinstance(payload, dict) or 'pagination' not in payload:
+            return None
+        pagination = self._order_detail_pagination(payload.get('pagination'))
+        if pagination is None:
+            return None
+        if section == 'inbound':
+            if set(payload) != {'summary', 'records', 'pagination'}:
+                return None
+            summary = self._translate_exact(
+                payload.get('summary'), self.ORDER_DETAIL_FIELDS['inbound_summary']
+            )
+            records = self._translate_order_detail_rows(section, payload.get('records'))
+            if summary is None or records is None:
+                return None
+            return {'入库概况': summary, '明细': records, '分页': pagination}
+        if section == 'delivery':
+            if set(payload) != {'summary', 'records', 'pagination'}:
+                return None
+            summary = self._translate_exact(
+                payload.get('summary'), self.ORDER_DETAIL_FIELDS['delivery_summary']
+            )
+            records = self._translate_order_detail_rows(section, payload.get('records'))
+            if summary is None or records is None:
+                return None
+            return {'派送汇总': summary, '明细': records, '分页': pagination}
+        if set(payload) != {'records', 'pagination'}:
+            return None
+        records = self._translate_order_detail_rows(section, payload.get('records'))
+        if records is None:
+            return None
+        return {'明细': records, '分页': pagination}
+
+    def _translate_order_detail_rows(self, section, records):
+        if not isinstance(records, list):
+            return None
+        mapping = self.ORDER_DETAIL_FIELDS.get(section)
+        if not isinstance(mapping, dict):
+            return None
+        translated_rows = []
+        for row in records:
+            translated = self._translate_exact(row, mapping)
+            if translated is None:
+                return None
+            if section == 'inbound':
+                mode = row.get('weight_mode')
+                if mode not in ('single_box', 'total'):
+                    return None
+                translated[
+                    '单箱入库重量' if mode == 'single_box' else '总重量KG'
+                ] = row.get('weight', '')
+            translated_rows.append(translated)
+        return translated_rows
+
+    @staticmethod
+    def _translate_exact(value, mapping):
+        if not isinstance(value, dict) or set(value) != set(mapping):
+            return None
+        result = {}
+        for key, label in mapping.items():
+            if label is None:
+                continue
+            item = value.get(key, '')
+            if isinstance(item, (dict, list)):
+                return None
+            result[label] = '' if item is None else item
+        return result
+
+    @staticmethod
+    def _order_detail_pagination(raw_meta):
+        if not isinstance(raw_meta, dict):
+            return None
+        if not all(key in raw_meta for key in ('page', 'limit', 'has_more')):
+            return None
+        page = raw_meta.get('page')
+        limit = raw_meta.get('limit')
+        has_more = raw_meta.get('has_more')
+        if isinstance(page, bool) or not isinstance(page, int) or page < 1:
+            return None
+        if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
+            return None
+        if not isinstance(has_more, bool):
+            return None
+        return {'页码': page, '每页数量': limit, '是否还有更多': has_more}
+
     # Map local tool exceptions without exposing internal error details.
     def present_exception(self, tool_name, exception):
         if not self.handles(tool_name):

+ 25 - 0
tests/test_app_coverage.py

@@ -237,6 +237,29 @@ class RunCliBranchCoverageTest(unittest.TestCase):
         self.assertEqual(7, payload['sales_id'])
         self.assertEqual(8, payload['department_id'])
 
+    def test_query_order_detail_requires_order_number_and_forwards_section(self):
+        with self.assertRaisesRegex(ValueError, 'order-number is required'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'query_order_detail',
+            ], stdout=io.StringIO())
+
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'query_order_detail',
+            '--order-number', 'ORDER-1', '--section', '附件信息',
+            '--page', '2', '--limit', '10',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual({
+            'page': 2, 'limit': 10,
+            'order_number': 'ORDER-1', 'section': '附件信息',
+        }, call_tool.call_args.args[1])
+
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'query_order_detail', '--order-number', 'ORDER-1',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual('全部', call_tool.call_args.args[1]['section'])
+
     def test_filter_options_requires_filter_type(self):
         with self.assertRaisesRegex(ValueError, 'filter-type is required'):
             GatewayApp().run_cli([
@@ -256,6 +279,7 @@ class RunCliBranchCoverageTest(unittest.TestCase):
             rate_limit_enabled=True,
             rate_limit_max_requests=12,
             rate_limit_window_seconds=34,
+            max_in_flight_per_tool=2,
         )
         with patch('app.GatewayConfig.from_env', return_value=config), \
                 patch('app.RedisSocketClient') as redis_cls, \
@@ -292,6 +316,7 @@ class RunCliBranchCoverageTest(unittest.TestCase):
             enable_rate_limit=True,
             rate_limit_max_requests=12,
             rate_limit_window_seconds=34,
+            max_in_flight_per_tool=2,
         )
 
     def test_unsupported_command_raises_runtime_error(self):

+ 4 - 0
tests/test_config_compat.py

@@ -162,6 +162,10 @@ class RateLimitConfigTest(unittest.TestCase):
         config = self._config_from_env({'FMS_RATE_LIMIT_WINDOW_SECONDS': '30'})
         self.assertEqual(30, config.rate_limit_window_seconds)
 
+    def test_max_in_flight_per_tool_reads_from_env(self):
+        config = self._config_from_env({'FMS_MAX_IN_FLIGHT_PER_TOOL': '3'})
+        self.assertEqual(3, config.max_in_flight_per_tool)
+
     def test_rate_limit_max_requests_os_env_with_inline_comment_does_not_crash(self):
         # OS env vars are not processed by _load_dotenv, so inline comments must be
         # stripped by _parse_int before int() conversion

+ 481 - 0
tests/test_order_detail_tool.py

@@ -0,0 +1,481 @@
+import json
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_order_detail import QueryOrderDetailTool
+
+
+class RecordingClient:
+    def __init__(self):
+        self.calls = []
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
+
+
+class OrderDetailToolTest(unittest.TestCase):
+    def test_schema_uses_only_chinese_sections_and_rejects_extra_fields(self):
+        schema = QueryOrderDetailTool().metadata()['input_schema']
+        self.assertEqual(['order_number'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([
+            '订单概览', '箱单信息', '箱单商品', 'DW授权信息', '附件信息',
+            '入库信息', '查验信息', '订单轨迹', '操作日志',
+            '应收与结算日志', '派送信息', '全部',
+        ], schema['properties']['section']['enum'])
+        self.assertNotIn('order_id', schema['properties'])
+        self.assertNotIn('company_id', schema['properties'])
+        self.assertEqual('全部', schema['properties']['section']['default'])
+
+    def test_call_forwards_normalized_payload_to_exact_route(self):
+        client = RecordingClient()
+        result = QueryOrderDetailTool(client).call(
+            order_number=' ORD001 ', section='附件信息', page=2, limit=10,
+            request_id='rq_detail',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual([(
+            'query_order_detail', '/mcp/tools/queryOrderDetail',
+            {'order_number': 'ORD001', 'section': '附件信息', 'page': 2, 'limit': 10},
+            'rq_detail',
+        )], client.calls)
+
+    def test_call_rejects_missing_invalid_and_out_of_range_parameters(self):
+        tool = QueryOrderDetailTool()
+        with self.assertRaisesRegex(RuntimeError, 'api client'):
+            tool.call(order_number='ORD001')
+
+        tool = QueryOrderDetailTool(RecordingClient())
+        invalid_calls = (
+            ({'order_number': None}, 'order_number'),
+            ({'order_number': ' '}, 'order_number'),
+            ({'order_number': 'A' * 101}, 'order_number'),
+            ({'order_number': 'ORD001', 'section': None}, 'section'),
+            ({'order_number': 'ORD001', 'section': 'unknown'}, 'section'),
+            ({'order_number': 'ORD001', 'page': True}, 'page'),
+            ({'order_number': 'ORD001', 'page': '01'}, 'page'),
+            ({'order_number': 'ORD001', 'page': 'x'}, 'page'),
+            ({'order_number': 'ORD001', 'limit': 0}, 'limit'),
+            ({'order_number': 'ORD001', 'limit': 101}, 'limit'),
+        )
+        for kwargs, expected in invalid_calls:
+            with self.subTest(kwargs=kwargs):
+                with self.assertRaisesRegex(ValueError, expected):
+                    tool.call(**kwargs)
+
+        tool.call(order_number='ORD001', page='2', limit='100')
+        self.assertEqual(2, tool.api_client.calls[-1][2]['page'])
+        self.assertEqual(100, tool.api_client.calls[-1][2]['limit'])
+
+    def test_call_forwards_all_section_without_changing_pagination_contract(self):
+        client = RecordingClient()
+        QueryOrderDetailTool(client).call(
+            order_number='ORD001', section='全部', page=1, limit=20,
+            request_id='rq_all',
+        )
+        self.assertEqual({
+            'order_number': 'ORD001', 'section': '全部', 'page': 1, 'limit': 20,
+        }, client.calls[0][2])
+
+    def test_call_defaults_to_all_when_section_is_omitted(self):
+        client = RecordingClient()
+        QueryOrderDetailTool(client).call(order_number='ORD001')
+        self.assertEqual('全部', client.calls[0][2]['section'])
+
+    def test_local_and_public_registries_include_order_detail(self):
+        local = GatewayApp(api_client=object())
+        public = PublicGatewayApp(session_store=object(), api_client=object())
+        self.assertIn('query_order_detail', local.registered_tool_names())
+        self.assertIn('query_order_detail', public.registered_tool_names())
+
+    def test_presenter_outputs_attachment_links_with_only_chinese_keys(self):
+        presenter = OutputPresenter()
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'attachments',
+                'order_number': 'ORD001',
+                'payload': {'records': [{
+                    'category': '订单附件', 'file_name': 'photo.jpg',
+                    'file_extension': 'jpg', 'is_image': True,
+                    'preview_url': 'https://files.example/photo.jpg',
+                    'download_url': 'https://files.example/download/photo.jpg',
+                    'cost_name': '',
+                }]},
+            },
+            'meta': {'request_id': 'rq_detail', 'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('ORD001', content['订单号'])
+        self.assertEqual('附件信息', content['详情模块'])
+        self.assertEqual('photo.jpg', content['明细'][0]['文件名称'])
+        self.assertEqual('https://files.example/photo.jpg', content['明细'][0]['预览链接'])
+        serialized = json.dumps(content, ensure_ascii=False) + result['text']
+        for internal in ('order_number', 'section', 'payload', 'file_name', 'preview_url', 'page', 'limit', 'has_more'):
+            self.assertNotIn(internal, serialized)
+
+    def test_presenter_outputs_complete_overview_and_translates_enums(self):
+        presenter = OutputPresenter()
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'overview', 'order_number': 'ORD001',
+                'payload': {
+                    'status_nodes': [{
+                        'stage': '起运段', 'label': '已提交', 'state': 'completed',
+                        'occurred_at': '2026-01-01', 'time_kind': 'actual',
+                    }],
+                    'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+                    'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+                    'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+                    'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+                },
+            },
+            'meta': {'request_id': 'rq_overview'},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('已完成', content['状态节点'][0]['状态'])
+        self.assertEqual('实际', content['状态节点'][0]['时间类型'])
+        self.assertEqual(
+            ['订单号', '详情模块', '状态节点', '订单信息', '货运信息', '箱单汇总', '进出口商'],
+            list(content.keys()),
+        )
+
+    def test_presenter_outputs_all_sections_as_strict_grouped_content(self):
+        presenter = OutputPresenter()
+        payload = self.valid_all_payload(presenter)
+
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
+            'meta': {'request_id': 'rq_all'},
+        })
+        self.assertFalse(result['is_error'])
+        content = result['structured_content']
+        self.assertEqual('全部', content['详情模块'])
+        self.assertIn('订单概览', content)
+        self.assertIn('箱单信息', content)
+        self.assertIn('分页', content['箱单信息'])
+        serialized = json.dumps(content, ensure_ascii=False) + result['text']
+        for internal in ('order_number', 'section', 'payload', 'preview_url', 'has_more'):
+            self.assertNotIn(internal, serialized)
+
+    def valid_all_payload(self, presenter):
+        overview = {
+            'status_nodes': [{
+                'stage': '起运段', 'label': '已提交', 'state': 'completed',
+                'occurred_at': '2026-01-01', 'time_kind': 'actual',
+            }],
+            'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+            'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+            'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+            'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+        }
+        pagination = {'page': 1, 'limit': 20, 'has_more': False}
+        payload = {'overview': overview}
+        for section in presenter.ORDER_DETAIL_SECTIONS:
+            if section in ('overview', 'all'):
+                continue
+            if section == 'inbound':
+                value = {
+                    'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound_summary']),
+                    'records': [],
+                }
+            elif section == 'delivery':
+                value = {
+                    'summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['delivery_summary']),
+                    'records': [],
+                }
+            else:
+                value = {'records': []}
+            value['pagination'] = dict(pagination)
+            payload[section] = value
+        return payload
+
+    def test_presenter_rejects_malformed_all_section_groups(self):
+        presenter = OutputPresenter()
+        cases = []
+
+        missing = self.valid_all_payload(presenter)
+        del missing['checks']
+        cases.append(missing)
+
+        bad_overview = self.valid_all_payload(presenter)
+        bad_overview['overview'] = []
+        cases.append(bad_overview)
+
+        bad_node = self.valid_all_payload(presenter)
+        bad_node['overview']['status_nodes'][0]['state'] = 'unknown'
+        cases.append(bad_node)
+
+        bad_node_fields = self.valid_all_payload(presenter)
+        del bad_node_fields['overview']['status_nodes'][0]['stage']
+        cases.append(bad_node_fields)
+
+        bad_group = self.valid_all_payload(presenter)
+        bad_group['overview']['order_info']['unexpected'] = ''
+        cases.append(bad_group)
+
+        bad_pagination = self.valid_all_payload(presenter)
+        bad_pagination['packages']['pagination']['page'] = True
+        cases.append(bad_pagination)
+
+        missing_pagination = self.valid_all_payload(presenter)
+        del missing_pagination['packages']['pagination']
+        cases.append(missing_pagination)
+
+        bad_inbound_keys = self.valid_all_payload(presenter)
+        bad_inbound_keys['inbound']['extra'] = ''
+        cases.append(bad_inbound_keys)
+
+        bad_inbound_summary = self.valid_all_payload(presenter)
+        bad_inbound_summary['inbound']['summary']['extra'] = ''
+        cases.append(bad_inbound_summary)
+
+        bad_delivery_keys = self.valid_all_payload(presenter)
+        bad_delivery_keys['delivery']['extra'] = ''
+        cases.append(bad_delivery_keys)
+
+        bad_delivery_summary = self.valid_all_payload(presenter)
+        bad_delivery_summary['delivery']['summary']['extra'] = ''
+        cases.append(bad_delivery_summary)
+
+        bad_rows = self.valid_all_payload(presenter)
+        bad_rows['packages']['records'] = {}
+        cases.append(bad_rows)
+
+        bad_generic_keys = self.valid_all_payload(presenter)
+        bad_generic_keys['packages']['extra'] = ''
+        cases.append(bad_generic_keys)
+
+        for payload in cases:
+            with self.subTest(payload_keys=list(payload)):
+                result = presenter.present('query_order_detail', {
+                    'code': 'MCP_0000',
+                    'data': {'section': 'all', 'order_number': 'ORD001', 'payload': payload},
+                    'meta': {'request_id': 'rq_all_bad'},
+                })
+                self.assertTrue(result['is_error'])
+
+    def test_presenter_outputs_inbound_and_delivery_sections(self):
+        presenter = OutputPresenter()
+        inbound_mapping = presenter.ORDER_DETAIL_FIELDS['inbound']
+        inbound_rows = []
+        for mode, expected_label in (
+            ('single_box', '单箱入库重量'),
+            ('total', '总重量KG'),
+        ):
+            row = self.fixed_values(inbound_mapping)
+            row['weight_mode'] = mode
+            row['weight'] = '12.50'
+            inbound_rows.append(row)
+        inbound = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'inbound', 'order_number': ' ORD001 ',
+                'payload': {
+                    'summary': self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['inbound_summary']
+                    ),
+                    'records': inbound_rows,
+                },
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': True},
+        })
+        self.assertFalse(inbound['is_error'])
+        self.assertEqual('ORD001', inbound['structured_content']['订单号'])
+        for index, label in enumerate(('单箱入库重量', '总重量KG')):
+            self.assertEqual('12.50', inbound['structured_content']['明细'][index][label])
+
+        delivery = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'delivery', 'order_number': 'ORD001',
+                'payload': {
+                    'summary': self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['delivery_summary']
+                    ),
+                    'records': [self.fixed_values(
+                        presenter.ORDER_DETAIL_FIELDS['delivery']
+                    )],
+                },
+            },
+            'meta': {'page': 2, 'limit': 10, 'has_more': False},
+        })
+        self.assertFalse(delivery['is_error'])
+        self.assertIn('派送汇总', delivery['structured_content'])
+
+    def test_package_item_product_image_is_exposed_as_count_only(self):
+        presenter = OutputPresenter()
+        mapping = presenter.ORDER_DETAIL_FIELDS['package_items']
+        self.assertEqual('商品图片数量', mapping.get('product_image_count'))
+        row = self.fixed_values(mapping)
+        row['product_image_count'] = 1
+        result = presenter.present('query_order_detail', {
+            'code': 'MCP_0000',
+            'data': {
+                'section': 'package_items', 'order_number': 'ORD001',
+                'payload': {'records': [row]},
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(1, result['structured_content']['明细'][0]['商品图片数量'])
+        self.assertNotIn('image_url', json.dumps(result, ensure_ascii=False))
+
+    def test_unknown_missing_or_extra_fields_fail_closed(self):
+        presenter = OutputPresenter()
+        cases = [
+            {'section': 'secret', 'order_number': 'ORD001', 'payload': {'records': []}},
+            {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{'category': 'x'}]}},
+            {'section': 'attachments', 'order_number': 'ORD001', 'payload': {'records': [{
+                'category': '', 'file_name': '', 'file_extension': '', 'is_image': False,
+                'preview_url': '', 'download_url': '', 'cost_name': '', 'secret_id': 9,
+            }]}},
+        ]
+        for data in cases:
+            with self.subTest(data=data):
+                result = presenter.present('query_order_detail', {
+                    'code': 'MCP_0000', 'data': data, 'meta': {},
+                })
+                self.assertTrue(result['is_error'])
+                self.assertNotIn('secret_id', json.dumps(result, ensure_ascii=False))
+
+    def test_presenter_rejects_every_invalid_order_detail_shape(self):
+        presenter = OutputPresenter()
+        valid_meta = {'page': 1, 'limit': 20, 'has_more': False}
+        overview_groups = {
+            'status_nodes': [],
+            'order_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['order_info']),
+            'freight_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['freight_info']),
+            'package_summary': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['package_summary']),
+            'trader_info': self.fixed_values(presenter.ORDER_DETAIL_FIELDS['overview']['trader_info']),
+        }
+        invalid_results = [
+            presenter._present_order_detail({'section': 'attachments'}, valid_meta, {}),
+            presenter._present_order_detail(
+                {'section': 'unknown', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 1, 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': ' ', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': []}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'overview', 'order_number': 'ORD', 'payload': {'status_nodes': []}}, {}, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'overview', 'order_number': 'ORD', 'payload': dict(overview_groups, status_nodes={})}, {}, {}
+            ),
+        ]
+
+        bad_node_groups = dict(overview_groups)
+        bad_node_groups['status_nodes'] = [{'stage': 'x'}]
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_node_groups}, {}, {}
+        ))
+        bad_enum_groups = dict(overview_groups)
+        bad_enum_groups['status_nodes'] = [{
+            'stage': 'x', 'label': 'x', 'state': 'secret',
+            'occurred_at': '', 'time_kind': 'empty',
+        }]
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_enum_groups}, {}, {}
+        ))
+        bad_group = dict(overview_groups)
+        bad_group['order_info'] = {}
+        invalid_results.append(presenter._present_order_detail(
+            {'section': 'overview', 'order_number': 'ORD', 'payload': bad_group}, {}, {}
+        ))
+
+        for section, summary_key in (
+            ('inbound', 'inbound_summary'),
+            ('delivery', 'delivery_summary'),
+        ):
+            mapping = presenter.ORDER_DETAIL_FIELDS[summary_key]
+            invalid_results.extend([
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {'records': []}}, valid_meta, {}
+                ),
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {'summary': {}, 'records': []}}, valid_meta, {}
+                ),
+                presenter._present_order_detail(
+                    {'section': section, 'order_number': 'ORD', 'payload': {
+                        'summary': self.fixed_values(mapping), 'records': []
+                    }}, {}, {}
+                ),
+            ])
+
+        invalid_results.extend([
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': {}}}, valid_meta, {}
+            ),
+            presenter._present_order_detail(
+                {'section': 'attachments', 'order_number': 'ORD', 'payload': {'records': []}}, {}, {}
+            ),
+        ])
+        self.assertTrue(all(result['is_error'] for result in invalid_results))
+
+    def test_order_detail_translation_helpers_reject_nested_and_invalid_values(self):
+        presenter = OutputPresenter()
+        self.assertIsNone(presenter._translate_order_detail_rows('attachments', {}))
+        self.assertIsNone(presenter._translate_order_detail_rows('unknown', []))
+        self.assertIsNone(presenter._translate_order_detail_rows('attachments', [{}]))
+
+        inbound = self.fixed_values(presenter.ORDER_DETAIL_FIELDS['inbound'])
+        inbound['weight_mode'] = 'invalid'
+        self.assertIsNone(presenter._translate_order_detail_rows('inbound', [inbound]))
+
+        self.assertIsNone(presenter._translate_exact([], {'key': '标签'}))
+        self.assertIsNone(presenter._translate_exact({'wrong': 1}, {'key': '标签'}))
+        self.assertIsNone(presenter._translate_exact({'key': []}, {'key': '标签'}))
+        self.assertEqual({'标签': ''}, presenter._translate_exact({'key': None}, {'key': '标签'}))
+        self.assertEqual({}, presenter._translate_exact({'key': 'hidden'}, {'key': None}))
+
+        invalid_meta = (
+            None,
+            {},
+            {'page': True, 'limit': 20, 'has_more': False},
+            {'page': 0, 'limit': 20, 'has_more': False},
+            {'page': 1, 'limit': True, 'has_more': False},
+            {'page': 1, 'limit': 0, 'has_more': False},
+            {'page': 1, 'limit': 20, 'has_more': 1},
+        )
+        for meta in invalid_meta:
+            with self.subTest(meta=meta):
+                self.assertIsNone(presenter._order_detail_pagination(meta))
+
+    def test_parameter_errors_use_chinese_business_labels(self):
+        presenter = OutputPresenter()
+        for backend, expected in (
+            ('order_number is required', '订单号参数不正确'),
+            ('section is invalid', '详情模块参数不正确'),
+            ('page is invalid', '页码参数不正确'),
+            ('limit is invalid', '每页数量参数不正确'),
+        ):
+            result = presenter.present('query_order_detail', {
+                'code': 'MCP_1401', 'msg': backend, 'data': {},
+            })
+            self.assertEqual(expected, result['structured_content']['message'])
+            self.assertNotIn(backend.split()[0], json.dumps(result, ensure_ascii=False))
+
+    @staticmethod
+    def fixed_values(mapping):
+        return {key: '' for key in mapping}
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 75 - 3
tests/test_public_server.py

@@ -1,7 +1,8 @@
 import json
 import unittest
+from unittest.mock import Mock
 
-from public_server import PublicMcpHttpHandler, extract_client_ip
+from public_server import PublicMcpHttpHandler, create_http_handler, extract_client_ip
 from utils.rate_limiter import SimpleRateLimiter
 
 
@@ -307,9 +308,13 @@ class PublicMcpHttpHandlerTest(unittest.TestCase):
 
 
 class RateLimitTest(unittest.TestCase):
-    def _make_handler(self, max_requests=2):
+    def _make_handler(self, max_requests=2, max_in_flight=2):
         gateway = FakeGateway()
-        limiter = SimpleRateLimiter(max_requests=max_requests, window_seconds=60)
+        limiter = SimpleRateLimiter(
+            max_requests=max_requests,
+            window_seconds=60,
+            max_in_flight=max_in_flight,
+        )
         return PublicMcpHttpHandler(gateway, context_parser=FakeParser(), rate_limiter=limiter)
 
     def _tools_call_msg(self, tool_name='query_order'):
@@ -405,5 +410,72 @@ class RateLimitTest(unittest.TestCase):
         )
         self.assertNotIn('error', response)
 
+    def test_completed_tool_call_releases_in_flight_slot_immediately(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=1)
+        headers = {'X-Gateway-Session': 'GWS_A'}
+
+        for request_id in range(1, 6):
+            message = self._tools_call_msg('query_order')
+            message['id'] = request_id
+            response = handler.handle_json_rpc(
+                headers,
+                message,
+                client_ip='1.2.3.4',
+            )
+            self.assertNotIn('error', response)
+
+    def test_exhausted_in_flight_slots_return_rate_limit_error(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=2)
+        key = 'GWS_A:query_order'
+        self.assertTrue(handler.rate_limiter.try_acquire(key))
+        self.assertTrue(handler.rate_limiter.try_acquire(key))
+
+        response = handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+
+        self.assertEqual(-32029, response['error']['code'])
+        self.assertIn('in progress', response['error']['message'])
+
+    def test_failed_tool_call_releases_in_flight_slot(self):
+        handler = self._make_handler(max_requests=0, max_in_flight=1)
+        original_call = handler.gateway_app.call_tool
+
+        def fail(*args, **kwargs):
+            raise RuntimeError('backend failed')
+
+        handler.gateway_app.call_tool = fail
+        handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+        handler.gateway_app.call_tool = original_call
+
+        response = handler.handle_json_rpc(
+            {'X-Gateway-Session': 'GWS_A'},
+            self._tools_call_msg('query_order'),
+            client_ip='1.2.3.4',
+        )
+        self.assertNotIn('error', response)
+
+
+class HttpDisconnectTest(unittest.TestCase):
+    def test_broken_pipe_while_writing_response_is_handled(self):
+        handler_class = create_http_handler(FakeGateway(), rate_limiter=None)
+        handler = object.__new__(handler_class)
+        handler.send_response = Mock()
+        handler.send_header = Mock()
+        handler.end_headers = Mock()
+        handler.wfile = Mock()
+        handler.wfile.write.side_effect = BrokenPipeError()
+
+        with self.assertLogs('public_server', level='INFO') as logs:
+            handler._write_json({'jsonrpc': '2.0', 'id': 1, 'result': {}})
+
+        self.assertIn('client disconnected before response', logs.output[0])
+
 if __name__ == '__main__':
     unittest.main()

+ 5 - 1
tests/test_public_server_coverage.py

@@ -100,7 +100,11 @@ class ServePublicLifecycleTest(unittest.TestCase):
                 rate_limit_window_seconds=34,
             )
 
-        limiter_class.assert_called_once_with(max_requests=12, window_seconds=34)
+            limiter_class.assert_called_once_with(
+                max_requests=12,
+                window_seconds=34,
+                max_in_flight=2,
+            )
         self.assertEqual(('127.0.0.1', 9876), server_class.call_args.args[0])
         thread_class.assert_called_once_with(
             target=thread_class.call_args.kwargs['target'],

+ 39 - 0
tests/test_rate_limiter.py

@@ -68,6 +68,45 @@ class TestSimpleRateLimiter(unittest.TestCase):
         limiter.cleanup(max_age_seconds=0.05)
         self.assertEqual(len(limiter._requests), 0)
 
+    def test_in_flight_slot_is_reusable_after_release(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=2,
+        )
+        key = 'GWS_A:query_order_detail'
+
+        self.assertTrue(limiter.try_acquire(key))
+        self.assertTrue(limiter.try_acquire(key))
+        self.assertFalse(limiter.try_acquire(key))
+
+        limiter.release(key)
+        self.assertTrue(limiter.try_acquire(key))
+        limiter.release(key)
+        limiter.release(key)
+        self.assertNotIn(key, limiter._in_flight)
+
+    def test_zero_window_limit_disables_request_count_limit(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=1,
+        )
+
+        for _ in range(100):
+            self.assertTrue(limiter.is_allowed('GWS_A:query_order_detail'))
+
+    def test_zero_in_flight_limit_is_unbounded_and_unknown_release_is_safe(self):
+        limiter = SimpleRateLimiter(
+            max_requests=0,
+            window_seconds=60,
+            max_in_flight=0,
+        )
+
+        self.assertTrue(limiter.try_acquire('GWS_A:query_order_detail'))
+        limiter.release('GWS_A:query_order_detail')
+        limiter.release('unknown')
+
 
 if __name__ == '__main__':
     unittest.main()

+ 3 - 1
tests/test_tool_description_boundaries.py

@@ -10,6 +10,7 @@ from tools.list_pending_outbound_export_filter_options import (
 )
 from tools.query_customs_declaration_files import QueryCustomsDeclarationFilesTool
 from tools.query_order import QueryOrderTool
+from tools.query_order_detail import QueryOrderDetailTool
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
@@ -58,7 +59,8 @@ class ToolDescriptionBoundaryTest(unittest.TestCase):
     def test_result_intent_distinguishes_adjacent_tools(self):
         expected = {
             QueryOrderTool(): ('旧版跨字段通用搜索', 'query_order_exact', '订单列表'),
-            QueryOrderExactTool(): ('订单资料', '排舱列表', '物流轨迹'),
+            QueryOrderExactTool(): ('订单列表', 'query_order_detail', '排舱列表'),
+            QueryOrderDetailTool(): ('单个订单的详情', '逐个调用', 'query_order_exact', '订单列表'),
             QueryTrackTool(): ('物流轨迹', '订单资料', '报关资料文件'),
             QueryCustomsDeclarationFilesTool(): ('报关资料文件', '完整排舱详情', '文件链接'),
             QueryOutboundListTool(): ('排舱列表', '19个字段', '排舱详情'),

+ 98 - 0
tools/query_order_detail.py

@@ -0,0 +1,98 @@
+class QueryOrderDetailTool:
+    name = 'query_order_detail'
+    route_path = '/mcp/tools/queryOrderDetail'
+    sections = (
+        '订单概览', '箱单信息', '箱单商品', 'DW授权信息', '附件信息',
+        '入库信息', '查验信息', '订单轨迹', '操作日志',
+        '应收与结算日志', '派送信息', '全部',
+    )
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '使用场景:用户明确提供订单号并要求查看订单详情时使用。可查询订单概览、'
+                '箱单、箱单商品、DW授权、附件、入库、查验、订单轨迹、操作日志、应收与'
+                '结算日志及派送信息。用户要求完整详情时可使用“全部”一次取得所有模块;'
+                '各明细模块仍按独立分页信息展示,任何模块为空都不能作为跳过其他模块的依据。'
+                '本工具只用于单个订单的详情查询;用户要求订单列表、批量筛选或多个订单汇总时,'
+                '必须改用 query_order_exact,不得使用本工具替代。即使用户提供了订单号,'
+                '只要目标是列表或批量结果,也不能调用本工具。用户要求分别查询多个订单详情时,'
+                '必须按订单号逐个调用本工具,每次只传一个订单号,不得合并成列表查询。'
+                '只接受订单号,不接受订单ID、箱单ID、查验ID、公司、员工'
+                '或权限字段。不得猜测号码类型或把同一号码跨工具试查。参数名仅用于工具'
+                '调用;向用户回答时只能展示固定中文业务名称,不得展示内部参数名。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'order_number': {
+                        'type': 'string', 'minLength': 1, 'maxLength': 100,
+                        'description': '明确的订单号。',
+                    },
+                    'section': {
+                        'type': 'string', 'enum': list(self.sections),
+                        'default': '全部', 'description': '要查看的中文详情模块;未指定时返回全部模块。',
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['order_number'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self,
+        order_number=None,
+        section='全部',
+        page=1,
+        limit=20,
+        request_id='rq_query_order_detail',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_order_detail')
+        if not isinstance(order_number, str):
+            raise ValueError('order_number is required')
+        order_number = order_number.strip()
+        if not order_number or len(order_number) > 100:
+            raise ValueError('order_number is required')
+        if not isinstance(section, str) or section.strip() not in self.sections:
+            raise ValueError('section is invalid')
+        section = section.strip()
+        page = self._bounded_integer(page, 'page')
+        limit = self._bounded_integer(limit, 'limit')
+        return self.api_client.call_tool(
+            self.name,
+            self.route_path,
+            {
+                'order_number': order_number,
+                'section': section,
+                'page': page,
+                'limit': limit,
+            },
+            request_id,
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        if isinstance(value, int):
+            number = value
+        elif isinstance(value, str) and value.isdigit() and not value.startswith('0'):
+            number = int(value)
+        else:
+            raise ValueError('{0} is invalid'.format(field))
+        if number < 1 or number > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return number

+ 3 - 1
tools/query_order_exact.py

@@ -177,9 +177,11 @@ class QueryOrderExactTool:
         return {
             'name': self.name,
             'description': (
-                '使用场景:用户明确要查看订单资料或订单列表,并且已经明确每个号码的'
+                '使用场景:用户要查看订单列表或按条件批量精准筛选订单,并且已经明确每个号码的'
                 '业务类型时,按指定字段精准查询订单;即使使用排舱单号、柜号或SO号筛选,'
                 '返回目标仍是订单资料。'
+                '本工具只返回订单列表结果;用户要求查看单个订单的详情、概览、箱单、商品、'
+                '轨迹、日志或其他详情模块时,必须改用 query_order_detail,不得使用本工具替代。'
                 '订单号、客户参考号、快递单号、排舱单号、柜号和 SO 号必须'
                 '使用各自对应字段。'
                 '所有单号格式均为开放格式,只能根据用户明确说出的业务类型'

+ 24 - 1
utils/rate_limiter.py

@@ -9,10 +9,12 @@ class SimpleRateLimiter:
     For production, consider using Redis-based rate limiting.
     """
 
-    def __init__(self, max_requests=60, window_seconds=60):
+    def __init__(self, max_requests=60, window_seconds=60, max_in_flight=2):
         self.max_requests = int(max_requests)
         self.window_seconds = int(window_seconds)
+        self.max_in_flight = int(max_in_flight)
         self._requests = defaultdict(list)
+        self._in_flight = defaultdict(int)
         self._lock = Lock()
 
     def is_allowed(self, key):
@@ -29,6 +31,8 @@ class SimpleRateLimiter:
         window_start = now - self.window_seconds
 
         with self._lock:
+            if self.max_requests <= 0:
+                return True
             # Clean old requests
             requests = self._requests[key]
             self._requests[key] = [ts for ts in requests if ts > window_start]
@@ -41,6 +45,25 @@ class SimpleRateLimiter:
             self._requests[key].append(now)
             return True
 
+    def try_acquire(self, key):
+        """Acquire one reusable in-flight slot for a session/tool key."""
+        with self._lock:
+            if self.max_in_flight <= 0:
+                return True
+            if self._in_flight[key] >= self.max_in_flight:
+                return False
+            self._in_flight[key] += 1
+            return True
+
+    def release(self, key):
+        """Release a previously acquired slot; extra releases are harmless."""
+        with self._lock:
+            if self.max_in_flight <= 0 or key not in self._in_flight:
+                return
+            self._in_flight[key] -= 1
+            if self._in_flight[key] <= 0:
+                del self._in_flight[key]
+
     def cleanup(self, max_age_seconds=3600):
         """
         Remove old entries to prevent memory leak.