test_destination_trailer_tools.py 19 KB

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