From 7fffa812a3f0adb80e3674ab9ac8dfa027db3c90 Mon Sep 17 00:00:00 2001 From: egg Date: Tue, 24 Feb 2026 18:41:24 +0800 Subject: [PATCH] fix(query-tool): export all selected CIDs instead of single, fix hold detail float precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export was sending only one container_id while the detail table loaded data for all selected CIDs (including subtree), causing "查無資料" errors. Now sends container_ids array and uses batch service functions. Also rounds hold detail age KPI cards to 1 decimal place. Co-Authored-By: Claude Opus 4.6 --- .../hold-detail/components/SummaryCards.vue | 3 +- .../query-tool/composables/useLotDetail.js | 6 +- src/mes_dashboard/routes/query_tool_routes.py | 53 +- tests/test_query_tool_routes.py | 1013 ++++++++++------- 4 files changed, 623 insertions(+), 452 deletions(-) diff --git a/frontend/src/hold-detail/components/SummaryCards.vue b/frontend/src/hold-detail/components/SummaryCards.vue index 450bb63..758cb6b 100644 --- a/frontend/src/hold-detail/components/SummaryCards.vue +++ b/frontend/src/hold-detail/components/SummaryCards.vue @@ -17,7 +17,8 @@ function formatAge(value) { if (value === null || value === undefined || value === '-') { return '-'; } - return `${value}天`; + const num = Number(value); + return `${Number.isFinite(num) ? num.toFixed(1) : value}天`; } diff --git a/frontend/src/query-tool/composables/useLotDetail.js b/frontend/src/query-tool/composables/useLotDetail.js index 8e9a2f3..734d10f 100644 --- a/frontend/src/query-tool/composables/useLotDetail.js +++ b/frontend/src/query-tool/composables/useLotDetail.js @@ -378,9 +378,9 @@ export function useLotDetail(initial = {}) { async function exportSubTab(tab) { const normalized = normalizeSubTab(tab); const exportType = EXPORT_TYPE_MAP[normalized]; - const containerId = selectedContainerId.value; + const cids = getActiveCids(); - if (!exportType || !containerId) { + if (!exportType || cids.length === 0) { return false; } @@ -389,7 +389,7 @@ export function useLotDetail(initial = {}) { try { const params = { - container_id: containerId, + container_ids: cids, }; if (normalized === 'jobs') { diff --git a/src/mes_dashboard/routes/query_tool_routes.py b/src/mes_dashboard/routes/query_tool_routes.py index 47fb259..766c1f7 100644 --- a/src/mes_dashboard/routes/query_tool_routes.py +++ b/src/mes_dashboard/routes/query_tool_routes.py @@ -40,6 +40,15 @@ from mes_dashboard.services.query_tool_service import ( validate_equipment_input, ) + +def _resolve_export_cids(params: dict) -> list[str]: + """Extract container_ids from export params, supporting both batch and single.""" + cids = params.get('container_ids') or [] + if isinstance(cids, list) and cids: + return [c for c in cids if c] + single = params.get('container_id') + return [single] if single else [] + # Create Blueprint query_tool_bp = Blueprint('query_tool', __name__) @@ -605,11 +614,14 @@ def export_csv(): try: if export_type == 'lot_history': - container_id = params.get('container_id') - if not container_id: + cids = _resolve_export_cids(params) + if not cids: return jsonify({'error': '請指定 CONTAINERID'}), 400 - result = get_lot_history(container_id) - filename = f'lot_history_{container_id}.csv' + if len(cids) > 1: + result = get_lot_history_batch(cids) + else: + result = get_lot_history(cids[0]) + filename = f'lot_history_{cids[0]}.csv' elif export_type == 'adjacent_lots': result = get_adjacent_lots( @@ -620,19 +632,34 @@ def export_csv(): filename = 'adjacent_lots.csv' elif export_type == 'lot_materials': - container_id = params.get('container_id') - result = get_lot_materials(container_id) - filename = f'lot_raw_materials_{container_id}.csv' + cids = _resolve_export_cids(params) + if not cids: + return jsonify({'error': '請指定 CONTAINERID'}), 400 + if len(cids) > 1: + result = get_lot_associations_batch(cids, 'materials') + else: + result = get_lot_materials(cids[0]) + filename = f'lot_raw_materials_{cids[0]}.csv' elif export_type == 'lot_rejects': - container_id = params.get('container_id') - result = get_lot_rejects(container_id) - filename = f'lot_rejects_{container_id}.csv' + cids = _resolve_export_cids(params) + if not cids: + return jsonify({'error': '請指定 CONTAINERID'}), 400 + if len(cids) > 1: + result = get_lot_associations_batch(cids, 'rejects') + else: + result = get_lot_rejects(cids[0]) + filename = f'lot_rejects_{cids[0]}.csv' elif export_type == 'lot_holds': - container_id = params.get('container_id') - result = get_lot_holds(container_id) - filename = f'lot_holds_{container_id}.csv' + cids = _resolve_export_cids(params) + if not cids: + return jsonify({'error': '請指定 CONTAINERID'}), 400 + if len(cids) > 1: + result = get_lot_associations_batch(cids, 'holds') + else: + result = get_lot_holds(cids[0]) + filename = f'lot_holds_{cids[0]}.csv' elif export_type == 'lot_splits': container_id = params.get('container_id') diff --git a/tests/test_query_tool_routes.py b/tests/test_query_tool_routes.py index b6ed930..a6479f1 100644 --- a/tests/test_query_tool_routes.py +++ b/tests/test_query_tool_routes.py @@ -7,35 +7,35 @@ Tests the API endpoints with mocked service dependencies: - Error handling """ -import pytest -import json -from unittest.mock import patch, MagicMock - -from mes_dashboard import create_app -from mes_dashboard.core.cache import NoOpCache -from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests +import pytest +import json +from unittest.mock import patch, MagicMock + +from mes_dashboard import create_app +from mes_dashboard.core.cache import NoOpCache +from mes_dashboard.core.rate_limit import reset_rate_limits_for_tests @pytest.fixture -def app(): - """Create test Flask application.""" - app = create_app() - app.config['TESTING'] = True - app.extensions["cache"] = NoOpCache() - return app +def app(): + """Create test Flask application.""" + app = create_app() + app.config['TESTING'] = True + app.extensions["cache"] = NoOpCache() + return app -@pytest.fixture -def client(app): - """Create test client.""" - return app.test_client() - - -@pytest.fixture(autouse=True) -def _reset_rate_limits(): - reset_rate_limits_for_tests() - yield - reset_rate_limits_for_tests() +@pytest.fixture +def client(app): + """Create test client.""" + return app.test_client() + + +@pytest.fixture(autouse=True) +def _reset_rate_limits(): + reset_rate_limits_for_tests() + yield + reset_rate_limits_for_tests() class TestQueryToolPage: @@ -53,32 +53,32 @@ class TestQueryToolPage: assert b'html' in response.data.lower() -class TestResolveEndpoint: - """Tests for /api/query-tool/resolve endpoint.""" - - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_non_json_payload_returns_415(self, mock_resolve, client): - response = client.post( - '/api/query-tool/resolve', - data='plain-text', - content_type='text/plain', - ) - assert response.status_code == 415 - payload = response.get_json() - assert 'error' in payload - mock_resolve.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_malformed_json_returns_400(self, mock_resolve, client): - response = client.post( - '/api/query-tool/resolve', - data='{"input_type":', - content_type='application/json', - ) - assert response.status_code == 400 - payload = response.get_json() - assert 'error' in payload - mock_resolve.assert_not_called() +class TestResolveEndpoint: + """Tests for /api/query-tool/resolve endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_non_json_payload_returns_415(self, mock_resolve, client): + response = client.post( + '/api/query-tool/resolve', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_resolve.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_malformed_json_returns_400(self, mock_resolve, client): + response = client.post( + '/api/query-tool/resolve', + data='{"input_type":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_resolve.assert_not_called() def test_missing_input_type(self, client): """Should return error without input_type.""" @@ -133,8 +133,8 @@ class TestResolveEndpoint: assert 'error' in data assert '超過上限' in data['error'] or '50' in data['error'] - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_resolve_success(self, mock_resolve, client): + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_resolve_success(self, mock_resolve, client): """Should return resolved LOT IDs on success.""" mock_resolve.return_value = { 'data': [ @@ -160,39 +160,39 @@ class TestResolveEndpoint: assert response.status_code == 200 data = json.loads(response.data) assert 'data' in data - assert data['total'] == 1 - assert data['data'][0]['lot_id'] == 'GA23100020-A00-001' - - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_resolve_supports_gd_lot_id(self, mock_resolve, client): - mock_resolve.return_value = { - 'data': [ - { - 'container_id': '4881038000260b21', - 'lot_id': 'GD25060502-A11', - 'input_value': 'GD25060502-A11', - } - ], - 'total': 1, - 'input_count': 1, - 'not_found': [], - } - - response = client.post( - '/api/query-tool/resolve', - json={ - 'input_type': 'gd_lot_id', - 'values': ['GD25060502-A11'], - } - ) - - assert response.status_code == 200 - payload = response.get_json() - assert payload['total'] == 1 - assert payload['data'][0]['lot_id'] == 'GD25060502-A11' + assert data['total'] == 1 + assert data['data'][0]['lot_id'] == 'GA23100020-A00-001' - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_resolve_not_found(self, mock_resolve, client): + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_resolve_supports_gd_lot_id(self, mock_resolve, client): + mock_resolve.return_value = { + 'data': [ + { + 'container_id': '4881038000260b21', + 'lot_id': 'GD25060502-A11', + 'input_value': 'GD25060502-A11', + } + ], + 'total': 1, + 'input_count': 1, + 'not_found': [], + } + + response = client.post( + '/api/query-tool/resolve', + json={ + 'input_type': 'gd_lot_id', + 'values': ['GD25060502-A11'], + } + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload['total'] == 1 + assert payload['data'][0]['lot_id'] == 'GD25060502-A11' + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_resolve_not_found(self, mock_resolve, client): """Should return not_found list for missing LOT IDs.""" mock_resolve.return_value = { 'data': [], @@ -210,59 +210,59 @@ class TestResolveEndpoint: ) assert response.status_code == 200 data = json.loads(response.data) - assert data['total'] == 0 - assert 'INVALID-LOT-ID' in data['not_found'] - - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - @patch('mes_dashboard.routes.query_tool_routes.cache_get') - def test_resolve_cache_hit_skips_service(self, mock_cache_get, mock_resolve, client): - mock_cache_get.return_value = { - 'data': [{'container_id': 'C1', 'input_value': 'LOT-1'}], - 'total': 1, - 'input_count': 1, - 'not_found': [], - } - - response = client.post( - '/api/query-tool/resolve', - json={'input_type': 'lot_id', 'values': ['LOT-1']}, - ) - - assert response.status_code == 200 - payload = response.get_json() - assert payload['total'] == 1 - mock_resolve.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.cache_set') - @patch('mes_dashboard.routes.query_tool_routes.cache_get', return_value=None) - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - def test_resolve_success_caches_result( - self, - mock_resolve, - _mock_cache_get, - mock_cache_set, - client, - ): - mock_resolve.return_value = { - 'data': [{'container_id': 'C1', 'input_value': 'LOT-1'}], - 'total': 1, - 'input_count': 1, - 'not_found': [], - } - - response = client.post( - '/api/query-tool/resolve', - json={'input_type': 'lot_id', 'values': ['LOT-1']}, - ) - - assert response.status_code == 200 - mock_cache_set.assert_called_once() - cache_key = mock_cache_set.call_args.args[0] - assert cache_key.startswith('qt:resolve:lot_id:') - assert mock_cache_set.call_args.kwargs['ttl'] == 60 + assert data['total'] == 0 + assert 'INVALID-LOT-ID' in data['not_found'] + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + @patch('mes_dashboard.routes.query_tool_routes.cache_get') + def test_resolve_cache_hit_skips_service(self, mock_cache_get, mock_resolve, client): + mock_cache_get.return_value = { + 'data': [{'container_id': 'C1', 'input_value': 'LOT-1'}], + 'total': 1, + 'input_count': 1, + 'not_found': [], + } + + response = client.post( + '/api/query-tool/resolve', + json={'input_type': 'lot_id', 'values': ['LOT-1']}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload['total'] == 1 + mock_resolve.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.cache_set') + @patch('mes_dashboard.routes.query_tool_routes.cache_get', return_value=None) + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_resolve_success_caches_result( + self, + mock_resolve, + _mock_cache_get, + mock_cache_set, + client, + ): + mock_resolve.return_value = { + 'data': [{'container_id': 'C1', 'input_value': 'LOT-1'}], + 'total': 1, + 'input_count': 1, + 'not_found': [], + } + + response = client.post( + '/api/query-tool/resolve', + json={'input_type': 'lot_id', 'values': ['LOT-1']}, + ) + + assert response.status_code == 200 + mock_cache_set.assert_called_once() + cache_key = mock_cache_set.call_args.args[0] + assert cache_key.startswith('qt:resolve:lot_id:') + assert mock_cache_set.call_args.kwargs['ttl'] == 60 -class TestLotHistoryEndpoint: +class TestLotHistoryEndpoint: """Tests for /api/query-tool/lot-history endpoint.""" def test_missing_container_id(self, client): @@ -294,24 +294,24 @@ class TestLotHistoryEndpoint: assert 'data' in data assert data['total'] == 1 - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - def test_lot_history_service_error(self, mock_query, client): - """Should return error from service.""" - mock_query.return_value = {'error': '查詢失敗'} + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_lot_history_service_error(self, mock_query, client): + """Should return error from service.""" + mock_query.return_value = {'error': '查詢失敗'} - response = client.get('/api/query-tool/lot-history?container_id=invalid') - assert response.status_code == 400 - data = json.loads(response.data) - assert 'error' in data - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch') - def test_lot_history_batch_over_limit_returns_413(self, mock_batch, client): - client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 2 - response = client.get('/api/query-tool/lot-history?container_ids=A,B,C') - assert response.status_code == 413 - payload = response.get_json() - assert 'error' in payload - mock_batch.assert_not_called() + response = client.get('/api/query-tool/lot-history?container_id=invalid') + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch') + def test_lot_history_batch_over_limit_returns_413(self, mock_batch, client): + client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 2 + response = client.get('/api/query-tool/lot-history?container_ids=A,B,C') + assert response.status_code == 413 + payload = response.get_json() + assert 'error' in payload + mock_batch.assert_not_called() class TestAdjacentLotsEndpoint: @@ -386,7 +386,7 @@ class TestAdjacentLotsEndpoint: assert '2024-01-15' in call_args[0][1] # target_time -class TestLotAssociationsEndpoint: +class TestLotAssociationsEndpoint: """Tests for /api/query-tool/lot-associations endpoint.""" def test_missing_container_id(self, client): @@ -413,8 +413,8 @@ class TestLotAssociationsEndpoint: assert 'error' in data assert '不支援' in data['error'] or 'type' in data['error'].lower() - @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') - def test_lot_materials_success(self, mock_query, client): + @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') + def test_lot_materials_success(self, mock_query, client): """Should return lot materials on success.""" mock_query.return_value = { 'data': [ @@ -432,176 +432,176 @@ class TestLotAssociationsEndpoint: ) assert response.status_code == 200 data = json.loads(response.data) - assert 'data' in data - assert data['total'] == 1 - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_splits') - def test_lot_splits_default_fast_mode(self, mock_query, client): - mock_query.return_value = {'data': [], 'total': 0} - - response = client.get( - '/api/query-tool/lot-associations?container_id=488103800029578b&type=splits' - ) - - assert response.status_code == 200 - mock_query.assert_called_once_with('488103800029578b', full_history=False) - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_splits') - def test_lot_splits_full_history_mode(self, mock_query, client): - mock_query.return_value = {'data': [], 'total': 0} - - response = client.get( - '/api/query-tool/lot-associations?' - 'container_id=488103800029578b&type=splits&full_history=true' - ) - - assert response.status_code == 200 - mock_query.assert_called_once_with('488103800029578b', full_history=True) - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') - def test_lot_associations_batch_over_limit_returns_413(self, mock_batch, client): - client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 1 - response = client.get( - '/api/query-tool/lot-associations?type=materials&container_ids=A,B' - ) - assert response.status_code == 413 - payload = response.get_json() - assert 'error' in payload - mock_batch.assert_not_called() - - -class TestQueryToolRateLimit: - """Rate-limit behavior for high-cost query-tool endpoints.""" - - @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5)) - def test_resolve_rate_limited_returns_429(self, _mock_limit, mock_resolve, client): - response = client.post( - '/api/query-tool/resolve', - json={'input_type': 'lot_id', 'values': ['GA23100020-A00-001']}, - ) - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '5' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_resolve.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) - def test_lot_history_rate_limited_returns_429(self, _mock_limit, mock_history, client): - response = client.get('/api/query-tool/lot-history?container_id=488103800029578b') - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '6' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_history.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7)) - def test_lot_association_rate_limited_returns_429( - self, - _mock_limit, - mock_materials, - client, - ): - response = client.get( - '/api/query-tool/lot-associations?container_id=488103800029578b&type=materials' - ) - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '7' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_materials.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_adjacent_lots') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8)) - def test_adjacent_lots_rate_limited_returns_429( - self, - _mock_limit, - mock_adjacent, - client, - ): - response = client.get( - '/api/query-tool/adjacent-lots?equipment_id=EQ001&target_time=2024-01-15T10:30:00' - ) - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '8' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_adjacent.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 9)) - def test_equipment_period_rate_limited_returns_429( - self, - _mock_limit, - mock_equipment, - client, - ): - response = client.post( - '/api/query-tool/equipment-period', - json={ - 'equipment_ids': ['EQ001'], - 'start_date': '2024-01-01', - 'end_date': '2024-01-31', - 'query_type': 'status_hours', - }, - ) - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '9' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_equipment.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 10)) - def test_export_rate_limited_returns_429(self, _mock_limit, mock_history, client): - response = client.post( - '/api/query-tool/export-csv', - json={ - 'export_type': 'lot_history', - 'params': {'container_id': '488103800029578b'}, - }, - ) - - assert response.status_code == 429 - assert response.headers.get('Retry-After') == '10' - payload = response.get_json() - assert payload['error']['code'] == 'TOO_MANY_REQUESTS' - mock_history.assert_not_called() + assert 'data' in data + assert data['total'] == 1 + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_splits') + def test_lot_splits_default_fast_mode(self, mock_query, client): + mock_query.return_value = {'data': [], 'total': 0} + + response = client.get( + '/api/query-tool/lot-associations?container_id=488103800029578b&type=splits' + ) + + assert response.status_code == 200 + mock_query.assert_called_once_with('488103800029578b', full_history=False) + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_splits') + def test_lot_splits_full_history_mode(self, mock_query, client): + mock_query.return_value = {'data': [], 'total': 0} + + response = client.get( + '/api/query-tool/lot-associations?' + 'container_id=488103800029578b&type=splits&full_history=true' + ) + + assert response.status_code == 200 + mock_query.assert_called_once_with('488103800029578b', full_history=True) + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') + def test_lot_associations_batch_over_limit_returns_413(self, mock_batch, client): + client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 1 + response = client.get( + '/api/query-tool/lot-associations?type=materials&container_ids=A,B' + ) + assert response.status_code == 413 + payload = response.get_json() + assert 'error' in payload + mock_batch.assert_not_called() -class TestEquipmentPeriodEndpoint: - """Tests for /api/query-tool/equipment-period endpoint.""" - - @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') - def test_non_json_payload_returns_415(self, mock_query, client): - response = client.post( - '/api/query-tool/equipment-period', - data='plain-text', - content_type='text/plain', - ) - assert response.status_code == 415 - payload = response.get_json() - assert 'error' in payload - mock_query.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') - def test_malformed_json_returns_400(self, mock_query, client): - response = client.post( - '/api/query-tool/equipment-period', - data='{"equipment_ids":', - content_type='application/json', - ) - assert response.status_code == 400 - payload = response.get_json() - assert 'error' in payload - mock_query.assert_not_called() +class TestQueryToolRateLimit: + """Rate-limit behavior for high-cost query-tool endpoints.""" + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5)) + def test_resolve_rate_limited_returns_429(self, _mock_limit, mock_resolve, client): + response = client.post( + '/api/query-tool/resolve', + json={'input_type': 'lot_id', 'values': ['GA23100020-A00-001']}, + ) + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '5' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_resolve.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) + def test_lot_history_rate_limited_returns_429(self, _mock_limit, mock_history, client): + response = client.get('/api/query-tool/lot-history?container_id=488103800029578b') + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '6' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_history.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7)) + def test_lot_association_rate_limited_returns_429( + self, + _mock_limit, + mock_materials, + client, + ): + response = client.get( + '/api/query-tool/lot-associations?container_id=488103800029578b&type=materials' + ) + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '7' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_materials.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_adjacent_lots') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8)) + def test_adjacent_lots_rate_limited_returns_429( + self, + _mock_limit, + mock_adjacent, + client, + ): + response = client.get( + '/api/query-tool/adjacent-lots?equipment_id=EQ001&target_time=2024-01-15T10:30:00' + ) + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '8' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_adjacent.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 9)) + def test_equipment_period_rate_limited_returns_429( + self, + _mock_limit, + mock_equipment, + client, + ): + response = client.post( + '/api/query-tool/equipment-period', + json={ + 'equipment_ids': ['EQ001'], + 'start_date': '2024-01-01', + 'end_date': '2024-01-31', + 'query_type': 'status_hours', + }, + ) + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '9' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_equipment.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 10)) + def test_export_rate_limited_returns_429(self, _mock_limit, mock_history, client): + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_history', + 'params': {'container_id': '488103800029578b'}, + }, + ) + + assert response.status_code == 429 + assert response.headers.get('Retry-After') == '10' + payload = response.get_json() + assert payload['error']['code'] == 'TOO_MANY_REQUESTS' + mock_history.assert_not_called() + + +class TestEquipmentPeriodEndpoint: + """Tests for /api/query-tool/equipment-period endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') + def test_non_json_payload_returns_415(self, mock_query, client): + response = client.post( + '/api/query-tool/equipment-period', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') + def test_malformed_json_returns_400(self, mock_query, client): + response = client.post( + '/api/query-tool/equipment-period', + data='{"equipment_ids":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() def test_missing_query_type(self, client): """Should return error without query_type.""" @@ -728,32 +728,32 @@ class TestEquipmentPeriodEndpoint: assert 'data' in data -class TestExportCsvEndpoint: - """Tests for /api/query-tool/export-csv endpoint.""" - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - def test_non_json_payload_returns_415(self, mock_get_history, client): - response = client.post( - '/api/query-tool/export-csv', - data='plain-text', - content_type='text/plain', - ) - assert response.status_code == 415 - payload = response.get_json() - assert 'error' in payload - mock_get_history.assert_not_called() - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - def test_malformed_json_returns_400(self, mock_get_history, client): - response = client.post( - '/api/query-tool/export-csv', - data='{"export_type":', - content_type='application/json', - ) - assert response.status_code == 400 - payload = response.get_json() - assert 'error' in payload - mock_get_history.assert_not_called() +class TestExportCsvEndpoint: + """Tests for /api/query-tool/export-csv endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_non_json_payload_returns_415(self, mock_get_history, client): + response = client.post( + '/api/query-tool/export-csv', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_get_history.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_malformed_json_returns_400(self, mock_get_history, client): + response = client.post( + '/api/query-tool/export-csv', + data='{"export_type":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_get_history.assert_not_called() def test_missing_export_type(self, client): """Should return error without export_type.""" @@ -782,10 +782,10 @@ class TestExportCsvEndpoint: assert '不支援' in data['error'] or 'type' in data['error'].lower() @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - def test_export_lot_history_success(self, mock_get_history, client): - """Should return CSV for lot history.""" - mock_get_history.return_value = { - 'data': [ + def test_export_lot_history_success(self, mock_get_history, client): + """Should return CSV for lot history.""" + mock_get_history.return_value = { + 'data': [ { 'EQUIPMENTNAME': 'ASSY-01', 'SPECNAME': 'SPEC-001', @@ -801,88 +801,231 @@ class TestExportCsvEndpoint: 'export_type': 'lot_history', 'params': {'container_id': '488103800029578b'} } - ) - assert response.status_code == 200 - assert 'text/csv' in response.content_type - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') - def test_export_lot_materials_uses_container_name_as_lot_id( - self, - mock_get_materials, - client, - ): - mock_get_materials.return_value = { - 'data': [ - { - 'CONTAINERID': '488103800029578b', - 'CONTAINERNAME': 'GA25010001-A01', - 'MATERIALPARTNAME': 'M-001', - 'MATERIALLOTNAME': 'LOT-MAT-01', - 'QTYCONSUMED': 10, - 'WORKCENTERNAME': 'DB', - 'SPECNAME': 'SPEC-DB', - 'EQUIPMENTNAME': 'EQ-01', - 'TXNDATE': '2026-02-22 10:00:00', - } - ], - 'total': 1, - } - - response = client.post( - '/api/query-tool/export-csv', - json={ - 'export_type': 'lot_materials', - 'params': {'container_id': '488103800029578b'} - } - ) - - assert response.status_code == 200 - assert 'lot_raw_materials_488103800029578b.csv' in response.headers.get('Content-Disposition', '') - decoded = response.data.decode('utf-8-sig') - assert 'LOT ID' in decoded - assert 'GA25010001-A01' in decoded - - @patch('mes_dashboard.routes.query_tool_routes.get_lot_holds') - def test_export_lot_holds_uses_container_name_as_lot_id( - self, - mock_get_holds, - client, - ): - mock_get_holds.return_value = { - 'data': [ - { - 'CONTAINERID': '488103800029578b', - 'CONTAINERNAME': 'GA25010001-A01', - 'WORKCENTERNAME': '成型', - 'HOLDTXNDATE': '2026-02-22 16:53:27', - 'RELEASETXNDATE': None, - 'HOLD_STATUS': 'HOLD', - 'HOLD_HOURS': 1.46, - 'HOLDREASONNAME': 'Q-Time Fail', - 'HOLDCOMMENTS': '', - 'HOLDEMP': 'U001', - 'HOLDEMPDEPTNAME': '成型(D)', - 'RELEASEEMP': '', - 'RELEASECOMMENTS': '', - 'NCRID': '', - } - ], - 'total': 1, - } - - response = client.post( - '/api/query-tool/export-csv', - json={ - 'export_type': 'lot_holds', - 'params': {'container_id': '488103800029578b'} - } - ) - - assert response.status_code == 200 - assert 'lot_holds_488103800029578b.csv' in response.headers.get('Content-Disposition', '') - decoded = response.data.decode('utf-8-sig') - assert 'LOT ID' in decoded - assert 'GA25010001-A01' in decoded + ) + assert response.status_code == 200 + assert 'text/csv' in response.content_type + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_materials') + def test_export_lot_materials_uses_container_name_as_lot_id( + self, + mock_get_materials, + client, + ): + mock_get_materials.return_value = { + 'data': [ + { + 'CONTAINERID': '488103800029578b', + 'CONTAINERNAME': 'GA25010001-A01', + 'MATERIALPARTNAME': 'M-001', + 'MATERIALLOTNAME': 'LOT-MAT-01', + 'QTYCONSUMED': 10, + 'WORKCENTERNAME': 'DB', + 'SPECNAME': 'SPEC-DB', + 'EQUIPMENTNAME': 'EQ-01', + 'TXNDATE': '2026-02-22 10:00:00', + } + ], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_materials', + 'params': {'container_id': '488103800029578b'} + } + ) + + assert response.status_code == 200 + assert 'lot_raw_materials_488103800029578b.csv' in response.headers.get('Content-Disposition', '') + decoded = response.data.decode('utf-8-sig') + assert 'LOT ID' in decoded + assert 'GA25010001-A01' in decoded + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_holds') + def test_export_lot_holds_uses_container_name_as_lot_id( + self, + mock_get_holds, + client, + ): + mock_get_holds.return_value = { + 'data': [ + { + 'CONTAINERID': '488103800029578b', + 'CONTAINERNAME': 'GA25010001-A01', + 'WORKCENTERNAME': '成型', + 'HOLDTXNDATE': '2026-02-22 16:53:27', + 'RELEASETXNDATE': None, + 'HOLD_STATUS': 'HOLD', + 'HOLD_HOURS': 1.46, + 'HOLDREASONNAME': 'Q-Time Fail', + 'HOLDCOMMENTS': '', + 'HOLDEMP': 'U001', + 'HOLDEMPDEPTNAME': '成型(D)', + 'RELEASEEMP': '', + 'RELEASECOMMENTS': '', + 'NCRID': '', + } + ], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_holds', + 'params': {'container_id': '488103800029578b'} + } + ) + + assert response.status_code == 200 + assert 'lot_holds_488103800029578b.csv' in response.headers.get('Content-Disposition', '') + decoded = response.data.decode('utf-8-sig') + assert 'LOT ID' in decoded + assert 'GA25010001-A01' in decoded + + +class TestExportCsvBatchEndpoint: + """Tests for /api/query-tool/export-csv with batch container_ids.""" + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch') + def test_export_lot_history_batch(self, mock_batch, client): + """Batch container_ids should call get_lot_history_batch.""" + mock_batch.return_value = { + 'data': [ + {'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1', 'TRACKINTIMESTAMP': '2026-01-01'}, + {'CONTAINERNAME': 'LOT-B', 'SPECNAME': 'S1', 'TRACKINTIMESTAMP': '2026-01-02'}, + ], + 'total': 2, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_history', + 'params': {'container_ids': ['aaa1', 'bbb2']}, + }, + ) + + assert response.status_code == 200 + assert 'text/csv' in response.content_type + mock_batch.assert_called_once_with(['aaa1', 'bbb2']) + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_export_lot_history_single_cid_in_array(self, mock_single, client): + """Single-element container_ids should call get_lot_history (not batch).""" + mock_single.return_value = { + 'data': [{'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1'}], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_history', + 'params': {'container_ids': ['aaa1']}, + }, + ) + + assert response.status_code == 200 + mock_single.assert_called_once_with('aaa1') + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') + def test_export_lot_materials_batch(self, mock_batch, client): + """Batch container_ids for materials should call get_lot_associations_batch.""" + mock_batch.return_value = { + 'data': [ + {'CONTAINERNAME': 'LOT-A', 'MATERIALPARTNAME': 'M1', 'QTYCONSUMED': 5}, + ], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_materials', + 'params': {'container_ids': ['aaa1', 'bbb2']}, + }, + ) + + assert response.status_code == 200 + assert 'text/csv' in response.content_type + mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'materials') + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') + def test_export_lot_rejects_batch(self, mock_batch, client): + """Batch container_ids for rejects should call get_lot_associations_batch.""" + mock_batch.return_value = { + 'data': [ + {'CONTAINERNAME': 'LOT-A', 'LOSSREASONNAME': 'IR', 'REJECT_TOTAL_QTY': 10}, + ], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_rejects', + 'params': {'container_ids': ['aaa1', 'bbb2']}, + }, + ) + + assert response.status_code == 200 + mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'rejects') + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') + def test_export_lot_holds_batch(self, mock_batch, client): + """Batch container_ids for holds should call get_lot_associations_batch.""" + mock_batch.return_value = { + 'data': [ + {'CONTAINERNAME': 'LOT-A', 'HOLDREASONNAME': 'Q-Time', 'HOLD_STATUS': 'HOLD'}, + ], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_holds', + 'params': {'container_ids': ['aaa1', 'bbb2']}, + }, + ) + + assert response.status_code == 200 + mock_batch.assert_called_once_with(['aaa1', 'bbb2'], 'holds') + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_export_backward_compat_container_id(self, mock_single, client): + """Legacy container_id param should still work.""" + mock_single.return_value = { + 'data': [{'CONTAINERNAME': 'LOT-A', 'SPECNAME': 'S1'}], + 'total': 1, + } + + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_history', + 'params': {'container_id': '488103800029578b'}, + }, + ) + + assert response.status_code == 200 + mock_single.assert_called_once_with('488103800029578b') + + def test_export_lot_history_no_cids_returns_400(self, client): + """Missing container_ids and container_id should return 400.""" + response = client.post( + '/api/query-tool/export-csv', + json={ + 'export_type': 'lot_history', + 'params': {}, + }, + ) + + assert response.status_code == 400 + data = response.get_json() + assert 'CONTAINERID' in data.get('error', '') class TestEquipmentListEndpoint: