Quellcode durchsuchen

master_task_7055442374

jackson vor 3 Wochen
Ursprung
Commit
5d03adad61

Datei-Diff unterdrückt, da er zu groß ist
+ 15 - 10
README.md


+ 49 - 1
app.py

@@ -18,6 +18,7 @@ from services.diagnostic_reporter import (
 from services.scoped_api_client import ScopedApiClient
 from services.token_store import FileTokenStore, RedisSocketClient, RedisTokenStore
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
 from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
@@ -36,6 +37,9 @@ from tools.query_export_task import QueryExportTaskTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
+from tools.query_customer_list import QueryCustomerListTool
+from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
+from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
 
 
 def parse_int_list(value):
@@ -74,12 +78,21 @@ class GatewayApp:
                 QueryCustomsDeclarationFilesTool(api_client=api_client),
             'query_outbound_list': QueryOutboundListTool(api_client=api_client),
             'query_outbound_detail': QueryOutboundDetailTool(api_client=api_client),
+            'query_customer_list': QueryCustomerListTool(api_client=api_client),
+            'query_customer_payment_followup': QueryCustomerPaymentFollowupTool(
+                api_client=api_client
+            ),
+            'query_customer_unverified_bill_details':
+                QueryCustomerUnverifiedBillDetailsTool(api_client=api_client),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=api_client
             ),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=api_client
             ),
+            'list_customer_filter_options': ListCustomerFilterOptionsTool(
+                api_client=api_client
+            ),
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=api_client
             ),
@@ -234,7 +247,14 @@ class GatewayApp:
         call_parser.add_argument('--receiver-country', default='')
         call_parser.add_argument('--product-ids', default='')
         call_parser.add_argument('--customer-ids', default='')
+        call_parser.add_argument('--customer-id', type=int, default=0)
         call_parser.add_argument('--sales-id', type=int, default=0)
+        call_parser.add_argument('--merchandiser-id', type=int, default=0)
+        call_parser.add_argument(
+            '--has-unverified-receivable-only',
+            choices=('true', 'false'),
+            default='true',
+        )
         call_parser.add_argument('--warehouse-ids', default='')
         call_parser.add_argument('--warehouse-id', type=int, default=0)
         call_parser.add_argument('--department-id', type=int, default=0)
@@ -373,6 +393,33 @@ class GatewayApp:
                     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_customer_list':
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('department_id', args.department_id),
+                    ('sales_id', args.sales_id),
+                    ('merchandiser_id', args.merchandiser_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+            elif args.tool == 'query_customer_payment_followup':
+                for field, value in (
+                    ('customer_id', args.customer_id),
+                    ('department_id', args.department_id),
+                    ('sales_id', args.sales_id),
+                    ('merchandiser_id', args.merchandiser_id),
+                ):
+                    if value > 0:
+                        tool_args[field] = value
+                tool_args['has_unverified_receivable_only'] = (
+                    args.has_unverified_receivable_only == 'true'
+                )
+            elif args.tool == 'query_customer_unverified_bill_details':
+                if args.customer_id <= 0:
+                    raise ValueError(
+                        '--customer-id is required for query_customer_unverified_bill_details'
+                    )
+                tool_args['customer_id'] = args.customer_id
             elif args.tool == 'query_customs_declaration_files':
                 if args.outbound_numbers:
                     tool_args['outbound_numbers'] = parse_string_list(
@@ -436,7 +483,8 @@ class GatewayApp:
                     )
                 tool_args = {'task_ref': args.task_ref}
             elif args.tool in (
-                'list_order_filter_options', 'list_outbound_filter_options'
+                'list_order_filter_options', 'list_outbound_filter_options',
+                'list_customer_filter_options'
             ):
                 if not args.filter_type:
                     raise ValueError(

+ 13 - 0
public_gateway.py

@@ -4,6 +4,7 @@ import uuid
 
 from constants import DEVICE_INVALID_MESSAGE
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
 from tools.list_outbound_filter_options import ListOutboundFilterOptionsTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.export_out_of_province_port_data import (
@@ -22,6 +23,9 @@ from tools.query_export_task import QueryExportTaskTool
 from tools.query_outbound_detail import QueryOutboundDetailTool
 from tools.query_outbound_list import QueryOutboundListTool
 from tools.query_track import QueryTrackTool
+from tools.query_customer_list import QueryCustomerListTool
+from tools.query_customer_payment_followup import QueryCustomerPaymentFollowupTool
+from tools.query_customer_unverified_bill_details import QueryCustomerUnverifiedBillDetailsTool
 from utils.security import hash_gateway_session_id
 
 
@@ -41,12 +45,21 @@ class PublicGatewayApp:
                 QueryCustomsDeclarationFilesTool(api_client=None),
             'query_outbound_list': QueryOutboundListTool(api_client=None),
             'query_outbound_detail': QueryOutboundDetailTool(api_client=None),
+            'query_customer_list': QueryCustomerListTool(api_client=None),
+            'query_customer_payment_followup': QueryCustomerPaymentFollowupTool(
+                api_client=None
+            ),
+            'query_customer_unverified_bill_details':
+                QueryCustomerUnverifiedBillDetailsTool(api_client=None),
             'list_outbound_filter_options': ListOutboundFilterOptionsTool(
                 api_client=None
             ),
             'list_order_filter_options': ListOrderFilterOptionsTool(
                 api_client=None
             ),
+            'list_customer_filter_options': ListCustomerFilterOptionsTool(
+                api_client=None
+            ),
             'export_pending_outbound_orders': ExportPendingOutboundOrdersTool(
                 api_client=None
             ),

+ 398 - 6
services/output_presenter.py

@@ -1,5 +1,6 @@
 import json
 import logging
+import math
 from urllib.parse import urlparse
 
 from constants import DEVICE_INVALID_MESSAGE
@@ -15,6 +16,11 @@ class OutputPresenter:
         'query_track',
         'query_customs_declaration_files',
         'query_outbound_list',
+        'query_customer_list',
+    ))
+    PAYMENT_FOLLOWUP_TOOLS = frozenset(('query_customer_payment_followup',))
+    PAYMENT_DETAIL_TOOLS = frozenset((
+        'query_customer_unverified_bill_details',
     ))
     DETAIL_TOOLS = frozenset(('query_outbound_detail',))
     ORDER_DETAIL_TOOLS = frozenset(('query_order_detail',))
@@ -22,6 +28,7 @@ class OutputPresenter:
         'list_order_filter_options',
         'list_outbound_filter_options',
         'list_pending_outbound_export_filter_options',
+        'list_customer_filter_options',
     ))
     EXPORT_TOOLS = frozenset((
         'export_pending_outbound_orders',
@@ -29,7 +36,8 @@ class OutputPresenter:
     ))
     TASK_TOOLS = frozenset(('query_export_task',))
     SAFE_TOOLS = (
-        TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS
+        TABLE_TOOLS | DETAIL_TOOLS | ORDER_DETAIL_TOOLS | PAYMENT_FOLLOWUP_TOOLS
+        | PAYMENT_DETAIL_TOOLS
         | OPTION_TOOLS | EXPORT_TOOLS | TASK_TOOLS
     )
 
@@ -290,6 +298,55 @@ class OutputPresenter:
             'package_method': ('包装类型',),
             'order_reply': ('到货回复',),
         },
