diff --git a/frontend/src/query-tool/components/LotDetail.vue b/frontend/src/query-tool/components/LotDetail.vue index 7ce276f..08ac2d3 100644 --- a/frontend/src/query-tool/components/LotDetail.vue +++ b/frontend/src/query-tool/components/LotDetail.vue @@ -111,6 +111,11 @@ const activeColumnLabels = computed(() => { CONTAINERNAME: 'LOT ID', }; } + if (props.activeSubTab === 'holds') { + return { + CONTAINERNAME: 'LOT ID', + }; + } return {}; }); @@ -118,6 +123,9 @@ const activeHiddenColumns = computed(() => { if (props.activeSubTab === 'materials') { return ['CONTAINERID', 'WORKCENTER_GROUP']; } + if (props.activeSubTab === 'holds') { + return ['CONTAINERID']; + } return []; }); @@ -134,6 +142,23 @@ const activeColumnOrder = computed(() => { 'TXNDATE', ]; } + if (props.activeSubTab === 'holds') { + return [ + 'CONTAINERNAME', + 'WORKCENTERNAME', + 'HOLDTXNDATE', + 'RELEASETXNDATE', + 'HOLD_STATUS', + 'HOLD_HOURS', + 'HOLDREASONNAME', + 'HOLDCOMMENTS', + 'HOLDEMP', + 'HOLDEMPDEPTNAME', + 'RELEASEEMP', + 'RELEASECOMMENTS', + 'NCRID', + ]; + } return []; }); diff --git a/src/mes_dashboard/routes/query_tool_routes.py b/src/mes_dashboard/routes/query_tool_routes.py index 60c7313..cc2c6b9 100644 --- a/src/mes_dashboard/routes/query_tool_routes.py +++ b/src/mes_dashboard/routes/query_tool_routes.py @@ -102,6 +102,29 @@ def _format_lot_materials_export_rows(rows): 'TXNDATE': row.get('TXNDATE', ''), }) return normalized_rows + + +def _format_lot_holds_export_rows(rows): + """Normalize LOT hold export columns for UI/CSV consistency.""" + normalized_rows = [] + for row in rows or []: + lot_id = row.get('CONTAINERNAME') or row.get('CONTAINERID') or '' + normalized_rows.append({ + 'LOT ID': lot_id, + 'WORKCENTERNAME': row.get('WORKCENTERNAME', ''), + 'HOLDTXNDATE': row.get('HOLDTXNDATE', ''), + 'RELEASETXNDATE': row.get('RELEASETXNDATE', ''), + 'HOLD_STATUS': row.get('HOLD_STATUS', ''), + 'HOLD_HOURS': row.get('HOLD_HOURS', ''), + 'HOLDREASONNAME': row.get('HOLDREASONNAME', ''), + 'HOLDCOMMENTS': row.get('HOLDCOMMENTS', ''), + 'HOLDEMP': row.get('HOLDEMP', ''), + 'HOLDEMPDEPTNAME': row.get('HOLDEMPDEPTNAME', ''), + 'RELEASEEMP': row.get('RELEASEEMP', ''), + 'RELEASECOMMENTS': row.get('RELEASECOMMENTS', ''), + 'NCRID': row.get('NCRID', ''), + }) + return normalized_rows # ============================================================ @@ -613,6 +636,8 @@ def export_csv(): if export_type == 'lot_materials': export_data = _format_lot_materials_export_rows(export_data) + elif export_type == 'lot_holds': + export_data = _format_lot_holds_export_rows(export_data) # Stream CSV response return Response( diff --git a/src/mes_dashboard/services/event_fetcher.py b/src/mes_dashboard/services/event_fetcher.py index ac1501f..a4f3481 100644 --- a/src/mes_dashboard/services/event_fetcher.py +++ b/src/mes_dashboard/services/event_fetcher.py @@ -49,8 +49,9 @@ _DOMAIN_SPECS: Dict[str, Dict[str, Any]] = { "default_window": 60, }, "holds": { - "filter_column": "CONTAINERID", + "filter_column": "h.CONTAINERID", "cache_ttl": 180, + "schema_version": 2, "bucket": "event-holds", "max_env": "EVT_HOLDS_RATE_MAX_REQUESTS", "window_env": "EVT_HOLDS_RATE_WINDOW_SECONDS", diff --git a/src/mes_dashboard/sql/query_tool/lot_holds.sql b/src/mes_dashboard/sql/query_tool/lot_holds.sql index d2d9842..4178b23 100644 --- a/src/mes_dashboard/sql/query_tool/lot_holds.sql +++ b/src/mes_dashboard/sql/query_tool/lot_holds.sql @@ -7,28 +7,31 @@ -- Note: Uses HOLDTXNDATE/RELEASETXNDATE (NOT TXNDATETIME) -- NULL RELEASETXNDATE means currently HOLD -SELECT - CONTAINERID, - WORKCENTERNAME, - HOLDTXNDATE, - HOLDEMP, - HOLDEMPDEPTNAME, - HOLDREASONNAME, - HOLDCOMMENTS, - RELEASETXNDATE, - RELEASEEMP, - RELEASECOMMENTS, - NCRID, - CASE - WHEN RELEASETXNDATE IS NULL THEN 'HOLD' - ELSE 'RELEASED' - END AS HOLD_STATUS, - CASE - WHEN RELEASETXNDATE IS NULL THEN - ROUND((SYSDATE - HOLDTXNDATE) * 24, 2) - ELSE - ROUND((RELEASETXNDATE - HOLDTXNDATE) * 24, 2) - END AS HOLD_HOURS -FROM DWH.DW_MES_HOLDRELEASEHISTORY -WHERE CONTAINERID = :container_id -ORDER BY HOLDTXNDATE DESC +SELECT + h.CONTAINERID, + NVL(TRIM(c.CONTAINERNAME), TRIM(h.CONTAINERID)) AS CONTAINERNAME, + h.WORKCENTERNAME, + h.HOLDTXNDATE, + h.HOLDEMP, + h.HOLDEMPDEPTNAME, + h.HOLDREASONNAME, + h.HOLDCOMMENTS, + h.RELEASETXNDATE, + h.RELEASEEMP, + h.RELEASECOMMENTS, + h.NCRID, + CASE + WHEN h.RELEASETXNDATE IS NULL THEN 'HOLD' + ELSE 'RELEASED' + END AS HOLD_STATUS, + CASE + WHEN h.RELEASETXNDATE IS NULL THEN + ROUND((SYSDATE - h.HOLDTXNDATE) * 24, 2) + ELSE + ROUND((h.RELEASETXNDATE - h.HOLDTXNDATE) * 24, 2) + END AS HOLD_HOURS +FROM DWH.DW_MES_HOLDRELEASEHISTORY h +LEFT JOIN DWH.DW_MES_CONTAINER c + ON c.CONTAINERID = h.CONTAINERID +WHERE h.CONTAINERID = :container_id +ORDER BY h.HOLDTXNDATE DESC diff --git a/tests/test_event_fetcher.py b/tests/test_event_fetcher.py index 05c4f36..8f82d59 100644 --- a/tests/test_event_fetcher.py +++ b/tests/test_event_fetcher.py @@ -136,3 +136,27 @@ def test_fetch_events_rejects_branch_replaces_aliased_container_filter( assert "r.CONTAINERID = :container_id" not in sql assert "IN" in sql.upper() assert params == {"p0": "CID-1", "p1": "CID-2"} + + +@patch("mes_dashboard.services.event_fetcher.cache_set") +@patch("mes_dashboard.services.event_fetcher.cache_get", return_value=None) +@patch("mes_dashboard.services.event_fetcher.read_sql_df") +@patch("mes_dashboard.services.event_fetcher.SQLLoader.load") +def test_fetch_events_holds_branch_replaces_aliased_container_filter( + mock_sql_load, + mock_read_sql_df, + _mock_cache_get, + _mock_cache_set, +): + mock_sql_load.return_value = ( + "SELECT * FROM t h LEFT JOIN c ON c.CONTAINERID = h.CONTAINERID " + "WHERE h.CONTAINERID = :container_id ORDER BY h.HOLDTXNDATE DESC" + ) + mock_read_sql_df.return_value = pd.DataFrame([]) + + EventFetcher.fetch_events(["CID-1", "CID-2"], "holds") + + sql, params = mock_read_sql_df.call_args.args + assert "h.CONTAINERID = :container_id" not in sql + assert "IN" in sql.upper() + assert params == {"p0": "CID-1", "p1": "CID-2"} diff --git a/tests/test_query_tool_routes.py b/tests/test_query_tool_routes.py index c102cf7..8ac4f99 100644 --- a/tests/test_query_tool_routes.py +++ b/tests/test_query_tool_routes.py @@ -749,6 +749,48 @@ class TestExportCsvEndpoint: 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 TestEquipmentListEndpoint: