test_destination_trailer_tools.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import importlib
  2. import io
  3. import unittest
  4. from app import GatewayApp
  5. from public_gateway import PublicGatewayApp
  6. from services.output_presenter import OutputPresenter
  7. PROGRESS_COLUMNS = [
  8. ('bl_number', '提单号'), ('container_code', '柜号'),
  9. ('container_type', '柜型'), ('warehouse_name', '仓库名称'),
  10. ('providers_name', '拖车行'), ('trailer_status', '提柜状态'),
  11. ('latest_remark', '最新操作备注'), ('is_direct_send', '直送'),
  12. ('cabinet_type', '整拼类型'), ('vessel_name', '船名航次'),
  13. ('route', '航线'), ('clearance_check', '清关查验'),
  14. ('etd', 'ETD'), ('atd', 'ATD'), ('eta', 'ETA'), ('ata', 'ATA'),
  15. ('loading_port', '起运港'), ('destination_port', '目的港'),
  16. ('wharf_name', '到港码头'), ('appt_time', 'APPT时间'),
  17. ('delivery_end_time', '卡车实际派送时间'),
  18. ('est_pickup_time', '预计提柜时间'), ('pickup_time', '实际提柜时间'),
  19. ('inbound_date', '到仓时间'), ('container_return_time', '还柜时间'),
  20. ('pickup_prescription', '提柜时效'), ('return_prescription', '还柜时效'),
  21. ('wharf_wait_time', '码头等待时间'), ('amazon_wait_time', '亚马逊等待时间'),
  22. ('bill_status', '应付费用状态'),
  23. ('issue_way', '出单方式'),
  24. ('release_status', '放货状态'),
  25. ]
  26. DO_COLUMNS = [
  27. ('trailer_number', '拖车单号'), ('outbound_number', '出库单号'),
  28. ('container_code', '柜号'), ('container_type', '柜型'),
  29. ('bl_number', '提单号'), ('is_direct_send', '是否直送'),
  30. ('original_do_file', '原DO单'), ('new_do_file', '新DO单'),
  31. ('email_subject', '邮件主题'), ('email_from', '邮件发送人'),
  32. ('received_at', '邮件接收时间'),
  33. ]
  34. FILTER_TYPES = [
  35. '海外提柜类型', '拖车行', '海外仓', '直送柜', '应付费用', '整拼类型', '运输方式',
  36. ]
  37. STAGES = [
  38. '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作',
  39. ]
  40. class RecordingApiClient:
  41. def __init__(self):
  42. self.calls = []
  43. def list_enabled_tools(self, request_id=''):
  44. return {
  45. 'code': 'MCP_0000',
  46. 'data': {
  47. 'tool_codes': [
  48. 'query_destination_trailer_list',
  49. 'list_destination_trailer_filter_options',
  50. ],
  51. },
  52. }
  53. def call_tool(self, tool_code, route_path, payload, request_id):
  54. self.calls.append((tool_code, route_path, payload, request_id))
  55. return {'code': 'MCP_0000', 'data': {}, 'meta': {}}
  56. class DestinationTrailerToolContractTest(unittest.TestCase):
  57. def tool_class(self, module_name, class_name):
  58. return getattr(importlib.import_module('tools.' + module_name), class_name)
  59. def test_query_schema_requires_stage_and_forbids_identity(self):
  60. cls = self.tool_class(
  61. 'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
  62. )
  63. metadata = cls().metadata()
  64. schema = metadata['input_schema']
  65. self.assertEqual('query_destination_trailer_list', metadata['name'])
  66. self.assertEqual('/mcp/tools/queryDestinationTrailerList', cls.route_path)
  67. self.assertFalse(schema['additionalProperties'])
  68. self.assertEqual(['trailer_stage'], schema['required'])
  69. self.assertEqual(STAGES, schema['properties']['trailer_stage']['enum'])
  70. self.assertNotIn('default', schema['properties']['trailer_stage'])
  71. for forbidden in ('company_id', 'admin_id', 'is_super', 'outbound_id'):
  72. self.assertNotIn(forbidden, schema['properties'])
  73. self.assertIn('使用场景:', metadata['description'])
  74. self.assertIn('禁止使用:', metadata['description'])
  75. self.assertIn('号码类型不明确时必须先询问用户', metadata['description'])
  76. self.assertIn('不得根据号码格式猜测', metadata['description'])
  77. self.assertIn('不得跨字段或跨工具试查', metadata['description'])
  78. self.assertIn('query_outbound_list', metadata['description'])
  79. self.assertIn('提单号、柜号、日期和其他筛选均为可选', metadata['description'])
  80. self.assertNotIn('必须再有', metadata['description'])
  81. def test_query_forwards_numbers_dates_and_rejects_invalid(self):
  82. cls = self.tool_class(
  83. 'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
  84. )
  85. client = RecordingApiClient()
  86. cls(client).call(
  87. trailer_stage='待提柜',
  88. bl_numbers=[' BL-1 ', 'BL-1'],
  89. container_codes=['CONT-1'],
  90. providers_id=8,
  91. warehouse_id=3,
  92. is_direct_send=0,
  93. bill_status=10,
  94. cabinet_type=1,
  95. shipping_method=2,
  96. eta_start='2026-07-01',
  97. eta_end='2026-07-31',
  98. page=2,
  99. limit=30,
  100. request_id='rq_dt',
  101. )
  102. self.assertEqual(
  103. {
  104. 'trailer_stage': '待提柜',
  105. 'bl_numbers': ['BL-1'],
  106. 'container_codes': ['CONT-1'],
  107. 'providers_id': 8,
  108. 'warehouse_id': 3,
  109. 'is_direct_send': 0,
  110. 'bill_status': 10,
  111. 'cabinet_type': 1,
  112. 'shipping_method': 2,
  113. 'eta_start': '2026-07-01',
  114. 'eta_end': '2026-07-31',
  115. 'page': 2,
  116. 'limit': 30,
  117. },
  118. client.calls[-1][2],
  119. )
  120. self.assertEqual(
  121. '/mcp/tools/queryDestinationTrailerList',
  122. client.calls[-1][1],
  123. )
  124. cls(client).call(trailer_stage='待提柜')
  125. self.assertEqual(
  126. {
  127. 'trailer_stage': '待提柜',
  128. 'page': 1,
  129. 'limit': 20,
  130. },
  131. client.calls[-1][2],
  132. )
  133. cls(client).call(trailer_stage='待提柜', providers_id=8)
  134. with self.assertRaises(RuntimeError):
  135. cls().call(trailer_stage='待提柜')
  136. invalid = (
  137. {'trailer_stage': '全部', 'bl_numbers': ['BL-1']},
  138. {'trailer_stage': '待提柜', 'eta_start': '2026-07-01'},
  139. {
  140. 'trailer_stage': '待提柜',
  141. 'eta_start': '2026-07-01',
  142. 'eta_end': '2026-08-01',
  143. },
  144. {
  145. 'trailer_stage': 'DO单制作',
  146. 'bl_numbers': ['BL-1'],
  147. 'providers_id': 8,
  148. },
  149. {
  150. 'trailer_stage': 'DO单制作',
  151. 'eta_start': '2026-07-01',
  152. 'eta_end': '2026-07-02',
  153. },
  154. )
  155. for arguments in invalid:
  156. with self.subTest(arguments=arguments):
  157. with self.assertRaises(ValueError):
  158. cls(client).call(**arguments)
  159. def test_filter_schema_and_forwarding(self):
  160. cls = self.tool_class(
  161. 'list_destination_trailer_filter_options',
  162. 'ListDestinationTrailerFilterOptionsTool',
  163. )
  164. metadata = cls().metadata()
  165. schema = metadata['input_schema']
  166. self.assertEqual(['filter_type'], schema['required'])
  167. self.assertEqual(FILTER_TYPES, schema['properties']['filter_type']['enum'])
  168. self.assertIn('使用场景:', metadata['description'])
  169. self.assertIn('禁止使用:', metadata['description'])
  170. client = RecordingApiClient()
  171. cls(client).call('拖车行', keyword='美西', page=2, limit=10)
  172. self.assertEqual(
  173. {
  174. 'filter_type': '拖车行',
  175. 'keyword': '美西',
  176. 'page': 2,
  177. 'limit': 10,
  178. },
  179. client.calls[-1][2],
  180. )
  181. with self.assertRaises(ValueError):
  182. cls(client).call('排舱阶段')
  183. with self.assertRaises(RuntimeError):
  184. cls().call('海外提柜类型')
  185. with self.assertRaises(ValueError):
  186. cls(client).call('海外提柜类型', keyword='x' * 101)
  187. with self.assertRaises(ValueError):
  188. cls(client).call('海外提柜类型', page=0)
  189. with self.assertRaises(ValueError):
  190. cls(client).call('海外提柜类型', limit=True)
  191. def test_query_covers_remaining_validation_branches(self):
  192. cls = self.tool_class(
  193. 'query_destination_trailer_list', 'QueryDestinationTrailerListTool'
  194. )
  195. client = RecordingApiClient()
  196. with self.assertRaises(ValueError):
  197. cls(client).call(
  198. trailer_stage='待提柜',
  199. bl_numbers=['X'] * 201,
  200. )
  201. with self.assertRaises(ValueError):
  202. cls(client).call(
  203. trailer_stage='待提柜',
  204. eta_start='2026-07-02',
  205. eta_end='2026-07-01',
  206. )
  207. with self.assertRaises(ValueError):
  208. cls(client).call(
  209. trailer_stage='待提柜',
  210. bl_numbers=['BL-1'],
  211. bill_status=99,
  212. )
  213. with self.assertRaises(ValueError):
  214. cls(client).call(trailer_stage='待提柜', bl_numbers='BL-1')
  215. with self.assertRaises(ValueError):
  216. cls(client).call(trailer_stage='待提柜', bl_numbers=[1])
  217. with self.assertRaises(ValueError):
  218. cls(client).call(trailer_stage='待提柜', bl_numbers=[' '])
  219. with self.assertRaises(ValueError):
  220. cls(client).call(
  221. trailer_stage='待提柜',
  222. eta_start=20260701,
  223. eta_end='2026-07-02',
  224. )
  225. with self.assertRaises(ValueError):
  226. cls(client).call(
  227. trailer_stage='待提柜',
  228. eta_start='2026-99-01',
  229. eta_end='2026-07-02',
  230. )
  231. with self.assertRaises(ValueError):
  232. cls(client).call(
  233. trailer_stage='待提柜',
  234. eta_start='2026-7-01',
  235. eta_end='2026-07-02',
  236. )
  237. with self.assertRaises(ValueError):
  238. cls(client).call(
  239. trailer_stage='待提柜',
  240. bl_numbers=['BL-1'],
  241. providers_id=True,
  242. )
  243. with self.assertRaises(ValueError):
  244. cls(client).call(
  245. trailer_stage='待提柜',
  246. bl_numbers=['BL-1'],
  247. warehouse_id=0,
  248. )
  249. with self.assertRaises(ValueError):
  250. cls(client).call(
  251. trailer_stage='待提柜',
  252. bl_numbers=['BL-1'],
  253. page=0,
  254. )
  255. with self.assertRaises(ValueError):
  256. cls(client).call(
  257. trailer_stage='待提柜',
  258. bl_numbers=['BL-1'],
  259. limit=True,
  260. )
  261. cls(client).call(
  262. trailer_stage='待安排拖车',
  263. pickup_time_start='2026-07-01',
  264. pickup_time_end='2026-07-02',
  265. providers_id=8,
  266. bill_status=20,
  267. )
  268. cls(client).call(trailer_stage='DO单制作', bl_numbers=['BL-1'])
  269. with self.assertRaises(ValueError):
  270. cls(client).call(
  271. trailer_stage='待提柜',
  272. eta_start='20260701',
  273. eta_end='2026-07-02',
  274. )
  275. def test_cli_forwards_optional_trailer_filters(self):
  276. client = RecordingApiClient()
  277. app = GatewayApp(api_client=client)
  278. with self.assertRaises(ValueError):
  279. app.run_cli([
  280. 'call', '--tool', 'query_destination_trailer_list',
  281. ], stdout=io.StringIO())
  282. app.run_cli([
  283. 'call', '--tool', 'query_destination_trailer_list',
  284. '--trailer-stage', '待还柜(海)',
  285. '--container-codes', 'C1',
  286. '--providers-id', '8',
  287. '--warehouse-id', '3',
  288. '--is-direct-send', '1',
  289. '--bill-status', '30',
  290. '--cabinet-type', '2',
  291. '--shipping-method', '2',
  292. '--eta-start', '2026-07-01',
  293. '--eta-end', '2026-07-02',
  294. '--pickup-time-start', '2026-07-03',
  295. '--pickup-time-end', '2026-07-04',
  296. '--container-return-time-start', '2026-07-05',
  297. '--container-return-time-end', '2026-07-06',
  298. ], stdout=io.StringIO())
  299. self.assertEqual(
  300. {
  301. 'trailer_stage': '待还柜(海)',
  302. 'container_codes': ['C1'],
  303. 'providers_id': 8,
  304. 'warehouse_id': 3,
  305. 'is_direct_send': 1,
  306. 'bill_status': 30,
  307. 'cabinet_type': 2,
  308. 'shipping_method': 2,
  309. 'eta_start': '2026-07-01',
  310. 'eta_end': '2026-07-02',
  311. 'pickup_time_start': '2026-07-03',
  312. 'pickup_time_end': '2026-07-04',
  313. 'container_return_time_start': '2026-07-05',
  314. 'container_return_time_end': '2026-07-06',
  315. 'page': 1,
  316. 'limit': 20,
  317. },
  318. client.calls[-1][2],
  319. )
  320. def test_presenter_fails_closed_on_unknown_shape(self):
  321. presenter = OutputPresenter()
  322. meta = {
  323. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_x',
  324. }
  325. self.assertTrue(presenter.present('query_destination_trailer_list', {
  326. 'code': 'MCP_0000',
  327. 'data': {'trailer_stage': '待提柜', 'columns': [], 'records': [], 'extra': 1},
  328. 'meta': meta,
  329. })['is_error'])
  330. self.assertTrue(presenter.present('query_destination_trailer_list', {
  331. 'code': 'MCP_0000',
  332. 'data': {
  333. 'trailer_stage': '未知',
  334. 'columns': [],
  335. 'records': [],
  336. },
  337. 'meta': meta,
  338. })['is_error'])
  339. self.assertTrue(presenter.present('query_destination_trailer_list', {
  340. 'code': 'MCP_0000',
  341. 'data': {
  342. 'trailer_stage': '待提柜',
  343. 'columns': [{'key': 'bl_number', 'name': '提单号'}],
  344. 'records': [],
  345. },
  346. 'meta': meta,
  347. })['is_error'])
  348. columns = [{'key': key, 'name': name} for key, name in PROGRESS_COLUMNS]
  349. columns[0] = {'key': 'bl_number', 'name': '错'}
  350. self.assertTrue(presenter.present('query_destination_trailer_list', {
  351. 'code': 'MCP_0000',
  352. 'data': {
  353. 'trailer_stage': '待提柜',
  354. 'columns': columns,
  355. 'records': [],
  356. },
  357. 'meta': meta,
  358. })['is_error'])
  359. record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
  360. record['bl_number'] = 1
  361. self.assertTrue(presenter.present('query_destination_trailer_list', {
  362. 'code': 'MCP_0000',
  363. 'data': {
  364. 'trailer_stage': '待提柜',
  365. 'columns': [
  366. {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
  367. ],
  368. 'records': [record],
  369. },
  370. 'meta': meta,
  371. })['is_error'])
  372. def test_local_public_registry_cli_and_safe_counts(self):
  373. local = GatewayApp().registered_tool_names()
  374. public = PublicGatewayApp(None, None).registered_tool_names()
  375. self.assertEqual(local, public)
  376. self.assertEqual(36, len(local))
  377. self.assertEqual(35, len(OutputPresenter.SAFE_TOOLS))
  378. self.assertIn('query_destination_trailer_list', local)
  379. self.assertIn('list_destination_trailer_filter_options', local)
  380. client = RecordingApiClient()
  381. app = GatewayApp(api_client=client)
  382. app.run_cli([
  383. 'call', '--tool', 'query_destination_trailer_list',
  384. '--trailer-stage', '待提柜', '--bl-numbers', ' BL-1,BL-2 ',
  385. '--page', '2', '--limit', '30',
  386. ], stdout=io.StringIO())
  387. self.assertEqual(
  388. {
  389. 'trailer_stage': '待提柜',
  390. 'bl_numbers': ['BL-1', 'BL-2'],
  391. 'page': 2,
  392. 'limit': 30,
  393. },
  394. client.calls[-1][2],
  395. )
  396. with self.assertRaises(ValueError):
  397. app.run_cli([
  398. 'call', '--tool', 'list_destination_trailer_filter_options',
  399. ], stdout=io.StringIO())
  400. app.run_cli([
  401. 'call', '--tool', 'list_destination_trailer_filter_options',
  402. '--filter-type', '海外提柜类型',
  403. ], stdout=io.StringIO())
  404. self.assertEqual(
  405. 'list_destination_trailer_filter_options',
  406. client.calls[-1][0],
  407. )
  408. def test_presenter_locks_thirty_and_eleven_columns(self):
  409. presenter = OutputPresenter()
  410. progress_record = {key: 'v' for key, _ in PROGRESS_COLUMNS}
  411. progress_record['trailer_status'] = '待提柜'
  412. result = presenter.present('query_destination_trailer_list', {
  413. 'code': 'MCP_0000',
  414. 'data': {
  415. 'trailer_stage': '待提柜',
  416. 'columns': [
  417. {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
  418. ],
  419. 'records': [progress_record],
  420. },
  421. 'meta': {
  422. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_p',
  423. },
  424. })
  425. self.assertFalse(result['is_error'])
  426. self.assertEqual(32, len(result['structured_content']['headers']))
  427. self.assertEqual('待提柜', result['structured_content']['trailer_stage'])
  428. do_record = {key: 'd' for key, _ in DO_COLUMNS}
  429. do_record['is_direct_send'] = '否'
  430. do_record['original_do_file'] = ''
  431. do_record['new_do_file'] = 'https://files.example/do.pdf'
  432. do_ok = presenter.present('query_destination_trailer_list', {
  433. 'code': 'MCP_0000',
  434. 'data': {
  435. 'trailer_stage': 'DO单制作',
  436. 'columns': [
  437. {'key': key, 'name': name} for key, name in DO_COLUMNS
  438. ],
  439. 'records': [do_record],
  440. },
  441. 'meta': {
  442. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_d',
  443. },
  444. })
  445. self.assertFalse(do_ok['is_error'])
  446. self.assertEqual(11, len(do_ok['structured_content']['headers']))
  447. bad_url = dict(do_record)
  448. bad_url['new_do_file'] = 'javascript:alert(1)'
  449. bad = presenter.present('query_destination_trailer_list', {
  450. 'code': 'MCP_0000',
  451. 'data': {
  452. 'trailer_stage': 'DO单制作',
  453. 'columns': [
  454. {'key': key, 'name': name} for key, name in DO_COLUMNS
  455. ],
  456. 'records': [bad_url],
  457. },
  458. 'meta': {
  459. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_b',
  460. },
  461. })
  462. self.assertTrue(bad['is_error'])
  463. extra = dict(progress_record)
  464. extra['outbound_number'] = 'OB1'
  465. extra_result = presenter.present('query_destination_trailer_list', {
  466. 'code': 'MCP_0000',
  467. 'data': {
  468. 'trailer_stage': '待提柜',
  469. 'columns': [
  470. {'key': key, 'name': name} for key, name in PROGRESS_COLUMNS
  471. ],
  472. 'records': [extra],
  473. },
  474. 'meta': {
  475. 'page': 1, 'limit': 20, 'has_more': False, 'request_id': 'rq_e',
  476. },
  477. })
  478. self.assertTrue(extra_result['is_error'])