+        'query_customer_list': {
+            'customer_name': ('客户名称',),
+            'customer_code': ('客户代码',),
+            'create_time': ('开户时间',),
+            'business_type': ('业务类型',),
+            'customer_attribute': ('客户属性',),
+            'customer_source': ('客户来源',),
+            'first_inbound_date': ('首次成交时间',),
+            'last_inbound_date': ('最后一次走货时间',),
+            'active_status': ('活跃状态',),
+            'contract_status': ('合同状态',),
+            'contract_validity': ('合同有效期',),
+            'credit_limit': ('信用额度',),
+            'currency_code': ('结算币种',),
+            'billing_modes': ('结算模式(分业务类型)',),
+            'sales_name': ('商务经理',),
+            'merchandiser_name': ('客户经理',),
+            'department_name': ('事业部',),
+        },
+    }
+
+    PAYMENT_FOLLOWUP_COLUMNS = {
+        'customer_name': '客户名称',
+        'settlement_currency': '结算币种',
+        'billed_unverified_amount': '已出账未核销金额',
+        'unbilled_amount': '未出账金额',
+        'overdue_unpaid_amount': '逾期未回款金额',
+        'billed_unverified_amount_cny': '已出账未核销金额(人民币)',
+        'unbilled_amount_cny': '未出账金额(人民币)',
+        'overdue_unpaid_amount_cny': '逾期未回款金额(人民币)',
+        'unverified_receivable_monthly_summary': '未回款月份汇总',
+        'receipt_unverified_amount': '收款单未核销金额',
+        'current_balance': '当前余额',
+        'credit_limit': '信用额度',
+        'bad_debt_total': '坏账合计',
+        'contract_status': '合同状态',
+    }
+    PAYMENT_FOLLOWUP_DETAIL_COLUMNS = {
+        'bill_month': '账单月份',
+        'bill_no': '账单号',
+        'business_type': '业务类型',
+        'settlement_mode': '结算模式',
+        'unverified_amount': '未核销金额',
+        'customer_receivable_date': '客户应收款日期',
+    }
+    PAYMENT_FOLLOWUP_MONTHLY_COLUMNS = {
+        'receivable_month': '应收月份',
+        'unverified_amount': '未核销金额',
+        'is_overdue': '是否逾期',
     }
 
     OUTBOUND_DETAIL_SUMMARY = {
@@ -388,6 +445,34 @@ class OutputPresenter:
             'page': '页码',
             'limit': '每页数量',
         },
