feat(reject-history): multi-pareto 3×2 grid with cross-filter linkage
Replace single-dimension Pareto dropdown with 6 simultaneous Pareto charts (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a responsive 3-column grid. Clicking items in one Pareto cross-filters the other 5 (exclude-self logic), and the detail table applies all dimension selections with AND logic. Backend: - Add batch-pareto endpoint (cache-only, no Oracle queries) - Add _apply_cross_filter() with exclude-self pattern - Extend view/export endpoints for multi-dimension sel_* params Frontend: - New ParetoGrid.vue wrapping 6 ParetoSection instances - Simplify ParetoSection: remove dimension dropdown, keep TOP20 toggle - Replace single-dimension state with paretoSelections reactive object - Adaptive x-axis labels (font size, rotation, hideOverlap) for compact grid - Responsive grid: 3-col desktop, 2-col tablet, 1-col mobile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -186,3 +186,109 @@ def test_apply_view_rejects_invalid_pareto_dimension(monkeypatch):
|
||||
pareto_dimension="invalid-dimension",
|
||||
pareto_values=["X"],
|
||||
)
|
||||
|
||||
|
||||
def test_compute_batch_pareto_applies_cross_filter_exclude_self(monkeypatch):
|
||||
df = pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"CONTAINERID": "C1",
|
||||
"TXN_DAY": pd.Timestamp("2026-02-01"),
|
||||
"LOSSREASONNAME": "R-A",
|
||||
"PRODUCTLINENAME": "PKG-1",
|
||||
"PJ_TYPE": "TYPE-1",
|
||||
"WORKFLOWNAME": "WF-1",
|
||||
"WORKCENTER_GROUP": "WB-1",
|
||||
"PRIMARY_EQUIPMENTNAME": "EQ-1",
|
||||
"SCRAP_OBJECTTYPE": "LOT",
|
||||
"LOSSREASON_CODE": "001_A",
|
||||
"MOVEIN_QTY": 100,
|
||||
"REJECT_TOTAL_QTY": 100,
|
||||
"DEFECT_QTY": 0,
|
||||
},
|
||||
{
|
||||
"CONTAINERID": "C2",
|
||||
"TXN_DAY": pd.Timestamp("2026-02-01"),
|
||||
"LOSSREASONNAME": "R-A",
|
||||
"PRODUCTLINENAME": "PKG-2",
|
||||
"PJ_TYPE": "TYPE-2",
|
||||
"WORKFLOWNAME": "WF-2",
|
||||
"WORKCENTER_GROUP": "WB-2",
|
||||
"PRIMARY_EQUIPMENTNAME": "EQ-2",
|
||||
"SCRAP_OBJECTTYPE": "LOT",
|
||||
"LOSSREASON_CODE": "001_A",
|
||||
"MOVEIN_QTY": 100,
|
||||
"REJECT_TOTAL_QTY": 50,
|
||||
"DEFECT_QTY": 0,
|
||||
},
|
||||
{
|
||||
"CONTAINERID": "C3",
|
||||
"TXN_DAY": pd.Timestamp("2026-02-01"),
|
||||
"LOSSREASONNAME": "R-B",
|
||||
"PRODUCTLINENAME": "PKG-1",
|
||||
"PJ_TYPE": "TYPE-2",
|
||||
"WORKFLOWNAME": "WF-2",
|
||||
"WORKCENTER_GROUP": "WB-1",
|
||||
"PRIMARY_EQUIPMENTNAME": "EQ-1",
|
||||
"SCRAP_OBJECTTYPE": "LOT",
|
||||
"LOSSREASON_CODE": "002_B",
|
||||
"MOVEIN_QTY": 100,
|
||||
"REJECT_TOTAL_QTY": 40,
|
||||
"DEFECT_QTY": 0,
|
||||
},
|
||||
{
|
||||
"CONTAINERID": "C4",
|
||||
"TXN_DAY": pd.Timestamp("2026-02-01"),
|
||||
"LOSSREASONNAME": "R-B",
|
||||
"PRODUCTLINENAME": "PKG-3",
|
||||
"PJ_TYPE": "TYPE-3",
|
||||
"WORKFLOWNAME": "WF-3",
|
||||
"WORKCENTER_GROUP": "WB-3",
|
||||
"PRIMARY_EQUIPMENTNAME": "EQ-3",
|
||||
"SCRAP_OBJECTTYPE": "LOT",
|
||||
"LOSSREASON_CODE": "002_B",
|
||||
"MOVEIN_QTY": 100,
|
||||
"REJECT_TOTAL_QTY": 30,
|
||||
"DEFECT_QTY": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _query_id: df)
|
||||
monkeypatch.setattr(
|
||||
"mes_dashboard.services.scrap_reason_exclusion_cache.get_excluded_reasons",
|
||||
lambda: [],
|
||||
)
|
||||
|
||||
result = cache_svc.compute_batch_pareto(
|
||||
query_id="qid-batch-1",
|
||||
metric_mode="reject_total",
|
||||
pareto_scope="all",
|
||||
include_excluded_scrap=True,
|
||||
pareto_selections={
|
||||
"reason": ["R-A"],
|
||||
"type": ["TYPE-2"],
|
||||
},
|
||||
)
|
||||
|
||||
reason_items = result["dimensions"]["reason"]["items"]
|
||||
type_items = result["dimensions"]["type"]["items"]
|
||||
package_items = result["dimensions"]["package"]["items"]
|
||||
|
||||
assert {item["reason"] for item in reason_items} == {"R-A", "R-B"}
|
||||
assert {item["reason"] for item in type_items} == {"TYPE-1", "TYPE-2"}
|
||||
assert [item["reason"] for item in package_items] == ["PKG-2"]
|
||||
|
||||
|
||||
def test_apply_pareto_selection_filter_supports_multi_dimension_and_logic():
|
||||
df = _build_detail_filter_df()
|
||||
|
||||
filtered = cache_svc._apply_pareto_selection_filter(
|
||||
df,
|
||||
pareto_selections={
|
||||
"reason": ["001_A"],
|
||||
"type": ["TYPE-B"],
|
||||
},
|
||||
)
|
||||
|
||||
assert len(filtered) == 1
|
||||
assert set(filtered["CONTAINERNAME"].tolist()) == {"LOT-002"}
|
||||
|
||||
@@ -256,6 +256,54 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase):
|
||||
self.assertIs(kwargs['exclude_pb_diode'], False)
|
||||
mock_sql_pareto.assert_not_called()
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.compute_batch_pareto')
|
||||
def test_batch_pareto_passes_multi_dimension_selection_params(self, mock_batch_pareto):
|
||||
mock_batch_pareto.return_value = {
|
||||
'dimensions': {
|
||||
'reason': {'items': []},
|
||||
'package': {'items': []},
|
||||
'type': {'items': []},
|
||||
'workflow': {'items': []},
|
||||
'workcenter': {'items': []},
|
||||
'equipment': {'items': []},
|
||||
}
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/reject-history/batch-pareto'
|
||||
'?query_id=qid-001'
|
||||
'&metric_mode=reject_total'
|
||||
'&pareto_scope=all'
|
||||
'&pareto_display_scope=top20'
|
||||
'&sel_reason=001_A'
|
||||
'&sel_type=TYPE-A'
|
||||
'&sel_type=TYPE-B'
|
||||
'&include_excluded_scrap=true'
|
||||
'&exclude_material_scrap=false'
|
||||
'&exclude_pb_diode=false'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(payload['success'])
|
||||
_, kwargs = mock_batch_pareto.call_args
|
||||
self.assertEqual(kwargs['query_id'], 'qid-001')
|
||||
self.assertEqual(kwargs['pareto_display_scope'], 'top20')
|
||||
self.assertEqual(kwargs['pareto_scope'], 'all')
|
||||
self.assertEqual(kwargs['pareto_selections'], {'reason': ['001_A'], 'type': ['TYPE-A', 'TYPE-B']})
|
||||
self.assertIs(kwargs['include_excluded_scrap'], True)
|
||||
self.assertIs(kwargs['exclude_material_scrap'], False)
|
||||
self.assertIs(kwargs['exclude_pb_diode'], False)
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.compute_batch_pareto', return_value=None)
|
||||
def test_batch_pareto_cache_miss_returns_400(self, _mock_batch_pareto):
|
||||
response = self.client.get('/api/reject-history/batch-pareto?query_id=missing-qid')
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(payload['success'])
|
||||
self.assertEqual(payload['error'], 'cache_miss')
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.apply_view')
|
||||
def test_view_passes_pareto_multi_select_filters(self, mock_apply_view):
|
||||
mock_apply_view.return_value = {
|
||||
@@ -296,6 +344,58 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase):
|
||||
self.assertFalse(payload['success'])
|
||||
mock_apply_view.assert_not_called()
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.apply_view')
|
||||
def test_view_passes_multi_dimension_selection_filters(self, mock_apply_view):
|
||||
mock_apply_view.return_value = {
|
||||
'analytics_raw': [],
|
||||
'summary': {},
|
||||
'detail': {'items': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/reject-history/view'
|
||||
'?query_id=qid-001'
|
||||
'&sel_reason=001_A'
|
||||
'&sel_type=TYPE-A'
|
||||
'&sel_workflow=WF-01'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(payload['success'])
|
||||
_, kwargs = mock_apply_view.call_args
|
||||
self.assertEqual(kwargs['pareto_selections'], {
|
||||
'reason': ['001_A'],
|
||||
'type': ['TYPE-A'],
|
||||
'workflow': ['WF-01'],
|
||||
})
|
||||
self.assertIsNone(kwargs['pareto_dimension'])
|
||||
self.assertIsNone(kwargs['pareto_values'])
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.apply_view')
|
||||
def test_view_sel_filters_take_precedence_over_legacy_dimension(self, mock_apply_view):
|
||||
mock_apply_view.return_value = {
|
||||
'analytics_raw': [],
|
||||
'summary': {},
|
||||
'detail': {'items': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}},
|
||||
}
|
||||
|
||||
response = self.client.get(
|
||||
'/api/reject-history/view'
|
||||
'?query_id=qid-001'
|
||||
'&sel_reason=001_A'
|
||||
'&pareto_dimension=invalid'
|
||||
'&pareto_values=bad'
|
||||
)
|
||||
payload = json.loads(response.data)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(payload['success'])
|
||||
_, kwargs = mock_apply_view.call_args
|
||||
self.assertEqual(kwargs['pareto_selections'], {'reason': ['001_A']})
|
||||
self.assertIsNone(kwargs['pareto_dimension'])
|
||||
self.assertIsNone(kwargs['pareto_values'])
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes._list_to_csv')
|
||||
@patch('mes_dashboard.routes.reject_history_routes.export_csv_from_cache')
|
||||
def test_export_cached_passes_pareto_multi_select_filters(
|
||||
@@ -333,6 +433,34 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase):
|
||||
self.assertFalse(payload['success'])
|
||||
mock_export_cached.assert_not_called()
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes._list_to_csv')
|
||||
@patch('mes_dashboard.routes.reject_history_routes.export_csv_from_cache')
|
||||
def test_export_cached_passes_multi_dimension_selection_filters(
|
||||
self,
|
||||
mock_export_cached,
|
||||
mock_list_to_csv,
|
||||
):
|
||||
mock_export_cached.return_value = [{'LOT': 'LOT-001'}]
|
||||
mock_list_to_csv.return_value = iter(['A,B\n', '1,2\n'])
|
||||
|
||||
response = self.client.get(
|
||||
'/api/reject-history/export-cached'
|
||||
'?query_id=qid-001'
|
||||
'&sel_reason=001_A'
|
||||
'&sel_type=TYPE-A'
|
||||
'&sel_equipment=EQ-01'
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
_, kwargs = mock_export_cached.call_args
|
||||
self.assertEqual(kwargs['pareto_selections'], {
|
||||
'reason': ['001_A'],
|
||||
'type': ['TYPE-A'],
|
||||
'equipment': ['EQ-01'],
|
||||
})
|
||||
self.assertIsNone(kwargs['pareto_dimension'])
|
||||
self.assertIsNone(kwargs['pareto_values'])
|
||||
|
||||
@patch('mes_dashboard.routes.reject_history_routes.query_list')
|
||||
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6))
|
||||
def test_list_rate_limited_returns_429(self, _mock_limit, mock_list):
|
||||
|
||||
Reference in New Issue
Block a user