Explorar o código

task-7137997996:燃油系数维护MCP(地址库、燃油系数)

chenxj hai 3 días
pai
achega
94ecd72276

+ 5 - 1
.gitignore

@@ -11,4 +11,8 @@ __pycache__
 .coverage
 htmlcov/
 coverage.xml
-.coverage.*
+.coverage.*
+/.idea
+/.vscode
+/.cursor
+*.log

+ 0 - 2
services/output_presenter.py

@@ -2958,8 +2958,6 @@ class OutputPresenter:
         if not text or len(text) > 200 * 101:
             return False
         parts = text.split('、')
-        if not parts:
-            return False
         for part in parts:
             if (
                 not part

+ 42 - 1
tests/test_app_coverage.py

@@ -266,6 +266,38 @@ class RunCliBranchCoverageTest(unittest.TestCase):
         self.assertEqual(0, result)
         self.assertEqual('全部', call_tool.call_args.args[1]['section'])
 
+    def test_inbound_receipt_cli_requires_container_codes(self):
+        for tool_name in ('list_inbound_receipt_templates', 'export_inbound_receipt'):
+            with self.assertRaisesRegex(ValueError, 'container-codes is required'):
+                GatewayApp().run_cli([
+                    'call', '--tool', tool_name,
+                ], stdout=io.StringIO())
+
+    def test_inbound_receipt_cli_forwards_container_codes(self):
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'list_inbound_receipt_templates',
+            '--container-codes', 'CONT-1, CONT-2',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual(['CONT-1', 'CONT-2'], call_tool.call_args.args[1]['container_codes'])
+        self.assertNotIn('page', call_tool.call_args.args[1])
+
+    def test_export_inbound_receipt_cli_requires_template_code(self):
+        with self.assertRaisesRegex(ValueError, 'template-code is required'):
+            GatewayApp().run_cli([
+                'call', '--tool', 'export_inbound_receipt',
+                '--container-codes', 'CONT-1',
+            ], stdout=io.StringIO())
+
+    def test_export_inbound_receipt_cli_forwards_template_code(self):
+        result, call_tool = self._run_with_mocked_call([
+            'call', '--tool', 'export_inbound_receipt',
+            '--container-codes', 'CONT-1',
+            '--template-code', 'FMS-INBOUND-LAX',
+        ])
+        self.assertEqual(0, result)
+        self.assertEqual('FMS-INBOUND-LAX', call_tool.call_args.args[1]['template_code'])
+
     def test_filter_options_requires_filter_type(self):
         with self.assertRaisesRegex(ValueError, 'filter-type is required'):
             GatewayApp().run_cli([
@@ -282,6 +314,7 @@ class RunCliBranchCoverageTest(unittest.TestCase):
             redis_prefix='gateway:',
             gateway_session_ttl_seconds=600,
             tools_base_url='https://tools.test',
+            auth_base_url='https://auth.test',
             rate_limit_enabled=True,
             rate_limit_max_requests=12,
             rate_limit_window_seconds=34,
@@ -311,10 +344,17 @@ class RunCliBranchCoverageTest(unittest.TestCase):
             prefix='gateway:',
             ttl_seconds=600,
         )
-        api_cls.assert_called_once_with('https://tools.test', timeout=9)
+        self.assertEqual(
+            [
+                (('https://tools.test',), {'timeout': 9}),
+                (('https://auth.test',), {'timeout': 9}),
+            ],
+            [(call.args, call.kwargs) for call in api_cls.call_args_list],
+        )
         public_app_cls.assert_called_once_with(
             session_store=store_cls.return_value,
             api_client=api_cls.return_value,
+            base_api_client=api_cls.return_value,
         )
         serve.assert_called_once_with(
             public_app_cls.return_value,
@@ -338,6 +378,7 @@ class RunCliBranchCoverageTest(unittest.TestCase):
             redis_prefix='gateway:',
             gateway_session_ttl_seconds=600,
             tools_base_url='https://tools.test',
+            auth_base_url='https://auth.test',
             rate_limit_enabled=False,
             rate_limit_max_requests=0,
             rate_limit_window_seconds=60,

+ 6 - 0
tests/test_arrange_destination_trailer_tool.py

@@ -166,6 +166,12 @@ class ArrangeDestinationTrailerToolTest(unittest.TestCase):
             'meta': {'request_id': 'rq1'},
         })
         self.assertEqual('请填写订单O1的重量和体积', result['structured_content']['message'])
+        known = presenter.present('arrange_destination_trailer', {
+            'code': 'MCP_1401',
+            'msg': '安排拖车行失败',
+            'meta': {'request_id': 'rq1'},
+        })
+        self.assertEqual('安排拖车行失败', known['structured_content']['message'])
         self.assertFalse(result['structured_content']['retryable'])
         hidden = presenter.present('arrange_destination_trailer', {
             'code': 'MCP_1401',

+ 102 - 0
tests/test_fuel_rate_tools.py

@@ -205,6 +205,108 @@ class FuelRateToolTest(unittest.TestCase):
         self.assertIn('RY12', rendered)
         self.assertIn('启用', rendered)
 
+    def test_tools_expose_metadata_and_reject_bad_input(self):
+        self.assertIn('fuel_no', QueryFuelRateListTool().metadata()['input_schema']['properties'])
+        self.assertIn('items', SaveFuelRateTool().metadata()['input_schema']['properties'])
+        self.assertIn('fuel_no', EnableFuelRateTool().metadata()['input_schema']['properties'])
+        with self.assertRaises(RuntimeError):
+            QueryFuelRateListTool().call()
+        with self.assertRaises(RuntimeError):
+            SaveFuelRateTool().call(price_type='1', start_date='2026-10-22', items=[])
+        with self.assertRaises(RuntimeError):
+            EnableFuelRateTool().call(fuel_no='RY12')
+
+        query = QueryFuelRateListTool(api_client=FakeClient())
+        for kwargs in (
+            {'page': True},
+            {'page': 0},
+            {'limit': 101},
+            {'product': 1},
+            {'channel': 'C' * 101},
+            {'price_type': True},
+            {'status': '停用'},
+        ):
+            with self.assertRaises(ValueError):
+                query.call(**kwargs)
+        query.call(product='   ', price_type=2, status=1)
+
+        save = SaveFuelRateTool(api_client=FakeClient())
+        good = {
+            'fuel_ratio': '0',
+            'conditions': [{
+                'condition': '物流产品',
+                'group': '1',
+                'condition_value': '美国专线',
+                'condition_relation': 'and',
+            }],
+        }
+        for kwargs in (
+            {'price_type': True, 'start_date': '2026-10-22', 'items': [good]},
+            {'price_type': '销售', 'start_date': '2026-10-22', 'items': [good]},
+            {'price_type': '1', 'start_date': '', 'items': [good]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'end_date': 'x' * 31, 'items': [good]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'fuel_no': 1, 'items': [good]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'fuel_no': ' ', 'items': [good]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'fuel_no': 'R' * 21, 'items': [good]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': {}},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': []},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [good] * 21},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': ['0.1']},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'extra': 1}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '   ', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '1.2.3', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '2', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0./', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.a', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '1.001', 'conditions': good['conditions']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': {}}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': []}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': [good['conditions'][0]] * 31}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': ['物流产品']}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': [{'condition': '物流产品', 'group': '1', 'condition_value': '美国专线', 'extra': 1}]}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': [{'condition': '', 'group': '1', 'condition_value': '美国专线'}]}]},
+            {'price_type': '1', 'start_date': '2026-10-22', 'items': [{'fuel_ratio': '0.1', 'conditions': [{'condition': '物流产品', 'group': '1', 'condition_value': '美国专线', 'condition_relation': 'xor'}]}]},
+        ):
+            with self.assertRaises(ValueError):
+                save.call(**kwargs)
+        client = FakeClient()
+        SaveFuelRateTool(api_client=client).call(
+            price_type='1',
+            start_date='2026-10-22',
+            end_date='2026-12-31',
+            items=[{
+                'fuel_ratio': 1,
+                'conditions': [{
+                    'condition': '物流产品',
+                    'group': '1',
+                    'condition_value': '美国专线',
+                    'condition_relation': 'or',
+                }],
+            }, {
+                'fuel_ratio': '1.0000',
+                'conditions': good['conditions'],
+            }],
+        )
+        self.assertEqual('2026-12-31', client.calls[0][2]['end_date'])
+        self.assertEqual('1.0000', client.calls[0][2]['items'][0]['fuel_ratio'])
+
+        enable = EnableFuelRateTool(api_client=FakeClient())
+        enable.call(fuel_no=' RY12 ')
+        for value in (1, [1], [' '], ['R' * 21], ['RY1'] * 21):
+            with self.assertRaises(ValueError):
+                enable.call(fuel_no=value)
+
+        presenter = OutputPresenter()
+        for message in ('', 'x' * 401, 'has sql', 'Exception', 'a\\b', '没有标记'):
+            result = presenter.present('save_fuel_rate', {
+                'code': 'MCP_1401',
+                'msg': message,
+                'data': {},
+                'meta': {'request_id': 'rq_fuel_save'},
+            })
+            self.assertIn('参数不正确', str(result))
+
 
 class FakeClient:
     def __init__(self):