+        'query_customer_list': {
+            'customer_id': '客户名称',
+            'department_id': '事业部',
+            'sales_id': '商务经理',
+            'merchandiser_id': '客户经理',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'query_customer_payment_followup': {
+            'customer_id': '客户名称',
+            'department_id': '事业部',
+            'sales_id': '商务经理',
+            'merchandiser_id': '客户经理',
+            'has_unverified_receivable_only': '只看有应收未核销费用的客户',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'query_customer_unverified_bill_details': {
+            'customer_id': '客户名称',
+            'page': '页码',
+            'limit': '每页数量',
+        },
+        'list_customer_filter_options': {
+            'filter_type': '筛选项类型',
+            'keyword': '关键词',
+            'page': '页码',
+            'limit': '每页数量',
+        },
         'export_pending_outbound_orders': {
             'number': '单号',
             'product_type_id': '产品分类',
@@ -483,6 +568,14 @@ class OutputPresenter:
             )
         if tool_name in self.ORDER_DETAIL_TOOLS:
             return self._present_order_detail(data, tool_result.get('meta'), meta)
+        if tool_name in self.PAYMENT_FOLLOWUP_TOOLS:
+            return self._present_customer_payment_followup(
+                data, tool_result.get('meta'), meta
+            )
+        if tool_name in self.PAYMENT_DETAIL_TOOLS:
+            return self._present_customer_unverified_bill_details(
+                data, tool_result.get('meta'), meta
+            )
         if tool_name in self.TABLE_TOOLS:
             return self._present_table(
                 tool_name,
@@ -491,6 +584,10 @@ class OutputPresenter:
                 meta,
             )
         if tool_name in self.OPTION_TOOLS:
+            if tool_name == 'list_customer_filter_options':
+                return self._present_customer_options(
+                    data, tool_result.get('meta'), meta
+                )
             return self._present_options(data, tool_result.get('meta'), meta)
         if tool_name in self.EXPORT_TOOLS:
             return self._present_export_submission(data, meta)
@@ -751,6 +848,15 @@ class OutputPresenter:
         if not isinstance(allowed_columns, dict):
             return self._format_error(meta)
 
+        if tool_name == 'query_customer_list':
+            expected = list(allowed_columns.keys())
+            actual = [
+                column.get('key') if isinstance(column, dict) else None
+                for column in columns
+            ]
+            if actual != expected or self._customer_pagination(raw_meta) is None:
+                return self._format_error(meta)
+
         headers = []
         keys = []
         for column in columns:
@@ -776,24 +882,297 @@ class OutputPresenter:
         for record in records:
             if not isinstance(record, dict):
                 return self._format_error(meta)
-            rows.append([
-                '' if record.get(key) is None else record.get(key, '')
-                for key in keys
-            ])
+            if tool_name == 'query_customer_list' and set(record) != set(keys):
+                return self._format_error(meta)
+            row = []
+            for key in keys:
+                value = '' if record.get(key) is None else record.get(key, '')
+                if tool_name == 'query_customer_list' and key == 'billing_modes':
+                    value = self._customer_billing_modes(value)
+                    if value is None:
+                        return self._format_error(meta)
+                elif tool_name == 'query_customer_list' and isinstance(value, (dict, list)):
+                    return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
 
         content = {
             'summary': self._safe_text(data.get('summary')),
             'headers': headers,
             'rows': rows,
         }
+        if tool_name == 'query_customer_list':
+            content['display_rules'] = {
+                'mode': 'complete',
+                'allow_summary': False,
+                'allow_omit_records': False,
+                'allow_omit_empty_fields': False,
+                'allow_rename_fields': False,
+                'preserve_record_order': True,
+                'required_field_count': len(headers),
+                'returned_record_count': len(rows),
+                'required_value_count': len(headers) * len(rows),
+                'instruction': '最终回复必须逐条展示全部记录及全部17个字段,不得摘要、省略或改写',
+            }
         tips = self._safe_tips(data.get('tips'))
         if tips:
             content['tips'] = tips
         pagination = self._build_pagination(raw_meta)
         if pagination:
             content['pagination'] = pagination
+        return self._success_result(
+            content,
+            self._render_table(
+                content,
+                require_complete=tool_name == 'query_customer_list',
+            ),
+            meta,
+        )
+
+    def _present_customer_payment_followup(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        expected_keys = list(self.PAYMENT_FOLLOWUP_COLUMNS)
+        if not isinstance(columns, list) or not isinstance(records, list):
+            return self._format_error(meta)
+        actual_keys = []
+        for column in columns:
+            if (
+                not isinstance(column, dict)
+                or set(column) != {'key', 'name'}
+                or not isinstance(column.get('key'), str)
+                or not isinstance(column.get('name'), str)
+                or not column.get('name').strip()
+            ):
+                return self._format_error(meta)
+            actual_keys.append(column['key'])
+        if actual_keys != expected_keys or pagination is None:
+            return self._format_error(meta)
+
+        amount_keys = {
+            'billed_unverified_amount', 'unbilled_amount',
+            'overdue_unpaid_amount', 'billed_unverified_amount_cny',
+            'unbilled_amount_cny', 'overdue_unpaid_amount_cny',
+            'receipt_unverified_amount', 'current_balance',
+            'credit_limit', 'bad_debt_total',
+        }
+        text_keys = {'customer_name', 'settlement_currency', 'contract_status'}
+        rows = []
+        for record in records:
+            if not isinstance(record, dict) or set(record) != set(expected_keys):
+                return self._format_error(meta)
+            row = []
+            for key in expected_keys:
+                value = record[key]
+                if key in amount_keys:
+                    if (
+                        isinstance(value, bool)
+                        or not isinstance(value, (int, float))
+                        or not math.isfinite(float(value))
+                    ):
+                        return self._format_error(meta)
+                elif key in text_keys:
+                    if not isinstance(value, str):
+                        return self._format_error(meta)
+                else:
+                    value = self._payment_followup_monthly_summaries(value)
+                    if value is None:
+                        return self._format_error(meta)
+                row.append(value)
+            rows.append(row)
+
+        headers = [
+            {'label': self.PAYMENT_FOLLOWUP_COLUMNS[key]}
+            for key in expected_keys
+        ]
+        content = {
+            'headers': headers,
+            'rows': rows,
+            'pagination': pagination,
+            'display_rules': {
+                'mode': 'complete',
+                'allow_summary': False,
+                'allow_omit_records': False,
+                'allow_omit_empty_fields': False,
+                'allow_rename_fields': False,
+                'preserve_record_order': True,
+                'required_field_count': len(headers),
+                'returned_record_count': len(rows),
+                'required_value_count': len(headers) * len(rows),
+                'instruction': '最终回复必须逐条展示全部客户、全部字段和全部未回款月份汇总',
+            },
+        }
+        return self._success_result(
+            content,
+            self._render_table(content, require_complete=True),
+            meta,
+        )
+
+    def _payment_followup_monthly_summaries(self, summaries):
+        if not isinstance(summaries, list):
+            return None
+        expected = set(self.PAYMENT_FOLLOWUP_MONTHLY_COLUMNS)
+        translated = []
+        for summary in summaries:
+            if not isinstance(summary, dict) or set(summary) != expected:
+                return None
+            amount = summary.get('unverified_amount')
+            if (
+                isinstance(amount, bool)
+                or not isinstance(amount, (int, float))
+                or not math.isfinite(float(amount))
+            ):
+                return None
+            if (
+                not isinstance(summary.get('receivable_month'), str)
+                or not isinstance(summary.get('is_overdue'), bool)
+            ):
+                return None
+            item = {}
+            for key, label in self.PAYMENT_FOLLOWUP_MONTHLY_COLUMNS.items():
+                value = summary[key]
+                item[label] = value
+            translated.append(item)
+        return translated
+
+    def _present_customer_unverified_bill_details(self, data, raw_meta, meta):
+        if set(data) != {'columns', 'records'}:
+            return self._format_error(meta)
+        columns = data.get('columns')
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        expected_keys = list(self.PAYMENT_FOLLOWUP_DETAIL_COLUMNS)
+        if not isinstance(columns, list) or not isinstance(records, list):
+            return self._format_error(meta)
+        actual_keys = []
+        for column in columns:
+            if (
+                not isinstance(column, dict)
+                or set(column) != {'key', 'name'}
+                or not isinstance(column.get('key'), str)
+                or not isinstance(column.get('name'), str)
+                or not column.get('name').strip()
+            ):
+                return self._format_error(meta)
+            actual_keys.append(column['key'])
+        if actual_keys != expected_keys or pagination is None:
+            return self._format_error(meta)
+
+        translated = self._payment_followup_details(records)
+        if translated is None:
+            return self._format_error(meta)
+        headers = [
+            {'label': self.PAYMENT_FOLLOWUP_DETAIL_COLUMNS[key]}
+            for key in expected_keys
+        ]
+        rows = [[item[header['label']] for header in headers] for item in translated]
+        content = {'headers': headers, 'rows': rows, 'pagination': pagination}
+        return self._success_result(content, self._render_table(content), meta)
+
+    def _payment_followup_details(self, details):
+        expected = set(self.PAYMENT_FOLLOWUP_DETAIL_COLUMNS)
+        translated = []
+        for detail in details:
+            if not isinstance(detail, dict) or set(detail) != expected:
+                return None
+            amount = detail.get('unverified_amount')
+            if (
+                isinstance(amount, bool)
+                or not isinstance(amount, (int, float))
+                or not math.isfinite(float(amount))
+            ):
+                return None
+            item = {}
+            for key, label in self.PAYMENT_FOLLOWUP_DETAIL_COLUMNS.items():
+                value = detail[key]
+                if key != 'unverified_amount' and not isinstance(value, str):
+                    return None
+                item[label] = value
+            translated.append(item)
+        return translated
+
+    @staticmethod
+    def _customer_billing_modes(value):
+        if not isinstance(value, list):
+            return None
+        result = []
+        business_types = set()
+        for item in value:
+            if not isinstance(item, dict) or set(item) != {
+                'business_type', 'settlement_mode',
+            }:
+                return None
+            business_type = item.get('business_type')
+            settlement_mode = item.get('settlement_mode')
+            if (
+                not isinstance(business_type, str) or not business_type.strip()
+                or not isinstance(settlement_mode, str) or not settlement_mode.strip()
+                or business_type in business_types
+            ):
+                return None
+            business_types.add(business_type)
+            result.append({
+                '业务类型': business_type.strip(),
+                '结算模式': settlement_mode.strip(),
+            })
+        return result
+
+    def _present_customer_options(self, data, raw_meta, meta):
+        if set(data) != {'records'}:
+            return self._format_error(meta)
+        records = data.get('records')
+        pagination = self._customer_pagination(raw_meta)
+        if not isinstance(records, list) or pagination is None:
+            return self._format_error(meta)
+        rows = []
+        for record in records:
+            if not isinstance(record, dict) or set(record) != {'value', 'label', 'code'}:
+                return self._format_error(meta)
+            value = record.get('value')
+            label = record.get('label')
+            code = record.get('code')
+            if (
+                isinstance(value, bool) or not isinstance(value, int) or value < 1
+                or not isinstance(label, str) or not label.strip()
+                or not isinstance(code, str)
+            ):
+                return self._format_error(meta)
+            rows.append([value, label.strip(), code.strip()])
+        content = {
+            'headers': [
+                {'label': '可传值'}, {'label': '显示名称'}, {'label': '业务编码'},
+            ],
+            'rows': rows,
+            'pagination': pagination,
+        }
         return self._success_result(content, self._render_table(content), meta)
 
+    @staticmethod
+    def _customer_pagination(raw_meta):
+        if not isinstance(raw_meta, dict):
+            return None
+        required = {'page', 'limit', 'has_more'}
+        if (
+            not required.issubset(raw_meta)
+            or not set(raw_meta).issubset(required | {'request_id'})
+        ):
+            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 or page > 100
+            or isinstance(limit, bool) or not isinstance(limit, int)
+            or limit < 1 or limit > 100
+            or not isinstance(has_more, bool)
+        ):
+            return None
+        return {'page': page, 'limit': limit, 'has_more': has_more}
+
     def _present_outbound_detail(self, tool_name, data, raw_meta, meta):
         summary = data.get('summary')
         columns = data.get('columns')
@@ -1096,10 +1475,17 @@ class OutputPresenter:
         return [cls._safe_text(item) for item in value if cls._safe_text(item)]
 
     @classmethod
-    def _render_table(cls, content):
+    def _render_table(cls, content, require_complete=False):
         headers = content.get('headers') or []
         rows = content.get('rows') or []
         lines = []
+        if require_complete:
+            lines.append('完整客户数据,禁止摘要、合并、隐藏字段或省略空字段。')
+            lines.append(
+                '本页记录数:{0};每条字段数:{1};应展示字段值总数:{2}。'.format(
+                    len(rows), len(headers), len(rows) * len(headers)
+                )
+            )
         summary = cls._safe_text(content.get('summary'))
         if summary:
             lines.append(summary)
@@ -1115,6 +1501,12 @@ class OutputPresenter:
         tips = content.get('tips') or []
         if tips:
             lines.append('提示:{0}'.format(';'.join(tips)))
+        if require_complete:
+            lines.append(
+                '展示完整性校验:已提供{0}条客户的全部{1}个字段。'.format(
+                    len(rows), len(headers)
+                )
+            )
         return '\n'.join(lines)
 
     @staticmethod

+ 80 - 0
tests/test_customer_filter_options_tool.py

@@ -0,0 +1,80 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code, 'route_path': route_path,
+            'payload': payload, 'request_id': request_id,
+        }
+        return {'code': 'MCP_0000'}
+
+    def list_enabled_tools(self, request_id=''):
+        return {'code': 'MCP_0000', 'data': {'tool_codes': ['list_customer_filter_options']}}
+
+
+class CustomerFilterOptionsToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_has_four_business_types(self):
+        metadata = ListCustomerFilterOptionsTool().metadata()
+        schema = metadata['input_schema']
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual(['filter_type'], schema['required'])
+        self.assertEqual(
+            ['客户名称', '事业部', '商务经理', '客户经理'],
+            schema['properties']['filter_type']['enum'],
+        )
+        self.assertEqual(100, schema['properties']['keyword']['maxLength'])
+        self.assertIn('query_customer_list', metadata['description'])
+        self.assertIn('query_customer_payment_followup', metadata['description'])
+
+    def test_call_normalizes_and_forwards(self):
+        client = RecordingApiClient()
+        result = ListCustomerFilterOptionsTool(client).call(
+            ' 客户名称 ', keyword=' 客户A ', page=2, limit=30,
+            request_id='rq_customer_options',
+        )
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('list_customer_filter_options', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/listCustomerFilterOptions', client.last_call['route_path'])
+        self.assertEqual({
+            'filter_type': '客户名称', 'keyword': '客户A', 'page': 2, 'limit': 30,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_bad_type_keyword_and_numbers(self):
+        tool = ListCustomerFilterOptionsTool(RecordingApiClient())
+        for kwargs, message in (
+            ({'filter_type': '客户'}, 'filter_type'),
+            ({'filter_type': '客户名称', 'keyword': 3}, 'keyword'),
+            ({'filter_type': '客户名称', 'keyword': 'x' * 101}, 'keyword'),
+            ({'filter_type': '客户名称', 'page': '2'}, 'page'),
+            ({'filter_type': '客户名称', 'limit': True}, 'limit'),
+        ):
+            with self.assertRaisesRegex(ValueError, message):
+                tool.call(**kwargs)
+
+    def test_call_rejects_non_string_filter_type_and_requires_client(self):
+        with self.assertRaisesRegex(ValueError, 'filter_type'):
+            ListCustomerFilterOptionsTool(RecordingApiClient()).call(3)
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            ListCustomerFilterOptionsTool().call('客户名称')
+
+    def test_cli_reuses_filter_type_and_keyword(self):
+        client = RecordingApiClient()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'list_customer_filter_options',
+            '--filter-type', '事业部', '--keyword', '华南',
+        ], stdout=StringIO())
+        self.assertEqual({
+            'filter_type': '事业部', 'keyword': '华南', 'page': 1, 'limit': 20,
+        }, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 273 - 0
tests/test_customer_payment_followup_tool.py

@@ -0,0 +1,273 @@
+import importlib.util
+import io
+import json
+import os
+import unittest
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.calls = []
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_payment_followup']},
+        }
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.calls.append((tool_code, route_path, payload, request_id))
+        return {'code': 'MCP_0000', 'data': {}}
+
+
+class CustomerPaymentFollowupToolTest(unittest.TestCase):
+    def load_tool_class(self):
+        path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'tools',
+            'query_customer_payment_followup.py',
+        )
+        self.assertTrue(os.path.isfile(path), '客户回款跟进 Gateway 工具尚未实现')
+        spec = importlib.util.spec_from_file_location('payment_followup_tool', path)
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        return module.QueryCustomerPaymentFollowupTool
+
+    def test_closed_schema_and_call_forwarding(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        metadata = tool.metadata()
+        schema = metadata['input_schema']
+
+        self.assertEqual('query_customer_payment_followup', metadata['name'])
+        self.assertIn('query_customer_unverified_bill_details', metadata['description'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        self.assertEqual({
+            'customer_id', 'department_id', 'sales_id', 'merchandiser_id',
+            'has_unverified_receivable_only', 'page', 'limit',
+        }, set(schema['properties']))
+        self.assertEqual(True, schema['properties']['has_unverified_receivable_only']['default'])
+        self.assertEqual(100, schema['properties']['page']['maximum'])
+        self.assertEqual(100, schema['properties']['limit']['maximum'])
+
+        result = tool.call(
+            customer_id=11,
+            department_id=12,
+            sales_id=13,
+            merchandiser_id=14,
+            has_unverified_receivable_only=False,
+            page=2,
+            limit=40,
+            request_id='rq_payment',
+        )
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_payment_followup',
+            '/mcp/tools/queryCustomerPaymentFollowup',
+            {
+                'page': 2,
+                'limit': 40,
+                'has_unverified_receivable_only': False,
+                'customer_id': 11,
+                'department_id': 12,
+                'sales_id': 13,
+                'merchandiser_id': 14,
+            },
+            'rq_payment',
+        ), client.calls[0])
+
+    def test_defaults_and_illegal_parameters_are_rejected(self):
+        tool_class = self.load_tool_class()
+        client = RecordingApiClient()
+        tool = tool_class(client)
+        tool.call()
+        self.assertEqual({
+            'page': 1,
+            'limit': 20,
+            'has_unverified_receivable_only': True,
+        }, client.calls[0][2])
+
+        invalid_calls = (
+            {'customer_id': True}, {'department_id': 0}, {'sales_id': 1.5},
+            {'merchandiser_id': '2'}, {'page': 0}, {'page': 101},
+            {'limit': False}, {'limit': 101},
+            {'has_unverified_receivable_only': 1},
+            {'has_unverified_receivable_only': 'true'},
+        )
+        for arguments in invalid_calls:
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+
+        with self.assertRaises(RuntimeError):
+            tool_class().call()
+
+    def test_local_public_registries_and_cli_have_seventeen_tools(self):
+        local = GatewayApp(api_client=RecordingApiClient())
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(17, len(local.registered_tool_names()))
+        self.assertIn('query_customer_payment_followup', local.registered_tool_names())
+
+        stdout = io.StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_payment_followup',
+            '--customer-id', '11', '--has-unverified-receivable-only', 'false',
+            '--page', '2', '--limit', '10', '--request-id', 'rq_cli_payment',
+        ], stdout=stdout)
+        self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
+        self.assertEqual({
+            'page': 2,
+            'limit': 10,
+            'customer_id': 11,
+            'has_unverified_receivable_only': False,
+        }, local.api_client.calls[-1][2])
+
+
+class CustomerPaymentFollowupPresenterTest(unittest.TestCase):
+    KEYS = [
+        'customer_name', 'settlement_currency', 'billed_unverified_amount',
+        'unbilled_amount', 'overdue_unpaid_amount',
+        'billed_unverified_amount_cny', 'unbilled_amount_cny',
+        'overdue_unpaid_amount_cny', 'unverified_receivable_monthly_summary',
+        'receipt_unverified_amount', 'current_balance', 'credit_limit',
+        'bad_debt_total', 'contract_status',
+    ]
+    SUMMARY_KEYS = [
+        'receivable_month', 'unverified_amount', 'is_overdue',
+    ]
+
+    def setUp(self):
+        self.presenter = OutputPresenter()
+
+    def payload(self):
+        record = {
+            'customer_name': '甲客户',
+            'settlement_currency': 'USD',
+            'billed_unverified_amount': 90.0,
+            'unbilled_amount': 50.0,
+            'overdue_unpaid_amount': 90.0,
+            'billed_unverified_amount_cny': 630.0,
+            'unbilled_amount_cny': 350.0,
+            'overdue_unpaid_amount_cny': 630.0,
+            'unverified_receivable_monthly_summary': [{
+                'receivable_month': '2026-01',
+                'unverified_amount': 90.0,
+                'is_overdue': True,
+            }],
+            'receipt_unverified_amount': 30.0,
+            'current_balance': -300.0,
+            'credit_limit': 1000.0,
+            'bad_debt_total': 15.0,
+            'contract_status': '生效中',
+        }
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [record],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_payment',
+            },
+        }
+
+    def test_success_translates_every_top_level_and_monthly_summary_field(self):
+        result = self.presenter.present(
+            'query_customer_payment_followup', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(14, len(result['structured_content']['headers']))
+        self.assertEqual(14, len(result['structured_content']['rows'][0]))
+        summaries = result['structured_content']['rows'][0][8]
+        self.assertEqual({
+            '应收月份', '未核销金额', '是否逾期',
+        }, set(summaries[0]))
+        self.assertEqual(
+            {'page': 1, 'limit': 20, 'has_more': False},
+            result['structured_content']['pagination'],
+        )
+        instruction = result['structured_content']['display_rules']['instruction']
+        self.assertIn('月份汇总', instruction)
+        self.assertNotIn('账单明细', instruction)
+        serialized = json.dumps(result, ensure_ascii=False)
+        for raw_key in self.KEYS + self.SUMMARY_KEYS:
+            self.assertNotIn(raw_key, serialized)
+
+    def test_missing_extra_or_reordered_top_level_fields_fail_closed(self):
+        cases = []
+        missing = self.payload()
+        del missing['data']['records'][0]['credit_limit']
+        cases.append(missing)
+        extra = self.payload()
+        extra['data']['records'][0]['secret'] = 'hidden'
+        cases.append(extra)
+        column_missing = self.payload()
+        column_missing['data']['columns'].pop()
+        cases.append(column_missing)
+        reordered = self.payload()
+        reordered['data']['columns'].reverse()
+        cases.append(reordered)
+        data_extra = self.payload()
+        data_extra['data']['internal'] = True
+        cases.append(data_extra)
+
+        for payload in cases:
+            with self.subTest(payload=payload):
+                result = self.presenter.present('query_customer_payment_followup', payload)
+                self.assertTrue(result['is_error'])
+
+    def test_malformed_monthly_summary_amounts_and_pagination_fail_closed(self):
+        cases = []
+        for summary in (
+            'bad',
+            [{'receivable_month': '2026-01'}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'secret': 1}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'receivable_month': []}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'unverified_amount': True}],
+            [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'is_overdue': 1}],
+        ):
+            payload = self.payload()
+            payload['data']['records'][0]['unverified_receivable_monthly_summary'] = summary
+            cases.append(payload)
+
+        bad_amount = self.payload()
+        bad_amount['data']['records'][0]['credit_limit'] = '1000'
+        cases.append(bad_amount)
+        bad_name = self.payload()
+        bad_name['data']['records'][0]['customer_name'] = []
+        cases.append(bad_name)
+        bad_record = self.payload()
+        bad_record['data']['records'] = ['bad']
+        cases.append(bad_record)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column_item = self.payload()
+        bad_column_item['data']['columns'][0]['internal'] = True
+        cases.append(bad_column_item)
+        bad_page = self.payload()
+        bad_page['meta']['has_more'] = 1
+        cases.append(bad_page)
+        page_over_limit = self.payload()
+        page_over_limit['meta']['page'] = 101
+        cases.append(page_over_limit)
+        extra_pagination = self.payload()
+        extra_pagination['meta']['total'] = 1
+        cases.append(extra_pagination)
+
+        for payload in cases:
+            with self.subTest(payload=payload):
+                result = self.presenter.present('query_customer_payment_followup', payload)
+                self.assertTrue(result['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 97 - 0
tests/test_customer_query_tools.py

@@ -0,0 +1,97 @@
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from tools.query_customer_list import QueryCustomerListTool
+
+
+class RecordingApiClient:
+    def __init__(self):
+        self.last_call = None
+
+    def call_tool(self, tool_code, route_path, payload, request_id):
+        self.last_call = {
+            'tool_code': tool_code, 'route_path': route_path,
+            'payload': payload, 'request_id': request_id,
+        }
+        return {'code': 'MCP_0000'}
+
+    def list_enabled_tools(self, request_id=''):
+        return {'code': 'MCP_0000', 'data': {'tool_codes': ['query_customer_list']}}
+
+
+class CustomerQueryToolTest(unittest.TestCase):
+    def test_schema_is_closed_and_uses_strict_positive_integers(self):
+        metadata = QueryCustomerListTool().metadata()
+        schema = metadata['input_schema']
+        self.assertIn('不得合并或缩写', metadata['description'])
+        self.assertIn('完整逐条展示17个字段', metadata['description'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual([], schema['required'])
+        for field in ('customer_id', 'department_id', 'sales_id', 'merchandiser_id'):
+            self.assertEqual({'type': 'integer', 'minimum': 1}, schema['properties'][field])
+        for field, default in (('page', 1), ('limit', 20)):
+            self.assertEqual(1, schema['properties'][field]['minimum'])
+            self.assertEqual(100, schema['properties'][field]['maximum'])
+            self.assertEqual(default, schema['properties'][field]['default'])
+
+    def test_call_forwards_all_filters(self):
+        client = RecordingApiClient()
+        result = QueryCustomerListTool(client).call(
+            customer_id=1, department_id=2, sales_id=3,
+            merchandiser_id=4, page=5, limit=6, request_id='rq_customer',
+        )
+        self.assertEqual({'code': 'MCP_0000'}, result)
+        self.assertEqual('query_customer_list', client.last_call['tool_code'])
+        self.assertEqual('/mcp/tools/queryCustomerList', client.last_call['route_path'])
+        self.assertEqual({
+            'customer_id': 1, 'department_id': 2, 'sales_id': 3,
+            'merchandiser_id': 4, 'page': 5, 'limit': 6,
+        }, client.last_call['payload'])
+
+    def test_call_rejects_non_strict_and_out_of_range_integers(self):
+        tool = QueryCustomerListTool(RecordingApiClient())
+        for field, value in (
+            ('customer_id', True), ('department_id', '2'), ('sales_id', 1.5),
+            ('merchandiser_id', 0), ('page', 101), ('limit', -1),
+        ):
+            with self.subTest(field=field, value=value):
+                with self.assertRaisesRegex(ValueError, field):
+                    tool.call(**{field: value})
+
+    def test_call_requires_api_client(self):
+        with self.assertRaisesRegex(RuntimeError, 'api client is required'):
+            QueryCustomerListTool().call()
+
+    def test_local_and_public_registries_are_identical_and_have_17_tools(self):
+        local = GatewayApp().registered_tool_names()
+        public = PublicGatewayApp(None, None).registered_tool_names()
+        self.assertEqual(local, public)
+        self.assertEqual(17, len(local))
+        self.assertIn('query_customer_list', local)
+        self.assertIn('list_customer_filter_options', local)
+
+    def test_cli_forwards_four_customer_filters(self):
+        client = RecordingApiClient()
+        output = StringIO()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'query_customer_list', '--customer-id', '11',
+            '--department-id', '12', '--sales-id', '13',
+            '--merchandiser-id', '14', '--page', '2', '--limit', '30',
+        ], stdout=output)
+        self.assertEqual({
+            'customer_id': 11, 'department_id': 12, 'sales_id': 13,
+            'merchandiser_id': 14, 'page': 2, 'limit': 30,
+        }, client.last_call['payload'])
+
+    def test_cli_allows_customer_query_without_filters(self):
+        client = RecordingApiClient()
+        GatewayApp(api_client=client).run_cli([
+            'call', '--tool', 'query_customer_list',
+        ], stdout=StringIO())
+        self.assertEqual({'page': 1, 'limit': 20}, client.last_call['payload'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 154 - 0
tests/test_customer_unverified_bill_details_tool.py

@@ -0,0 +1,154 @@
+import json
+import unittest
+from io import StringIO
+
+from app import GatewayApp
+from public_gateway import PublicGatewayApp
+from services.output_presenter import OutputPresenter
+from tools.query_customer_unverified_bill_details import (
+    QueryCustomerUnverifiedBillDetailsTool,
+)
+
+
+class RecordingApiClient:
+    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': {}}
+
+    def list_enabled_tools(self, request_id=''):
+        return {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_customer_unverified_bill_details']},
+        }
+
+
+class CustomerUnverifiedBillDetailsToolTest(unittest.TestCase):
+    def test_schema_is_closed_requires_customer_and_forwards_call(self):
+        client = RecordingApiClient()
+        tool = QueryCustomerUnverifiedBillDetailsTool(client)
+        schema = tool.metadata()['input_schema']
+
+        self.assertEqual(['customer_id'], schema['required'])
+        self.assertFalse(schema['additionalProperties'])
+        self.assertEqual({'customer_id', 'page', 'limit'}, set(schema['properties']))
+        result = tool.call(customer_id=7, page=2, limit=30, request_id='rq_detail')
+        self.assertEqual('MCP_0000', result['code'])
+        self.assertEqual((
+            'query_customer_unverified_bill_details',
+            '/mcp/tools/queryCustomerUnverifiedBillDetails',
+            {'customer_id': 7, 'page': 2, 'limit': 30},
+            'rq_detail',
+        ), client.calls[0])
+
+    def test_invalid_arguments_and_missing_client_fail(self):
+        tool = QueryCustomerUnverifiedBillDetailsTool(RecordingApiClient())
+        for arguments in (
+            {'customer_id': True}, {'customer_id': 0}, {'customer_id': '7'},
+            {'customer_id': 7, 'page': 0}, {'customer_id': 7, 'page': 101},
+            {'customer_id': 7, 'limit': False}, {'customer_id': 7, 'limit': 101},
+        ):
+            with self.subTest(arguments=arguments):
+                with self.assertRaises(ValueError):
+                    tool.call(**arguments)
+        with self.assertRaises(RuntimeError):
+            QueryCustomerUnverifiedBillDetailsTool().call(customer_id=7)
+
+    def test_registries_and_cli_include_detail_tool(self):
+        client = RecordingApiClient()
+        local = GatewayApp(api_client=client)
+        public = PublicGatewayApp(None, None)
+        self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
+        self.assertEqual(17, len(local.registered_tool_names()))
+        self.assertIn('query_customer_unverified_bill_details', local.registered_tool_names())
+
+        output = StringIO()
+        local.run_cli([
+            'call', '--tool', 'query_customer_unverified_bill_details',
+            '--customer-id', '7', '--page', '2', '--limit', '30',
+        ], stdout=output)
+        self.assertEqual('MCP_0000', json.loads(output.getvalue())['code'])
+        self.assertEqual({'customer_id': 7, 'page': 2, 'limit': 30}, client.calls[-1][2])
+
+        with self.assertRaises(ValueError):
+            local.run_cli([
+                'call', '--tool', 'query_customer_unverified_bill_details',
+            ], stdout=StringIO())
+
+
+class CustomerUnverifiedBillDetailsPresenterTest(unittest.TestCase):
+    KEYS = [
+        'bill_month', 'bill_no', 'business_type', 'settlement_mode',
+        'unverified_amount', 'customer_receivable_date',
+    ]
+
+    def payload(self):
+        return {
+            'code': 'MCP_0000',
+            'data': {
+                'columns': [{'key': key, 'name': key} for key in self.KEYS],
+                'records': [{
+                    'bill_month': '2026-01',
+                    'bill_no': 'DFC2601010001',
+                    'business_type': '头程业务',
+                    'settlement_mode': '月结30天',
+                    'unverified_amount': 90.0,
+                    'customer_receivable_date': '2026-01-31',
+                }],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+
+    def test_translates_all_six_fields(self):
+        result = OutputPresenter().present(
+            'query_customer_unverified_bill_details', self.payload()
+        )
+        self.assertFalse(result['is_error'])
+        self.assertEqual(6, len(result['structured_content']['headers']))
+        self.assertEqual(6, len(result['structured_content']['rows'][0]))
+
+    def test_missing_extra_malformed_or_bad_pagination_fails_closed(self):
+        cases = []
+        missing = self.payload()
+        del missing['data']['records'][0]['bill_no']
+        cases.append(missing)
+        extra = self.payload()
+        extra['data']['records'][0]['secret'] = 1
+        cases.append(extra)
+        bad_amount = self.payload()
+        bad_amount['data']['records'][0]['unverified_amount'] = True
+        cases.append(bad_amount)
+        bad_text = self.payload()
+        bad_text['data']['records'][0]['bill_month'] = []
+        cases.append(bad_text)
+        bad_page = self.payload()
+        bad_page['meta']['page'] = 101
+        cases.append(bad_page)
+        extra_meta = self.payload()
+        extra_meta['meta']['total'] = 1
+        cases.append(extra_meta)
+        extra_data = self.payload()
+        extra_data['data']['internal'] = True
+        cases.append(extra_data)
+        bad_records = self.payload()
+        bad_records['data']['records'] = 'bad'
+        cases.append(bad_records)
+        bad_columns = self.payload()
+        bad_columns['data']['columns'] = 'bad'
+        cases.append(bad_columns)
+        bad_column = self.payload()
+        bad_column['data']['columns'][0]['internal'] = True
+        cases.append(bad_column)
+
+        presenter = OutputPresenter()
+        for payload in cases:
+            with self.subTest(payload=payload):
+                self.assertTrue(presenter.present(
+                    'query_customer_unverified_bill_details', payload
+                )['is_error'])
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 188 - 0
tests/test_output_presenter.py

@@ -5,18 +5,206 @@ from services.output_presenter import OutputPresenter
 from tools.export_out_of_province_port_data import ExportOutOfProvincePortDataTool
 from tools.export_pending_outbound_orders import ExportPendingOutboundOrdersTool
 from tools.list_order_filter_options import ListOrderFilterOptionsTool
+from tools.list_customer_filter_options import ListCustomerFilterOptionsTool
 from tools.list_pending_outbound_export_filter_options import (
     ListPendingOutboundExportFilterOptionsTool,
 )
 from tools.query_order import QueryOrderTool
 from tools.query_order_exact import QueryOrderExactTool
 from tools.query_track import QueryTrackTool
+from tools.query_customer_list import QueryCustomerListTool
 
 
 class OutputPresenterTest(unittest.TestCase):
     def setUp(self):
         self.presenter = OutputPresenter()
 
+    def test_customer_list_uses_fixed_17_column_safe_table(self):
+        keys = [
+            'customer_name', 'customer_code', 'create_time', 'business_type',
+            'customer_attribute', 'customer_source', 'first_inbound_date',
+            'last_inbound_date', 'active_status', 'contract_status',
+            'contract_validity', 'credit_limit', 'currency_code',
+            'billing_modes', 'sales_name', 'merchandiser_name',
+            'department_name',
+        ]
+        result = self.presenter.present('query_customer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '当前页返回1个客户',
+                'columns': [{'key': key, 'name': '不可信名称'} for key in keys],
+                'records': [dict(
+                    (key, [
+                        {'business_type': '头程业务', 'settlement_mode': '月结'},
+                        {'business_type': '转运业务', 'settlement_mode': '票结'},
+                    ] if key == 'billing_modes' else '值{0}'.format(index))
+                    for index, key in enumerate(keys)
+                )],
+                'tips': [],
+            },
+            'meta': {
+                'page': 1, 'limit': 20, 'has_more': False,
+                'request_id': 'rq_customer',
+            },
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual(17, len(result['structured_content']['headers']))
+        self.assertEqual('客户名称', result['structured_content']['headers'][0]['label'])
+        self.assertEqual('事业部', result['structured_content']['headers'][-1]['label'])
+        self.assertEqual([
+            {'业务类型': '头程业务', '结算模式': '月结'},
+            {'业务类型': '转运业务', '结算模式': '票结'},
+        ], result['structured_content']['rows'][0][13])
+        self.assertEqual({
+            'mode': 'complete',
+            'allow_summary': False,
+            'allow_omit_records': False,
+            'allow_omit_empty_fields': False,
+            'allow_rename_fields': False,
+            'preserve_record_order': True,
+            'required_field_count': 17,
+            'returned_record_count': 1,
+            'required_value_count': 17,
+            'instruction': '最终回复必须逐条展示全部记录及全部17个字段,不得摘要、省略或改写',
+        }, result['structured_content']['display_rules'])
+        self.assertIn('完整客户数据,禁止摘要、合并、隐藏字段或省略空字段。', result['text'])
+        self.assertIn('本页记录数:1;每条字段数:17;应展示字段值总数:17。', result['text'])
+        self.assertIn('展示完整性校验:已提供1条客户的全部17个字段。', result['text'])
+        serialized = json.dumps(result, ensure_ascii=False)
+        self.assertNotIn('customer_name', serialized)
+        self.assertNotIn('不可信名称', serialized)
+
+    def test_customer_list_rejects_malformed_structured_billing_modes(self):
+        keys = list(self.presenter.TABLE_COLUMNS['query_customer_list'])
+        for billing_modes in (
+            '头程业务:月结',
+            [{'business_type': '头程业务'}],
+            [{'business_type': '头程业务', 'settlement_mode': '月结', 'id': 1}],
+            [{'business_type': '', 'settlement_mode': '月结'}],
+        ):
+            with self.subTest(billing_modes=billing_modes):
+                record = dict((key, '') for key in keys)
+                record['billing_modes'] = billing_modes
+                result = self.presenter.present('query_customer_list', {
+                    'code': 'MCP_0000',
+                    'data': {
+                        'summary': '',
+                        'columns': [{'key': key, 'name': key} for key in keys],
+                        'records': [record],
+                        'tips': [],
+                    },
+                    'meta': {'page': 1, 'limit': 20, 'has_more': False},
+                })
+                self.assertTrue(result['is_error'])
+
+        record = dict((key, '') for key in keys)
+        record['billing_modes'] = []
+        record['customer_name'] = ['不允许的嵌套值']
+        result = self.presenter.present('query_customer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '',
+                'columns': [{'key': key, 'name': key} for key in keys],
+                'records': [record],
+                'tips': [],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertTrue(result['is_error'])
+
+    def test_customer_list_fails_closed_on_incomplete_columns_or_bad_meta(self):
+        base = {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '',
+                'columns': [{'key': 'customer_name', 'name': '客户名称'}],
+                'records': [], 'tips': [],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        }
+        self.assertTrue(self.presenter.present('query_customer_list', base)['is_error'])
+        keys = list(self.presenter.TABLE_COLUMNS['query_customer_list'])
+        base['data']['columns'] = [{'key': key, 'name': key} for key in keys]
+        base['meta']['has_more'] = 'false'
+        self.assertTrue(self.presenter.present('query_customer_list', base)['is_error'])
+
+    def test_customer_list_rejects_record_with_internal_field(self):
+        keys = list(self.presenter.TABLE_COLUMNS['query_customer_list'])
+        record = dict((key, '') for key in keys)
+        record['customer_id'] = 9
+        result = self.presenter.present('query_customer_list', {
+            'code': 'MCP_0000',
+            'data': {
+                'summary': '', 'columns': [{'key': key, 'name': key} for key in keys],
+                'records': [record], 'tips': [],
+            },
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertTrue(result['is_error'])
+
+    def test_customer_options_use_safe_shape_and_reject_unknown_fields(self):
+        result = self.presenter.present('list_customer_filter_options', {
+            'code': 'MCP_0000',
+            'data': {'records': [{'value': 7, 'label': '客户甲-C001', 'code': 'C001'}]},
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertFalse(result['is_error'])
+        self.assertEqual([[7, '客户甲-C001', 'C001']], result['structured_content']['rows'])
+        malformed = self.presenter.present('list_customer_filter_options', {
+            'code': 'MCP_0000',
+            'data': {'records': [{
+                'value': 7, 'label': '客户甲', 'code': 'C001', 'admin_id': 99,
+            }]},
+            'meta': {'page': 1, 'limit': 20, 'has_more': False},
+        })
+        self.assertTrue(malformed['is_error'])
+        self.assertNotIn('99', malformed['text'])
+
+    def test_customer_options_reject_malformed_records_and_pagination(self):
+        def present(records, meta):
+            return self.presenter.present('list_customer_filter_options', {
+                'code': 'MCP_0000', 'data': {'records': records}, 'meta': meta,
+            })
+
+        good_meta = {'page': 1, 'limit': 20, 'has_more': False}
+        bad_records = [
+            'record',
+            {'value': 1, 'label': '客户甲'},
+            {'value': True, 'label': '客户甲', 'code': ''},
+            {'value': '1', 'label': '客户甲', 'code': ''},
+            {'value': 0, 'label': '客户甲', 'code': ''},
+            {'value': 1, 'label': 2, 'code': ''},
+            {'value': 1, 'label': ' ', 'code': ''},
+            {'value': 1, 'label': '客户甲', 'code': 3},
+        ]
+        for record in bad_records:
+            with self.subTest(record=record):
+                self.assertTrue(present([record], good_meta)['is_error'])
+        self.assertTrue(present('records', good_meta)['is_error'])
+        self.assertTrue(self.presenter.present('list_customer_filter_options', {
+            'code': 'MCP_0000', 'data': {'records': [], 'extra': 1}, 'meta': good_meta,
+        })['is_error'])
+
+        bad_meta = [
+            None, {},
+            {'page': True, 'limit': 20, 'has_more': False},
+            {'page': '1', 'limit': 20, 'has_more': False},
+            {'page': 0, 'limit': 20, 'has_more': False},
+            {'page': 1, 'limit': True, 'has_more': False},
+            {'page': 1, 'limit': '20', 'has_more': False},
+            {'page': 1, 'limit': 0, 'has_more': False},
+            {'page': 1, 'limit': 101, 'has_more': False},
+            {'page': 1, 'limit': 20, 'has_more': 'false'},
+        ]
+        for meta in bad_meta:
+            with self.subTest(meta=meta):
+                self.assertTrue(present([], meta)['is_error'])
+
+    def test_customer_tools_are_safe_and_total_is_16(self):
+        self.assertTrue(self.presenter.handles(QueryCustomerListTool.name))
+        self.assertTrue(self.presenter.handles(ListCustomerFilterOptionsTool.name))
+        self.assertEqual(16, len(self.presenter.SAFE_TOOLS))
+
     def test_exact_order_uses_labels_and_drops_internal_fields(self):
         result = self.presenter.present(
             'query_order_exact',

+ 2 - 2
tests/test_query_export_task_tool.py

@@ -58,12 +58,12 @@ class QueryExportTaskToolTest(unittest.TestCase):
         with self.assertRaises(RuntimeError):
             QueryExportTaskTool().call(task_ref='mexp_abc')
 
-    def test_local_and_public_registries_contain_same_thirteen_tools(self):
+    def test_local_and_public_registries_contain_same_seventeen_tools(self):
         local = GatewayApp(api_client=RecordingApiClient()).registered_tool_names()
         public = PublicGatewayApp(None, None).registered_tool_names()
 
         self.assertEqual(local, public)
-        self.assertEqual(13, len(local))
+        self.assertEqual(17, len(local))
         self.assertIn('query_export_task', local)
 
     def test_cli_forwards_only_task_reference(self):

+ 74 - 0
tools/list_customer_filter_options.py

@@ -0,0 +1,74 @@
+class ListCustomerFilterOptionsTool:
+    name = 'list_customer_filter_options'
+    route_path = '/mcp/tools/listCustomerFilterOptions'
+    FILTER_TYPES = ('客户名称', '事业部', '商务经理', '客户经理')
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '为query_customer_list和query_customer_payment_followup取得当前员工授权范围内的'
+                '客户名称、事业部、商务经理或'
+                '客户经理筛选值。用户提供名称时必须调用本工具选择,不得猜测内部ID。'
+                '本工具只返回筛选选项,不返回客户业务列表。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'filter_type': {
+                        'type': 'string', 'enum': list(self.FILTER_TYPES),
+                    },
+                    'keyword': {'type': 'string', 'maxLength': 100},
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['filter_type'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, filter_type, keyword='', page=1, limit=20,
+        request_id='rq_list_customer_filter_options',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for list_customer_filter_options'
+            )
+        if not isinstance(filter_type, str):
+            raise ValueError('filter_type is invalid')
+        filter_type = filter_type.strip()
+        if filter_type not in self.FILTER_TYPES:
+            raise ValueError('filter_type is invalid')
+        if not isinstance(keyword, str):
+            raise ValueError('keyword is invalid')
+        keyword = keyword.strip()
+        if len(keyword) > 100:
+            raise ValueError('keyword is invalid')
+        payload = {
+            'filter_type': filter_type,
+            'keyword': keyword,
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _bounded_integer(value, field):
+        if (
+            isinstance(value, bool) or not isinstance(value, int)
+            or value < 1 or value > 100
+        ):
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 75 - 0
tools/query_customer_list.py

@@ -0,0 +1,75 @@
+class QueryCustomerListTool:
+    name = 'query_customer_list'
+    route_path = '/mcp/tools/queryCustomerList'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        positive_id = {'type': 'integer', 'minimum': 1}
+        return {
+            'name': self.name,
+            'description': (
+                '查询当前员工有权查看的启用主客户列表。可按客户名称、事业部、商务经理或'
+                '客户经理筛选;筛选值必须先调用list_customer_filter_options取得,禁止猜测'
+                '内部ID。未指定筛选时查询全部授权客户。公司、员工和权限范围由当前设备会话'
+                '确定,调用方不得覆盖。结算模式按业务类型返回结构化列表,展示时必须完整保留'
+                '每个业务类型和对应结算模式,不得合并或缩写。最终回复必须完整逐条展示17个字段,'
+                '包括空字段,不得摘要、隐藏、重命名或省略任何字段。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'customer_id': dict(positive_id),
+                    'department_id': dict(positive_id),
+                    'sales_id': dict(positive_id),
+                    'merchandiser_id': dict(positive_id),
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': [],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, customer_id=None, department_id=None, sales_id=None,
+        merchandiser_id=None, page=1, limit=20,
+        request_id='rq_query_customer_list',
+    ):
+        if self.api_client is None:
+            raise RuntimeError('api client is required for query_customer_list')
+        payload = {
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        for field, value in (
+            ('customer_id', customer_id),
+            ('department_id', department_id),
+            ('sales_id', sales_id),
+            ('merchandiser_id', merchandiser_id),
+        ):
+            if value is not None:
+                payload[field] = self._positive_integer(value, field)
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _positive_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._positive_integer(value, field)
+        if value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 91 - 0
tools/query_customer_payment_followup.py

@@ -0,0 +1,91 @@
+class QueryCustomerPaymentFollowupTool:
+    name = 'query_customer_payment_followup'
+    route_path = '/mcp/tools/queryCustomerPaymentFollowup'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        positive_id = {'type': 'integer', 'minimum': 1}
+        return {
+            'name': self.name,
+            'description': (
+                '查询当前员工有权查看的客户回款跟进数据,包括已出账未核销、未出账、'
+                '逾期、坏账、收款单未核销、余额、信用额度和按应收月份聚合的未回款汇总。客户、事业部、'
+                '商务经理和客户经理ID必须先调用list_customer_filter_options取得。公司、员工、'
+                '菜单权限和数据范围由当前设备会话确定,调用方不得覆盖。用户要求展开某客户时,'
+                '使用同一客户ID调用query_customer_unverified_bill_details并跟随分页。最终回复必须逐条完整'
+                '展示所有客户、全部字段和月份汇总,不得摘要、隐藏、重命名或省略。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'customer_id': dict(positive_id),
+                    'department_id': dict(positive_id),
+                    'sales_id': dict(positive_id),
+                    'merchandiser_id': dict(positive_id),
+                    'has_unverified_receivable_only': {
+                        'type': 'boolean',
+                        'default': True,
+                    },
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': [],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, customer_id=None, department_id=None, sales_id=None,
+        merchandiser_id=None, has_unverified_receivable_only=True,
+        page=1, limit=20, request_id='rq_query_customer_payment_followup',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_customer_payment_followup'
+            )
+        payload = {
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+            'has_unverified_receivable_only': self._strict_boolean(
+                has_unverified_receivable_only,
+                'has_unverified_receivable_only',
+            ),
+        }
+        for field, value in (
+            ('customer_id', customer_id),
+            ('department_id', department_id),
+            ('sales_id', sales_id),
+            ('merchandiser_id', merchandiser_id),
+        ):
+            if value is not None:
+                payload[field] = self._positive_integer(value, field)
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _positive_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._positive_integer(value, field)
+        if value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @staticmethod
+    def _strict_boolean(value, field):
+        if not isinstance(value, bool):
+            raise ValueError('{0} is invalid'.format(field))
+        return value

+ 62 - 0
tools/query_customer_unverified_bill_details.py

@@ -0,0 +1,62 @@
+class QueryCustomerUnverifiedBillDetailsTool:
+    name = 'query_customer_unverified_bill_details'
+    route_path = '/mcp/tools/queryCustomerUnverifiedBillDetails'
+
+    def __init__(self, api_client=None):
+        self.api_client = api_client
+
+    def metadata(self):
+        return {
+            'name': self.name,
+            'description': (
+                '分页查询当前员工有权查看的单个客户未核销账单明细。customer_id必须先从'
+                'list_customer_filter_options取得;主回款跟进中的月份汇总需要展开时再调用本工具。'
+                '公司、员工、菜单权限和数据范围由当前设备会话确定,调用方不得覆盖。'
+            ),
+            'input_schema': {
+                'type': 'object',
+                'properties': {
+                    'customer_id': {'type': 'integer', 'minimum': 1},
+                    'page': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 1,
+                    },
+                    'limit': {
+                        'type': 'integer', 'minimum': 1, 'maximum': 100,
+                        'default': 20,
+                    },
+                },
+                'required': ['customer_id'],
+                'additionalProperties': False,
+            },
+        }
+
+    def call(
+        self, customer_id, page=1, limit=20,
+        request_id='rq_query_customer_unverified_bill_details',
+    ):
+        if self.api_client is None:
+            raise RuntimeError(
+                'api client is required for query_customer_unverified_bill_details'
+            )
+        payload = {
+            'customer_id': self._positive_integer(customer_id, 'customer_id'),
+            'page': self._bounded_integer(page, 'page'),
+            'limit': self._bounded_integer(limit, 'limit'),
+        }
+        return self.api_client.call_tool(
+            self.name, self.route_path, payload, request_id
+        )
+
+    @staticmethod
+    def _positive_integer(value, field):
+        if isinstance(value, bool) or not isinstance(value, int) or value < 1:
+            raise ValueError('{0} is invalid'.format(field))
+        return value
+
+    @classmethod
+    def _bounded_integer(cls, value, field):
+        value = cls._positive_integer(value, field)
+        if value > 100:
+            raise ValueError('{0} is invalid'.format(field))
+        return value