test_receivable_cost_list_tools.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  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_optional_customer_id(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.assertNotIn('allOf', schema)
  195. self.assertIn('子客户', json.dumps(schema, ensure_ascii=False))
  196. self.assertIn('禁止猜测', metadata['description'])
  197. self.assertIn('可选', metadata['description'])
  198. def test_call_forwards_sub_customer_with_optional_customer_id(self):
  199. tool_class = self.tool_class()
  200. client = RecordingApiClient()
  201. tool = tool_class(client)
  202. tool.call(
  203. filter_type=' 子客户 ',
  204. customer_id=7,
  205. keyword=' 子 ',
  206. page=2,
  207. limit=30,
  208. request_id='rq_filters',
  209. )
  210. self.assertEqual((
  211. 'list_receivable_cost_filter_options',
  212. '/mcp/tools/listReceivableCostFilterOptions',
  213. {
  214. 'filter_type': '子客户',
  215. 'customer_id': 7,
  216. 'keyword': '子',
  217. 'page': 2,
  218. 'limit': 30,
  219. },
  220. 'rq_filters',
  221. ), client.calls[-1])
  222. tool.call(filter_type='子客户')
  223. self.assertEqual(
  224. {
  225. 'filter_type': '子客户',
  226. 'keyword': '',
  227. 'page': 1,
  228. 'limit': 20,
  229. },
  230. client.calls[-1][2],
  231. )
  232. tool.call(filter_type='主客户')
  233. for arguments in (
  234. {'filter_type': 7},
  235. {'filter_type': '子客户', 'customer_id': 0},
  236. {'filter_type': '主客户', 'customer_id': 7},
  237. {'filter_type': '其他'},
  238. {'filter_type': '主客户', 'keyword': 7},
  239. {'filter_type': '主客户', 'keyword': 'x' * 101},
  240. {'filter_type': '主客户', 'page': 0},
  241. {'filter_type': '主客户', 'limit': True},
  242. {'filter_type': '主客户', 'limit': 101},
  243. ):
  244. with self.subTest(arguments=arguments):
  245. with self.assertRaises((TypeError, ValueError)):
  246. tool.call(**arguments)
  247. self.assertEqual(3, len(client.calls))
  248. with self.assertRaisesRegex(RuntimeError, 'api client is required'):
  249. tool_class().call('主客户')
  250. class ReceivableCostIntegrationTest(unittest.TestCase):
  251. def test_registries_are_identical_ordered_and_counts_are_current(self):
  252. local = GatewayApp().registered_tool_names()
  253. public = PublicGatewayApp(None, None).registered_tool_names()
  254. self.assertEqual(local, public)
  255. self.assertEqual(25, len(local))
  256. self.assertEqual(24, len(OutputPresenter.SAFE_TOOLS))
  257. self.assertEqual(
  258. (
  259. 'query_receivable_cost_list',
  260. 'list_receivable_cost_filter_options',
  261. ),
  262. tuple(
  263. name for name in local
  264. if name in {
  265. 'query_receivable_cost_list',
  266. 'list_receivable_cost_filter_options',
  267. }
  268. ),
  269. )
  270. def test_cli_forwards_both_tools(self):
  271. client = RecordingApiClient()
  272. app = GatewayApp(api_client=client)
  273. app.run_cli([
  274. 'call', '--tool', 'query_receivable_cost_list',
  275. '--reference-numbers', ' REF-1,REF-2 ',
  276. '--bill-numbers', ' BILL-1 ',
  277. '--business-date-start', '2026-07-01',
  278. '--business-date-end', '2026-07-31',
  279. '--customer-id', '7', '--sub-customer-id', '9',
  280. '--billing-status', '0', '--verification-status', '-1',
  281. '--document-type', '0', '--cost-type-id', '12',
  282. '--page', '2', '--limit', '30',
  283. ], stdout=io.StringIO())
  284. self.assertEqual({
  285. 'reference_numbers': ['REF-1', 'REF-2'],
  286. 'bill_numbers': ['BILL-1'],
  287. 'business_date_start': '2026-07-01',
  288. 'business_date_end': '2026-07-31',
  289. 'customer_id': 7,
  290. 'sub_customer_id': 9,
  291. 'billing_status': 0,
  292. 'verification_status': -1,
  293. 'document_type': 0,
  294. 'cost_type_id': 12,
  295. 'page': 2,
  296. 'limit': 30,
  297. }, client.calls[-1][2])
  298. app.run_cli([
  299. 'call', '--tool', 'query_receivable_cost_list',
  300. '--order-numbers', 'ORDER-1',
  301. ], stdout=io.StringIO())
  302. self.assertEqual({
  303. 'order_numbers': ['ORDER-1'], 'page': 1, 'limit': 20,
  304. }, client.calls[-1][2])
  305. app.run_cli([
  306. 'call', '--tool', 'list_receivable_cost_filter_options',
  307. '--filter-type', '子客户', '--customer-id', '7',
  308. '--keyword', '子',
  309. ], stdout=io.StringIO())
  310. self.assertEqual({
  311. 'filter_type': '子客户', 'customer_id': 7,
  312. 'keyword': '子', 'page': 1, 'limit': 20,
  313. }, client.calls[-1][2])
  314. app.run_cli([
  315. 'call', '--tool', 'list_receivable_cost_filter_options',
  316. '--filter-type', '主客户',
  317. ], stdout=io.StringIO())
  318. self.assertEqual({
  319. 'filter_type': '主客户', 'keyword': '',
  320. 'page': 1, 'limit': 20,
  321. }, client.calls[-1][2])
  322. with self.assertRaisesRegex(ValueError, '--filter-type is required'):
  323. app.run_cli([
  324. 'call', '--tool', 'list_receivable_cost_filter_options',
  325. ], stdout=io.StringIO())
  326. class ReceivableCostPresenterTest(unittest.TestCase):
  327. KEYS = [
  328. 'main_customer_name', 'sub_customer_name', 'customer_attribute',
  329. 'department_name', 'document_type', 'warehouse_name', 'business_number',
  330. 'reference_number', 'cost_name', 'original_amount', 'total_amount',
  331. 'billed_amount', 'unbilled_amount', 'bill_number', 'verification_status',
  332. 'receipt_number', 'receipt_date', 'sales_name', 'merchandiser_name',
  333. 'drainage_user_name', 'first_leg_order_status', 'settlement_mode',
  334. 'cost_occurred_at', 'business_occurred_at',
  335. ]
  336. NAMES = [
  337. '主客户', '子客户', '客户属性', '事业部', '单据类型', '仓库', '业务单号',
  338. '参考号', '费用项', '原币金额', '总金额', '已出账金额', '未出账金额',
  339. '账单编号', '核销状态', '收款水单号', '收款日期', '商务经理',
  340. '客户经理', '引流人', '头程订单状态', '结算模式', '费用发生时间',
  341. '业务发生时间',
  342. ]
  343. AMOUNT_KEYS = {
  344. 'original_amount', 'total_amount', 'billed_amount', 'unbilled_amount',
  345. }
  346. def payload(self):
  347. record = {key: '值' for key in self.KEYS}
  348. record.update({
  349. 'original_amount': {'amount': 10.25, 'currency': 'USD'},
  350. 'total_amount': {'amount': 70.5, 'currency': 'CNY'},
  351. 'billed_amount': {'amount': 20.0, 'currency': 'CNY'},
  352. 'unbilled_amount': {'amount': 50.5, 'currency': 'CNY'},
  353. })
  354. return {
  355. 'code': 'MCP_0000',
  356. 'data': {
  357. 'columns': [
  358. {'key': key, 'name': name}
  359. for key, name in zip(self.KEYS, self.NAMES)
  360. ],
  361. 'records': [record],
  362. },
  363. 'meta': {
  364. 'page': 1, 'limit': 20, 'has_more': False,
  365. 'request_id': 'rq_receivable',
  366. },
  367. }
  368. def test_query_presenter_requires_exact_contract(self):
  369. result = OutputPresenter().present(
  370. 'query_receivable_cost_list',
  371. self.payload(),
  372. )
  373. self.assertFalse(result['is_error'])
  374. self.assertEqual(
  375. [{'label': name} for name in self.NAMES],
  376. result['structured_content']['headers'],
  377. )
  378. self.assertEqual(24, len(result['structured_content']['rows'][0]))
  379. self.assertEqual(
  380. {'page': 1, 'limit': 20, 'has_more': False},
  381. result['structured_content']['pagination'],
  382. )
  383. def test_query_presenter_fails_closed_on_malformed_contract(self):
  384. cases = []
  385. for mutation in (
  386. lambda value: value['data']['columns'].reverse(),
  387. lambda value: value['data']['columns'][0].update({'secret': True}),
  388. lambda value: value['data']['columns'][0].update({'name': '错误名称'}),
  389. lambda value: value['data']['records'][0].update({'secret': 'hidden'}),
  390. lambda value: value['data']['records'][0].pop('cost_name'),
  391. lambda value: value['data']['records'][0].update({'cost_name': []}),
  392. lambda value: value['data'].update({'secret': True}),
  393. lambda value: value['meta'].update({'total': 1}),
  394. ):
  395. payload = self.payload()
  396. mutation(payload)
  397. cases.append(payload)
  398. for key in self.AMOUNT_KEYS:
  399. for amount in (True, '10', float('inf'), float('-inf'), float('nan')):
  400. payload = self.payload()
  401. payload['data']['records'][0][key]['amount'] = amount
  402. cases.append(payload)
  403. for malformed in (
  404. {'amount': 1},
  405. {'amount': 1, 'currency': 'CNY', 'secret': True},
  406. {'amount': 1, 'currency': 7},
  407. ):
  408. payload = self.payload()
  409. payload['data']['records'][0][key] = malformed
  410. cases.append(payload)
  411. for meta in (
  412. {'page': 0, 'limit': 20, 'has_more': False},
  413. {'page': 1, 'limit': 101, 'has_more': False},
  414. {'page': 1, 'limit': 20, 'has_more': 0},
  415. ):
  416. payload = self.payload()
  417. payload['meta'] = meta
  418. cases.append(payload)
  419. presenter = OutputPresenter()
  420. for payload in cases:
  421. with self.subTest(payload=payload):
  422. self.assertTrue(presenter.present(
  423. 'query_receivable_cost_list', payload
  424. )['is_error'])
  425. def test_filter_presenter_preserves_zero_and_negative_values(self):
  426. payload = {
  427. 'code': 'MCP_0000',
  428. 'data': {
  429. 'records': [
  430. {'value': 0, 'label': '未出账', 'code': 'unbilled'},
  431. {'value': -1, 'label': '未核销', 'code': 'unverified'},
  432. ],
  433. },
  434. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  435. }
  436. result = OutputPresenter().present(
  437. 'list_receivable_cost_filter_options',
  438. payload,
  439. )
  440. self.assertFalse(result['is_error'])
  441. self.assertEqual(
  442. [[0, '未出账', 'unbilled'], [-1, '未核销', 'unverified']],
  443. result['structured_content']['rows'],
  444. )
  445. for mutation in (
  446. lambda value: value['data'].update({'secret': True}),
  447. lambda value: value['data']['records'][0].update({'secret': True}),
  448. lambda value: value['data']['records'][0].update({'value': True}),
  449. lambda value: value['data']['records'][0].update({'label': ''}),
  450. lambda value: value['data']['records'][0].update({'code': 1}),
  451. lambda value: value['meta'].update({'total': 2}),
  452. ):
  453. malformed = copy.deepcopy(payload)
  454. mutation(malformed)
  455. self.assertTrue(OutputPresenter().present(
  456. 'list_receivable_cost_filter_options',
  457. malformed,
  458. )['is_error'])
  459. if __name__ == '__main__':
  460. unittest.main()