jackson дней назад: 4
Родитель
Сommit
959c39dc98
3 измененных файлов с 96 добавлено и 9 удалено
  1. 4 3
      README.md
  2. 54 4
      services/output_presenter.py
  3. 38 2
      tests/test_order_receivable_cost_details_tool.py

+ 4 - 3
README.md

@@ -37,7 +37,7 @@ Gateway 保持“薄网关”边界:
 | `query_customer_payment_followup` | 查询客户应收、未出账、逾期、坏账和未回款月份汇总 | `/mcp/tools/queryCustomerPaymentFollowup` | 固定客户字段与三字段月份汇总 |
 | `query_customer_payment_followup` | 查询客户应收、未出账、逾期、坏账和未回款月份汇总 | `/mcp/tools/queryCustomerPaymentFollowup` | 固定客户字段与三字段月份汇总 |
 | `query_customer_unverified_bill_details` | 分页查询单个授权客户的未核销账单 | `/mcp/tools/queryCustomerUnverifiedBillDetails` | 固定六字段账单明细 |
 | `query_customer_unverified_bill_details` | 分页查询单个授权客户的未核销账单 | `/mcp/tools/queryCustomerUnverifiedBillDetails` | 固定六字段账单明细 |
 | `query_customer_payment_records` | 按客户和可选收款日期查询逐笔收款及核销 | `/mcp/tools/queryCustomerPaymentRecords` | 固定八字段收款记录 |
 | `query_customer_payment_records` | 按客户和可选收款日期查询逐笔收款及核销 | `/mcp/tools/queryCustomerPaymentRecords` | 固定八字段收款记录 |
-| `query_order_receivable_cost_details` | 按明确订单号查询应收费用确认、关账和核销 | `/mcp/tools/queryOrderReceivableCostDetails` | 固定十三字段费用明细 |
+| `query_order_receivable_cost_details` | 按明确订单号查询应收费用确认、关账和核销 | `/mcp/tools/queryOrderReceivableCostDetails` | 四项 CNY 汇总 + 固定十三字段费用明细 |
 | `query_receivable_cost_list` | 按明确四类号码或最多 31 天业务日期查询应收费用单列表 | `/mcp/tools/queryReceivableCostList` | 固定 24 列安全表格 |
 | `query_receivable_cost_list` | 按明确四类号码或最多 31 天业务日期查询应收费用单列表 | `/mcp/tools/queryReceivableCostList` | 固定 24 列安全表格 |
 | `query_payable_cost_list` | 按业务类型、五类号码或最多 31 天日期查询应付费用列表 | `/mcp/tools/queryPayableCostList` | 按页签严格动态列安全表格 |
 | `query_payable_cost_list` | 按业务类型、五类号码或最多 31 天日期查询应付费用列表 | `/mcp/tools/queryPayableCostList` | 按页签严格动态列安全表格 |
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
 | `query_customs_declaration_files` | 按订单号或排舱单号查询报关资料 | `/mcp/tools/queryCustomsDeclarationFiles` | 安全表格 |
@@ -80,10 +80,11 @@ Gateway 在 `tools/call` 最终边界处理展示字段,不改变 ThinkPHP 内
 
 
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
 - `query_order` 保持原有 `columns + records` 结果和文本展示,不参与本次转换。
 - `services/output_presenter.py` 对其余 27 个安全工具执行显式白名单展示。
 - `services/output_presenter.py` 对其余 27 个安全工具执行显式白名单展示。
