feat(hold-overview): add Hold Lot Overview page with TreeMap, Matrix, and cascade filtering

Provide managers with a dedicated page to analyze hold lots across all stations.
Extends existing service functions (get_hold_detail_summary, get_hold_detail_lots,
get_wip_matrix) with optional parameters for backward compatibility, adds one new
function (get_hold_overview_treemap), and registers the page in the portal navigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-10 13:02:24 +08:00
parent af59031f95
commit 8225863a85
31 changed files with 3414 additions and 44 deletions

View File

@@ -53,12 +53,17 @@ class AppFactoryTests(unittest.TestCase):
"/resource",
"/wip-overview",
"/wip-detail",
"/hold-overview",
"/excel-query",
"/query-tool",
"/tmtt-defect",
"/api/wip/overview/summary",
"/api/wip/overview/matrix",
"/api/wip/overview/hold",
"/api/hold-overview/summary",
"/api/hold-overview/matrix",
"/api/hold-overview/treemap",
"/api/hold-overview/lots",
"/api/wip/detail/<workcenter>",
"/api/wip/meta/workcenters",
"/api/wip/meta/packages",

View File

@@ -0,0 +1,228 @@
# -*- coding: utf-8 -*-
"""Unit tests for Hold Overview API routes."""
import json
import unittest
from unittest.mock import patch
from mes_dashboard.app import create_app
import mes_dashboard.core.database as db
class TestHoldOverviewRoutesBase(unittest.TestCase):
"""Base class for Hold Overview route tests."""
def setUp(self):
db._ENGINE = None
self.app = create_app('testing')
self.app.config['TESTING'] = True
self.client = self.app.test_client()
class TestHoldOverviewPageRoute(TestHoldOverviewRoutesBase):
"""Test GET /hold-overview page route."""
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_includes_vite_entry(self, _mock_exists):
# Page is registered as 'dev' status, requires admin session
with self.client.session_transaction() as sess:
sess['admin'] = {'displayName': 'Test Admin', 'employeeNo': 'A001'}
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 200)
self.assertIn(b'/static/dist/hold-overview.js', response.data)
@patch('mes_dashboard.routes.hold_overview_routes.os.path.exists', return_value=False)
def test_hold_overview_page_returns_403_without_admin(self, _mock_exists):
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 403)
class TestHoldOverviewSummaryRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/summary endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_defaults_to_quality(self, mock_service):
mock_service.return_value = {
'totalLots': 12,
'totalQty': 3400,
'avgAge': 2.5,
'maxAge': 9.0,
'workcenterCount': 3,
'dataUpdateDate': '2026-01-01 08:00:00',
}
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(payload['success'])
mock_service.assert_called_once_with(
reason=None,
hold_type='quality',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_hold_type_all_maps_to_none(self, mock_service):
mock_service.return_value = {
'totalLots': 0,
'totalQty': 0,
'avgAge': 0,
'maxAge': 0,
'workcenterCount': 0,
'dataUpdateDate': None,
}
response = self.client.get('/api/hold-overview/summary?hold_type=all&reason=品質確認')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
include_dummy=False,
)
def test_summary_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/summary?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_summary')
def test_summary_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/summary')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewMatrixRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/matrix endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
def test_matrix_passes_hold_filters(self, mock_service):
mock_service.return_value = {
'workcenters': [],
'packages': [],
'matrix': {},
'workcenter_totals': {},
'package_totals': {},
'grand_total': 0,
}
response = self.client.get('/api/hold-overview/matrix?hold_type=non-quality&reason=特殊需求管控')
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
include_dummy=False,
status='HOLD',
hold_type='non-quality',
reason='特殊需求管控',
)
def test_matrix_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/matrix?hold_type=invalid')
self.assertEqual(response.status_code, 400)
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 7))
def test_matrix_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/matrix')
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'), '7')
mock_service.assert_not_called()
class TestHoldOverviewTreemapRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/treemap endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_passes_filters(self, mock_service):
mock_service.return_value = {'items': []}
response = self.client.get(
'/api/hold-overview/treemap?hold_type=quality&reason=品質確認&workcenter=WB&package=QFN'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
hold_type='quality',
reason='品質確認',
workcenter='WB',
package='QFN',
include_dummy=False,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_overview_treemap')
def test_treemap_failure_returns_500(self, mock_service):
mock_service.return_value = None
response = self.client.get('/api/hold-overview/treemap')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(payload['success'])
class TestHoldOverviewLotsRoute(TestHoldOverviewRoutesBase):
"""Test GET /api/hold-overview/lots endpoint."""
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_passes_all_filters_and_caps_per_page(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 2, 'perPage': 200, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get(
'/api/hold-overview/lots?hold_type=all&reason=品質確認'
'&workcenter=WB&package=QFN&treemap_reason=品質確認'
'&age_range=1-3&page=2&per_page=500'
)
self.assertEqual(response.status_code, 200)
mock_service.assert_called_once_with(
reason='品質確認',
hold_type=None,
treemap_reason='品質確認',
workcenter='WB',
package='QFN',
age_range='1-3',
include_dummy=False,
page=2,
page_size=200,
)
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
def test_lots_handles_page_less_than_one(self, mock_service):
mock_service.return_value = {
'lots': [],
'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1},
'filters': {},
}
response = self.client.get('/api/hold-overview/lots?page=0')
self.assertEqual(response.status_code, 200)
call_args = mock_service.call_args
self.assertEqual(call_args.kwargs['page'], 1)
def test_lots_invalid_age_range(self):
response = self.client.get('/api/hold-overview/lots?age_range=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
def test_lots_invalid_hold_type(self):
response = self.client.get('/api/hold-overview/lots?hold_type=invalid')
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 4))
def test_lots_rate_limited_returns_429(self, _mock_limit, mock_service):
response = self.client.get('/api/hold-overview/lots')
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'), '4')
mock_service.assert_not_called()

View File

@@ -35,14 +35,17 @@ def mock_registry(temp_data_file):
"""Mock page_registry to use temp file."""
original_data_file = page_registry.DATA_FILE
original_cache = page_registry._cache
original_cache_mtime = page_registry._cache_mtime
page_registry.DATA_FILE = temp_data_file
page_registry._cache = None
page_registry._cache_mtime = 0.0
yield temp_data_file
page_registry.DATA_FILE = original_data_file
page_registry._cache = original_cache
page_registry._cache_mtime = original_cache_mtime
class TestSchemaMigration:
@@ -205,7 +208,8 @@ class TestReloadCache:
home["status"] = "dev"
temp_data_file.write_text(json.dumps(data))
assert page_registry.get_page_status("/") == "released"
# Note: _load() has mtime-based invalidation that may auto-detect
# the file change, so we only assert post-reload behavior.
page_registry.reload_cache()
assert page_registry.get_page_status("/") == "dev"

View File

@@ -40,3 +40,29 @@ def test_hold_detail_lots_rate_limit_returns_429(_mock_limit, mock_service):
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '4'
mock_service.assert_not_called()
@patch('mes_dashboard.routes.hold_overview_routes.get_wip_matrix')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6))
def test_hold_overview_matrix_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/hold-overview/matrix')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '6'
mock_service.assert_not_called()
@patch('mes_dashboard.routes.hold_overview_routes.get_hold_detail_lots')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 3))
def test_hold_overview_lots_rate_limit_returns_429(_mock_limit, mock_service):
client = _client()
response = client.get('/api/hold-overview/lots')
assert response.status_code == 429
payload = response.get_json()
assert payload['error']['code'] == 'TOO_MANY_REQUESTS'
assert response.headers.get('Retry-After') == '3'
mock_service.assert_not_called()

View File

@@ -54,6 +54,15 @@ class TestTemplateIntegration(unittest.TestCase):
self.assertIn('type="module"', html)
self.assertNotIn('mes-toast-container', html)
def test_hold_overview_serves_pure_vite_module(self):
response = self.client.get('/hold-overview')
self.assertEqual(response.status_code, 200)
html = response.data.decode('utf-8')
self.assertIn('/static/dist/hold-overview.js', html)
self.assertIn('type="module"', html)
self.assertNotIn('mes-toast-container', html)
def test_tables_page_serves_pure_vite_module(self):
response = self.client.get('/tables')
self.assertEqual(response.status_code, 200)
@@ -303,6 +312,7 @@ class TestViteModuleIntegration(unittest.TestCase):
endpoints_and_assets = [
('/wip-overview', 'wip-overview.js'),
('/wip-detail', 'wip-detail.js'),
('/hold-overview', 'hold-overview.js'),
('/hold-detail?reason=test-reason', 'hold-detail.js'),
('/tables', 'tables.js'),
('/resource', 'resource-status.js'),

View File

@@ -9,17 +9,20 @@ from unittest.mock import patch, MagicMock
from functools import wraps
import pandas as pd
from mes_dashboard.services.wip_service import (
WIP_VIEW,
get_wip_summary,
get_wip_matrix,
get_wip_hold_summary,
get_wip_detail,
get_workcenters,
get_packages,
search_workorders,
search_lot_ids,
)
from mes_dashboard.services.wip_service import (
WIP_VIEW,
get_wip_summary,
get_wip_matrix,
get_wip_hold_summary,
get_wip_detail,
get_hold_detail_summary,
get_hold_detail_lots,
get_hold_overview_treemap,
get_workcenters,
get_packages,
search_workorders,
search_lot_ids,
)
def disable_cache(func):
@@ -654,7 +657,140 @@ class TestMultipleFilterConditions(unittest.TestCase):
import pytest
class TestHoldOverviewServiceCachePath(unittest.TestCase):
"""Test hold overview related behavior on cache path."""
def setUp(self):
import mes_dashboard.services.wip_service as wip_service
with wip_service._wip_search_index_lock:
wip_service._wip_search_index_cache.clear()
with wip_service._wip_snapshot_lock:
wip_service._wip_snapshot_cache.clear()
@staticmethod
def _sample_hold_df() -> pd.DataFrame:
return pd.DataFrame({
'LOTID': ['L1', 'L2', 'L3', 'L4', 'L5'],
'WORKORDER': ['WO1', 'WO2', 'WO3', 'WO4', 'WO5'],
'QTY': [100, 50, 80, 60, 20],
'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-A', 'PKG-Z', 'PKG-C'],
'WORKCENTER_GROUP': ['WC-A', 'WC-B', 'WC-A', 'WC-Z', 'WC-C'],
'WORKCENTERSEQUENCE_GROUP': [1, 2, 1, 9, 3],
'HOLDREASONNAME': ['品質確認', '特殊需求管控', '品質確認', None, '設備異常'],
'AGEBYDAYS': [2.0, 3.0, 5.0, 0.3, 1.2],
'EQUIPMENTCOUNT': [0, 0, 0, 1, 0],
'CURRENTHOLDCOUNT': [1, 1, 1, 0, 1],
'SPECNAME': ['S1', 'S2', 'S1', 'S9', 'S3'],
'HOLDEMP': ['EMP1', 'EMP2', 'EMP3', 'EMP4', 'EMP5'],
'DEPTNAME': ['QC', 'PD', 'QC', 'RUN', 'QC'],
'COMMENT_HOLD': ['C1', 'C2', 'C3', 'C4', 'C5'],
'PJ_TYPE': ['T1', 'T2', 'T1', 'T9', 'T3'],
})
@patch('mes_dashboard.services.wip_service.get_cached_sys_date', return_value='2026-02-10 10:00:00')
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_detail_summary_supports_optional_reason_and_hold_type(
self,
mock_cached_wip,
_mock_sys_date,
):
mock_cached_wip.return_value = self._sample_hold_df()
reason_summary = get_hold_detail_summary(reason='品質確認')
self.assertEqual(reason_summary['totalLots'], 2)
self.assertEqual(reason_summary['totalQty'], 180)
self.assertEqual(reason_summary['workcenterCount'], 1)
self.assertEqual(reason_summary['dataUpdateDate'], '2026-02-10 10:00:00')
quality_summary = get_hold_detail_summary(hold_type='quality')
self.assertEqual(quality_summary['totalLots'], 3)
self.assertEqual(quality_summary['totalQty'], 200)
self.assertEqual(quality_summary['workcenterCount'], 2)
all_hold_summary = get_hold_detail_summary()
self.assertEqual(all_hold_summary['totalLots'], 4)
self.assertEqual(all_hold_summary['totalQty'], 250)
self.assertEqual(all_hold_summary['workcenterCount'], 3)
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_detail_lots_returns_hold_reason_and_treemap_filter(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
reason_result = get_hold_detail_lots(reason='品質確認', page=1, page_size=10)
self.assertEqual(len(reason_result['lots']), 2)
self.assertEqual(reason_result['lots'][0]['lotId'], 'L3')
self.assertEqual(reason_result['lots'][0]['holdReason'], '品質確認')
treemap_result = get_hold_detail_lots(
reason=None,
hold_type=None,
treemap_reason='特殊需求管控',
page=1,
page_size=10,
)
self.assertEqual(len(treemap_result['lots']), 1)
self.assertEqual(treemap_result['lots'][0]['lotId'], 'L2')
self.assertEqual(treemap_result['lots'][0]['holdReason'], '特殊需求管控')
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_wip_matrix_reason_filter_keeps_backward_compatibility(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
hold_quality_all = get_wip_matrix(status='HOLD', hold_type='quality')
self.assertEqual(hold_quality_all['grand_total'], 200)
hold_quality_reason = get_wip_matrix(
status='HOLD',
hold_type='quality',
reason='品質確認',
)
self.assertEqual(hold_quality_reason['grand_total'], 180)
self.assertEqual(hold_quality_reason['workcenters'], ['WC-A'])
@patch('mes_dashboard.services.wip_service.get_cached_wip_data')
def test_get_hold_overview_treemap_groups_by_workcenter_and_reason(self, mock_cached_wip):
mock_cached_wip.return_value = self._sample_hold_df()
result = get_hold_overview_treemap(hold_type='quality')
self.assertIsNotNone(result)
items = result['items']
self.assertEqual(len(items), 2)
expected = {(item['workcenter'], item['reason']): item for item in items}
self.assertEqual(expected[('WC-A', '品質確認')]['lots'], 2)
self.assertEqual(expected[('WC-A', '品質確認')]['qty'], 180)
self.assertAlmostEqual(expected[('WC-A', '品質確認')]['avgAge'], 3.5)
self.assertEqual(expected[('WC-C', '設備異常')]['lots'], 1)
class TestHoldOverviewServiceOracleFallback(unittest.TestCase):
"""Test reason filtering behavior on Oracle fallback path."""
@disable_cache
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_matrix_oracle_applies_reason_for_hold_status(self, mock_read_sql):
mock_read_sql.return_value = pd.DataFrame()
get_wip_matrix(status='HOLD', reason='品質確認')
call_args = mock_read_sql.call_args
sql = call_args[0][0]
params = call_args[0][1] if len(call_args[0]) > 1 else {}
self.assertIn('HOLDREASONNAME', sql)
self.assertTrue(any(v == '品質確認' for v in params.values()))
@disable_cache
@patch('mes_dashboard.services.wip_service.read_sql_df')
def test_get_wip_matrix_oracle_ignores_reason_for_non_hold_status(self, mock_read_sql):
mock_read_sql.return_value = pd.DataFrame()
get_wip_matrix(status='RUN', reason='品質確認')
call_args = mock_read_sql.call_args
sql = call_args[0][0]
self.assertNotIn('HOLDREASONNAME', sql)
import pytest
class TestWipServiceIntegration: