test_receive_volume_tools.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import importlib
  2. import io
  3. import math
  4. import unittest
  5. from app import GatewayApp
  6. from public_gateway import PublicGatewayApp
  7. from services.output_presenter import OutputPresenter
  8. COLUMNS = [
  9. ('order_number', '订单号'),
  10. ('customer_name', '客户名称'),
  11. ('packing_type', '货物类型'),
  12. ('inbound_volume', '入库体积'),
  13. ('inbound_date', '入库时间'),
  14. ('sales_name', '商务经理'),
  15. ('merchandiser_name', '客户经理'),
  16. ('department_name', '事业部'),
  17. ('product_name', '物流产品'),
  18. ]
  19. FILTER_TYPES = ['客户名称', '商务经理', '客户经理', '货物类型', '物流产品']
  20. class RecordingApiClient:
  21. def __init__(self):
  22. self.calls = []
  23. def list_enabled_tools(self, request_id=''):
  24. return {
  25. 'code': 'MCP_0000',
  26. 'data': {
  27. 'tool_codes': [
  28. 'query_receive_volume_list',
  29. 'list_receive_volume_filter_options',
  30. ],
  31. },
  32. }
  33. def call_tool(self, tool_code, route_path, payload, request_id):
  34. self.calls.append((tool_code, route_path, payload, request_id))
  35. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  36. class ReceiveVolumeToolContractTest(unittest.TestCase):
  37. def tool_class(self, module_name, class_name):
  38. return getattr(importlib.import_module('tools.' + module_name), class_name)
  39. def test_query_schema_requires_inbound_dates_and_forbids_identity(self):
  40. cls = self.tool_class(
  41. 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
  42. )
  43. metadata = cls().metadata()
  44. schema = metadata['input_schema']
  45. self.assertEqual('query_receive_volume_list', metadata['name'])
  46. self.assertEqual('/mcp/tools/queryReceiveVolumeList', cls.route_path)
  47. self.assertFalse(schema['additionalProperties'])
  48. self.assertEqual(
  49. ['inbound_date_start', 'inbound_date_end'],
  50. schema['required'],
  51. )
  52. for forbidden in (
  53. 'company_id', 'admin_id', 'is_super', 'order_numbers',
  54. 'business_date_start',
  55. ):
  56. self.assertNotIn(forbidden, schema['properties'])
  57. self.assertIn('使用场景:', metadata['description'])
  58. self.assertIn('禁止使用:', metadata['description'])
  59. self.assertIn('入库时间', metadata['description'])
  60. self.assertIn('admin/Report/receiveForm', metadata['description'])
  61. self.assertIn('list_receive_volume_filter_options', metadata['description'])
  62. self.assertIn('不得改走query_order', metadata['description'])
  63. def test_query_forwards_optional_filters(self):
  64. cls = self.tool_class(
  65. 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
  66. )
  67. client = RecordingApiClient()
  68. cls(client).call(
  69. inbound_date_start='2026-09-01',
  70. inbound_date_end='2026-09-30',
  71. customer_ids=[11, 11, 12],
  72. sales_ids=[3],
  73. merchandiser_ids=[4],
  74. packing_types=[' 散货 ', '散货'],
  75. product_ids=[8],
  76. page=2,
  77. limit=30,
  78. request_id='rq_rv',
  79. )
  80. self.assertEqual(
  81. (
  82. 'query_receive_volume_list',
  83. '/mcp/tools/queryReceiveVolumeList',
  84. {
  85. 'inbound_date_start': '2026-09-01',
  86. 'inbound_date_end': '2026-09-30',
  87. 'customer_ids': [11, 12],
  88. 'sales_ids': [3],
  89. 'merchandiser_ids': [4],
  90. 'packing_types': ['散货'],
  91. 'product_ids': [8],
  92. 'page': 2,
  93. 'limit': 30,
  94. },
  95. 'rq_rv',
  96. ),
  97. client.calls[-1],
  98. )
  99. cls(client).call(
  100. inbound_date_start='2026-09-01',
  101. inbound_date_end='2026-09-01',
  102. )
  103. self.assertEqual(
  104. {
  105. 'inbound_date_start': '2026-09-01',
  106. 'inbound_date_end': '2026-09-01',
  107. 'page': 1,
  108. 'limit': 20,
  109. },
  110. client.calls[-1][2],
  111. )
  112. with self.assertRaises(RuntimeError):
  113. cls().call(
  114. inbound_date_start='2026-09-01',
  115. inbound_date_end='2026-09-30',
  116. )
  117. def test_query_rejects_invalid_window_and_ids(self):
  118. cls = self.tool_class(
  119. 'query_receive_volume_list', 'QueryReceiveVolumeListTool'
  120. )
  121. client = RecordingApiClient()
  122. with self.assertRaises(ValueError):
  123. cls(client).call(
  124. inbound_date_start='2026-09-30',
  125. inbound_date_end='2026-09-01',
  126. )
  127. with self.assertRaises(ValueError):
  128. cls(client).call(
  129. inbound_date_start='2026-08-01',
  130. inbound_date_end='2026-09-01',
  131. )
  132. with self.assertRaises(ValueError):
  133. cls(client).call(
  134. inbound_date_start='2026-09-01',
  135. inbound_date_end='2026-09-30',
  136. customer_ids=[0],
  137. )
  138. with self.assertRaises(ValueError):
  139. cls(client).call(
  140. inbound_date_start='2026-09-01',
  141. inbound_date_end='2026-09-30',
  142. packing_types=[''],
  143. )
  144. with self.assertRaises(ValueError):
  145. cls(client).call(
  146. inbound_date_start='2026-09-01',
  147. inbound_date_end='2026-09-30',
  148. sales_ids=list(range(1, 202)),
  149. )
  150. with self.assertRaises(ValueError):
  151. cls(client).call(
  152. inbound_date_start='2026-09-01',
  153. inbound_date_end='2026-09-30',
  154. packing_types=['x' * 101],
  155. )
  156. with self.assertRaises(ValueError):
  157. cls(client).call(
  158. inbound_date_start='2026-09-01',
  159. inbound_date_end='2026-09-30',
  160. packing_types=[1],
  161. )
  162. with self.assertRaises(ValueError):
  163. cls(client).call(
  164. inbound_date_start='2026-09-01',
  165. inbound_date_end='2026-09-30',
  166. packing_types=[],
  167. )
  168. with self.assertRaises(ValueError):
  169. cls(client).call(
  170. inbound_date_start='bad',
  171. inbound_date_end='2026-09-30',
  172. )
  173. with self.assertRaises(ValueError):
  174. cls(client).call(
  175. inbound_date_start='2026-9-01',
  176. inbound_date_end='2026-09-30',
  177. )
  178. with self.assertRaises(ValueError):
  179. cls(client).call(
  180. inbound_date_start='2026-09-01',
  181. inbound_date_end='2026-09-30',
  182. packing_types=['n{0}'.format(i) for i in range(201)],
  183. )
  184. with self.assertRaises(ValueError):
  185. cls(client).call(
  186. inbound_date_start='2026-09-01',
  187. inbound_date_end='2026-09-30',
  188. product_ids=['8'],
  189. )
  190. with self.assertRaises(ValueError):
  191. cls(client).call(
  192. inbound_date_start='2026-09-01',
  193. inbound_date_end='2026-09-30',
  194. merchandiser_ids=[],
  195. )
  196. with self.assertRaises(ValueError):
  197. cls(client).call(
  198. inbound_date_start='2026-09-01',
  199. inbound_date_end='2026-09-30',
  200. page=101,
  201. )
  202. with self.assertRaises(ValueError):
  203. cls(client).call(
  204. inbound_date_start='2026-09-01',
  205. inbound_date_end='2026-09-30',
  206. limit=True,
  207. )
  208. def test_filter_schema_and_call(self):
  209. cls = self.tool_class(
  210. 'list_receive_volume_filter_options',
  211. 'ListReceiveVolumeFilterOptionsTool',
  212. )
  213. metadata = cls().metadata()
  214. schema = metadata['input_schema']
  215. self.assertEqual('list_receive_volume_filter_options', metadata['name'])
  216. self.assertEqual(
  217. '/mcp/tools/listReceiveVolumeFilterOptions', cls.route_path
  218. )
  219. self.assertEqual(['filter_type'], schema['required'])
  220. self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
  221. self.assertIn('query_receive_volume_list', metadata['description'])
  222. self.assertIn('禁止猜测', metadata['description'])
  223. self.assertIn('使用场景:', metadata['description'])
  224. self.assertIn('禁止使用:', metadata['description'])
  225. client = RecordingApiClient()
  226. cls(client).call('货物类型', keyword='散', page=2, limit=10)
  227. self.assertEqual(
  228. {
  229. 'filter_type': '货物类型',
  230. 'keyword': '散',
  231. 'page': 2,
  232. 'limit': 10,
  233. },
  234. client.calls[-1][2],
  235. )
  236. with self.assertRaises(ValueError):
  237. cls(client).call('事业部')
  238. with self.assertRaises(RuntimeError):
  239. cls().call('客户名称')
  240. with self.assertRaises(ValueError):
  241. cls(client).call('客户名称', keyword='x' * 101)
  242. with self.assertRaises(ValueError):
  243. cls(client).call('客户名称', page=0)
  244. with self.assertRaises(ValueError):
  245. cls(client).call('客户名称', page=True)
  246. def test_cli_forwards_dates_and_filters(self):
  247. client = RecordingApiClient()
  248. app = GatewayApp(api_client=client)
  249. with self.assertRaises(ValueError):
  250. app.run_cli([
  251. 'call', '--tool', 'query_receive_volume_list',
  252. ], stdout=io.StringIO())
  253. app.run_cli([
  254. 'call', '--tool', 'query_receive_volume_list',
  255. '--inbound-date-start', '2026-09-01',
  256. '--inbound-date-end', '2026-09-30',
  257. '--customer-ids', '11,12',
  258. '--sales-ids', '3',
  259. '--merchandiser-ids', '4',
  260. '--packing-types', '散货,整柜',
  261. '--product-ids', '8',
  262. ], stdout=io.StringIO())
  263. self.assertEqual(
  264. {
  265. 'inbound_date_start': '2026-09-01',
  266. 'inbound_date_end': '2026-09-30',
  267. 'customer_ids': [11, 12],
  268. 'sales_ids': [3],
  269. 'merchandiser_ids': [4],
  270. 'packing_types': ['散货', '整柜'],
  271. 'product_ids': [8],
  272. 'page': 1,
  273. 'limit': 20,
  274. },
  275. client.calls[-1][2],
  276. )
  277. app.run_cli([
  278. 'call', '--tool', 'query_receive_volume_list',
  279. '--inbound-date-start', '2026-09-01',
  280. '--inbound-date-end', '2026-09-02',
  281. ], stdout=io.StringIO())
  282. self.assertEqual(
  283. {
  284. 'inbound_date_start': '2026-09-01',
  285. 'inbound_date_end': '2026-09-02',
  286. 'page': 1,
  287. 'limit': 20,
  288. },
  289. client.calls[-1][2],
  290. )
  291. app.run_cli([
  292. 'call', '--tool', 'list_receive_volume_filter_options',
  293. '--filter-type', '物流产品',
  294. ], stdout=io.StringIO())
  295. self.assertEqual(
  296. {
  297. 'filter_type': '物流产品',
  298. 'keyword': '',
  299. 'page': 1,
  300. 'limit': 20,
  301. },
  302. client.calls[-1][2],
  303. )
  304. def test_presenter_accepts_nine_columns_and_finite_volume(self):
  305. presenter = OutputPresenter()
  306. columns = [{'key': key, 'name': name} for key, name in COLUMNS]
  307. record = {
  308. 'order_number': 'FMS-1',
  309. 'customer_name': '客户甲',
  310. 'packing_type': '散货',
  311. 'inbound_volume': 12.5,
  312. 'inbound_date': '2026-09-01',
  313. 'sales_name': '张三',
  314. 'merchandiser_name': '李四',
  315. 'department_name': '事业部A',
  316. 'product_name': '美线-US01',
  317. }
  318. meta = {
  319. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  320. }
  321. ok = presenter.present('query_receive_volume_list', {
  322. 'code': 'MCP_0000',
  323. 'data': {'columns': columns, 'records': [record]},
  324. 'meta': meta,
  325. })
  326. self.assertFalse(ok['is_error'])
  327. self.assertEqual(12.5, ok['structured_content']['rows'][0][3])
  328. string_volume = dict(record)
  329. string_volume['inbound_volume'] = '12.5'
  330. self.assertTrue(presenter.present('query_receive_volume_list', {
  331. 'code': 'MCP_0000',
  332. 'data': {'columns': columns, 'records': [string_volume]},
  333. 'meta': meta,
  334. })['is_error'])
  335. inf_volume = dict(record)
  336. inf_volume['inbound_volume'] = math.inf
  337. self.assertTrue(presenter.present('query_receive_volume_list', {
  338. 'code': 'MCP_0000',
  339. 'data': {'columns': columns, 'records': [inf_volume]},
  340. 'meta': meta,
  341. })['is_error'])
  342. extra = dict(record)
  343. extra['inbound_pieces'] = '1'
  344. self.assertTrue(presenter.present('query_receive_volume_list', {
  345. 'code': 'MCP_0000',
  346. 'data': {'columns': columns, 'records': [extra]},
  347. 'meta': meta,
  348. })['is_error'])
  349. self.assertTrue(presenter.present('query_receive_volume_list', {
  350. 'code': 'MCP_0000',
  351. 'data': {'columns': columns, 'records': [], 'extra': 1},
  352. 'meta': meta,
  353. })['is_error'])
  354. bad_columns = list(columns)
  355. bad_columns[0] = {'key': 'order_number', 'name': '错'}
  356. self.assertTrue(presenter.present('query_receive_volume_list', {
  357. 'code': 'MCP_0000',
  358. 'data': {'columns': bad_columns, 'records': []},
  359. 'meta': meta,
  360. })['is_error'])
  361. non_string = dict(record)
  362. non_string['order_number'] = 1
  363. self.assertTrue(presenter.present('query_receive_volume_list', {
  364. 'code': 'MCP_0000',
  365. 'data': {'columns': columns, 'records': [non_string]},
  366. 'meta': meta,
  367. })['is_error'])
  368. self.assertTrue(presenter.present('query_receive_volume_list', {
  369. 'code': 'MCP_0000',
  370. 'data': {'columns': columns, 'records': []},
  371. 'meta': {
  372. 'page': 1, 'limit': 20, 'has_more': False,
  373. 'request_id': 'rq_x', 'total': 1,
  374. },
  375. })['is_error'])
  376. options = presenter.present('list_receive_volume_filter_options', {
  377. 'code': 'MCP_0000',
  378. 'data': {
  379. 'records': [
  380. {'value': '散货', 'label': '散货', 'code': ''},
  381. ],
  382. },
  383. 'meta': meta,
  384. })
  385. self.assertFalse(options['is_error'])
  386. def test_local_public_registry_includes_both_tools(self):
  387. local = GatewayApp().registered_tool_names()
  388. public = PublicGatewayApp(None, None).registered_tool_names()
  389. self.assertEqual(local, public)
  390. self.assertEqual(34, len(local))
  391. self.assertEqual(33, len(OutputPresenter.SAFE_TOOLS))
  392. self.assertIn('query_receive_volume_list', local)
  393. self.assertIn('list_receive_volume_filter_options', local)