export_pallet_data.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. from datetime import datetime
  2. import re
  3. class ExportPalletDataTool:
  4. name = 'export_pallet_data'
  5. route_path = '/mcp/tools/exportPalletData'
  6. DATE_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$')
  7. DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$')
  8. def __init__(self, api_client=None):
  9. self.api_client = api_client
  10. def metadata(self):
  11. number_items = {
  12. 'type': 'string',
  13. 'minLength': 1,
  14. 'maxLength': 100,
  15. 'pattern': '.*\\S.*',
  16. }
  17. number_array = {
  18. 'type': 'array',
  19. 'minItems': 1,
  20. 'maxItems': 200,
  21. 'items': dict(number_items),
  22. }
  23. time_field = {
  24. 'type': 'string',
  25. 'description': (
  26. '海外仓入库时间,格式 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS。'
  27. '只填日期时开始日 00:00:00、结束日 23:59:59。'
  28. ),
  29. }
  30. return {
  31. 'name': self.name,
  32. 'description': (
  33. '本工具只提交异步导出任务,不会在本次调用中等待文件生成,也禁止在一次调用内'
  34. '轮询任务状态。成功后返回任务引用(task_ref)和建议等待时间'
  35. '(retry_after_seconds)。稍后单独使用 query_export_task 查询任务状态或下载链接。'
  36. '使用场景:只有用户明确要求导出打托数据并需要下载文件时才可调用。'
  37. '必须提供柜号数组、提单号数组或海外仓入库时间起止,三者严格三选一,'
  38. '不可混用。号码类型不明确时必须先询问用户;用户没有明确号码类型时必须先询问:'
  39. '“请确认使用哪种号码导出:柜号还是提单号?”确认前不得调用。'
  40. '禁止使用:查看入仓列表、按订单号或打托批次号查询、勾选内部 ID、'
  41. '混用柜号/提单号/入库时间、一次调用内轮询 query_export_task。'
  42. '不得根据号码格式猜测,不得跨字段或跨工具试查。'
  43. '参数名仅用于工具调用;向用户回答时只能使用中文业务名称,'
  44. '不得展示内部参数名。'
  45. ),
  46. 'input_schema': {
  47. 'type': 'object',
  48. 'properties': {
  49. 'container_codes': dict(
  50. number_array,
  51. description=(
  52. '柜号数组。仅当用户明确说柜号时使用;'
  53. '不得放入提单号、订单号或打托批次号。'
  54. ),
  55. ),
  56. 'bl_numbers': dict(
  57. number_array,
  58. description=(
  59. '提单号数组。仅当用户明确说提单号时使用;'
  60. '不得放入柜号、订单号或打托批次号。'
  61. ),
  62. ),
  63. 'inbound_time_start': dict(time_field),
  64. 'inbound_time_end': dict(time_field),
  65. },
  66. 'required': [],
  67. 'additionalProperties': False,
  68. },
  69. }
  70. def call(
  71. self,
  72. container_codes=None,
  73. bl_numbers=None,
  74. inbound_time_start=None,
  75. inbound_time_end=None,
  76. request_id='rq_export_pallet_data',
  77. ):
  78. if self.api_client is None:
  79. raise RuntimeError('api client is required for export_pallet_data')
  80. payload = {}
  81. effective_container = self._is_effective_number_selector(container_codes)
  82. effective_bl = self._is_effective_number_selector(bl_numbers)
  83. has_start = inbound_time_start is not None
  84. has_end = inbound_time_end is not None
  85. if has_start != has_end:
  86. raise ValueError('inbound time range requires both start and end')
  87. time_mode = has_start and has_end
  88. number_modes = int(effective_container) + int(effective_bl)
  89. if number_modes > 1:
  90. raise ValueError('cannot mix container codes and bl numbers')
  91. if number_modes >= 1 and time_mode:
  92. raise ValueError('cannot mix numbers and inbound time')
  93. if effective_container:
  94. payload['container_codes'] = self._clean_number_list(
  95. 'container_codes', container_codes
  96. )
  97. elif effective_bl:
  98. payload['bl_numbers'] = self._clean_number_list(
  99. 'bl_numbers', bl_numbers
  100. )
  101. elif time_mode:
  102. start_bound = self._parse_bound(inbound_time_start)
  103. end_bound = self._parse_bound(inbound_time_end)
  104. if end_bound.date() < start_bound.date() or (
  105. end_bound.date() - start_bound.date()
  106. ).days > 30:
  107. raise ValueError('inbound time range must be within 31 days')
  108. payload['inbound_time_start'] = str(inbound_time_start).strip()
  109. payload['inbound_time_end'] = str(inbound_time_end).strip()
  110. elif container_codes is not None:
  111. self._clean_number_list('container_codes', container_codes)
  112. elif bl_numbers is not None:
  113. self._clean_number_list('bl_numbers', bl_numbers)
  114. else:
  115. raise ValueError(
  116. 'provide container codes, bl numbers, or an inbound time range'
  117. )
  118. return self.api_client.call_tool(
  119. self.name, self.route_path, payload, request_id
  120. )
  121. @staticmethod
  122. def _is_effective_number_selector(values):
  123. return isinstance(values, list) and len(values) > 0
  124. def _clean_number_list(self, field_name, values):
  125. if not isinstance(values, list) or not values:
  126. raise ValueError('{0} must be a non-empty list'.format(field_name))
  127. cleaned = []
  128. for value in values:
  129. if not isinstance(value, str):
  130. raise ValueError('{0} items must be strings'.format(field_name))
  131. item = value.strip()
  132. if not item or len(item) > 100:
  133. raise ValueError(
  134. '{0} items must be 1 to 100 chars'.format(field_name)
  135. )
  136. if item not in cleaned:
  137. cleaned.append(item)
  138. if len(cleaned) > 200:
  139. raise ValueError('at most 200 {0}'.format(field_name))
  140. return cleaned
  141. def _parse_bound(self, value):
  142. text = str(value).strip()
  143. if self.DATE_RE.match(text):
  144. return datetime.strptime(text, '%Y-%m-%d')
  145. if self.DATETIME_RE.match(text):
  146. return datetime.strptime(text, '%Y-%m-%d %H:%M:%S')
  147. raise ValueError('inbound time must be date or datetime')