+ 63 - 0
tests/test_inbound_receipt_tools.py

@@ -80,6 +80,69 @@ class InboundReceiptToolTest(unittest.TestCase):
         self.assertNotIn('SQLSTATE', hidden['text'])
         self.assertIn('没有可导出的数据', hidden['text'])
 
+    def test_container_codes_and_template_code_are_validated(self):
+        listing = ListInboundReceiptTemplatesTool(api_client=_Api())
+        exporting = ExportInboundReceiptTool(api_client=_Api())
+        with self.assertRaises(RuntimeError):
+            ListInboundReceiptTemplatesTool().call(['CONT-1'])
+        with self.assertRaises(RuntimeError):
+            ExportInboundReceiptTool().call(['CONT-1'], 'CODE')
+        for value in (None, [], ['CONT-1'] * 201, [1], [' '], ['C' * 101]):
+            with self.assertRaises(ValueError):
+                listing.clean_container_codes(value)
+        for value in (None, 1, ' ', 'C' * 101):
+            with self.assertRaises(ValueError):
+                exporting.call(['CONT-1'], value)
+
+        presenter = OutputPresenter()
+        ok = {
+            'receipt_kind': '非直送入仓单',
+            'templates': [{'name': ' 非直送柜 ', 'template_code': 'CODE'}],
+        }
+        shown = presenter.present('list_inbound_receipt_templates', {
+            'code': 'MCP_0000',
+            'data': ok,
+            'meta': {'request_id': 'rq_ok'},
+        })
+        self.assertFalse(shown['is_error'])
+        for data in (
+            dict(ok, extra=1),
+            dict(ok, receipt_kind='其他'),
+            dict(ok, templates=[]),
+            dict(ok, templates=[{'name': '模板'}]),
+            dict(ok, templates=[{'name': ' ', 'template_code': 'CODE'}]),
+            dict(ok, templates=[{'name': '模板', 'template_code': 1}]),
+        ):
+            result = presenter.present('list_inbound_receipt_templates', {
+                'code': 'MCP_0000',
+                'data': data,
+                'meta': {'request_id': 'rq_bad'},
+            })
+            self.assertTrue(result['is_error'])
+        for message in (
+            '当前没有可用的入仓单模板',
+            '所选模板不属于这次柜号对应的入仓单',
+            '这些柜号没有对应的排舱单:',
+            '这些柜号没有对应的排舱单:' + ('C' * 101),
+            '这些柜号没有对应的排舱单:CONT-1、 CONT-2',
+            '这些柜号没有对应的排舱单:CONT-1、\n',
+            '这些柜号是直送柜:CONT-1。这些柜号是非直送柜:CONT-2。',
+            '这些柜号是直送柜: CONT-1。这些柜号是非直送柜:CONT-2。',
+        ):
+            result = presenter.present('list_inbound_receipt_templates', {
+                'code': 'MCP_1601',
+                'msg': message,
+                'data': {},
+                'meta': {'request_id': 'rq_msg'},
+            })
+            if message.startswith('这些柜号是直送柜:CONT-1。') or message in (
+                '当前没有可用的入仓单模板',
+                '所选模板不属于这次柜号对应的入仓单',
+            ):
+                self.assertIn(message, result['text'])
+            else:
+                self.assertNotIn('CONT-1、', result['text'])
+
 
 if __name__ == '__main__':
     unittest.main()

