test_receivable_cost_list_tools.py 19 KB

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