query_payable_cost_list.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import re
  2. from datetime import date
  3. class QueryPayableCostListTool:
  4. name = 'query_payable_cost_list'
  5. route_path = '/mcp/tools/queryPayableCostList'
  6. NUMBER_FIELDS = (
  7. 'order_numbers', 'tracking_numbers', 'container_codes',
  8. 'bl_numbers', 'so_numbers',
  9. )
  10. BUSINESS_TYPES = (1, 2, 3, 4, 5, 7)
  11. def __init__(self, api_client=None):
  12. self.api_client = api_client
  13. def metadata(self):
  14. number_array = {
  15. 'type': 'array', 'minItems': 1, 'maxItems': 200,
  16. 'items': {
  17. 'type': 'string', 'minLength': 1, 'maxLength': 100,
  18. 'pattern': '.*\\S.*',
  19. },
  20. }
  21. date_field = {
  22. 'type': 'string', 'format': 'date',
  23. 'pattern': '^\\d{4}-\\d{2}-\\d{2}$',
  24. }
  25. properties = {
  26. 'business_type': {'type': 'integer', 'enum': list(self.BUSINESS_TYPES)},
  27. 'order_numbers': dict(number_array),
  28. 'tracking_numbers': dict(number_array),
  29. 'container_codes': dict(number_array),
  30. 'bl_numbers': dict(number_array),
  31. 'so_numbers': dict(number_array),
  32. 'business_date_start': dict(date_field),
  33. 'business_date_end': dict(date_field),
  34. 'cost_date_start': dict(date_field),
  35. 'cost_date_end': dict(date_field),
  36. 'operation_date_start': dict(date_field),
  37. 'operation_date_end': dict(date_field),
  38. 'business_node_id': {'type': 'integer', 'minimum': 1},
  39. 'provider_id': {'type': 'integer', 'minimum': 1},
  40. 'cost_type_ids': {
  41. 'type': 'array', 'minItems': 1, 'maxItems': 200,
  42. 'items': {'type': 'integer', 'minimum': 1},
  43. },
  44. 'billing_status': {'type': 'integer', 'enum': [0, 1]},
  45. 'payment_status': {'type': 'integer', 'enum': [-1, 1]},
  46. 'verification_status': {'type': 'integer', 'enum': [0, 1, 2]},
  47. 'document_type': {'type': 'integer', 'minimum': 0},
  48. 'page': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 1},
  49. 'limit': {'type': 'integer', 'minimum': 1, 'maximum': 100, 'default': 20},
  50. }
  51. return {
  52. 'name': self.name,
  53. 'description': (
  54. '按结算新版页签查询当前员工公司内的应付费用列表。五类号码字段分别精确筛选,'
  55. '不同字段与其他条件均为AND;禁止混合单号猜测、跨字段试查或使用未确认的费用项。'
  56. '头程(business_type=1)的order_numbers同时匹配单号与展示订单号(sub_number)。'
  57. '不传号码时至少提供一组不超过31日的业务、费用或操作日期闭区间。'
  58. '筛选值必须先调用list_payable_cost_filter_options取得,禁止猜测内部ID。'
  59. ),
  60. 'input_schema': {
  61. 'type': 'object', 'properties': properties,
  62. 'required': ['business_type'], 'additionalProperties': False,
  63. },
  64. }
  65. def call(
  66. self, business_type, order_numbers=None, tracking_numbers=None,
  67. container_codes=None, bl_numbers=None, so_numbers=None,
  68. business_date_start=None, business_date_end=None,
  69. cost_date_start=None, cost_date_end=None,
  70. operation_date_start=None, operation_date_end=None,
  71. business_node_id=None, provider_id=None, cost_type_ids=None,
  72. billing_status=None, payment_status=None, verification_status=None,
  73. document_type=None, page=1, limit=20,
  74. request_id='rq_query_payable_cost_list',
  75. ):
  76. if self.api_client is None:
  77. raise RuntimeError('api client is required for query_payable_cost_list')
  78. business_type = self._integer(business_type, 'business_type')
  79. if business_type not in self.BUSINESS_TYPES:
  80. raise ValueError('business_type is invalid')
  81. payload = self._filters(
  82. business_type, order_numbers, tracking_numbers, container_codes,
  83. bl_numbers, so_numbers, business_date_start, business_date_end,
  84. cost_date_start, cost_date_end, operation_date_start,
  85. operation_date_end, business_node_id, provider_id, cost_type_ids,
  86. billing_status, payment_status, verification_status, document_type,
  87. )
  88. payload['page'] = self._bounded_integer(page, 'page')
  89. payload['limit'] = self._bounded_integer(limit, 'limit')
  90. return self.api_client.call_tool(self.name, self.route_path, payload, request_id)
  91. @classmethod
  92. def _filters(
  93. cls, business_type, order_numbers, tracking_numbers, container_codes,
  94. bl_numbers, so_numbers, business_date_start, business_date_end,
  95. cost_date_start, cost_date_end, operation_date_start, operation_date_end,
  96. business_node_id, provider_id, cost_type_ids, billing_status,
  97. payment_status, verification_status, document_type,
  98. ):
  99. values = {
  100. 'order_numbers': order_numbers, 'tracking_numbers': tracking_numbers,
  101. 'container_codes': container_codes, 'bl_numbers': bl_numbers,
  102. 'so_numbers': so_numbers,
  103. }
  104. payload = {}
  105. payload['business_type'] = business_type
  106. total = 0
  107. for field in cls.NUMBER_FIELDS:
  108. if values[field] is None:
  109. continue
  110. cleaned = cls._number_list(values[field], field)
  111. payload[field] = cleaned
  112. total += len(values[field])
  113. if total > 200:
  114. raise ValueError('at most 200 numbers are allowed')
  115. date_pairs = (
  116. ('business_date_start', business_date_start, business_date_end),
  117. ('cost_date_start', cost_date_start, cost_date_end),
  118. ('operation_date_start', operation_date_start, operation_date_end),
  119. )
  120. complete_dates = False
  121. for field, start_value, end_value in date_pairs:
  122. start = cls._optional_date(start_value, field)
  123. end = cls._optional_date(end_value, field.replace('_start', '_end'))
  124. if bool(start) != bool(end):
  125. raise ValueError(field + ' range is invalid')
  126. if start:
  127. if (date.fromisoformat(end) - date.fromisoformat(start)).days > 30:
  128. raise ValueError(field + ' range is invalid')
  129. if end < start:
  130. raise ValueError(field + ' range is invalid')
  131. payload[field] = start
  132. payload[field.replace('_start', '_end')] = end
  133. complete_dates = True
  134. if total == 0 and not complete_dates:
  135. raise ValueError('number or date filters are required')
  136. for field, value, minimum in (
  137. ('business_node_id', business_node_id, 1),
  138. ('provider_id', provider_id, 1),
  139. ('document_type', document_type, 0),
  140. ):
  141. if value is not None:
  142. payload[field] = cls._integer(value, field, minimum)
  143. if cost_type_ids is not None:
  144. if not isinstance(cost_type_ids, list) or not cost_type_ids:
  145. raise ValueError('cost_type_ids is invalid')
  146. if len(cost_type_ids) > 200:
  147. raise ValueError('cost_type_ids is invalid')
  148. payload['cost_type_ids'] = [cls._integer(item, 'cost_type_ids', 1) for item in cost_type_ids]
  149. for field, value, allowed in (
  150. ('billing_status', billing_status, (0, 1)),
  151. ('payment_status', payment_status, (-1, 1)),
  152. ('verification_status', verification_status, (0, 1, 2)),
  153. ):
  154. if value is not None:
  155. checked = cls._integer(value, field)
  156. if checked not in allowed:
  157. raise ValueError(field + ' is invalid')
  158. payload[field] = checked
  159. return payload
  160. @staticmethod
  161. def _number_list(values, field):
  162. if not isinstance(values, list) or not values:
  163. raise ValueError(field + ' is invalid')
  164. result = []
  165. for value in values:
  166. if not isinstance(value, str):
  167. raise ValueError(field + ' is invalid')
  168. value = value.strip()
  169. if not value or len(value) > 100:
  170. raise ValueError(field + ' is invalid')
  171. if value not in result:
  172. result.append(value)
  173. return result
  174. @staticmethod
  175. def _optional_date(value, field):
  176. if value is None or value == '':
  177. return ''
  178. if not isinstance(value, str):
  179. raise ValueError(field + ' is invalid')
  180. if re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}', value) is None:
  181. raise ValueError(field + ' is invalid')
  182. try:
  183. date.fromisoformat(value)
  184. except ValueError:
  185. raise ValueError(field + ' is invalid')
  186. return value
  187. @staticmethod
  188. def _integer(value, field, minimum=None):
  189. if isinstance(value, bool) or not isinstance(value, int):
  190. raise ValueError(field + ' is invalid')
  191. if minimum is not None and value < minimum:
  192. raise ValueError(field + ' is invalid')
  193. return value
  194. @classmethod
  195. def _bounded_integer(cls, value, field):
  196. value = cls._integer(value, field, 1)
  197. if value > 100:
  198. raise ValueError(field + ' is invalid')
  199. return value