-- `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`,不返回内部字段键。
+- `query_order_exact`、`query_customer_list`、`query_customer_payment_followup`、`query_customer_unverified_bill_details`、`query_customer_payment_records`、`query_receivable_cost_list`、`query_track`、`query_customs_declaration_files`、`query_outbound_list` 对外使用中文 `headers + rows + pagination`,不返回内部字段键。
+- 订单应收费用明细对外使用四项 CNY `summary`(订单总应收、订单总结算、特批金额、结算毛利)加固定 13 列 `headers + rows + pagination`,不返回内部字段键。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
 - 客户回款跟进严格校验三字段月份汇总;单个客户的六字段账单明细由独立分页工具返回。未知、缺失、额外字段或畸形分页均返回安全错误,不静默丢弃。
 - 客户逐笔回款记录固定展示客户名称、水单号、原币/实际收款、收款日期、已/未核销和审核状态八列;所有金额必须为有限数值,未知字段关闭失败。
 - 客户逐笔回款记录固定展示客户名称、水单号、原币/实际收款、收款日期、已/未核销和审核状态八列;所有金额必须为有限数值,未知字段关闭失败。
-- 订单应收费用明细固定展示订单号、客户/费用客户、费用项、计费数据、两项 CNY 金额及确认/关账/核销状态 13 列;金额必须为有限数值,未知字段关闭失败。
+- 订单应收费用明细固定展示确认页同源的四项 CNY 汇总,以及订单号、客户/费用客户、费用项、计费数据、两项 CNY 金额及确认/关账/核销状态 13 列;金额必须为有限数值,未知字段关闭失败。
 - 应收费用单列表固定展示后台 24 个业务列;四个金额单元格必须包含有限数值和币种。配套筛选项保留整数 `0/-1` 状态值;未知字段、列重排或畸形分页关闭失败。
 - 应收费用单列表固定展示后台 24 个业务列;四个金额单元格必须包含有限数值和币种。配套筛选项保留整数 `0/-1` 状态值;未知字段、列重排或畸形分页关闭失败。
 - 应付费用列表按六个业务类型严格返回后台页签列集(17/17/19/15/15/18 列);五类号码字段分别 `IN` 且彼此与其他条件 `AND`,无号码时要求一组最多 31 日日期。头程 `order_numbers` 同时精确匹配单号与关联订单号(`sub_number`)。列缺失、额外、错序或非有限金额关闭失败。
 - 应付费用列表按六个业务类型严格返回后台页签列集(17/17/19/15/15/18 列);五类号码字段分别 `IN` 且彼此与其他条件 `AND`,无号码时要求一组最多 31 日日期。头程 `order_numbers` 同时精确匹配单号与关联订单号(`sub_number`)。列缺失、额外、错序或非有限金额关闭失败。
 - 海外提柜列表前五类固定 30 列,DO单制作固定 11 列;DO 原/新文件只接受空串或安全 HTTP(S) URL。类型必选,提单号、柜号、日期和其他筛选均为可选。
 - 海外提柜列表前五类固定 30 列,DO单制作固定 11 列;DO 原/新文件只接受空串或安全 HTTP(S) URL。类型必选,提单号、柜号、日期和其他筛选均为可选。

+ 54 - 4
services/output_presenter.py

@@ -411,6 +411,12 @@ class OutputPresenter:
         'closing_status': '关账状态',
         'closing_status': '关账状态',
         'verification_status': '核销状态',
         'verification_status': '核销状态',
     }
     }
