feat: dimension pareto cache-based computation, filter propagation, and MSD events cache isolation

Reject History:
- Compute dimension pareto (package/type/workflow/workcenter/equipment) from
  cached DataFrame instead of re-querying Oracle per dimension change
- Propagate supplementary filters and trend date selection to dimension pareto
- Add staleness tracking to prevent race conditions on rapid dimension switches
- Add WORKFLOWNAME to detail and export outputs
- Fix button hover visibility with CSS specificity

MSD (製程不良追溯分析):
- Separate raw events caching from aggregation computation so changing
  loss_reasons uses EventFetcher per-domain cache (fast) and recomputes
  aggregation with current filters instead of returning stale cached results
- Exclude loss_reasons from MSD seed cache key since seed resolution does
  not use it, avoiding unnecessary Oracle re-queries
- Add suspect context panel, analysis summary, upstream station/spec filters
- Add machine bar click drill-down and filtered attribution charts

Query Tool:
- Support batch container_ids in lot CSV export (history/materials/rejects/holds)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-25 09:02:39 +08:00
parent 983737ca1a
commit 86984cfeb1
28 changed files with 1768 additions and 86 deletions

View File

@@ -1152,6 +1152,62 @@ class TestWorkcenterGroupsEndpoint:
assert 'error' in data
class TestEquipmentRecentJobsEndpoint:
"""Tests for /api/query-tool/equipment-recent-jobs/<equipment_id> endpoint."""
@patch('mes_dashboard.core.database.read_sql_df')
@patch('mes_dashboard.sql.SQLLoader.load', return_value='SELECT 1')
def test_returns_recent_jobs(self, _mock_sql, mock_read_sql, client):
"""Should return recent JOB records for given equipment."""
import pandas as pd
mock_read_sql.return_value = pd.DataFrame([
{
'JOBID': 'JOB-001',
'JOBSTATUS': 'Complete',
'JOBMODELNAME': 'MODEL-A',
'CREATEDATE': '2026-02-01 10:00:00',
'COMPLETEDATE': '2026-02-01 12:00:00',
'CAUSECODENAME': 'CAUSE-1',
'REPAIRCODENAME': 'REPAIR-1',
'RESOURCENAME': 'EQ-001',
},
])
response = client.get('/api/query-tool/equipment-recent-jobs/EQ001')
assert response.status_code == 200
data = json.loads(response.data)
assert len(data['data']) == 1
assert data['data'][0]['JOBID'] == 'JOB-001'
assert data['total'] == 1
@patch('mes_dashboard.core.database.read_sql_df')
@patch('mes_dashboard.sql.SQLLoader.load', return_value='SELECT 1')
def test_returns_empty_when_no_jobs(self, _mock_sql, mock_read_sql, client):
"""Should return empty list when no jobs found."""
import pandas as pd
mock_read_sql.return_value = pd.DataFrame()
response = client.get('/api/query-tool/equipment-recent-jobs/EQ002')
assert response.status_code == 200
data = json.loads(response.data)
assert data['data'] == []
assert data['total'] == 0
@patch('mes_dashboard.core.database.read_sql_df')
@patch('mes_dashboard.sql.SQLLoader.load', return_value='SELECT 1')
def test_handles_db_error(self, _mock_sql, mock_read_sql, client):
"""Should return 500 on database error."""
mock_read_sql.side_effect = Exception('DB connection failed')
response = client.get('/api/query-tool/equipment-recent-jobs/EQ003')
assert response.status_code == 500
data = json.loads(response.data)
assert 'error' in data
class TestLotHistoryWithWorkcenterFilter:
"""Tests for /api/query-tool/lot-history with workcenter filter."""