test_customer_payment_followup_tool.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import importlib.util
  2. import io
  3. import json
  4. import os
  5. import unittest
  6. from app import GatewayApp
  7. from public_gateway import PublicGatewayApp
  8. from services.output_presenter import OutputPresenter
  9. class RecordingApiClient:
  10. def __init__(self):
  11. self.calls = []
  12. def list_enabled_tools(self, request_id=''):
  13. return {
  14. 'code': 'MCP_0000',
  15. 'data': {'tool_codes': ['query_customer_payment_followup']},
  16. }
  17. def call_tool(self, tool_code, route_path, payload, request_id):
  18. self.calls.append((tool_code, route_path, payload, request_id))
  19. return {'code': 'MCP_0000', 'data': {}}
  20. class CustomerPaymentFollowupToolTest(unittest.TestCase):
  21. def load_tool_class(self):
  22. path = os.path.join(
  23. os.path.dirname(os.path.dirname(__file__)),
  24. 'tools',
  25. 'query_customer_payment_followup.py',
  26. )
  27. self.assertTrue(os.path.isfile(path), '客户回款跟进 Gateway 工具尚未实现')
  28. spec = importlib.util.spec_from_file_location('payment_followup_tool', path)
  29. module = importlib.util.module_from_spec(spec)
  30. spec.loader.exec_module(module)
  31. return module.QueryCustomerPaymentFollowupTool
  32. def test_closed_schema_and_call_forwarding(self):
  33. tool_class = self.load_tool_class()
  34. client = RecordingApiClient()
  35. tool = tool_class(client)
  36. metadata = tool.metadata()
  37. schema = metadata['input_schema']
  38. self.assertEqual('query_customer_payment_followup', metadata['name'])
  39. self.assertIn('query_customer_unverified_bill_details', metadata['description'])
  40. self.assertFalse(schema['additionalProperties'])
  41. self.assertEqual([], schema['required'])
  42. self.assertEqual({
  43. 'customer_id', 'department_id', 'sales_id', 'merchandiser_id',
  44. 'has_unverified_receivable_only', 'page', 'limit',
  45. }, set(schema['properties']))
  46. self.assertEqual(True, schema['properties']['has_unverified_receivable_only']['default'])
  47. self.assertEqual(100, schema['properties']['page']['maximum'])
  48. self.assertEqual(100, schema['properties']['limit']['maximum'])
  49. result = tool.call(
  50. customer_id=11,
  51. department_id=12,
  52. sales_id=13,
  53. merchandiser_id=14,
  54. has_unverified_receivable_only=False,
  55. page=2,
  56. limit=40,
  57. request_id='rq_payment',
  58. )
  59. self.assertEqual('MCP_0000', result['code'])
  60. self.assertEqual((
  61. 'query_customer_payment_followup',
  62. '/mcp/tools/queryCustomerPaymentFollowup',
  63. {
  64. 'page': 2,
  65. 'limit': 40,
  66. 'has_unverified_receivable_only': False,
  67. 'customer_id': 11,
  68. 'department_id': 12,
  69. 'sales_id': 13,
  70. 'merchandiser_id': 14,
  71. },
  72. 'rq_payment',
  73. ), client.calls[0])
  74. def test_defaults_and_illegal_parameters_are_rejected(self):
  75. tool_class = self.load_tool_class()
  76. client = RecordingApiClient()
  77. tool = tool_class(client)
  78. tool.call()
  79. self.assertEqual({
  80. 'page': 1,
  81. 'limit': 20,
  82. 'has_unverified_receivable_only': True,
  83. }, client.calls[0][2])
  84. invalid_calls = (
  85. {'customer_id': True}, {'department_id': 0}, {'sales_id': 1.5},
  86. {'merchandiser_id': '2'}, {'page': 0}, {'page': 101},
  87. {'limit': False}, {'limit': 101},
  88. {'has_unverified_receivable_only': 1},
  89. {'has_unverified_receivable_only': 'true'},
  90. )
  91. for arguments in invalid_calls:
  92. with self.subTest(arguments=arguments):
  93. with self.assertRaises(ValueError):
  94. tool.call(**arguments)
  95. with self.assertRaises(RuntimeError):
  96. tool_class().call()
  97. def test_local_public_registries_and_cli_have_eighteen_tools(self):
  98. local = GatewayApp(api_client=RecordingApiClient())
  99. public = PublicGatewayApp(None, None)
  100. self.assertEqual(local.registered_tool_names(), public.registered_tool_names())
  101. self.assertEqual(19, len(local.registered_tool_names()))
  102. self.assertIn('query_customer_payment_followup', local.registered_tool_names())
  103. stdout = io.StringIO()
  104. local.run_cli([
  105. 'call', '--tool', 'query_customer_payment_followup',
  106. '--customer-id', '11', '--has-unverified-receivable-only', 'false',
  107. '--page', '2', '--limit', '10', '--request-id', 'rq_cli_payment',
  108. ], stdout=stdout)
  109. self.assertEqual('MCP_0000', json.loads(stdout.getvalue())['code'])
  110. self.assertEqual({
  111. 'page': 2,
  112. 'limit': 10,
  113. 'customer_id': 11,
  114. 'has_unverified_receivable_only': False,
  115. }, local.api_client.calls[-1][2])
  116. class CustomerPaymentFollowupPresenterTest(unittest.TestCase):
  117. KEYS = [
  118. 'customer_name', 'settlement_currency', 'billed_unverified_amount',
  119. 'unbilled_amount', 'overdue_unpaid_amount',
  120. 'billed_unverified_amount_cny', 'unbilled_amount_cny',
  121. 'overdue_unpaid_amount_cny', 'unverified_receivable_monthly_summary',
  122. 'receipt_unverified_amount', 'current_balance', 'credit_limit',
  123. 'bad_debt_total', 'contract_status',
  124. ]
  125. SUMMARY_KEYS = [
  126. 'receivable_month', 'unverified_amount', 'is_overdue',
  127. ]
  128. def setUp(self):
  129. self.presenter = OutputPresenter()
  130. def payload(self):
  131. record = {
  132. 'customer_name': '甲客户',
  133. 'settlement_currency': 'USD',
  134. 'billed_unverified_amount': 90.0,
  135. 'unbilled_amount': 50.0,
  136. 'overdue_unpaid_amount': 90.0,
  137. 'billed_unverified_amount_cny': 630.0,
  138. 'unbilled_amount_cny': 350.0,
  139. 'overdue_unpaid_amount_cny': 630.0,
  140. 'unverified_receivable_monthly_summary': [{
  141. 'receivable_month': '2026-01',
  142. 'unverified_amount': 90.0,
  143. 'is_overdue': True,
  144. }],
  145. 'receipt_unverified_amount': 30.0,
  146. 'current_balance': -300.0,
  147. 'credit_limit': 1000.0,
  148. 'bad_debt_total': 15.0,
  149. 'contract_status': '生效中',
  150. }
  151. return {
  152. 'code': 'MCP_0000',
  153. 'data': {
  154. 'columns': [{'key': key, 'name': key} for key in self.KEYS],
  155. 'records': [record],
  156. },
  157. 'meta': {
  158. 'page': 1, 'limit': 20, 'has_more': False,
  159. 'request_id': 'rq_payment',
  160. },
  161. }
  162. def test_success_translates_every_top_level_and_monthly_summary_field(self):
  163. result = self.presenter.present(
  164. 'query_customer_payment_followup', self.payload()
  165. )
  166. self.assertFalse(result['is_error'])
  167. self.assertEqual(14, len(result['structured_content']['headers']))
  168. self.assertEqual(14, len(result['structured_content']['rows'][0]))
  169. summaries = result['structured_content']['rows'][0][8]
  170. self.assertEqual({
  171. '应收月份', '未核销金额', '是否逾期',
  172. }, set(summaries[0]))
  173. self.assertEqual(
  174. {'page': 1, 'limit': 20, 'has_more': False},
  175. result['structured_content']['pagination'],
  176. )
  177. instruction = result['structured_content']['display_rules']['instruction']
  178. self.assertIn('月份汇总', instruction)
  179. self.assertNotIn('账单明细', instruction)
  180. serialized = json.dumps(result, ensure_ascii=False)
  181. for raw_key in self.KEYS + self.SUMMARY_KEYS:
  182. self.assertNotIn(raw_key, serialized)
  183. def test_missing_extra_or_reordered_top_level_fields_fail_closed(self):
  184. cases = []
  185. missing = self.payload()
  186. del missing['data']['records'][0]['credit_limit']
  187. cases.append(missing)
  188. extra = self.payload()
  189. extra['data']['records'][0]['secret'] = 'hidden'
  190. cases.append(extra)
  191. column_missing = self.payload()
  192. column_missing['data']['columns'].pop()
  193. cases.append(column_missing)
  194. reordered = self.payload()
  195. reordered['data']['columns'].reverse()
  196. cases.append(reordered)
  197. data_extra = self.payload()
  198. data_extra['data']['internal'] = True
  199. cases.append(data_extra)
  200. for payload in cases:
  201. with self.subTest(payload=payload):
  202. result = self.presenter.present('query_customer_payment_followup', payload)
  203. self.assertTrue(result['is_error'])
  204. def test_malformed_monthly_summary_amounts_and_pagination_fail_closed(self):
  205. cases = []
  206. for summary in (
  207. 'bad',
  208. [{'receivable_month': '2026-01'}],
  209. [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'secret': 1}],
  210. [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'receivable_month': []}],
  211. [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'unverified_amount': True}],
  212. [{**self.payload()['data']['records'][0]['unverified_receivable_monthly_summary'][0], 'is_overdue': 1}],
  213. ):
  214. payload = self.payload()
  215. payload['data']['records'][0]['unverified_receivable_monthly_summary'] = summary
  216. cases.append(payload)
  217. bad_amount = self.payload()
  218. bad_amount['data']['records'][0]['credit_limit'] = '1000'
  219. cases.append(bad_amount)
  220. bad_name = self.payload()
  221. bad_name['data']['records'][0]['customer_name'] = []
  222. cases.append(bad_name)
  223. bad_record = self.payload()
  224. bad_record['data']['records'] = ['bad']
  225. cases.append(bad_record)
  226. bad_columns = self.payload()
  227. bad_columns['data']['columns'] = 'bad'
  228. cases.append(bad_columns)
  229. bad_column_item = self.payload()
  230. bad_column_item['data']['columns'][0]['internal'] = True
  231. cases.append(bad_column_item)
  232. bad_page = self.payload()
  233. bad_page['meta']['has_more'] = 1
  234. cases.append(bad_page)
  235. page_over_limit = self.payload()
  236. page_over_limit['meta']['page'] = 101
  237. cases.append(page_over_limit)
  238. extra_pagination = self.payload()
  239. extra_pagination['meta']['total'] = 1
  240. cases.append(extra_pagination)
  241. for payload in cases:
  242. with self.subTest(payload=payload):
  243. result = self.presenter.present('query_customer_payment_followup', payload)
  244. self.assertTrue(result['is_error'])
  245. if __name__ == '__main__':
  246. unittest.main()