test_payable_cost_tools.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. import copy
  2. import importlib
  3. import io
  4. import math
  5. from pathlib import Path
  6. import unittest
  7. from app import GatewayApp
  8. from public_gateway import PublicGatewayApp
  9. from services.output_presenter import OutputPresenter
  10. class RecordingApiClient:
  11. def __init__(self):
  12. self.calls = []
  13. def list_enabled_tools(self, request_id=''):
  14. return {
  15. 'code': 'MCP_0000',
  16. 'data': {
  17. 'tool_codes': [
  18. 'query_payable_cost_list',
  19. 'list_payable_cost_filter_options',
  20. 'export_payable_cost_list',
  21. ],
  22. },
  23. }
  24. def call_tool(self, tool_code, route_path, payload, request_id):
  25. self.calls.append((tool_code, route_path, payload, request_id))
  26. if tool_code == 'export_payable_cost_list':
  27. return {
  28. 'code': 'MCP_0000',
  29. 'data': {
  30. 'task_ref': 'mexp_payable',
  31. 'status': 'queued',
  32. 'retry_after_seconds': 10,
  33. },
  34. }
  35. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  36. class PayableCostToolContractTest(unittest.TestCase):
  37. FILTER_TYPES = (
  38. '业务类型', '业务节点', '物流商', '费用项',
  39. '是否生成账单', '是否付款', '核销状态', '单据类型',
  40. )
  41. QUERY_PROPERTIES = {
  42. 'business_type', 'order_numbers', 'tracking_numbers',
  43. 'container_codes', 'bl_numbers', 'so_numbers',
  44. 'business_date_start', 'business_date_end',
  45. 'cost_date_start', 'cost_date_end',
  46. 'operation_date_start', 'operation_date_end',
  47. 'business_node_id', 'provider_id', 'cost_type_ids',
  48. 'billing_status', 'payment_status', 'verification_status',
  49. 'document_type', 'page', 'limit',
  50. }
  51. def tool_class(self, module_name, class_name):
  52. path = Path(__file__).parents[1] / 'tools' / (module_name + '.py')
  53. self.assertTrue(path.is_file(), str(path))
  54. return getattr(importlib.import_module('tools.' + module_name), class_name)
  55. def test_query_schema_is_closed_and_locks_required_business_type(self):
  56. cls = self.tool_class(
  57. 'query_payable_cost_list', 'QueryPayableCostListTool'
  58. )
  59. metadata = cls().metadata()
  60. schema = metadata['input_schema']
  61. self.assertEqual('query_payable_cost_list', metadata['name'])
  62. self.assertFalse(schema['additionalProperties'])
  63. self.assertEqual(self.QUERY_PROPERTIES, set(schema['properties']))
  64. self.assertIn('business_type', schema['required'])
  65. self.assertEqual(
  66. [1, 2, 3, 4, 5, 7],
  67. schema['properties']['business_type']['enum'],
  68. )
  69. self.assertIn('list_payable_cost_filter_options', metadata['description'])
  70. self.assertIn('禁止', metadata['description'])
  71. def test_query_call_forwards_five_number_fields_and_three_dates(self):
  72. cls = self.tool_class(
  73. 'query_payable_cost_list', 'QueryPayableCostListTool'
  74. )
  75. client = RecordingApiClient()
  76. cls(client).call(
  77. business_type=1,
  78. order_numbers=[' O-1 '],
  79. tracking_numbers=[' T-1 '],
  80. container_codes=[' C-1 '],
  81. bl_numbers=[' B-1 '],
  82. so_numbers=[' S-1 '],
  83. business_date_start='2026-07-01',
  84. business_date_end='2026-07-31',
  85. cost_date_start='2026-07-02',
  86. cost_date_end='2026-07-30',
  87. operation_date_start='2026-07-03',
  88. operation_date_end='2026-07-29',
  89. business_node_id=11,
  90. provider_id=12,
  91. cost_type_ids=[13, 14],
  92. billing_status=0,
  93. payment_status=-1,
  94. verification_status=1,
  95. document_type=0,
  96. page=2,
  97. limit=100,
  98. request_id='rq_payable',
  99. )
  100. self.assertEqual(
  101. (
  102. 'query_payable_cost_list',
  103. '/mcp/tools/queryPayableCostList',
  104. {
  105. 'business_type': 1,
  106. 'order_numbers': ['O-1'],
  107. 'tracking_numbers': ['T-1'],
  108. 'container_codes': ['C-1'],
  109. 'bl_numbers': ['B-1'],
  110. 'so_numbers': ['S-1'],
  111. 'business_date_start': '2026-07-01',
  112. 'business_date_end': '2026-07-31',
  113. 'cost_date_start': '2026-07-02',
  114. 'cost_date_end': '2026-07-30',
  115. 'operation_date_start': '2026-07-03',
  116. 'operation_date_end': '2026-07-29',
  117. 'business_node_id': 11,
  118. 'provider_id': 12,
  119. 'cost_type_ids': [13, 14],
  120. 'billing_status': 0,
  121. 'payment_status': -1,
  122. 'verification_status': 1,
  123. 'document_type': 0,
  124. 'page': 2,
  125. 'limit': 100,
  126. },
  127. 'rq_payable',
  128. ),
  129. client.calls[-1],
  130. )
  131. def test_query_call_rejects_number_date_and_filter_boundaries(self):
  132. cls = self.tool_class(
  133. 'query_payable_cost_list', 'QueryPayableCostListTool'
  134. )
  135. tool = cls(RecordingApiClient())
  136. invalid = [
  137. {},
  138. {'business_type': 6, 'order_numbers': ['O']},
  139. {'business_type': 1},
  140. {'business_type': 1, 'order_numbers': 'O'},
  141. {'business_type': 1, 'order_numbers': [1]},
  142. {'business_type': 1, 'order_numbers': ['']},
  143. {'business_type': 1, 'order_numbers': ['x' * 101]},
  144. {'business_type': 1, 'order_numbers': [str(i) for i in range(201)]},
  145. {'business_type': 1, 'order_numbers': ['O'] * 201},
  146. {'business_type': 1, 'business_date_start': '2026-07-01'},
  147. {
  148. 'business_type': 1,
  149. 'cost_date_start': '2026-07-01',
  150. 'cost_date_end': '2026-08-01',
  151. },
  152. {'business_type': 1, 'operation_date_end': '2026-07-01'},
  153. {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': []},
  154. {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': [True]},
  155. {'business_type': 1, 'order_numbers': ['O'], 'cost_type_ids': list(range(1, 202))},
  156. {'business_type': 1, 'order_numbers': ['O'], 'page': 101},
  157. {'business_type': 1, 'order_numbers': ['O'], 'limit': 0},
  158. ]
  159. for arguments in invalid:
  160. with self.subTest(arguments=arguments):
  161. with self.assertRaises((TypeError, ValueError)):
  162. tool.call(**arguments)
  163. def test_filter_schema_and_call_lock_eight_types_and_cost_linkage(self):
  164. cls = self.tool_class(
  165. 'list_payable_cost_filter_options',
  166. 'ListPayableCostFilterOptionsTool',
  167. )
  168. metadata = cls().metadata()
  169. schema = metadata['input_schema']
  170. self.assertFalse(schema['additionalProperties'])
  171. self.assertEqual(
  172. {'filter_type', 'business_type', 'keyword', 'page', 'limit'},
  173. set(schema['properties']),
  174. )
  175. self.assertEqual(
  176. list(self.FILTER_TYPES),
  177. schema['properties']['filter_type']['enum'],
  178. )
  179. client = RecordingApiClient()
  180. cls(client).call(
  181. filter_type=' 费用项 ', business_type=3,
  182. keyword=' 运 ', page=2, limit=30,
  183. )
  184. self.assertEqual(
  185. {
  186. 'filter_type': '费用项', 'business_type': 3,
  187. 'keyword': '运', 'page': 2, 'limit': 30,
  188. },
  189. client.calls[-1][2],
  190. )
  191. for arguments in (
  192. {'filter_type': '费用项'},
  193. {'filter_type': '业务节点', 'business_type': 6},
  194. {'filter_type': '未知'},
  195. {'filter_type': '业务类型', 'keyword': 1},
  196. {'filter_type': '业务类型', 'page': 0},
  197. ):
  198. with self.subTest(arguments=arguments):
  199. with self.assertRaises((TypeError, ValueError)):
  200. cls(client).call(**arguments)
  201. def test_export_schema_matches_query_without_pagination(self):
  202. cls = self.tool_class(
  203. 'export_payable_cost_list', 'ExportPayableCostListTool'
  204. )
  205. metadata = cls().metadata()
  206. schema = metadata['input_schema']
  207. self.assertEqual('export_payable_cost_list', metadata['name'])
  208. self.assertFalse(schema['additionalProperties'])
  209. self.assertEqual(
  210. self.QUERY_PROPERTIES - {'page', 'limit'},
  211. set(schema['properties']),
  212. )
  213. self.assertIn('business_type', schema['required'])
  214. self.assertIn('query_export_task', metadata['description'])
  215. self.assertIn('单页签', metadata['description'])
  216. def test_local_public_registry_cli_and_safe_counts_are_current(self):
  217. local = GatewayApp().registered_tool_names()
  218. public = PublicGatewayApp(None, None).registered_tool_names()
  219. self.assertEqual(local, public)
  220. self.assertEqual(25, len(local))
  221. self.assertEqual(24, len(OutputPresenter.SAFE_TOOLS))
  222. self.assertEqual(
  223. (
  224. 'query_payable_cost_list',
  225. 'list_payable_cost_filter_options',
  226. 'export_payable_cost_list',
  227. ),
  228. tuple(name for name in local if 'payable_cost' in name),
  229. )
  230. client = RecordingApiClient()
  231. app = GatewayApp(api_client=client)
  232. app.run_cli([
  233. 'call', '--tool', 'query_payable_cost_list',
  234. '--business-type', '1', '--order-numbers', ' O-1,O-2 ',
  235. '--cost-type-ids', '13,14', '--page', '2', '--limit', '30',
  236. ], stdout=io.StringIO())
  237. self.assertEqual(
  238. {
  239. 'business_type': 1, 'order_numbers': ['O-1', 'O-2'],
  240. 'cost_type_ids': [13, 14], 'page': 2, 'limit': 30,
  241. },
  242. client.calls[-1][2],
  243. )
  244. app.run_cli([
  245. 'call', '--tool', 'list_payable_cost_filter_options',
  246. '--filter-type', '费用项', '--business-type', '3',
  247. ], stdout=io.StringIO())
  248. self.assertEqual('list_payable_cost_filter_options', client.calls[-1][0])
  249. app.run_cli([
  250. 'call', '--tool', 'export_payable_cost_list',
  251. '--business-type', '7', '--order-numbers', 'E-1',
  252. ], stdout=io.StringIO())
  253. self.assertEqual('export_payable_cost_list', client.calls[-1][0])
  254. def test_new_tool_error_paths_and_cli_forwarding_are_covered(self):
  255. query_cls = self.tool_class(
  256. 'query_payable_cost_list', 'QueryPayableCostListTool'
  257. )
  258. with self.assertRaises(RuntimeError):
  259. query_cls().call(1, order_numbers=['O-1'])
  260. query = query_cls(RecordingApiClient())
  261. query.call(business_type=1, order_numbers=['O', 'O'])
  262. invalid = (
  263. {
  264. 'business_type': 1, 'business_date_start': '2026-07-02',
  265. 'business_date_end': '2026-07-01',
  266. },
  267. {
  268. 'business_type': 1, 'business_date_start': 1,
  269. 'business_date_end': 1,
  270. },
  271. {
  272. 'business_type': 1, 'business_date_start': '2026-99-01',
  273. 'business_date_end': '2026-99-02',
  274. },
  275. {
  276. 'business_type': 1, 'business_date_start': '20260101',
  277. 'business_date_end': '20260102',
  278. },
  279. {'business_type': 1, 'order_numbers': ['O'], 'billing_status': 2},
  280. )
  281. for arguments in invalid:
  282. with self.subTest(arguments=arguments):
  283. with self.assertRaises((TypeError, ValueError)):
  284. query.call(**arguments)
  285. export_cls = self.tool_class(
  286. 'export_payable_cost_list', 'ExportPayableCostListTool'
  287. )
  288. with self.assertRaises(RuntimeError):
  289. export_cls().call(1, order_numbers=['O-1'])
  290. with self.assertRaises(ValueError):
  291. export_cls(RecordingApiClient()).call(6, order_numbers=['O-1'])
  292. filter_cls = self.tool_class(
  293. 'list_payable_cost_filter_options',
  294. 'ListPayableCostFilterOptionsTool',
  295. )
  296. with self.assertRaises(RuntimeError):
  297. filter_cls().call('业务类型')
  298. filter_tool = filter_cls(RecordingApiClient())
  299. with self.assertRaises(ValueError):
  300. filter_tool.call(1)
  301. with self.assertRaises(ValueError):
  302. filter_tool.call('业务类型', business_type=True)
  303. with self.assertRaises(ValueError):
  304. filter_tool.call('业务类型', keyword='x' * 101)
  305. filter_tool.call('业务类型')
  306. calls = []
  307. app = GatewayApp()
  308. app.call_tool = lambda name, arguments, request_id='': (
  309. calls.append((name, arguments, request_id))
  310. or {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  311. )
  312. output = io.StringIO()
  313. app.run_cli([
  314. 'call', '--tool', 'query_payable_cost_list', '--business-type', '1',
  315. '--order-numbers', 'O-1', '--tracking-numbers', 'T-1',
  316. '--container-codes', 'C-1', '--bl-numbers', 'B-1', '--so-numbers', 'S-1',
  317. '--business-date-start', '2026-07-01', '--business-date-end', '2026-07-02',
  318. '--cost-date-start', '2026-07-03', '--cost-date-end', '2026-07-04',
  319. '--operation-date-start', '2026-07-05', '--operation-date-end', '2026-07-06',
  320. '--business-node-id', '11', '--provider-id', '12', '--cost-type-ids', '13,14',
  321. '--billing-status', '1', '--payment-status', '-1',
  322. '--verification-status', '2', '--document-type', '0',
  323. ], stdout=output)
  324. self.assertEqual('query_payable_cost_list', calls[-1][0])
  325. app.run_cli([
  326. 'call', '--tool', 'export_payable_cost_list', '--business-type', '1',
  327. '--order-numbers', 'O-1',
  328. ], stdout=io.StringIO())
  329. self.assertEqual('export_payable_cost_list', calls[-1][0])
  330. with self.assertRaises(ValueError):
  331. app.run_cli(['call', '--tool', 'query_payable_cost_list'], stdout=io.StringIO())
  332. app.run_cli([
  333. 'call', '--tool', 'list_payable_cost_filter_options',
  334. '--filter-type', '业务类型',
  335. ], stdout=io.StringIO())
  336. app.run_cli([
  337. 'call', '--tool', 'list_payable_cost_filter_options',
  338. '--filter-type', '业务类型', '--business-type', '3',
  339. ], stdout=io.StringIO())
  340. with self.assertRaises(ValueError):
  341. app.run_cli([
  342. 'call', '--tool', 'list_payable_cost_filter_options'
  343. ], stdout=io.StringIO())
  344. for argv in (
  345. [
  346. 'call', '--tool', 'query_customer_payment_followup',
  347. '--customer-id', '1', '--department-id', '2', '--sales-id', '3',
  348. '--merchandiser-id', '4', '--has-unverified-receivable-only', 'false',
  349. ],
  350. [
  351. 'call', '--tool', 'query_customer_unverified_bill_details',
  352. '--customer-id', '1',
  353. ],
  354. [
  355. 'call', '--tool', 'query_customer_payment_records',
  356. '--customer-id', '1', '--receive-date-start', '2026-07-01',
  357. '--receive-date-end', '2026-07-02',
  358. ],
  359. [
  360. 'call', '--tool', 'query_order_receivable_cost_details',
  361. '--order-number', 'O-1',
  362. ],
  363. ):
  364. app.run_cli(argv, stdout=io.StringIO())
  365. app.run_cli([
  366. 'call', '--tool', 'query_customer_payment_followup',
  367. ], stdout=io.StringIO())
  368. for argv in (
  369. ['call', '--tool', 'query_customer_unverified_bill_details'],
  370. ['call', '--tool', 'query_customer_payment_records'],
  371. ['call', '--tool', 'query_order_receivable_cost_details'],
  372. ):
  373. with self.assertRaises(ValueError):
  374. app.run_cli(argv, stdout=io.StringIO())
  375. app.run_cli([
  376. 'call', '--tool', 'query_customer_payment_records',
  377. '--customer-id', '1', '--receive-date-start', '2026-07-01',
  378. ], stdout=io.StringIO())
  379. app.run_cli([
  380. 'call', '--tool', 'query_customer_payment_records',
  381. '--customer-id', '1', '--receive-date-end', '2026-07-02',
  382. ], stdout=io.StringIO())
  383. class PayableCostPresenterContractTest(unittest.TestCase):
  384. COLUMN_MAP = {
  385. 1: [
  386. ('number', '单号'), ('so_number', 'SO号'),
  387. ('container_code', '柜号'), ('bl_number', '提单号'),
  388. ('sub_number', '订单号'), ('business_node_name', '业务节点'),
  389. ('providers_name', '物流商'), ('cost_name', '费用名称'),
  390. ('payable_cost_q', '金额'), ('currency', '币种'),
  391. ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
  392. ('trade_date', '费用发生时间'),
  393. ('verify_status_text', '核销状态'), ('remark', '备注'),
  394. ('bill_no', '账单编号'),
  395. ('payment_order_number', '付款单号'),
  396. ],
  397. 2: [
  398. ('number', '单号'), ('sub_number', '跟踪单号'),
  399. ('workorder_number', '工单号'), ('order_type_name', '单据类型'),
  400. ('providers_name', '物流商'), ('cost_name', '费用名称'),
  401. ('payable_cost_q', '应付原币金额'), ('currency', '应付原币币种'),
  402. ('company_money_q', '本位币金额CNY'), ('yf_lock', '应付锁定'),
  403. ('business_date', '业务发生时间'),
  404. ('trade_date', '费用发生时间'), ('create_date', '操作时间'),
  405. ('verify_status_text', '核销状态'), ('remark', '备注'),
  406. ('bill_no', '账单编号'),
  407. ('payment_order_number', '付款单号'),
  408. ],
  409. 3: [
  410. ('number', '单号'), ('order_status_text', '包裹状态'),
  411. ('providers_name', '物流商'),
  412. ('business_node_name', '费用业务节点'),
  413. ('cost_name', '费用名称'), ('detail_price', '计费单价'),
  414. ('charge_weight', '计费重'), ('quote_cost', '计费金额'),
  415. ('payable_cost_q', '实际应付金额'),
  416. ('quote_currency', '原币币种'),
  417. ('company_money_q', '计费本位币金额'),
  418. ('yf_lock', '应付锁定'), ('bill_no', '账单编号'),
  419. ('payment_order_number', '付款单号'),
  420. ('trade_date', '费用发生时间'),
  421. ('verify_status_text', '核销状态'), ('remark', '财务备注'),
  422. ('create_user_name', '操作人'), ('create_date', '录入时间'),
  423. ],
  424. 4: [
  425. ('number', '订单号'), ('business_bill_no', '货代账单号'),
  426. ('providers_name', '物流商'), ('cost_name', '费用名称'),
  427. ('payable_cost_q', '金额'), ('currency', '币种'),
  428. ('company_money_q', '本位币金额'),
  429. ('department_name', '事业部'), ('yf_lock', '应付锁定'),
  430. ('trade_date', '费用发生时间'),
  431. ('verify_status_text', '核销状态'),
  432. ('business_node_name', '业务节点'), ('remark', '备注'),
  433. ('bill_no', '应付账单编号'),
  434. ('payment_order_number', '付款单号'),
  435. ],
  436. 5: [
  437. ('order_type_name', '单据类型'), ('number', '订单号'),
  438. ('warehouse_name', '仓库'),
  439. ('business_node_name', '业务节点'),
  440. ('providers_name', '物流商'), ('cost_name', '费用名称'),
  441. ('payable_cost_q', '金额'), ('currency', '币种'),
  442. ('company_money_q', '本位币金额'), ('yf_lock', '应付锁定'),
  443. ('trade_date', '费用发生时间'),
  444. ('verify_status_text', '核销状态'), ('remark', '备注'),
  445. ('bill_no', '账单编号'),
  446. ('payment_order_number', '付款单号'),
  447. ],
  448. 7: [
  449. ('number', '订单号'), ('ep_order_status_text', '订单状态'),
  450. ('business_node_name', '业务节点'),
  451. ('providers_name', '物流商'), ('cost_name', '费用名称'),
  452. ('detail_price', '计费单价'), ('charge_weight', '计费重'),
  453. ('quote_cost', '计费金额'),
  454. ('payable_cost_q', '实际应付金额'),
  455. ('quote_currency', '原币币种'),
  456. ('company_money_q', '本位币金额'),
  457. ('trade_date', '费用发生时间'), ('remark', '备注'),
  458. ('verify_status_text', '核销状态'), ('bill_no', '账单编号'),
  459. ('payment_order_number', '付款单号'),
  460. ('create_user_name', '操作人'), ('create_date', '录入时间'),
  461. ],
  462. }
  463. AMOUNT_KEYS = {
  464. 'detail_price', 'charge_weight', 'quote_cost',
  465. 'payable_cost_q', 'company_money_q',
  466. }
  467. def payload(self, business_type):
  468. columns = self.COLUMN_MAP[business_type]
  469. record = {}
  470. for key, _ in columns:
  471. record[key] = 12.5 if key in self.AMOUNT_KEYS else key + '-value'
  472. return {
  473. 'code': 'MCP_0000',
  474. 'data': {
  475. 'business_type': business_type,
  476. 'company_currency': 'CNY',
  477. 'columns': [
  478. {'key': key, 'name': name} for key, name in columns
  479. ],
  480. 'records': [record],
  481. },
  482. 'meta': {
  483. 'page': 1, 'limit': 20, 'has_more': False,
  484. 'request_id': 'rq_payable',
  485. },
  486. }
  487. def test_six_business_types_have_exact_dynamic_columns(self):
  488. presenter = OutputPresenter()
  489. for business_type, columns in self.COLUMN_MAP.items():
  490. with self.subTest(business_type=business_type):
  491. result = presenter.present(
  492. 'query_payable_cost_list', self.payload(business_type)
  493. )
  494. self.assertFalse(result['is_error'])
  495. self.assertEqual(
  496. [{'label': name} for _, name in columns],
  497. result['structured_content']['headers'],
  498. )
  499. self.assertEqual(
  500. len(columns),
  501. len(result['structured_content']['rows'][0]),
  502. )
  503. self.assertEqual(
  504. business_type,
  505. result['structured_content']['business_type'],
  506. )
  507. def test_unknown_missing_wrong_order_and_non_finite_values_fail_closed(self):
  508. presenter = OutputPresenter()
  509. self.assertTrue(presenter.handles('query_payable_cost_list'))
  510. cases = []
  511. payload = self.payload(1)
  512. payload['data']['columns'].append({'key': 'secret', 'name': '秘密'})
  513. payload['data']['records'][0]['secret'] = 'hidden'
  514. cases.append(payload)
  515. payload = self.payload(1)
  516. payload['data']['columns'].reverse()
  517. cases.append(payload)
  518. payload = self.payload(1)
  519. payload['data']['records'][0].pop('cost_name')
  520. cases.append(payload)
  521. payload = self.payload(1)
  522. payload['data']['records'][0]['secret'] = 'hidden'
  523. cases.append(payload)
  524. payload = self.payload(6) if 6 in self.COLUMN_MAP else self.payload(1)
  525. payload['data']['business_type'] = 6
  526. cases.append(payload)
  527. for business_type in self.COLUMN_MAP:
  528. for key, _ in self.COLUMN_MAP[business_type]:
  529. if key not in self.AMOUNT_KEYS:
  530. continue
  531. for value in (True, '12.5', math.inf, -math.inf, math.nan):
  532. payload = self.payload(business_type)
  533. payload['data']['records'][0][key] = value
  534. cases.append(payload)
  535. for payload in cases:
  536. with self.subTest(payload=payload):
  537. self.assertTrue(presenter.present(
  538. 'query_payable_cost_list', payload
  539. )['is_error'])
  540. def test_filter_and_export_presenters_use_safe_contracts(self):
  541. presenter = OutputPresenter()
  542. filtered = presenter.present(
  543. 'list_payable_cost_filter_options',
  544. {
  545. 'code': 'MCP_0000',
  546. 'data': {
  547. 'records': [
  548. {'value': 0, 'label': '否', 'code': 'no'},
  549. {'value': -1, 'label': '未付款', 'code': 'unpaid'},
  550. ],
  551. },
  552. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  553. },
  554. )
  555. self.assertFalse(filtered['is_error'])
  556. self.assertEqual(
  557. [[0, '否', 'no'], [-1, '未付款', 'unpaid']],
  558. filtered['structured_content']['rows'],
  559. )
  560. exported = presenter.present(
  561. 'export_payable_cost_list',
  562. {
  563. 'code': 'MCP_0000',
  564. 'data': {
  565. 'task_ref': 'mexp_payable',
  566. 'status': 'queued',
  567. 'retry_after_seconds': 10,
  568. },
  569. },
  570. )
  571. self.assertFalse(exported['is_error'])
  572. self.assertEqual(
  573. 'mexp_payable', exported['structured_content']['task']['task_ref']
  574. )
  575. def test_payable_filter_presenter_rejects_malformed_records_and_meta(self):
  576. presenter = OutputPresenter()
  577. valid = {
  578. 'code': 'MCP_0000',
  579. 'data': {
  580. 'records': [{'value': 0, 'label': 'ok', 'code': 'no'}],
  581. },
  582. 'meta': {'page': 1, 'limit': 20, 'has_more': False},
  583. }
  584. cases = []
  585. malformed = copy.deepcopy(valid)
  586. malformed['data']['unexpected'] = True
  587. cases.append(malformed)
  588. malformed = copy.deepcopy(valid)
  589. malformed['data']['records'][0].pop('code')
  590. cases.append(malformed)
  591. malformed = copy.deepcopy(valid)
  592. malformed['data']['records'][0]['value'] = '0'
  593. cases.append(malformed)
  594. malformed = copy.deepcopy(valid)
  595. malformed['data']['records'][0]['label'] = ''
  596. cases.append(malformed)
  597. malformed = copy.deepcopy(valid)
  598. malformed['meta'].pop('has_more')
  599. cases.append(malformed)
  600. malformed = copy.deepcopy(valid)
  601. malformed['meta']['unexpected'] = True
  602. cases.append(malformed)
  603. for payload in cases:
  604. with self.subTest(payload=payload):
  605. self.assertTrue(
  606. presenter.present(
  607. 'list_payable_cost_filter_options', payload
  608. )['is_error']
  609. )
  610. def test_presenter_rejects_unknown_top_level_and_non_string_text_cells(self):
  611. presenter = OutputPresenter()
  612. payload = self.payload(1)
  613. payload['data']['unexpected'] = True
  614. self.assertTrue(
  615. presenter.present('query_payable_cost_list', payload)['is_error']
  616. )
  617. payload = self.payload(1)
  618. payload['data']['records'][0]['remark'] = 7
  619. self.assertTrue(
  620. presenter.present('query_payable_cost_list', payload)['is_error']
  621. )
  622. if __name__ == '__main__':
  623. unittest.main()