+ 25 - 0
tests/test_public_gateway_unit.py

@@ -189,6 +189,31 @@ class TestPublicGatewayApp(unittest.TestCase):
         self.assertEqual(call_args['request_id'], 'rq_test')
         self.assertEqual(result['code'], '0')
 
+    def test_base_tool_uses_base_api_client(self):
+        base_client = MagicMock()
+        app = PublicGatewayApp(
+            session_store=self.mock_session_store,
+            api_client=self.mock_api_client,
+            base_api_client=base_client,
+        )
+        self.mock_session_store.get.return_value = {
+            'mcp_token': 'MT_token123',
+            'admin_id': 456,
+            'company_id': 200,
+        }
+        self.mock_api_client.list_enabled_tools.return_value = {
+            'code': 'MCP_0000',
+            'data': {'tool_codes': ['query_remote_address_list']},
+        }
+        base_client.call_tool.return_value = {'code': 'MCP_0000', 'data': {}}
+
+        result = app.call_tool('GWS_valid', 'query_remote_address_list', {}, 'rq_base')
+
+        base_client.call_tool.assert_called_once()
+        self.mock_api_client.call_tool.assert_not_called()
+        self.assertEqual('query_remote_address_list', base_client.call_tool.call_args.kwargs['tool_code'])
+        self.assertEqual('MCP_0000', result['code'])
+
     def test_call_tool_succeeds_when_store_has_no_touch_method(self):
         class ReadOnlySessionStore:
             def get(self, gateway_session_id):

