feat: dataset cache for hold/resource history + slow connection migration

Two changes combined:

1. historical-query-slow-connection: Migrate all historical query pages
   to read_sql_df_slow with semaphore concurrency control (max 3),
   raise DB slow timeout to 300s, gunicorn timeout to 360s, and
   unify frontend timeouts to 360s for all historical pages.

2. hold-resource-history-dataset-cache: Convert hold-history and
   resource-history from multi-query to single-query + dataset cache
   pattern (L1 ProcessLevelCache + L2 Redis parquet/base64, TTL=900s).
   Replace old GET endpoints with POST /query + GET /view two-phase
   API. Frontend auto-retries on 410 cache_expired.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-25 13:15:02 +08:00
parent cd061e0cfd
commit 71c8102de6
64 changed files with 3806 additions and 1442 deletions

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""Unit tests for Hold History API routes."""
"""Unit tests for Hold History API routes (two-phase query/view pattern)."""
import json
import unittest
@@ -35,215 +35,220 @@ class TestHoldHistoryPageRoute(TestHoldHistoryRoutesBase):
self.assertTrue(response.location.endswith('/portal-shell/hold-history'))
class TestHoldHistoryTrendRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/trend endpoint."""
class TestHoldHistoryQueryRoute(TestHoldHistoryRoutesBase):
"""Test POST /api/hold-history/query endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
def test_trend_passes_date_range(self, mock_trend):
mock_trend.return_value = {
'days': [
{
'date': '2026-02-01',
'quality': {'holdQty': 10, 'newHoldQty': 2, 'releaseQty': 3, 'futureHoldQty': 1},
'non_quality': {'holdQty': 5, 'newHoldQty': 1, 'releaseQty': 2, 'futureHoldQty': 0},
'all': {'holdQty': 15, 'newHoldQty': 3, 'releaseQty': 5, 'futureHoldQty': 1},
}
]
}
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
self.assertIn('days', payload['data'])
mock_trend.assert_called_once_with('2026-02-01', '2026-02-07')
def test_trend_invalid_date_returns_400(self):
response = self.client.get('/api/hold-history/trend?start_date=2026/02/01&end_date=2026-02-07')
def test_query_missing_dates_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-01'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_trend')
def test_query_invalid_date_format_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026/02/01', 'end_date': '2026-02-07'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_query_end_before_start_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-07', 'end_date': '2026-02-01'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_query_invalid_record_type_returns_400(self):
response = self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'record_type': 'invalid',
},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
def test_query_success(self, mock_exec):
mock_exec.return_value = {
'query_id': 'abc123',
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
response = self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'hold_type': 'quality',
},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
self.assertIn('query_id', payload['data'])
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
def test_query_passes_params(self, mock_exec):
mock_exec.return_value = {'query_id': 'x', 'trend': {}, 'reason_pareto': {}, 'duration': {}, 'list': {}}
self.client.post(
'/api/hold-history/query',
json={
'start_date': '2026-02-01',
'end_date': '2026-02-07',
'hold_type': 'non-quality',
'record_type': 'on_hold',
},
)
mock_exec.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='non-quality',
record_type='on_hold',
)
@patch('mes_dashboard.routes.hold_history_routes.execute_primary_query')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 8))
def test_trend_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-history/trend?start_date=2026-02-01&end_date=2026-02-07')
def test_query_rate_limited_returns_429(self, _mock_limit, mock_exec):
response = self.client.post(
'/api/hold-history/query',
json={'start_date': '2026-02-01', 'end_date': '2026-02-07'},
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '8')
mock_service.assert_not_called()
mock_exec.assert_not_called()
class TestHoldHistoryReasonParetoRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/reason-pareto endpoint."""
class TestHoldHistoryViewRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/view endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_passes_hold_type_and_record_type(self, mock_service):
mock_service.return_value = {'items': []}
def test_view_missing_query_id_returns_400(self):
response = self.client.get('/api/hold-history/view')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
self.assertIn('query_id', payload['error'])
def test_view_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=non-quality&record_type=on_hold'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'non-quality', 'on_hold')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_defaults_record_type_to_new(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&hold_type=quality'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'new')
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_reason_pareto')
def test_reason_pareto_multi_record_type(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=quality&record_type=on_hold,released'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'on_hold,released')
def test_reason_pareto_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=invalid'
'/api/hold-history/view?query_id=abc123&record_type=bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_reason_pareto_partial_invalid_record_type_returns_400(self):
def test_view_invalid_duration_range_returns_400(self):
response = self.client.get(
'/api/hold-history/reason-pareto?start_date=2026-02-01&end_date=2026-02-07&record_type=on_hold,bogus'
'/api/hold-history/view?query_id=abc123&duration_range=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_cache_expired_returns_410(self, mock_view):
mock_view.return_value = None
class TestHoldHistoryDurationRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/duration endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
def test_duration_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07')
response = self.client.get('/api/hold-history/view?query_id=abc123')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.status_code, 410)
self.assertFalse(payload['success'])
self.assertEqual(payload['error'], 'cache_expired')
def test_duration_invalid_hold_type(self):
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&hold_type=invalid'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_duration')
def test_duration_passes_record_type(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=released'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with('2026-02-01', '2026-02-07', 'quality', 'released')
def test_duration_invalid_record_type_returns_400(self):
response = self.client.get(
'/api/hold-history/duration?start_date=2026-02-01&end_date=2026-02-07&record_type=bogus'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
class TestHoldHistoryListRoute(TestHoldHistoryRoutesBase):
"""Test GET /api/hold-history/list endpoint."""
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
def test_list_caps_per_page_and_sets_page_floor(self, mock_service):
mock_service.return_value = {
'items': [],
'pagination': {'page': 1, 'perPage': 200, 'total': 0, 'totalPages': 1},
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_success(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07'
'&hold_type=all&page=0&per_page=500&reason=品質確認'
'/api/hold-history/view?query_id=abc123&hold_type=non-quality&reason=品質確認&page=2&per_page=20'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='all',
self.assertTrue(payload['success'])
mock_view.assert_called_once_with(
query_id='abc123',
hold_type='non-quality',
reason='品質確認',
record_type='new',
duration_range=None,
page=1,
per_page=200,
page=2,
per_page=20,
)
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
def test_list_passes_duration_range(self, mock_service):
mock_service.return_value = {
'items': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_caps_per_page(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=<4h'
self.client.get(
'/api/hold-history/view?query_id=abc123&page=0&per_page=500'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
start_date='2026-02-01',
end_date='2026-02-07',
hold_type='quality',
reason=None,
record_type='new',
duration_range='<4h',
page=1,
per_page=50,
call_kwargs = mock_view.call_args[1]
self.assertEqual(call_kwargs['page'], 1)
self.assertEqual(call_kwargs['per_page'], 200)
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
def test_view_passes_duration_range(self, mock_view):
mock_view.return_value = {
'trend': {'days': []},
'reason_pareto': {'items': []},
'duration': {'items': []},
'list': {'items': [], 'pagination': {}},
}
self.client.get(
'/api/hold-history/view?query_id=abc123&duration_range=<4h'
)
def test_list_invalid_duration_range_returns_400(self):
response = self.client.get(
'/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07&duration_range=invalid'
)
payload = json.loads(response.data)
call_kwargs = mock_view.call_args[1]
self.assertEqual(call_kwargs['duration_range'], '<4h')
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_history_routes.get_hold_history_list')
@patch('mes_dashboard.routes.hold_history_routes.apply_view')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 5))
def test_list_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-history/list?start_date=2026-02-01&end_date=2026-02-07')
def test_view_rate_limited_returns_429(self, _mock_limit, mock_view):
response = self.client.get('/api/hold-history/view?query_id=abc123')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 429)
self.assertEqual(payload['error']['code'], 'TOO_MANY_REQUESTS')
self.assertEqual(response.headers.get('Retry-After'), '5')
mock_service.assert_not_called()
mock_view.assert_not_called()

View File

@@ -61,8 +61,8 @@ class TestResourceHistoryOptionsAPI(unittest.TestCase):
self.assertIn('error', data)
class TestResourceHistorySummaryAPI(unittest.TestCase):
"""Integration tests for /api/resource/history/summary endpoint."""
class TestResourceHistoryQueryAPI(unittest.TestCase):
"""Integration tests for POST /api/resource/history/query endpoint."""
def setUp(self):
"""Set up test client."""
@@ -73,7 +73,10 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
def test_missing_start_date(self):
"""Missing start_date should return 400."""
response = self.client.get('/api/resource/history/summary?end_date=2024-01-31')
response = self.client.post(
'/api/resource/history/query',
json={'end_date': '2024-01-31'},
)
self.assertEqual(response.status_code, 400)
data = json.loads(response.data)
@@ -82,74 +85,69 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
def test_missing_end_date(self):
"""Missing end_date should return 400."""
response = self.client.get('/api/resource/history/summary?start_date=2024-01-01')
response = self.client.post(
'/api/resource/history/query',
json={'start_date': '2024-01-01'},
)
self.assertEqual(response.status_code, 400)
data = json.loads(response.data)
self.assertFalse(data['success'])
self.assertIn('end_date', data['error'])
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
def test_date_range_exceeds_limit(self, mock_query):
"""Date range exceeding 730 days should return error."""
mock_query.return_value = {'error': '查詢範圍不可超過 730 天(兩年)'}
response = self.client.get(
'/api/resource/history/summary?start_date=2024-01-01&end_date=2026-01-02'
)
self.assertEqual(response.status_code, 400)
data = json.loads(response.data)
self.assertFalse(data['success'])
self.assertIn('730', data['error'])
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
def test_successful_summary(self, mock_query):
"""Successful summary request should return all data sections."""
@patch('mes_dashboard.routes.resource_history_routes.execute_primary_query')
def test_successful_query(self, mock_query):
"""Successful query should return query_id, summary, and detail."""
mock_query.return_value = {
'kpi': {
'ou_pct': 80.0,
'prd_hours': 800,
'sby_hours': 100,
'udt_hours': 50,
'sdt_hours': 30,
'egt_hours': 20,
'nst_hours': 100,
'machine_count': 10
'query_id': 'abc123',
'summary': {
'kpi': {'ou_pct': 80.0, 'machine_count': 10},
'trend': [{'date': '2024-01-01', 'ou_pct': 80.0}],
'heatmap': [{'workcenter': 'WC01', 'date': '2024-01-01', 'ou_pct': 80.0}],
'workcenter_comparison': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
},
'detail': {
'data': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
'total': 1,
'truncated': False,
'max_records': None,
},
'trend': [{'date': '2024-01-01', 'ou_pct': 80.0}],
'heatmap': [{'workcenter': 'WC01', 'date': '2024-01-01', 'ou_pct': 80.0}],
'workcenter_comparison': [{'workcenter': 'WC01', 'ou_pct': 80.0}]
}
response = self.client.get(
'/api/resource/history/summary?start_date=2024-01-01&end_date=2024-01-07'
response = self.client.post(
'/api/resource/history/query',
json={'start_date': '2024-01-01', 'end_date': '2024-01-07'},
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertTrue(data['success'])
self.assertIn('kpi', data['data'])
self.assertIn('trend', data['data'])
self.assertIn('heatmap', data['data'])
self.assertIn('workcenter_comparison', data['data'])
self.assertIn('query_id', data)
self.assertIn('summary', data)
self.assertIn('detail', data)
self.assertIn('kpi', data['summary'])
self.assertIn('trend', data['summary'])
@patch('mes_dashboard.routes.resource_history_routes.query_summary')
def test_summary_with_filters(self, mock_query):
"""Summary with filters should pass them to service."""
mock_query.return_value = {'kpi': {}, 'trend': [], 'heatmap': [], 'workcenter_comparison': []}
@patch('mes_dashboard.routes.resource_history_routes.execute_primary_query')
def test_query_with_filters(self, mock_query):
"""Query with filters should pass them to service."""
mock_query.return_value = {
'query_id': 'abc123',
'summary': {'kpi': {}, 'trend': [], 'heatmap': [], 'workcenter_comparison': []},
'detail': {'data': [], 'total': 0, 'truncated': False, 'max_records': None},
}
response = self.client.get(
'/api/resource/history/summary'
'?start_date=2024-01-01'
'&end_date=2024-01-07'
'&granularity=week'
'&workcenter_groups=焊接_DB'
'&workcenter_groups=成型'
'&families=FAM01'
'&families=FAM02'
'&is_production=1'
'&is_key=1'
response = self.client.post(
'/api/resource/history/query',
json={
'start_date': '2024-01-01',
'end_date': '2024-01-07',
'granularity': 'week',
'workcenter_groups': ['焊接_DB', '成型'],
'families': ['FAM01', 'FAM02'],
'is_production': True,
'is_key': True,
},
)
self.assertEqual(response.status_code, 200)
@@ -162,8 +160,8 @@ class TestResourceHistorySummaryAPI(unittest.TestCase):
self.assertTrue(call_kwargs['is_key'])
class TestResourceHistoryDetailAPI(unittest.TestCase):
"""Integration tests for /api/resource/history/detail endpoint."""
class TestResourceHistoryViewAPI(unittest.TestCase):
"""Integration tests for GET /api/resource/history/view endpoint."""
def setUp(self):
"""Set up test client."""
@@ -172,60 +170,54 @@ class TestResourceHistoryDetailAPI(unittest.TestCase):
self.app.config['TESTING'] = True
self.client = self.app.test_client()
def test_missing_dates(self):
"""Missing dates should return 400."""
response = self.client.get('/api/resource/history/detail')
def test_missing_query_id(self):
"""Missing query_id should return 400."""
response = self.client.get('/api/resource/history/view')
self.assertEqual(response.status_code, 400)
data = json.loads(response.data)
self.assertFalse(data['success'])
self.assertIn('query_id', data['error'])
@patch('mes_dashboard.routes.resource_history_routes.query_detail')
def test_successful_detail(self, mock_query):
"""Successful detail request should return data with total and truncated flag."""
mock_query.return_value = {
'data': [
{'workcenter': 'WC01', 'family': 'FAM01', 'resource': 'RES01', 'ou_pct': 80.0}
],
'total': 100,
'truncated': False,
'max_records': None
@patch('mes_dashboard.routes.resource_history_routes.apply_view')
def test_cache_expired(self, mock_view):
"""Expired cache should return 410."""
mock_view.return_value = None
response = self.client.get('/api/resource/history/view?query_id=abc123')
self.assertEqual(response.status_code, 410)
data = json.loads(response.data)
self.assertFalse(data['success'])
self.assertEqual(data['error'], 'cache_expired')
@patch('mes_dashboard.routes.resource_history_routes.apply_view')
def test_successful_view(self, mock_view):
"""Successful view should return summary and detail."""
mock_view.return_value = {
'summary': {
'kpi': {'ou_pct': 80.0},
'trend': [],
'heatmap': [],
'workcenter_comparison': [],
},
'detail': {
'data': [{'workcenter': 'WC01', 'ou_pct': 80.0}],
'total': 1,
'truncated': False,
'max_records': None,
},
}
response = self.client.get(
'/api/resource/history/detail?start_date=2024-01-01&end_date=2024-01-07'
'/api/resource/history/view?query_id=abc123&granularity=week'
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertTrue(data['success'])
self.assertIn('data', data)
self.assertIn('total', data)
self.assertIn('truncated', data)
self.assertFalse(data['truncated'])
@patch('mes_dashboard.routes.resource_history_routes.query_detail')
def test_detail_truncated_warning(self, mock_query):
"""Detail with truncated data should return truncated flag and max_records."""
mock_query.return_value = {
'data': [{'workcenter': 'WC01', 'family': 'FAM01', 'resource': 'RES01', 'ou_pct': 80.0}],
'total': 6000,
'truncated': True,
'max_records': 5000
}
response = self.client.get(
'/api/resource/history/detail'
'?start_date=2024-01-01'
'&end_date=2024-01-07'
)
self.assertEqual(response.status_code, 200)
data = json.loads(response.data)
self.assertTrue(data['success'])
self.assertTrue(data['truncated'])
self.assertEqual(data['max_records'], 5000)
self.assertEqual(data['total'], 6000)
self.assertIn('summary', data)
self.assertIn('detail', data)
class TestResourceHistoryExportAPI(unittest.TestCase):