query_destination_trailer_list.py 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import re
  2. from datetime import date
  3. from typing import Literal
  4. class QueryDestinationTrailerListTool:
  5. name = 'query_destination_trailer_list'
  6. route_path = '/mcp/tools/queryDestinationTrailerList'
  7. STAGES = (
  8. '待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作',
  9. )
  10. DO_FORBIDDEN = (
  11. 'providers_id', 'warehouse_id', 'is_direct_send', 'bill_status',
  12. 'cabinet_type', 'shipping_method', 'eta_start', 'eta_end',
  13. 'pickup_time_start', 'pickup_time_end',
  14. 'container_return_time_start', 'container_return_time_end',
  15. )
  16. def __init__(self, api_client=None):
  17. self.api_client = api_client
  18. def metadata(self):
  19. number_array = {
  20. 'type': 'array', 'minItems': 1, 'maxItems': 200,
  21. 'items': {
  22. 'type': 'string', 'minLength': 1, 'maxLength': 100,
  23. 'pattern': '.*\\S.*',
  24. },
  25. }
  26. date_field = {
  27. 'type': 'string', 'format': 'date',
  28. 'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
  29. }
  30. properties = {
  31. 'trailer_stage': {
  32. 'type': 'string',
  33. 'enum': list[Literal['待安排拖车', '待提柜', '待还柜(海)', '已还柜(海)', '已提货(空)', 'DO单制作']](self.STAGES),
  34. 'description': (
  35. '海外提柜类型。必须先调用list_destination_trailer_filter_options并选择'
  36. '“海外提柜类型”取得六个中文值之一;用户未明确时必须先询问,不得默认某一类'
  37. '或六类一起查,也不得改走排舱列表。'
  38. ),
  39. },
  40. 'bl_numbers': dict(
  41. number_array,
  42. description='提单号数组。仅当用户明确说提单号时使用;不得放入柜号或其他号码。',
  43. ),
  44. 'container_codes': dict(
  45. number_array,
  46. description='柜号数组。仅当用户明确说柜号时使用;不得放入提单号或其他号码。',
  47. ),
  48. 'providers_id': {
  49. 'type': 'integer', 'minimum': 1,
  50. 'description': '拖车行筛选值,必须先通过筛选项工具取得。',
  51. },
  52. 'warehouse_id': {
  53. 'type': 'integer', 'minimum': 1,
  54. 'description': '海外仓筛选值,必须先通过筛选项工具取得;只命中非直送。',
  55. },
  56. 'is_direct_send': {
  57. 'type': 'integer', 'enum': [0, 1],
  58. 'description': '直送柜筛选值,先通过筛选项工具取得。',
  59. },
  60. 'bill_status': {
  61. 'type': 'integer', 'enum': [10, 20, 30, 35, 40],
  62. 'description': '应付费用状态筛选值,先通过筛选项工具取得。',
  63. },
  64. 'cabinet_type': {
  65. 'type': 'integer', 'enum': [1, 2, 3, 4],
  66. 'description': '整拼类型筛选值,先通过筛选项工具取得。',
  67. },
  68. 'shipping_method': {
  69. 'type': 'integer', 'enum': [1, 2, 3],
  70. 'description': '运输方式筛选值,先通过筛选项工具取得。',
  71. },
  72. 'eta_start': dict(date_field),
  73. 'eta_end': dict(date_field),
  74. 'pickup_time_start': dict(date_field),
  75. 'pickup_time_end': dict(date_field),
  76. 'container_return_time_start': dict(date_field),
  77. 'container_return_time_end': dict(date_field),
  78. 'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
  79. 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
  80. }
  81. return {
  82. 'name': self.name,
  83. 'description': (
  84. '使用场景:用户要看海外提柜进度或DO单制作列表时,先确认六个类型之一即可查询当前页签;'
  85. '提单号、柜号、日期和其他筛选均为可选。传了号码则类型必须明确,'
  86. '号码类型不明确时必须先询问用户,'
  87. '不得根据号码格式猜测,不得跨字段或跨工具试查,也不得把海外提柜当成排舱列表调用'
  88. 'query_outbound_list。日期若传入必须成对且最多31个目的港当地日历日。'
  89. '禁止使用:安排拖车、确认还柜、港后数据/应付/入仓单/POD/'
  90. '装柜顺序导出,以及混查订单号、拖车单号、排舱单号或SO。参数只用于工具内部调用;'
  91. '最终回答只能展示中文业务名称;描述筛选条件时不得展示筛选字段的英文参数名。'
  92. ),
  93. 'input_schema': {
  94. 'type': 'object',
  95. 'properties': properties,
  96. 'required': ['trailer_stage'],
  97. 'additionalProperties': False,
  98. },
  99. }
  100. def call(
  101. self, trailer_stage, bl_numbers=None, container_codes=None,
  102. providers_id=None, warehouse_id=None, is_direct_send=None,
  103. bill_status=None, cabinet_type=None, shipping_method=None,
  104. eta_start=None, eta_end=None, pickup_time_start=None,
  105. pickup_time_end=None, container_return_time_start=None,
  106. container_return_time_end=None, page=1, limit=20,
  107. request_id='rq_query_destination_trailer_list',
  108. ):
  109. if self.api_client is None:
  110. raise RuntimeError(
  111. 'api client is required for query_destination_trailer_list'
  112. )
  113. stage = str(trailer_stage or '').strip()
  114. if stage not in self.STAGES:
  115. raise ValueError('trailer_stage is invalid')
  116. payload = {'trailer_stage': stage}
  117. total = 0
  118. for field, values in (
  119. ('bl_numbers', bl_numbers),
  120. ('container_codes', container_codes),
  121. ):
  122. if values is None:
  123. continue
  124. cleaned = self._number_list(values, field)
  125. payload[field] = cleaned
  126. total += len(values)
  127. if total > 200:
  128. raise ValueError('at most 200 numbers are allowed')
  129. for start_name, start_value, end_value in (
  130. ('eta_start', eta_start, eta_end),
  131. ('pickup_time_start', pickup_time_start, pickup_time_end),
  132. (
  133. 'container_return_time_start',
  134. container_return_time_start,
  135. container_return_time_end,
  136. ),
  137. ):
  138. end_name = start_name.replace('_start', '_end')
  139. start = self._optional_date(start_value, start_name)
  140. end = self._optional_date(end_value, end_name)
  141. if bool(start) != bool(end):
  142. raise ValueError(start_name + ' range is invalid')
  143. if start:
  144. if (date.fromisoformat(end) - date.fromisoformat(start)).days > 30:
  145. raise ValueError(start_name + ' range is invalid')
  146. if end < start:
  147. raise ValueError(start_name + ' range is invalid')
  148. payload[start_name] = start
  149. payload[end_name] = end
  150. for field, value, allowed in (
  151. ('providers_id', providers_id, None),
  152. ('warehouse_id', warehouse_id, None),
  153. ('is_direct_send', is_direct_send, (0, 1)),
  154. ('bill_status', bill_status, (10, 20, 30, 35, 40)),
  155. ('cabinet_type', cabinet_type, (1, 2, 3, 4)),
  156. ('shipping_method', shipping_method, (1, 2, 3)),
  157. ):
  158. if value is None:
  159. continue
  160. checked = self._integer(value, field, 0 if field == 'is_direct_send' else 1)
  161. if allowed is not None and checked not in allowed:
  162. raise ValueError(field + ' is invalid')
  163. payload[field] = checked
  164. if stage == 'DO单制作':
  165. for field in self.DO_FORBIDDEN:
  166. if field in payload:
  167. raise ValueError('DO stage does not accept extra filters')
  168. payload['page'] = self._bounded_integer(page, 'page')
  169. payload['limit'] = self._bounded_integer(limit, 'limit')
  170. return self.api_client.call_tool(
  171. self.name, self.route_path, payload, request_id,
  172. )
  173. @staticmethod
  174. def _number_list(values, field):
  175. if not isinstance(values, list) or not values:
  176. raise ValueError(field + ' is invalid')
  177. result = []
  178. for value in values:
  179. if not isinstance(value, str):
  180. raise ValueError(field + ' is invalid')
  181. value = value.strip()
  182. if not value or len(value) > 100:
  183. raise ValueError(field + ' is invalid')
  184. if value not in result:
  185. result.append(value)
  186. return result
  187. @staticmethod
  188. def _optional_date(value, field):
  189. if value is None or value == '':
  190. return ''
  191. if not isinstance(value, str):
  192. raise ValueError(field + ' is invalid')
  193. if re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', value) is None:
  194. raise ValueError(field + ' is invalid')
  195. try:
  196. date.fromisoformat(value)
  197. except ValueError:
  198. raise ValueError(field + ' is invalid')
  199. return value
  200. @staticmethod
  201. def _integer(value, field, minimum=None):
  202. if isinstance(value, bool) or not isinstance(value, int):
  203. raise ValueError(field + ' is invalid')
  204. if minimum is not None and value < minimum:
  205. raise ValueError(field + ' is invalid')
  206. return value
  207. @staticmethod
  208. def _bounded_integer(value, field):
  209. if isinstance(value, bool) or not isinstance(value, int):
  210. raise ValueError(field + ' is invalid')
  211. if value < 1 or value > 100:
  212. raise ValueError(field + ' is invalid')
  213. return value