test_receivable_cost_list_tools.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import copy
  2. import importlib.util
  3. import io
  4. import json
  5. import math
  6. import os
  7. import unittest
  8. from app import GatewayApp
  9. from public_gateway import PublicGatewayApp
  10. from services.output_presenter import OutputPresenter
  11. ROOT = os.path.dirname(os.path.dirname(__file__))
  12. class RecordingApiClient:
  13. def __init__(self):
  14. self.calls = []
  15. def list_enabled_tools(self, request_id=''):
  16. return {
  17. 'code': 'MCP_0000',
  18. 'data': {
  19. 'tool_codes': [
  20. 'query_receivable_cost_list',
  21. 'list_receivable_cost_filter_options',
  22. ],
  23. },
  24. }
  25. def call_tool(self, tool_code, route_path, payload, request_id):
  26. self.calls.append((tool_code, route_path, payload, request_id))
  27. return {'code': 'MCP_0000', 'data': {}}
  28. class ToolLoaderMixin:
  29. def load_tool(self, filename, class_name):
  30. path = os.path.join(ROOT, 'tools', filename)
  31. self.assertTrue(os.path.isfile(path), filename + ' is not implemented')
  32. spec = importlib.util.spec_from_file_location(class_name, path)
  33. module = importlib.util.module_from_spec(spec)
  34. spec.loader.exec_module(module)
  35. return getattr(module, class_name)
  36. class ReceivableCostListToolTest(ToolLoaderMixin, unittest.TestCase):
  37. NUMBER_FIELDS = (
  38. 'reference_numbers',
  39. 'tracking_numbers',
  40. 'order_numbers',
  41. 'bill_numbers',
  42. )
  43. def tool_class(self):
  44. return self.load_tool(
  45. 'query_receivable_cost_list.py',
  46. 'QueryReceivableCostListTool',
  47. )
  48. def test_schema_is_closed_exact_and_zero_inference(self):
  49. metadata = self.tool_class()().metadata()
  50. schema = metadata['input_schema']
  51. self.assertEqual('query_receivable_cost_list', metadata['name'])
  52. self.assertFalse(schema['additionalProperties'])
  53. self.assertNotIn('number', schema['properties'])
  54. self.assertEqual({
  55. 'reference_numbers', 'tracking_numbers', 'order_numbers',
  56. 'bill_numbers', 'business_date_start', 'business_date_end',
  57. 'customer_id', 'sub_customer_id', 'billing_status',
  58. 'verification_status', 'document_type', 'cost_type_id',
  59. 'page', 'limit',
  60. }, set(schema['properties']))
  61. for field in self.NUMBER_FIELDS:
  62. definition = schema['properties'][field]
  63. self.assertEqual('array', definition['type'])
  64. self.assertEqual(1, definition['minItems'])
  65. self.assertEqual(200, definition['maxItems'])
  66. self.assertEqual({
  67. 'type': 'string', 'minLength': 1, 'maxLength': 100,
  68. 'pattern': '.*\\S.*',
  69. }, definition['items'])
  70. self.assertEqual([0, 1], schema['properties']['billing_status']['enum'])
  71. self.assertEqual(
  72. [-1, 0, 1],
  73. schema['properties']['verification_status']['enum'],
  74. )
  75. self.assertEqual(0, schema['properties']['document_type']['minimum'])
  76. self.assertEqual(1, schema['properties']['cost_type_id']['minimum'])
  77. self.assertIn('禁止根据格式猜测', metadata['description'])
  78. self.assertIn(
  79. 'list_receivable_cost_filter_options',
  80. metadata['description'],
  81. )
  82. def test_call_forwards_exact_filters_and_date_pair(self):
  83. tool_class = self.tool_class()
  84. client = RecordingApiClient()
  85. result = tool_class(client).call(
  86. reference_numbers=[' REF-1 '],
  87. tracking_numbers=['TRACK-1'],
  88. order_numbers=['ORDER-1'],
  89. bill_numbers=['BILL-1'],
  90. business_date_start='2026-07-01',
  91. business_date_end='2026-07-31',
  92. customer_id=7,
  93. sub_customer_id=9,
  94. billing_status=0,
  95. verification_status=-1,
  96. document_type=0,
  97. cost_type_id=12,
  98. page=2,
  99. limit=30,
  100. request_id='rq_receivable',
  101. )
  102. self.assertEqual('MCP_0000', result['code'])
  103. self.assertEqual((
  104. 'query_receivable_cost_list',
  105. '/mcp/tools/queryReceivableCostList',
  106. {
  107. 'reference_numbers': ['REF-1'],
  108. 'tracking_numbers': ['TRACK-1'],
  109. 'order_numbers': ['ORDER-1'],
  110. 'bill_numbers': ['BILL-1'],
  111. 'business_date_start': '2026-07-01',
  112. 'business_date_end': '2026-07-31',
  113. 'customer_id': 7,
  114. 'sub_customer_id': 9,
  115. 'billing_status': 0,
  116. 'verification_status': -1,
  117. 'document_type': 0,
  118. 'cost_type_id': 12,
  119. 'page': 2,
  120. 'limit': 30,
  121. },
  122. 'rq_receivable',
  123. ), client.calls[-1])
  124. def test_call_enforces_cross_field_and_strict_boundaries(self):
  125. tool = self.tool_class()(RecordingApiClient())
  126. invalid = [
  127. {},
  128. {'business_date_start': '2026-07-01'},
  129. {'business_date_end': '2026-07-31'},
  130. {
  131. 'business_date_start': '2026-07-01',
  132. 'business_date_end': '2026-08-01',
  133. },
  134. {'reference_numbers': []},
  135. {'reference_numbers': [7]},
  136. {'reference_numbers': [' ']},
  137. {'reference_numbers': ['x' * 101]},
  138. {'reference_numbers': ['x'] * 201},
  139. {
  140. 'reference_numbers': ['x'] * 101,
  141. 'tracking_numbers': ['y'] * 100,
  142. },
  143. {'reference_numbers': ['x'], 'sub_customer_id': 9},
  144. {'reference_numbers': ['x'], 'customer_id': True},
  145. {'reference_numbers': ['x'], 'billing_status': 2},
  146. {'reference_numbers': ['x'], 'verification_status': 2},
  147. {'reference_numbers': ['x'], 'document_type': -1},
  148. {'reference_numbers': ['x'], 'cost_type_id': 0},
  149. {'reference_numbers': ['x'], 'page': 101},
  150. {'reference_numbers': ['x'], 'limit': 1.5},
  151. {
  152. 'reference_numbers': ['x'],
  153. 'business_date_start': 20260701,
  154. 'business_date_end': '2026-07-31',
  155. },
  156. {
  157. 'reference_numbers': ['x'],
  158. 'business_date_start': 'not-a-date',
  159. 'business_date_end': '2026-07-31',
  160. },
  161. {
  162. 'reference_numbers': ['x'],
  163. 'business_date_start': '20260701',
  164. 'business_date_end': '2026-07-31',
  165. },
  166. ]
  167. for arguments in invalid:
  168. with self.subTest(arguments=arguments):
  169. with self.assertRaises((TypeError, ValueError)):
  170. tool.call(**arguments)
  171. self.assertEqual([], tool.api_client.calls)
  172. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  173. self.tool_class()().call(reference_numbers=['REF-1'])
  174. class ReceivableCostFilterOptionsToolTest(ToolLoaderMixin, unittest.TestCase):
  175. FILTER_TYPES = ['主客户', '子客户', '出账状态', '核销状态', '单据类型', '费用项']
  176. def tool_class(self):
  177. return self.load_tool(
  178. 'list_receivable_cost_filter_options.py',
  179. 'ListReceivableCostFilterOptionsTool',
  180. )
  181. def test_schema_has_exact_types_and_customer_dependency(self):
  182. metadata = self.tool_class()().metadata()
  183. schema = metadata['input_schema']
  184. self.assertFalse(schema['additionalProperties'])
  185. self.assertEqual(['filter_type'], schema['required'])
  186. self.assertEqual(
  187. self.FILTER_TYPES,
  188. schema['properties']['filter_type']['enum'],
  189. )
  190. self.assertEqual(
  191. {'type': 'integer', 'minimum': 1},
  192. schema['properties']['customer_id'],
  193. )
  194. self.assertIn('子客户', json.dumps(schema, ensure_ascii=False))
  195. self.assertIn('禁止猜测', metadata['description'])
  196. def test_call_forwards_and_enforces_customer_linkage(self):
  197. tool_class = self.tool_class()
  198. client = RecordingApiClient()
  199. tool = tool_class(client)
  200. tool.call(
  201. filter_type=' 子客户 ',
  202. customer_id=7,
  203. keyword=' 子 ',
  204. page=2,
  205. limit=30,
  206. request_id='rq_filters',
  207. )
  208. self.assertEqual((
  209. 'list_receivable_cost_filter_options',
  210. '/mcp/tools/listReceivableCostFilterOptions',
  211. {
  212. 'filter_type': '子客户',
  213. 'customer_id': 7,
  214. 'keyword': '子',
  215. 'page': 2,
  216. 'limit': 30,
  217. },
  218. 'rq_filters',
  219. ), client.calls[-1])
  220. tool.call(filter_type='主客户')
  221. for arguments in (
  222. {'filter_type': '子客户'},
  223. {'filter_type': 7},
  224. {'filter_type': '子客户', 'customer_id': 0},
  225. {'filter_type': '主客户', 'customer_id': 7},
  226. {'filter_type': '其他'},
  227. {'filter_type': '主客户', 'keyword': 7},
  228. {'filter_type': '主客户', 'keyword': 'x' * 101},
  229. {'filter_type': '主客户', 'page': 0},
  230. {'filter_type': '主客户', 'limit': True},
  231. {'filter_type': '主客户', 'limit': 101},
  232. ):
  233. with self.subTest(arguments=arguments):
  234. with self.assertRaises((TypeError, ValueError)):
  235. tool.call(**arguments)
  236. self.assertEqual(2, len(client.calls))
  237. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  238. tool_class().call('主客户')
  239. class ReceivableCostIntegrationTest(unittest.TestCase):
  240. def test_registries_are_identical_ordered_and_counts_are_current(self):
  241. local = GatewayApp().registered_tool_names()
  242. public = PublicGatewayApp(None, None).registered_tool_names()
  243. self.assertEqual(local, public)
  244. self.assertEqual(22, len(local))
  245. self.assertEqual(21, len(OutputPresenter.SAFE_TOOLS))
  246. self.assertEqual(
  247. (
  248. 'query_receivable_cost_list',
  249. 'list_receivable_cost_filter_options',
  250. ),
  251. tuple(
  252. name for name in local
  253. if name in {
  254. 'query_receivable_cost_list',
  255. 'list_receivable_cost_filter_options',
  256. }
  257. ),
  258. )
  259. def test_cli_forwards_both_tools(self):
  260. client = RecordingApiClient()
  261. app = GatewayApp(api_client=client)
  262. app.run_cli([
  263. 'call', '--tool', 'query_receivable_cost_list',
  264. '--reference-numbers', ' REF-1,REF-2 ',
  265. '--bill-numbers', ' BILL-1 ',
  266. '--business-date-start', '2026-07-01',
  267. '--business-date-end', '2026-07-31',
  268. '--customer-id', '7', '--sub-customer-id', '9',
  269. '--billing-status', '0', '--verification-status', '-1',
  270. '--document-type', '0', '--cost-type-id', '12',
  271. '--page', '2', '--limit', '30',
  272. ], stdout=io.StringIO())
  273. self.assertEqual({
  274. 'reference_numbers': ['REF-1', 'REF-2'],
  275. 'bill_numbers': ['BILL-1'],
  276. 'business_date_start': '2026-07-01',
  277. 'business_date_end': '2026-07-31',
  278. 'customer_id': 7,
  279. 'sub_customer_id': 9,
  280. 'billing_status': 0,
  281. 'verification_status': -1,
  282. 'document_type': 0,
  283. 'cost_type_id': 12,
  284. 'page': 2,
  285. 'limit': 30,
  286. }, client.calls[-1][2])
  287. app.run_cli([
  288. 'call', '--tool', 'query_receivable_cost_list',
  289. '--order-numbers', 'ORDER-1',
  290. ], stdout=io.StringIO())
  291. self.assertEqual({
  292. 'order_numbers': ['ORDER-1'], 'page': 1, 'limit': 20,
  293. }, client.calls[-1][2])
  294. app.run_cli([
  295. 'call', '--tool', 'list_receivable_cost_filter_options',
  296. '--filter-type', '子客户', '--customer-id', '7',
  297. '--keyword', '子',
  298. ], stdout=io.StringIO())
  299. self.assertEqual({
  300. 'filter_type': '子客户', 'customer_id': 7,
  301. 'keyword': '子', 'page': 1, 'limit': 20,
  302. }, client.calls[-1][2])
  303. app.run_cli([
  304. 'call', '--tool', 'list_receivable_cost_filter_options',
  305. '--filter-type', '主客户',
  306. ], stdout=io.StringIO())
  307. self.assertEqual({
  308. 'filter_type': '主客户', 'keyword': '',
  309. 'page': 1, 'limit': 20,
  310. }, client.calls[-1][2])
  311. with self.assertRaisesRegex(ValueError, '--filter-type is required'):
  312. app.run_cli([
  313. 'call', '--tool', 'list_receivable_cost_filter_options',
  314. ], stdout=io.StringIO())
  315. class ReceivableCostPresenterTest(unittest.TestCase):
  316. KEYS = [
  317. 'main_customer_name', 'sub_customer_name', 'customer_attribute',
  318. 'department_name', 'document_type', 'warehouse_name', 'business_number',
  319. 'reference_number', 'cost_name', 'original_amount', 'total_amount',
  320. 'billed_amount', 'unbilled_amount', 'bill_number', 'verification_status',
  321. 'receipt_number', 'receipt_date', 'sales_name', 'merchandiser_name',
  322. 'drainage_user_name', 'first_leg_order_status', 'settlement_mode',
  323. 'cost_occurred_at', 'business_occurred_at',
  324. ]
  325. NAMES = [
  326. '主客户', '子客户', '客户属性', '事业部', '单据类型', '仓库', '业务单号',
  327. '参考号', '费用项', '原币金额', '总金额', '已出账金额', '未出账金额',
  328. '账单编号', '核销状态', '收款水单号', '收款日期', '商务经理',
  329. '客户经理', '引流人', '头程订单状态', '结算模式', '费用发生时间',
  330. '业务发生时间',
  331. ]
  332. AMOUNT_KEYS = {
  333. 'original_amount', 'total_amount', 'billed_amount', 'unbilled_amount',
  334. }
  335. def payload(self):
  336. record = {key: '值' for key in self.KEYS}
  337. record.update({
  338. 'original_amount': {'amount': 10.25, 'currency': 'USD'},
  339. 'total_amount': {'amount': 70.5, 'currency': 'CNY'},
  340. 'billed_amount': {'amount': 20.0, 'currency': 'CNY'},
  341. 'unbilled_amount': {'amount': 50.5, 'currency': 'CNY'},
  342. })
  343. return {
  344. 'code': 'MCP_0000',
  345. 'data': {
  346. 'columns': [
  347. {'key': key, 'name': name}
  348. for key, name in zip(self.KEYS, self.NAMES)
  349. ],
  350. 'records': [record],
  351. },
  352. 'meta': {
  353. 'page': 1, 'limit': 20, 'has_more': False,
  354. 'request_id': 'rq_receivable',
  355. },
  356. }
  357. def test_query_presenter_requires_exact_contract(self):
  358. result = OutputPresenter().present(
  359. 'query_receivable_cost_list',
  360. self.payload(),
  361. )
  362. self.assertFalse(result['is_error'])
  363. self.assertEqual(
  364. [{'label': name} for name in self.NAMES],
  365. result['structured_content']['headers'],
  366. )
  367. self.assertEqual(24, len(result['structured_content']['rows'][0]))
  368. self.assertEqual(
  369. {'page': 1, 'limit': 20, 'has_more': False},
  370. result['structured_content']['pagination'],
  371. )
  372. def test_query_presenter_fails_closed_on_malformed_contract(self):
  373. cases = []
  374. for mutation in (
  375. lambda value: value['data']['columns'].reverse(),
  376. lambda value: value['data']['columns'][0].update({'secret': True}),
  377. lambda value: value['data']['columns'][0].update({'name': '错误名称'}),
  378. lambda value: value['data']['records'][0].update({'secret': 'hidden'}),
  379. lambda value: value['data']['records'][0].pop('cost_name'),
  380. lambda value: value['data']['records'][0].update({'cost_name': []}),
  381. lambda value: value['data'].update({'secret': True}),
  382. lambda value: value['meta'].update({'total': 1}),
  383. ):
  384. payload = self.payload()
  385. mutation(payload)
  386. cases.append(payload)
  387. for key in self.AMOUNT_KEYS:
  388. for amount in (True, '10', float('inf'), float('-inf'), float('nan')):
  389. payload = self.payload()
  390. payload['data']['records'][0][key]['amount'] = amount
  391. cases.append(payload)
  392. for malformed in (
  393. {'amount': 1},
  394. {'amount': 1, 'currency': 'CNY', 'secret': True},
  395. {'amount': 1, 'currency': 7},
  396. ):
  397. payload = self.payload()
  398. payload['data']['records'][0][key] = malformed
  399. cases.append(payload)
  400. for meta in (
  401. {'page': 0, 'limit': 20, 'has_more': False},
  402. {'page': 1, 'limit': 101, 'has_more': False},
  403. {'page': 1, 'limit': 20, 'has_more': 0},
  404. ):
  405. payload = self.payload()
  406. payload['meta'] = meta
  407. cases.append(payload)
  408. presenter = OutputPresenter()
  409. for payload in cases:
  410. with self.subTest(payload=payload):
  411. self.assertTrue(presenter.present(
  412. 'query_receivable_cost_list', payload
  413. )['is_error'])
  414. def test_filter_presenter_preserves_zero_and_negative_values(self):
  415. payload = {
  416. 'code': 'MCP_0000',
  417. 'data': {
  418. 'records': [
  419. {'value': 0, 'label': '未出账', 'code': 'unbilled'},
  420. {'value': -1, 'label': '未核销', 'code': 'unverified'},
  421. ],
  422. },
  423. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  424. }
  425. result = OutputPresenter().present(
  426. 'list_receivable_cost_filter_options',
  427. payload,
  428. )
  429. self.assertFalse(result['is_error'])
  430. self.assertEqual(
  431. [[0, '未出账', 'unbilled'], [-1, '未核销', 'unverified']],
  432. result['structured_content']['rows'],
  433. )
  434. for mutation in (
  435. lambda value: value['data'].update({'secret': True}),
  436. lambda value: value['data']['records'][0].update({'secret': True}),
  437. lambda value: value['data']['records'][0].update({'value': True}),
  438. lambda value: value['data']['records'][0].update({'label': ''}),
  439. lambda value: value['data']['records'][0].update({'code': 1}),
  440. lambda value: value['meta'].update({'total': 2}),
  441. ):
  442. malformed = copy.deepcopy(payload)
  443. mutation(malformed)
  444. self.assertTrue(OutputPresenter().present(
  445. 'list_receivable_cost_filter_options',
  446. malformed,
  447. )['is_error'])
  448. if __name__ == '__main__':
  449. unittest.main()