+    RECEIVABLE_COST_SUMMARY = {
+        'order_total_receivable_cny': '订单总应收(CNY)',
+        'order_total_settlement_cny': '订单总结算(CNY)',
+        'special_approval_amount_cny': '特批金额(CNY)',
+        'settlement_gross_profit_cny': '结算毛利(CNY)',
+    }
     RECEIVABLE_LIST_COLUMNS = {
     RECEIVABLE_LIST_COLUMNS = {
         'main_customer_name': '主客户',
         'main_customer_name': '主客户',
         'sub_customer_name': '子客户',
         'sub_customer_name': '子客户',
@@ -1391,13 +1397,20 @@ class OutputPresenter:
         return self._success_result(content, self._render_table(content), meta)
         return self._success_result(content, self._render_table(content), meta)
 
 
     def _present_order_receivable_cost_details(self, data, raw_meta, meta):
     def _present_order_receivable_cost_details(self, data, raw_meta, meta):
-        if set(data) != {'columns', 'records'}:
+        if set(data) != {'summary', 'columns', 'records'}:
             return self._format_error(meta)
             return self._format_error(meta)
         columns = data.get('columns')
         columns = data.get('columns')
         records = data.get('records')
         records = data.get('records')
+        summary = data.get('summary')
         pagination = self._customer_pagination(raw_meta)
         pagination = self._customer_pagination(raw_meta)
         expected_keys = list(self.RECEIVABLE_COST_COLUMNS)
         expected_keys = list(self.RECEIVABLE_COST_COLUMNS)
-        if not isinstance(columns, list) or not isinstance(records, list):
+        expected_summary = list(self.RECEIVABLE_COST_SUMMARY)
+        if (
+            not isinstance(columns, list)
+            or not isinstance(records, list)
+            or not isinstance(summary, dict)
+            or set(summary) != set(expected_summary)
+        ):
             return self._format_error(meta)
             return self._format_error(meta)
         actual_keys = []
         actual_keys = []
         for column in columns:
         for column in columns:
@@ -1413,6 +1426,17 @@ class OutputPresenter:
         if actual_keys != expected_keys or pagination is None:
         if actual_keys != expected_keys or pagination is None:
             return self._format_error(meta)
             return self._format_error(meta)
 
 
+        summary_row = []
+        for key in expected_summary:
+            value = summary[key]
+            if (
+                isinstance(value, bool)
+                or not isinstance(value, (int, float))
+                or not math.isfinite(float(value))
+            ):
+                return self._format_error(meta)
+            summary_row.append(value)
+
         numeric_keys = {
         numeric_keys = {
             'charge_weight', 'unit_price', 'receivable_original_amount',
             'charge_weight', 'unit_price', 'receivable_original_amount',
             'receivable_amount_cny', 'settlement_amount_cny',
             'receivable_amount_cny', 'settlement_amount_cny',
@@ -1440,8 +1464,34 @@ class OutputPresenter:
             {'label': self.RECEIVABLE_COST_COLUMNS[key]}
             {'label': self.RECEIVABLE_COST_COLUMNS[key]}
             for key in expected_keys
             for key in expected_keys
         ]
         ]
-        content = {'headers': headers, 'rows': rows, 'pagination': pagination}
-        return self._success_result(content, self._render_table(content), meta)
+        content = {
+            'summary': {
+                'headers': [
+                    {'label': self.RECEIVABLE_COST_SUMMARY[key]}
+                    for key in expected_summary
+                ],
+                'row': summary_row,
+            },
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+        }
+        return self._success_result(
+            content, self._render_order_receivable_cost_details(content), meta
+        )
+
+    @classmethod
+    def _render_order_receivable_cost_details(cls, content):
+        lines = ['订单汇总:']
+        summary = content['summary']
+        for header, value in zip(summary['headers'], summary['row']):
+            lines.append('- {0}: {1}'.format(header['label'], value))
+        lines.append('费用明细:')
+        lines.append(cls._render_table({
+            'headers': content['headers'],
+            'rows': content['rows'],
+        }))
+        return '\n'.join(lines)
 
 
     def _present_receivable_cost_list(self, data, raw_meta, meta):
     def _present_receivable_cost_list(self, data, raw_meta, meta):
         if set(data) != {'columns', 'records'}:
         if set(data) != {'columns', 'records'}:

+ 38 - 2
tests/test_order_receivable_cost_details_tool.py

@@ -117,10 +117,26 @@ class OrderReceivableCostDetailsPresenterTest(unittest.TestCase):
         '费用确认状态', '关账状态', '核销状态',
         '费用确认状态', '关账状态', '核销状态',
     ]
     ]
 
 
