fix(hold): dedup equipment cache, fix portal iframe, improve Hold dashboards

- Equipment cache: add freshness gate so only 1 Oracle query per 5-min cycle
  across 4 gunicorn workers; sync worker waits before first refresh
- Portal: add frame-busting to prevent recursive iframe nesting
- Hold Overview: remove redundant TreeMap, add Product & Future Hold Comment
  columns to LotTable
- Hold History: switch list.sql JOIN from DW_MES_LOT_V (WIP snapshot) to
  DW_MES_CONTAINER (historical master) for reliable Product data; add
  Future Hold Comment column; fix comment truncation with hover tooltip
- Page status: reorganize drawer groupings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-11 09:01:02 +08:00
parent be22571421
commit e2ce75b004
18 changed files with 420 additions and 237 deletions

View File

@@ -4,10 +4,11 @@
Tests aggregation, status classification, and cache query functionality.
"""
import pytest
from unittest.mock import patch, MagicMock
import json
import pandas as pd
import pytest
from unittest.mock import patch, MagicMock
import json
from datetime import datetime, timedelta
import pandas as pd
class TestClassifyStatus:
@@ -516,7 +517,7 @@ class TestGetEquipmentStatusCacheStatus:
assert result['count'] == 1000
class TestEquipmentProcessLevelCache:
class TestEquipmentProcessLevelCache:
"""Test bounded process-level cache behavior for equipment status."""
def test_lru_eviction_prefers_recent_keys(self):
@@ -535,11 +536,99 @@ class TestEquipmentProcessLevelCache:
def test_global_equipment_cache_uses_bounded_config(self):
import mes_dashboard.services.realtime_equipment_cache as eq
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
class TestSharedQueryFragments:
assert eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE >= 1
assert eq._equipment_status_cache.max_size == eq.EQUIPMENT_PROCESS_CACHE_MAX_SIZE
class TestEquipmentRefreshDedup:
"""Test refresh de-dup behavior and sync worker startup timing."""
def test_refresh_skips_when_recently_updated(self):
"""Should skip Oracle query when cache is fresh and force=False."""
import mes_dashboard.services.realtime_equipment_cache as eq
recent_updated = (datetime.now() - timedelta(seconds=10)).isoformat()
mock_client = MagicMock()
mock_client.get.return_value = recent_updated
with patch.object(eq, "_SYNC_INTERVAL", 300):
with patch.object(eq, "try_acquire_lock", return_value=True):
with patch.object(eq, "release_lock") as mock_release_lock:
with patch.object(eq, "get_redis_client", return_value=mock_client):
with patch.object(eq, "get_key_prefix", return_value="mes_wip"):
with patch.object(eq, "_load_equipment_status_from_oracle") as mock_oracle:
with patch.object(eq, "_save_to_redis", return_value=True) as mock_save:
result = eq.refresh_equipment_status_cache(force=False)
assert result is False
mock_oracle.assert_not_called()
mock_save.assert_not_called()
mock_client.get.assert_called_once_with("mes_wip:equipment_status:meta:updated")
mock_release_lock.assert_called_once_with("equipment_status_cache_update")
def test_refresh_proceeds_when_stale(self):
"""Should proceed with Oracle query when cache is stale."""
import mes_dashboard.services.realtime_equipment_cache as eq
stale_updated = (datetime.now() - timedelta(seconds=200)).isoformat()
mock_client = MagicMock()
mock_client.get.return_value = stale_updated
with patch.object(eq, "_SYNC_INTERVAL", 300):
with patch.object(eq, "try_acquire_lock", return_value=True):
with patch.object(eq, "release_lock"):
with patch.object(eq, "get_redis_client", return_value=mock_client):
with patch.object(eq, "get_key_prefix", return_value="mes_wip"):
with patch.object(eq, "_load_equipment_status_from_oracle", return_value=[{"RESOURCEID": "R001"}]) as mock_oracle:
with patch.object(eq, "_aggregate_by_resourceid", return_value=[{"RESOURCEID": "R001"}]):
with patch.object(eq, "_save_to_redis", return_value=True) as mock_save:
result = eq.refresh_equipment_status_cache(force=False)
assert result is True
mock_oracle.assert_called_once()
mock_save.assert_called_once()
def test_refresh_proceeds_when_force(self):
"""Should bypass freshness gate when force=True."""
import mes_dashboard.services.realtime_equipment_cache as eq
with patch.object(eq, "_SYNC_INTERVAL", 300):
with patch.object(eq, "try_acquire_lock", return_value=True):
with patch.object(eq, "release_lock"):
with patch.object(eq, "get_redis_client") as mock_get_redis_client:
with patch.object(eq, "_load_equipment_status_from_oracle", return_value=[{"RESOURCEID": "R001"}]) as mock_oracle:
with patch.object(eq, "_aggregate_by_resourceid", return_value=[{"RESOURCEID": "R001"}]):
with patch.object(eq, "_save_to_redis", return_value=True) as mock_save:
result = eq.refresh_equipment_status_cache(force=True)
assert result is True
mock_oracle.assert_called_once()
mock_save.assert_called_once()
mock_get_redis_client.assert_not_called()
def test_sync_worker_waits_before_first_refresh(self):
"""Sync worker should not refresh immediately on startup."""
import mes_dashboard.services.realtime_equipment_cache as eq
class StopImmediatelyEvent:
def __init__(self):
self.timeouts = []
def wait(self, timeout=None):
self.timeouts.append(timeout)
return True
fake_stop_event = StopImmediatelyEvent()
with patch.object(eq, "_STOP_EVENT", fake_stop_event):
with patch.object(eq, "refresh_equipment_status_cache") as mock_refresh:
eq._sync_worker(interval=300)
mock_refresh.assert_not_called()
assert fake_stop_event.timeouts == [300]
class TestSharedQueryFragments:
"""Test shared SQL fragment governance for equipment cache."""
def test_equipment_load_uses_shared_sql_fragment(self):