test_destination_trailer_tools.py 20 KB

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