+    SUMMARY_KEYS = [
+        'order_total_receivable_cny',
+        'order_total_settlement_cny',
+        'special_approval_amount_cny',
+        'settlement_gross_profit_cny',
+    ]
+    SUMMARY_HEADERS = [
+        '订单总应收(CNY)', '订单总结算(CNY)', '特批金额(CNY)', '结算毛利(CNY)',
+    ]
+
     def payload(self):
     def payload(self):
         return {
         return {
             'code': 'MCP_0000',
             'code': 'MCP_0000',
             'data': {
             'data': {
+                'summary': {
+                    'order_total_receivable_cny': 72.0,
+                    'order_total_settlement_cny': 39.0,
+                    'special_approval_amount_cny': -14.4,
+                    'settlement_gross_profit_cny': 33.0,
+                },
                 'columns': [{'key': key, 'name': key} for key in self.KEYS],
                 'columns': [{'key': key, 'name': key} for key in self.KEYS],
                 'records': [{
                 'records': [{
                     'order_number': 'ORD-260001', 'customer_name': '主客户',
                     'order_number': 'ORD-260001', 'customer_name': '主客户',
@@ -135,18 +151,28 @@ class OrderReceivableCostDetailsPresenterTest(unittest.TestCase):
             'meta': {'page': 1, 'limit': 20, 'has_more': False},
             'meta': {'page': 1, 'limit': 20, 'has_more': False},
         }
         }
 
 
-    def test_presenter_translates_exact_thirteen_columns(self):
+    def test_presenter_translates_summary_and_exact_thirteen_columns(self):
         result = OutputPresenter().present(
         result = OutputPresenter().present(
             'query_order_receivable_cost_details', self.payload()
             'query_order_receivable_cost_details', self.payload()
         )
         )
         self.assertFalse(result['is_error'])
         self.assertFalse(result['is_error'])
+        self.assertEqual(
+            [{'label': header} for header in self.SUMMARY_HEADERS],
+            result['structured_content']['summary']['headers'],
+        )
+        self.assertEqual(
+            [72.0, 39.0, -14.4, 33.0],
+            result['structured_content']['summary']['row'],
+        )
         self.assertEqual(
         self.assertEqual(
             [{'label': header} for header in self.HEADERS],
             [{'label': header} for header in self.HEADERS],
             result['structured_content']['headers'],
             result['structured_content']['headers'],
         )
         )
         self.assertEqual(13, len(result['structured_content']['rows'][0]))
         self.assertEqual(13, len(result['structured_content']['rows'][0]))
+        self.assertIn('订单总应收(CNY)', result['text'])
+        self.assertIn('结算毛利(CNY)', result['text'])
         serialized = json.dumps(result, ensure_ascii=False)
         serialized = json.dumps(result, ensure_ascii=False)
-        for key in self.KEYS:
+        for key in self.KEYS + self.SUMMARY_KEYS:
             self.assertNotIn(key, serialized)
             self.assertNotIn(key, serialized)
 
 
     def test_unknown_missing_reordered_and_bad_values_fail_closed(self):
     def test_unknown_missing_reordered_and_bad_values_fail_closed(self):
@@ -169,6 +195,16 @@ class OrderReceivableCostDetailsPresenterTest(unittest.TestCase):
         extra_data = self.payload()
         extra_data = self.payload()
         extra_data['data']['internal'] = True
         extra_data['data']['internal'] = True
         cases.append(extra_data)
         cases.append(extra_data)
+        missing_summary = self.payload()
+        del missing_summary['data']['summary']
+        cases.append(missing_summary)
+        extra_summary = self.payload()
+        extra_summary['data']['summary']['secret'] = 1
+        cases.append(extra_summary)
+        for amount in (True, '10.00', float('inf'), float('-inf'), float('nan')):
+            bad_summary = self.payload()
+            bad_summary['data']['summary']['order_total_receivable_cny'] = amount
+            cases.append(bad_summary)
         bad_meta = self.payload()
         bad_meta = self.payload()
         bad_meta['meta']['total'] = 1
         bad_meta['meta']['total'] = 1
         cases.append(bad_meta)
         cases.append(bad_meta)