+ 1 - 0
tests/test_query_freightower_track_tool.py

@@ -205,6 +205,7 @@ class QueryFreightowerTrackToolTest(unittest.TestCase):
         bad_port_value['ports'][0]['eta'] = 1
         cases.append(bad_port_value)
         cases.append(dict(sample_data(), ports=['x'], tracks=[1]))
+        cases.append(dict(sample_data(), ports='x'))
         bad_track_keys = sample_data()
         bad_track_keys['tracks'] = [{'time': '2026-09-20'}]
         cases.append(bad_track_keys)

+ 165 - 0
tests/test_remote_address_tools.py

@@ -163,6 +163,171 @@ class RemoteAddressToolTest(unittest.TestCase):
         })
         self.assertIn('地址库没有详情,请导入', str(result))
 
+    def test_tools_expose_metadata_and_reject_bad_input(self):
+        self.assertIn('address_no', QueryRemoteAddressListTool().metadata()['input_schema']['properties'])
+        self.assertIn('address_no', MaintainRemoteAddressTool().metadata()['input_schema']['properties'])
+        self.assertIn('details', SaveRemoteAddressTool().metadata()['input_schema']['properties'])
+        with self.assertRaises(RuntimeError):
+            QueryRemoteAddressListTool().call()
+        with self.assertRaises(RuntimeError):
+            MaintainRemoteAddressTool().call()
+        with self.assertRaises(RuntimeError):
+            SaveRemoteAddressTool().call(remote_name='美西', status=1, details=[{'country': 'US'}])
+
+        query = QueryRemoteAddressListTool(api_client=FakeClient())
+        for kwargs in (
+            {'country': 1},
+            {'status': True},
+            {'status': 2},
+            {'page': True},
+            {'page': 0},
+            {'limit': 101},
+        ):
+            with self.assertRaises(ValueError):
+                query.call(**kwargs)
+        query.call(province='CA', city='LA', remote_name='   ')
+
+        maintain = MaintainRemoteAddressTool(api_client=FakeClient())
+        with self.assertRaises(ValueError):
+            maintain.call(address_no=12)
+        with self.assertRaises(ValueError):
+            maintain.call(page=True)
+        with self.assertRaises(ValueError):
+            maintain.call(limit=0)
+        maintain.call(address_no='  ')
+        maintain.call(address_no=' PY12 ')
+
+        save = SaveRemoteAddressTool(api_client=FakeClient())
+        detail = {'country': 'US', 'province': '', 'start_postcode': 'A 1-b'}
+        for kwargs in (
+            {'remote_name': 1, 'status': 1, 'details': [detail]},
+            {'remote_name': ' ', 'status': 1, 'details': [detail]},
+            {'remote_name': '名' * 101, 'status': 1, 'details': [detail]},
+            {'remote_name': '美西', 'status': True, 'details': [detail]},
+            {'remote_name': '美西', 'status': 2, 'details': [detail]},
+            {'remote_name': '美西', 'status': 1, 'address_no': 1, 'details': [detail]},
+            {'remote_name': '美西', 'status': 1, 'address_no': 'P' * 21, 'details': [detail]},
+            {'remote_name': '美西', 'status': 1},
+            {'remote_name': '美西', 'status': 1, 'details': 'US'},
+            {'remote_name': '美西', 'status': 1, 'details': []},
+            {'remote_name': '美西', 'status': 1, 'details': [{'country': 'US'}] * 201},
+            {'remote_name': '美西', 'status': 1, 'details': ['US']},
+            {'remote_name': '美西', 'status': 1, 'details': [{'country': 'US', 'extra': '1'}]},
+            {'remote_name': '美西', 'status': 1, 'details': [{'country': 1}]},
+            {'remote_name': '美西', 'status': 1, 'details': [{'country': 'U' * 51}]},
+            {'remote_name': '美西', 'status': 1, 'details': [{'province': 'CA'}]},
+            {'remote_name': '美西', 'status': 1, 'details': [{'shipper_country': 'CN', 'shipper_end_postcode': '518099'}]},
+        ):
+            with self.assertRaises(ValueError):
+                save.call(**kwargs)
+        client = FakeClient()
+        SaveRemoteAddressTool(api_client=client).call(
+            remote_name='美西',
+            status=1,
+            address_no='   ',
+            details=[detail],
+        )
+        self.assertNotIn('address_no', client.calls[0][2])
+        SaveRemoteAddressTool(api_client=client).call(
+            address_no='PY12',
+            remote_name='美西',
+            status=0,
+        )
+
+    def test_presenter_rejects_bad_maintain_and_save_payloads(self):
+        presenter = OutputPresenter()
+        columns = [
+            {'key': key, 'name': name}
+            for key, name in OutputPresenter.REMOTE_ADDRESS_DETAIL_COLUMNS
+        ]
+        record = {key: '' for key, _name in OutputPresenter.REMOTE_ADDRESS_DETAIL_COLUMNS}
+        base = {
+            'summary': '准备新增地址库',
+            'mode_text': '新增',
+            'address_no': '',
+            'remote_name': '',
+            'status_text': '',
+            'temp_key': 'temp',
+            'columns': columns,
+            'records': [record],
+        }
+        bad_payloads = [None, dict(base, extra=1), dict(base, mode_text='查看')]
+        missing = dict(base)
+        missing.pop('summary')
+        bad_payloads.append(missing)
+        bad_payloads.append(dict(base, summary=1))
+        bad_payloads.append(dict(base, columns=[]))
+        bad_payloads.append(dict(base, records={}))
+        bad_columns = [dict(column) for column in columns]
+        bad_columns[0] = {'key': 'other', 'name': columns[0]['name']}
+        bad_payloads.append(dict(base, columns=bad_columns))
+        bad_payloads.append(dict(base, records=[{}]))
+        for data in bad_payloads:
+            result = presenter.present('maintain_remote_address', {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': data,
+                'meta': {'request_id': 'rq_maintain'},
+            })
+            self.assertIn('格式异常', str(result))
+
+        tipped = dict(base, tips=['该地址库还没有地址明细'])
+        tipped_result = presenter.present('maintain_remote_address', {
+            'code': 'MCP_0000',
+            'msg': 'success',
+            'data': tipped,
+            'meta': {'request_id': 'rq_maintain'},
+        })
+        self.assertIn('该地址库还没有地址明细', str(tipped_result))
+
+        saved = {
+            'summary': '已新增地址库 PY12 美西,状态启用',
+            'mode_text': '新增',
+            'address_no': 'PY12',
+            'remote_name': '美西',
+            'status_text': '启用',
+        }
+        for data in (
+            dict(saved, extra=1),
+            dict(saved, mode_text='查看'),
+            dict(saved, status_text='草稿'),
+            dict(saved, address_no='12'),
+            dict(saved, remote_name=' '),
+            dict(saved, summary=1),
+        ):
+            result = presenter.present('save_remote_address', {
+                'code': 'MCP_0000',
+                'msg': 'success',
+                'data': data,
+                'meta': {'request_id': 'rq_save_remote'},
+            })
+            self.assertIn('格式异常', str(result))
+
+        for message in (
+            '一次最多保存200条地址明细',
+            '第1条收件人邮编不正确',
+            '不能保存',
+        ):
+            result = presenter.present('save_remote_address', {
+                'code': 'MCP_1401',
+                'msg': message,
+                'data': {},
+                'meta': {'request_id': 'rq_save_remote'},
+            })
+            rendered = str(result)
+            if message == '不能保存':
+                self.assertIn('参数不正确', rendered)
+            else:
+                self.assertIn(message, rendered)
+        for tool_name in ('maintain_remote_address', 'save_remote_address'):
+            result = presenter.present(tool_name, {
+                'code': 'MCP_1501',
+                'msg': '地址库不存在',
+                'data': {},
+                'meta': {'request_id': 'rq_missing'},
+            })
+            self.assertIn('地址库不存在', str(result))
+
 
 class FakeClient:
     def __init__(self):

+ 0 - 2
tools/enable_fuel_rate.py

@@ -62,6 +62,4 @@ def _fuel_numbers(value):
             continue
         seen.add(number)
         numbers.append(number)
-    if not numbers:
-        raise ValueError('fuel_no is invalid')
     return numbers

+ 0 - 2
tools/list_inbound_receipt_templates.py

@@ -57,8 +57,6 @@ class ListInboundReceiptTemplatesTool:
                 raise ValueError('container code length is invalid')
             if text not in cleaned:
                 cleaned.append(text)
-        if not cleaned or len(cleaned) > 200:
-            raise ValueError('container_codes is required')
         return cleaned
 
     def call(self, container_codes=None, request_id='rq_list_inbound_receipt_templates'):