| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481 |
- import copy
- import importlib.util
- import io
- import json
- import math
- import os
- import unittest
- from app import GatewayApp
- from public_gateway import PublicGatewayApp
- from services.output_presenter import OutputPresenter
- ROOT = os.path.dirname(os.path.dirname(__file__))
- class RecordingApiClient:
- def __init__(self):
- self.calls = []
- def list_enabled_tools(self, request_id=''):
- return {
- 'code': 'MCP_0000',
- 'data': {
- 'tool_codes': [
- 'query_receivable_cost_list',
- 'list_receivable_cost_filter_options',
- ],
- },
- }
- def call_tool(self, tool_code, route_path, payload, request_id):
- self.calls.append((tool_code, route_path, payload, request_id))
- return {'code': 'MCP_0000', 'data': {}}
- class ToolLoaderMixin:
- def load_tool(self, filename, class_name):
- path = os.path.join(ROOT, 'tools', filename)
- self.assertTrue(os.path.isfile(path), filename + ' is not implemented')
- spec = importlib.util.spec_from_file_location(class_name, path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- return getattr(module, class_name)
- class ReceivableCostListToolTest(ToolLoaderMixin, unittest.TestCase):
- NUMBER_FIELDS = (
- 'reference_numbers',
- 'tracking_numbers',
- 'order_numbers',
- 'bill_numbers',
- )
- def tool_class(self):
- return self.load_tool(
- 'query_receivable_cost_list.py',
- 'QueryReceivableCostListTool',
- )
- def test_schema_is_closed_exact_and_zero_inference(self):
- metadata = self.tool_class()().metadata()
- schema = metadata['input_schema']
- self.assertEqual('query_receivable_cost_list', metadata['name'])
- self.assertFalse(schema['additionalProperties'])
- self.assertNotIn('number', schema['properties'])
- self.assertEqual({
- 'reference_numbers', 'tracking_numbers', 'order_numbers',
- 'bill_numbers', 'business_date_start', 'business_date_end',
- 'customer_id', 'sub_customer_id', 'billing_status',
- 'verification_status', 'document_type', 'cost_type_id',
- 'page', 'limit',
- }, set(schema['properties']))
- for field in self.NUMBER_FIELDS:
- definition = schema['properties'][field]
- self.assertEqual('array', definition['type'])
- self.assertEqual(1, definition['minItems'])
- self.assertEqual(200, definition['maxItems'])
- self.assertEqual({
- 'type': 'string', 'minLength': 1, 'maxLength': 100,
- 'pattern': '.*\\S.*',
- }, definition['items'])
- self.assertEqual([0, 1], schema['properties']['billing_status']['enum'])
- self.assertEqual(
- [-1, 0, 1],
- schema['properties']['verification_status']['enum'],
- )
- self.assertEqual(0, schema['properties']['document_type']['minimum'])
- self.assertEqual(1, schema['properties']['cost_type_id']['minimum'])
- self.assertIn('禁止根据格式猜测', metadata['description'])
- self.assertIn(
- 'list_receivable_cost_filter_options',
- metadata['description'],
- )
- def test_call_forwards_exact_filters_and_date_pair(self):
- tool_class = self.tool_class()
- client = RecordingApiClient()
- result = tool_class(client).call(
- reference_numbers=[' REF-1 '],
- tracking_numbers=['TRACK-1'],
- order_numbers=['ORDER-1'],
- bill_numbers=['BILL-1'],
- business_date_start='2026-07-01',
- business_date_end='2026-07-31',
- customer_id=7,
- sub_customer_id=9,
- billing_status=0,
- verification_status=-1,
- document_type=0,
- cost_type_id=12,
- page=2,
- limit=30,
- request_id='rq_receivable',
- )
- self.assertEqual('MCP_0000', result['code'])
- self.assertEqual((
- 'query_receivable_cost_list',
- '/mcp/tools/queryReceivableCostList',
- {
- 'reference_numbers': ['REF-1'],
- 'tracking_numbers': ['TRACK-1'],
- 'order_numbers': ['ORDER-1'],
- 'bill_numbers': ['BILL-1'],
- 'business_date_start': '2026-07-01',
- 'business_date_end': '2026-07-31',
- 'customer_id': 7,
- 'sub_customer_id': 9,
- 'billing_status': 0,
- 'verification_status': -1,
- 'document_type': 0,
- 'cost_type_id': 12,
- 'page': 2,
- 'limit': 30,
- },
- 'rq_receivable',
- ), client.calls[-1])
- def test_call_enforces_cross_field_and_strict_boundaries(self):
- tool = self.tool_class()(RecordingApiClient())
- invalid = [
- {},
- {'business_date_start': '2026-07-01'},
- {'business_date_end': '2026-07-31'},
- {
- 'business_date_start': '2026-07-01',
- 'business_date_end': '2026-08-01',
- },
- {'reference_numbers': []},
- {'reference_numbers': [7]},
- {'reference_numbers': [' ']},
- {'reference_numbers': ['x' * 101]},
- {'reference_numbers': ['x'] * 201},
- {
- 'reference_numbers': ['x'] * 101,
- 'tracking_numbers': ['y'] * 100,
- },
- {'reference_numbers': ['x'], 'sub_customer_id': 9},
- {'reference_numbers': ['x'], 'customer_id': True},
- {'reference_numbers': ['x'], 'billing_status': 2},
- {'reference_numbers': ['x'], 'verification_status': 2},
- {'reference_numbers': ['x'], 'document_type': -1},
- {'reference_numbers': ['x'], 'cost_type_id': 0},
- {'reference_numbers': ['x'], 'page': 101},
- {'reference_numbers': ['x'], 'limit': 1.5},
- {
- 'reference_numbers': ['x'],
- 'business_date_start': 20260701,
- 'business_date_end': '2026-07-31',
- },
- {
- 'reference_numbers': ['x'],
- 'business_date_start': 'not-a-date',
- 'business_date_end': '2026-07-31',
- },
- {
- 'reference_numbers': ['x'],
- 'business_date_start': '20260701',
- 'business_date_end': '2026-07-31',
- },
- ]
- for arguments in invalid:
- with self.subTest(arguments=arguments):
- with self.assertRaises((TypeError, ValueError)):
- tool.call(**arguments)
- self.assertEqual([], tool.api_client.calls)
- with self.assertRaisesRegex(RuntimeError, 'api client is required'):
- self.tool_class()().call(reference_numbers=['REF-1'])
- class ReceivableCostFilterOptionsToolTest(ToolLoaderMixin, unittest.TestCase):
- FILTER_TYPES = ['主客户', '子客户', '出账状态', '核销状态', '单据类型', '费用项']
- def tool_class(self):
- return self.load_tool(
- 'list_receivable_cost_filter_options.py',
- 'ListReceivableCostFilterOptionsTool',
- )
- def test_schema_has_exact_types_and_customer_dependency(self):
- metadata = self.tool_class()().metadata()
- schema = metadata['input_schema']
- self.assertFalse(schema['additionalProperties'])
- self.assertEqual(['filter_type'], schema['required'])
- self.assertEqual(
- self.FILTER_TYPES,
- schema['properties']['filter_type']['enum'],
- )
- self.assertEqual(
- {'type': 'integer', 'minimum': 1},
- schema['properties']['customer_id'],
- )
- self.assertIn('子客户', json.dumps(schema, ensure_ascii=False))
- self.assertIn('禁止猜测', metadata['description'])
- def test_call_forwards_and_enforces_customer_linkage(self):
- tool_class = self.tool_class()
- client = RecordingApiClient()
- tool = tool_class(client)
- tool.call(
- filter_type=' 子客户 ',
- customer_id=7,
- keyword=' 子 ',
- page=2,
- limit=30,
- request_id='rq_filters',
- )
- self.assertEqual((
- 'list_receivable_cost_filter_options',
- '/mcp/tools/listReceivableCostFilterOptions',
- {
- 'filter_type': '子客户',
- 'customer_id': 7,
- 'keyword': '子',
- 'page': 2,
- 'limit': 30,
- },
- 'rq_filters',
- ), client.calls[-1])
- tool.call(filter_type='主客户')
- for arguments in (
- {'filter_type': '子客户'},
- {'filter_type': 7},
- {'filter_type': '子客户', 'customer_id': 0},
- {'filter_type': '主客户', 'customer_id': 7},
- {'filter_type': '其他'},
- {'filter_type': '主客户', 'keyword': 7},
- {'filter_type': '主客户', 'keyword': 'x' * 101},
- {'filter_type': '主客户', 'page': 0},
- {'filter_type': '主客户', 'limit': True},
- {'filter_type': '主客户', 'limit': 101},
- ):
- with self.subTest(arguments=arguments):
- with self.assertRaises((TypeError, ValueError)):
- tool.call(**arguments)
- self.assertEqual(2, len(client.calls))
- with self.assertRaisesRegex(RuntimeError, 'api client is required'):
- tool_class().call('主客户')
- class ReceivableCostIntegrationTest(unittest.TestCase):
- def test_registries_are_identical_ordered_and_counts_are_current(self):
- local = GatewayApp().registered_tool_names()
- public = PublicGatewayApp(None, None).registered_tool_names()
- self.assertEqual(local, public)
- self.assertEqual(25, len(local))
- self.assertEqual(24, len(OutputPresenter.SAFE_TOOLS))
- self.assertEqual(
- (
- 'query_receivable_cost_list',
- 'list_receivable_cost_filter_options',
- ),
- tuple(
- name for name in local
- if name in {
- 'query_receivable_cost_list',
- 'list_receivable_cost_filter_options',
- }
- ),
- )
- def test_cli_forwards_both_tools(self):
- client = RecordingApiClient()
- app = GatewayApp(api_client=client)
- app.run_cli([
- 'call', '--tool', 'query_receivable_cost_list',
- '--reference-numbers', ' REF-1,REF-2 ',
- '--bill-numbers', ' BILL-1 ',
- '--business-date-start', '2026-07-01',
- '--business-date-end', '2026-07-31',
- '--customer-id', '7', '--sub-customer-id', '9',
- '--billing-status', '0', '--verification-status', '-1',
- '--document-type', '0', '--cost-type-id', '12',
- '--page', '2', '--limit', '30',
- ], stdout=io.StringIO())
- self.assertEqual({
- 'reference_numbers': ['REF-1', 'REF-2'],
- 'bill_numbers': ['BILL-1'],
- 'business_date_start': '2026-07-01',
- 'business_date_end': '2026-07-31',
- 'customer_id': 7,
- 'sub_customer_id': 9,
- 'billing_status': 0,
- 'verification_status': -1,
- 'document_type': 0,
- 'cost_type_id': 12,
- 'page': 2,
- 'limit': 30,
- }, client.calls[-1][2])
- app.run_cli([
- 'call', '--tool', 'query_receivable_cost_list',
- '--order-numbers', 'ORDER-1',
- ], stdout=io.StringIO())
- self.assertEqual({
- 'order_numbers': ['ORDER-1'], 'page': 1, 'limit': 20,
- }, client.calls[-1][2])
- app.run_cli([
- 'call', '--tool', 'list_receivable_cost_filter_options',
- '--filter-type', '子客户', '--customer-id', '7',
- '--keyword', '子',
- ], stdout=io.StringIO())
- self.assertEqual({
- 'filter_type': '子客户', 'customer_id': 7,
- 'keyword': '子', 'page': 1, 'limit': 20,
- }, client.calls[-1][2])
- app.run_cli([
- 'call', '--tool', 'list_receivable_cost_filter_options',
- '--filter-type', '主客户',
- ], stdout=io.StringIO())
- self.assertEqual({
- 'filter_type': '主客户', 'keyword': '',
- 'page': 1, 'limit': 20,
- }, client.calls[-1][2])
- with self.assertRaisesRegex(ValueError, '--filter-type is required'):
- app.run_cli([
- 'call', '--tool', 'list_receivable_cost_filter_options',
- ], stdout=io.StringIO())
- class ReceivableCostPresenterTest(unittest.TestCase):
- KEYS = [
- 'main_customer_name', 'sub_customer_name', 'customer_attribute',
- 'department_name', 'document_type', 'warehouse_name', 'business_number',
- 'reference_number', 'cost_name', 'original_amount', 'total_amount',
- 'billed_amount', 'unbilled_amount', 'bill_number', 'verification_status',
- 'receipt_number', 'receipt_date', 'sales_name', 'merchandiser_name',
- 'drainage_user_name', 'first_leg_order_status', 'settlement_mode',
- 'cost_occurred_at', 'business_occurred_at',
- ]
- NAMES = [
- '主客户', '子客户', '客户属性', '事业部', '单据类型', '仓库', '业务单号',
- '参考号', '费用项', '原币金额', '总金额', '已出账金额', '未出账金额',
- '账单编号', '核销状态', '收款水单号', '收款日期', '商务经理',
- '客户经理', '引流人', '头程订单状态', '结算模式', '费用发生时间',
- '业务发生时间',
- ]
- AMOUNT_KEYS = {
- 'original_amount', 'total_amount', 'billed_amount', 'unbilled_amount',
- }
- def payload(self):
- record = {key: '值' for key in self.KEYS}
- record.update({
- 'original_amount': {'amount': 10.25, 'currency': 'USD'},
- 'total_amount': {'amount': 70.5, 'currency': 'CNY'},
- 'billed_amount': {'amount': 20.0, 'currency': 'CNY'},
- 'unbilled_amount': {'amount': 50.5, 'currency': 'CNY'},
- })
- return {
- 'code': 'MCP_0000',
- 'data': {
- 'columns': [
- {'key': key, 'name': name}
- for key, name in zip(self.KEYS, self.NAMES)
- ],
- 'records': [record],
- },
- 'meta': {
- 'page': 1, 'limit': 20, 'has_more': False,
- 'request_id': 'rq_receivable',
- },
- }
- def test_query_presenter_requires_exact_contract(self):
- result = OutputPresenter().present(
- 'query_receivable_cost_list',
- self.payload(),
- )
- self.assertFalse(result['is_error'])
- self.assertEqual(
- [{'label': name} for name in self.NAMES],
- result['structured_content']['headers'],
- )
- self.assertEqual(24, len(result['structured_content']['rows'][0]))
- self.assertEqual(
- {'page': 1, 'limit': 20, 'has_more': False},
- result['structured_content']['pagination'],
- )
- def test_query_presenter_fails_closed_on_malformed_contract(self):
- cases = []
- for mutation in (
- lambda value: value['data']['columns'].reverse(),
- lambda value: value['data']['columns'][0].update({'secret': True}),
- lambda value: value['data']['columns'][0].update({'name': '错误名称'}),
- lambda value: value['data']['records'][0].update({'secret': 'hidden'}),
- lambda value: value['data']['records'][0].pop('cost_name'),
- lambda value: value['data']['records'][0].update({'cost_name': []}),
- lambda value: value['data'].update({'secret': True}),
- lambda value: value['meta'].update({'total': 1}),
- ):
- payload = self.payload()
- mutation(payload)
- cases.append(payload)
- for key in self.AMOUNT_KEYS:
- for amount in (True, '10', float('inf'), float('-inf'), float('nan')):
- payload = self.payload()
- payload['data']['records'][0][key]['amount'] = amount
- cases.append(payload)
- for malformed in (
- {'amount': 1},
- {'amount': 1, 'currency': 'CNY', 'secret': True},
- {'amount': 1, 'currency': 7},
- ):
- payload = self.payload()
- payload['data']['records'][0][key] = malformed
- cases.append(payload)
- for meta in (
- {'page': 0, 'limit': 20, 'has_more': False},
- {'page': 1, 'limit': 101, 'has_more': False},
- {'page': 1, 'limit': 20, 'has_more': 0},
- ):
- payload = self.payload()
- payload['meta'] = meta
- cases.append(payload)
- presenter = OutputPresenter()
- for payload in cases:
- with self.subTest(payload=payload):
- self.assertTrue(presenter.present(
- 'query_receivable_cost_list', payload
- )['is_error'])
- def test_filter_presenter_preserves_zero_and_negative_values(self):
- payload = {
- 'code': 'MCP_0000',
- 'data': {
- 'records': [
- {'value': 0, 'label': '未出账', 'code': 'unbilled'},
- {'value': -1, 'label': '未核销', 'code': 'unverified'},
- ],
- },
- 'meta': {'page': 1, 'limit': 20, 'has_more': False},
- }
- result = OutputPresenter().present(
- 'list_receivable_cost_filter_options',
- payload,
- )
- self.assertFalse(result['is_error'])
- self.assertEqual(
- [[0, '未出账', 'unbilled'], [-1, '未核销', 'unverified']],
- result['structured_content']['rows'],
- )
- for mutation in (
- lambda value: value['data'].update({'secret': True}),
- lambda value: value['data']['records'][0].update({'secret': True}),
- lambda value: value['data']['records'][0].update({'value': True}),
- lambda value: value['data']['records'][0].update({'label': ''}),
- lambda value: value['data']['records'][0].update({'code': 1}),
- lambda value: value['meta'].update({'total': 2}),
- ):
- malformed = copy.deepcopy(payload)
- mutation(malformed)
- self.assertTrue(OutputPresenter().present(
- 'list_receivable_cost_filter_options',
- malformed,
- )['is_error'])
- if __name__ == '__main__':
- unittest.main()
|