feat(query-tool): 新增站點群組篩選功能

在批次追蹤工具中加入 WORKCENTER GROUP 篩選功能,讓使用者可以
選擇特定站點群組來過濾生產歷程,減少資料量提升查詢效能。

變更內容:
- 新增 /api/query-tool/workcenter-groups API 端點
- 修改 lot-history API 支援 workcenter_groups 參數
- 前端新增多選下拉選單篩選器 UI
- 後端 SQL 層級過濾,複用 filter_cache 機制
- 新增對應的單元測試和整合測試

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-02-05 20:03:25 +08:00
parent 94e5d8c5c3
commit d468adaf3b
7 changed files with 648 additions and 12 deletions

View File

@@ -104,15 +104,24 @@ def query_lot_history():
Query params: Query params:
container_id: CONTAINERID (16-char hex) container_id: CONTAINERID (16-char hex)
workcenter_groups: Optional comma-separated list of WORKCENTER_GROUP names
Returns production history records. Returns production history records.
""" """
container_id = request.args.get('container_id') container_id = request.args.get('container_id')
workcenter_groups_param = request.args.get('workcenter_groups')
if not container_id: if not container_id:
return jsonify({'error': '請指定 CONTAINERID'}), 400 return jsonify({'error': '請指定 CONTAINERID'}), 400
result = get_lot_history(container_id) # Parse workcenter_groups if provided
workcenter_groups = None
if workcenter_groups_param:
workcenter_groups = [
g.strip() for g in workcenter_groups_param.split(',') if g.strip()
]
result = get_lot_history(container_id, workcenter_groups=workcenter_groups)
if 'error' in result: if 'error' in result:
return jsonify(result), 400 return jsonify(result), 400
@@ -317,6 +326,33 @@ def get_equipment_list():
return jsonify({'error': f'載入設備資料失敗: {str(exc)}'}), 500 return jsonify({'error': f'載入設備資料失敗: {str(exc)}'}), 500
# ============================================================
# Workcenter Groups API (for filtering)
# ============================================================
@query_tool_bp.route('/api/query-tool/workcenter-groups', methods=['GET'])
def get_workcenter_groups_list():
"""Get available workcenter groups for filtering.
Returns workcenter groups list sorted by sequence.
Used for production history filtering UI.
"""
from mes_dashboard.services.filter_cache import get_workcenter_groups
try:
groups = get_workcenter_groups()
if groups is None:
return jsonify({'error': '無法載入站點群組資料'}), 500
return jsonify({
'data': groups,
'total': len(groups)
})
except Exception as exc:
return jsonify({'error': f'載入站點群組失敗: {str(exc)}'}), 500
# ============================================================ # ============================================================
# CSV Export API # CSV Export API
# ============================================================ # ============================================================

View File

@@ -432,11 +432,28 @@ def _resolve_by_work_order(work_orders: List[str]) -> Dict[str, Any]:
# LOT History Functions # LOT History Functions
# ============================================================ # ============================================================
def get_lot_history(container_id: str) -> Dict[str, Any]: def _get_workcenters_for_groups(groups: List[str]) -> List[str]:
"""Get workcenter names for given groups using filter_cache.
Args:
groups: List of WORKCENTER_GROUP names
Returns:
List of WORKCENTERNAME values
"""
from mes_dashboard.services.filter_cache import get_workcenters_for_groups
return get_workcenters_for_groups(groups)
def get_lot_history(
container_id: str,
workcenter_groups: Optional[List[str]] = None
) -> Dict[str, Any]:
"""Get production history for a LOT. """Get production history for a LOT.
Args: Args:
container_id: CONTAINERID (16-char hex) container_id: CONTAINERID (16-char hex)
workcenter_groups: Optional list of WORKCENTER_GROUP names to filter by
Returns: Returns:
Dict with 'data' (history records) and 'total', or 'error'. Dict with 'data' (history records) and 'total', or 'error'.
@@ -448,6 +465,20 @@ def get_lot_history(container_id: str) -> Dict[str, Any]:
sql = SQLLoader.load("query_tool/lot_history") sql = SQLLoader.load("query_tool/lot_history")
params = {'container_id': container_id} params = {'container_id': container_id}
# Add workcenter filter if groups specified
workcenter_filter = ""
if workcenter_groups:
workcenters = _get_workcenters_for_groups(workcenter_groups)
if workcenters:
workcenter_filter = f"AND {_build_in_filter(workcenters, 'h.WORKCENTERNAME')}"
logger.debug(
f"Filtering by {len(workcenter_groups)} groups "
f"({len(workcenters)} workcenters)"
)
# Replace placeholder in SQL
sql = sql.replace("{{ WORKCENTER_FILTER }}", workcenter_filter)
df = read_sql_df(sql, params) df = read_sql_df(sql, params)
data = _df_to_records(df) data = _df_to_records(df)
@@ -457,6 +488,7 @@ def get_lot_history(container_id: str) -> Dict[str, Any]:
'data': data, 'data': data,
'total': len(data), 'total': len(data),
'container_id': container_id, 'container_id': container_id,
'filtered_by_groups': workcenter_groups or [],
} }
except Exception as exc: except Exception as exc:

View File

@@ -3,6 +3,7 @@
-- --
-- Parameters: -- Parameters:
-- :container_id - CONTAINERID to query (16-char hex) -- :container_id - CONTAINERID to query (16-char hex)
-- {{ WORKCENTER_FILTER }} - Optional workcenter name filter (replaced by service)
-- --
-- Note: Uses EQUIPMENTID/EQUIPMENTNAME (NOT RESOURCEID/RESOURCENAME) -- Note: Uses EQUIPMENTID/EQUIPMENTNAME (NOT RESOURCEID/RESOURCENAME)
-- Time fields: TRACKINTIMESTAMP/TRACKOUTTIMESTAMP (NOT TXNDATETIME) -- Time fields: TRACKINTIMESTAMP/TRACKOUTTIMESTAMP (NOT TXNDATETIME)
@@ -33,6 +34,7 @@ WITH ranked_history AS (
WHERE h.CONTAINERID = :container_id WHERE h.CONTAINERID = :container_id
AND h.EQUIPMENTID IS NOT NULL AND h.EQUIPMENTID IS NOT NULL
AND h.TRACKINTIMESTAMP IS NOT NULL AND h.TRACKINTIMESTAMP IS NOT NULL
{{ WORKCENTER_FILTER }}
) )
SELECT SELECT
CONTAINERID, CONTAINERID,

View File

@@ -20,6 +20,10 @@ const QueryToolState = {
timelineSelectedLots: new Set(), // Set of indices for timeline display timelineSelectedLots: new Set(), // Set of indices for timeline display
currentLotIndex: 0, // For association highlight currentLotIndex: 0, // For association highlight
// Workcenter group filter
workcenterGroups: [], // All available groups [{name, sequence}]
selectedWorkcenterGroups: new Set(), // Selected group names for filtering
// Equipment query // Equipment query
allEquipments: [], allEquipments: [],
selectedEquipments: new Set(), selectedEquipments: new Set(),
@@ -46,6 +50,13 @@ function clearQueryState() {
QueryToolState.timelineSelectedLots = new Set(); QueryToolState.timelineSelectedLots = new Set();
QueryToolState.currentLotIndex = 0; QueryToolState.currentLotIndex = 0;
// Clear workcenter group selection (keep workcenterGroups as it's reused)
QueryToolState.selectedWorkcenterGroups = new Set();
// Hide workcenter group selector
const wcContainer = document.getElementById('workcenterGroupSelectorContainer');
if (wcContainer) wcContainer.style.display = 'none';
// Clear equipment query state // Clear equipment query state
QueryToolState.equipmentResults = null; QueryToolState.equipmentResults = null;
// Note: Keep allEquipments and selectedEquipments as they are reused // Note: Keep allEquipments and selectedEquipments as they are reused
@@ -99,6 +110,7 @@ window.clearQueryState = clearQueryState;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadEquipments(); loadEquipments();
loadWorkcenterGroups(); // Load workcenter groups for filtering
setLast30Days(); setLast30Days();
// Close dropdowns when clicking outside // Close dropdowns when clicking outside
@@ -116,6 +128,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (lotSelector && !lotSelector.contains(e.target)) { if (lotSelector && !lotSelector.contains(e.target)) {
lotDropdown.classList.remove('show'); lotDropdown.classList.remove('show');
} }
// Workcenter group dropdown
const wcDropdown = document.getElementById('wcGroupDropdown');
const wcSelector = document.getElementById('workcenterGroupSelectorContainer');
if (wcSelector && !wcSelector.contains(e.target)) {
if (wcDropdown) wcDropdown.classList.remove('show');
}
}); });
// Handle Enter key in search input // Handle Enter key in search input
@@ -259,9 +278,15 @@ async function executeLotQuery() {
// Initialize with empty selection - user must confirm // Initialize with empty selection - user must confirm
QueryToolState.timelineSelectedLots = new Set(); QueryToolState.timelineSelectedLots = new Set();
// Clear workcenter group selection for new query
QueryToolState.selectedWorkcenterGroups = new Set();
// Hide LOT info bar initially // Hide LOT info bar initially
document.getElementById('lotInfoBar').style.display = 'none'; document.getElementById('lotInfoBar').style.display = 'none';
// Show workcenter group selector for filtering
showWorkcenterGroupSelector();
if (resolveResult.data.length === 1) { if (resolveResult.data.length === 1) {
// Single result - auto-select and show directly // Single result - auto-select and show directly
QueryToolState.timelineSelectedLots.add(0); QueryToolState.timelineSelectedLots.add(0);
@@ -372,8 +397,14 @@ async function confirmLotSelection() {
return; return;
} }
// Close dropdown // Close dropdowns
document.getElementById('lotSelectorDropdown').classList.remove('show'); document.getElementById('lotSelectorDropdown').classList.remove('show');
const wcDropdown = document.getElementById('wcGroupDropdown');
if (wcDropdown) wcDropdown.classList.remove('show');
// Build workcenter_groups parameter
const wcGroups = Array.from(QueryToolState.selectedWorkcenterGroups);
const wcGroupsParam = wcGroups.length > 0 ? wcGroups.join(',') : null;
// Update display // Update display
const count = selectedIndices.length; const count = selectedIndices.length;
@@ -383,24 +414,29 @@ async function confirmLotSelection() {
document.getElementById('lotInfoBar').style.display = 'none'; document.getElementById('lotInfoBar').style.display = 'none';
const panel = document.getElementById('lotResultsContent'); const panel = document.getElementById('lotResultsContent');
const filterInfo = wcGroupsParam ? ` (篩選: ${wcGroups.length} 個站點群組)` : '';
panel.innerHTML = ` panel.innerHTML = `
<div class="loading"> <div class="loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
<br>載入所選批次資料... <br>載入所選批次資料...${filterInfo}
</div> </div>
`; `;
// Load history for all selected lots // Clear cached histories when filter changes
QueryToolState.lotHistories = {};
// Load history for all selected lots WITH workcenter filter
try { try {
await Promise.all(selectedIndices.map(async (idx) => { await Promise.all(selectedIndices.map(async (idx) => {
const lot = QueryToolState.resolvedLots[idx]; const lot = QueryToolState.resolvedLots[idx];
if (!QueryToolState.lotHistories[lot.container_id]) { const params = { container_id: lot.container_id };
const result = await MesApi.get('/api/query-tool/lot-history', { if (wcGroupsParam) {
params: { container_id: lot.container_id } params.workcenter_groups = wcGroupsParam;
}); }
if (!result.error) {
QueryToolState.lotHistories[lot.container_id] = result.data || []; const result = await MesApi.get('/api/query-tool/lot-history', { params });
} if (!result.error) {
QueryToolState.lotHistories[lot.container_id] = result.data || [];
} }
})); }));
@@ -2146,6 +2182,170 @@ async function showAdjacentLots(equipmentId, specName, targetTime) {
} }
} }
// ============================================================
// Workcenter Group Filter Functions
// ============================================================
async function loadWorkcenterGroups() {
try {
const result = await MesApi.get('/api/query-tool/workcenter-groups', {
silent: true
});
if (result.error) {
console.error('Failed to load workcenter groups:', result.error);
return;
}
QueryToolState.workcenterGroups = result.data || [];
console.log(`[QueryTool] Loaded ${QueryToolState.workcenterGroups.length} workcenter groups`);
} catch (error) {
console.error('Error loading workcenter groups:', error);
}
}
function renderWorkcenterGroupSelector() {
const container = document.getElementById('workcenterGroupSelector');
if (!container) return;
const groups = QueryToolState.workcenterGroups;
const selected = QueryToolState.selectedWorkcenterGroups;
const count = selected.size;
let html = `
<div class="wc-group-selector">
<button type="button" class="wc-group-btn" onclick="toggleWorkcenterGroupDropdown()">
<span id="wcGroupDisplay">${count === 0 ? '全部站點' : `${count} 個站點群組`}</span>
<span class="wc-group-badge" id="wcGroupBadge" style="display: ${count > 0 ? 'inline-block' : 'none'};">${count}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style="margin-left: auto;">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="wc-group-dropdown" id="wcGroupDropdown">
<div class="wc-group-header">
<label style="cursor: pointer; display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wcGroupSelectAll" onchange="toggleAllWorkcenterGroups(this.checked)"
${selected.size === groups.length && groups.length > 0 ? 'checked' : ''}>
全選
</label>
<span id="wcGroupSelectedCount">已選 ${count}</span>
</div>
<div class="wc-group-search">
<input type="text" placeholder="搜尋站點群組..." oninput="filterWorkcenterGroups(this.value)">
</div>
<div class="wc-group-list" id="wcGroupList">
`;
groups.forEach(group => {
const isSelected = selected.has(group.name);
html += `
<div class="wc-group-item ${isSelected ? 'selected' : ''}" data-group="${group.name}">
<label onclick="event.stopPropagation();">
<input type="checkbox" ${isSelected ? 'checked' : ''}
onchange="toggleWorkcenterGroup('${group.name}', this.checked)">
${group.name}
</label>
</div>
`;
});
html += `
</div>
<div class="wc-group-footer">
<button type="button" class="btn btn-sm btn-secondary" onclick="clearWorkcenterGroups()">清除</button>
<button type="button" class="btn btn-sm btn-primary" onclick="closeWorkcenterGroupDropdown()">確定</button>
</div>
</div>
</div>
`;
container.innerHTML = html;
}
function toggleWorkcenterGroup(groupName, checked) {
if (checked) {
QueryToolState.selectedWorkcenterGroups.add(groupName);
} else {
QueryToolState.selectedWorkcenterGroups.delete(groupName);
}
updateWorkcenterGroupUI();
}
function toggleAllWorkcenterGroups(checked) {
if (checked) {
QueryToolState.workcenterGroups.forEach(g => {
QueryToolState.selectedWorkcenterGroups.add(g.name);
});
} else {
QueryToolState.selectedWorkcenterGroups.clear();
}
renderWorkcenterGroupSelector();
}
function clearWorkcenterGroups() {
QueryToolState.selectedWorkcenterGroups.clear();
renderWorkcenterGroupSelector();
}
function updateWorkcenterGroupUI() {
const count = QueryToolState.selectedWorkcenterGroups.size;
const display = document.getElementById('wcGroupDisplay');
const badge = document.getElementById('wcGroupBadge');
const countEl = document.getElementById('wcGroupSelectedCount');
const selectAll = document.getElementById('wcGroupSelectAll');
// Update item visual state
document.querySelectorAll('.wc-group-item').forEach(item => {
const groupName = item.dataset.group;
const isSelected = QueryToolState.selectedWorkcenterGroups.has(groupName);
item.classList.toggle('selected', isSelected);
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = isSelected;
});
// Update display text and badge
if (display) {
display.textContent = count === 0 ? '全部站點' : `${count} 個站點群組`;
}
if (badge) {
badge.textContent = count;
badge.style.display = count > 0 ? 'inline-block' : 'none';
}
if (countEl) {
countEl.textContent = `已選 ${count}`;
}
if (selectAll) {
selectAll.checked = count === QueryToolState.workcenterGroups.length && count > 0;
}
}
function toggleWorkcenterGroupDropdown() {
const dropdown = document.getElementById('wcGroupDropdown');
if (dropdown) dropdown.classList.toggle('show');
}
function closeWorkcenterGroupDropdown() {
const dropdown = document.getElementById('wcGroupDropdown');
if (dropdown) dropdown.classList.remove('show');
}
function filterWorkcenterGroups(searchText) {
const items = document.querySelectorAll('.wc-group-item');
const search = searchText.toLowerCase();
items.forEach(item => {
const groupName = item.dataset.group.toLowerCase();
item.style.display = groupName.includes(search) ? 'flex' : 'none';
});
}
function showWorkcenterGroupSelector() {
const container = document.getElementById('workcenterGroupSelectorContainer');
if (container && QueryToolState.workcenterGroups.length > 0) {
container.style.display = 'block';
renderWorkcenterGroupSelector();
}
}
// ============================================================ // ============================================================
// Equipment Query Functions // Equipment Query Functions
// ============================================================ // ============================================================

View File

@@ -196,6 +196,136 @@
z-index: 1; z-index: 1;
} }
/* Workcenter Group Selector */
.wc-group-selector {
position: relative;
}
.wc-group-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
background: white;
cursor: pointer;
font-size: 14px;
min-width: 160px;
transition: border-color 0.2s;
}
.wc-group-btn:hover {
border-color: #4e54c8;
}
.wc-group-badge {
background: #4e54c8;
color: white;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.wc-group-dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 280px;
max-height: 400px;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
display: none;
margin-top: 4px;
}
.wc-group-dropdown.show {
display: block;
}
.wc-group-header {
padding: 10px 12px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
background: #fafafa;
}
.wc-group-search {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.wc-group-search input {
width: 100%;
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
.wc-group-search input:focus {
outline: none;
border-color: #4e54c8;
}
.wc-group-list {
max-height: 280px;
overflow-y: auto;
}
.wc-group-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.15s;
}
.wc-group-item:hover {
background: #f5f7ff;
}
.wc-group-item.selected {
background: #e8f4e8;
}
.wc-group-item label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
width: 100%;
}
.wc-group-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #4e54c8;
}
.wc-group-footer {
padding: 10px 12px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 8px;
background: #fafafa;
}
.wc-group-footer .btn-sm {
padding: 6px 12px;
font-size: 12px;
}
/* LOT info bar */ /* LOT info bar */
.lot-info-bar { .lot-info-bar {
display: flex; display: flex;
@@ -991,6 +1121,14 @@
<!-- LOT options will be populated here --> <!-- LOT options will be populated here -->
</div> </div>
</div> </div>
<!-- Workcenter Group Filter (appears after query) -->
<div class="filter-item" id="workcenterGroupSelectorContainer" style="display: none;">
<label>站點篩選</label>
<div id="workcenterGroupSelector">
<!-- Rendered by JS -->
</div>
</div>
</div> </div>
<!-- Equipment Query Filter Bar (hidden by default) --> <!-- Equipment Query Filter Bar (hidden by default) -->

View File

@@ -536,3 +536,105 @@ class TestEquipmentListEndpoint:
assert response.status_code == 500 assert response.status_code == 500
data = json.loads(response.data) data = json.loads(response.data)
assert 'error' in data assert 'error' in data
class TestWorkcenterGroupsEndpoint:
"""Tests for /api/query-tool/workcenter-groups endpoint."""
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
def test_returns_groups_list(self, mock_get_groups, client):
"""Should return workcenter groups list."""
mock_get_groups.return_value = [
{'name': 'DB', 'sequence': 1},
{'name': 'WB', 'sequence': 2},
]
response = client.get('/api/query-tool/workcenter-groups')
assert response.status_code == 200
data = json.loads(response.data)
assert 'data' in data
assert len(data['data']) == 2
assert data['total'] == 2
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
def test_handles_cache_failure(self, mock_get_groups, client):
"""Should return 500 when cache fails."""
mock_get_groups.return_value = None
response = client.get('/api/query-tool/workcenter-groups')
assert response.status_code == 500
data = json.loads(response.data)
assert 'error' in data
@patch('mes_dashboard.services.filter_cache.get_workcenter_groups')
def test_handles_exception(self, mock_get_groups, client):
"""Should handle exception gracefully."""
mock_get_groups.side_effect = Exception('Cache error')
response = client.get('/api/query-tool/workcenter-groups')
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."""
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_accepts_workcenter_groups_param(self, mock_query, client):
"""Should pass workcenter_groups parameter to service."""
mock_query.return_value = {
'data': [],
'total': 0,
'filtered_by_groups': ['DB', 'WB']
}
response = client.get(
'/api/query-tool/lot-history?'
'container_id=abc123&workcenter_groups=DB,WB'
)
assert response.status_code == 200
# Verify the service was called with workcenter_groups
call_args = mock_query.call_args
assert call_args[1].get('workcenter_groups') == ['DB', 'WB']
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_empty_workcenter_groups_ignored(self, mock_query, client):
"""Should ignore empty workcenter_groups parameter."""
mock_query.return_value = {
'data': [],
'total': 0,
'filtered_by_groups': []
}
response = client.get(
'/api/query-tool/lot-history?'
'container_id=abc123&workcenter_groups='
)
assert response.status_code == 200
# Verify workcenter_groups is None (not empty list)
call_args = mock_query.call_args
assert call_args[1].get('workcenter_groups') is None
@patch('mes_dashboard.routes.query_tool_routes.get_lot_history')
def test_returns_filtered_by_groups_in_response(self, mock_query, client):
"""Should include filtered_by_groups in response."""
mock_query.return_value = {
'data': [{'CONTAINERID': 'abc123'}],
'total': 1,
'filtered_by_groups': ['DB']
}
response = client.get(
'/api/query-tool/lot-history?'
'container_id=abc123&workcenter_groups=DB'
)
assert response.status_code == 200
data = json.loads(response.data)
assert data.get('filtered_by_groups') == ['DB']

View File

@@ -292,3 +292,129 @@ class TestServiceConstants:
def test_max_equipments_is_reasonable(self): def test_max_equipments_is_reasonable(self):
"""Max equipments should be sensible.""" """Max equipments should be sensible."""
assert 5 <= MAX_EQUIPMENTS <= 50 assert 5 <= MAX_EQUIPMENTS <= 50
class TestGetWorkcenterForGroups:
"""Tests for _get_workcenters_for_groups helper function."""
def test_calls_filter_cache(self):
"""Should call filter_cache.get_workcenters_for_groups."""
from unittest.mock import patch
with patch('mes_dashboard.services.filter_cache.get_workcenters_for_groups') as mock_get:
from mes_dashboard.services.query_tool_service import _get_workcenters_for_groups
mock_get.return_value = ['DB_1', 'DB_2']
result = _get_workcenters_for_groups(['DB'])
mock_get.assert_called_once_with(['DB'])
assert result == ['DB_1', 'DB_2']
def test_returns_empty_list_for_unknown_group(self):
"""Should return empty list for unknown group."""
from unittest.mock import patch
with patch('mes_dashboard.services.filter_cache.get_workcenters_for_groups') as mock_get:
from mes_dashboard.services.query_tool_service import _get_workcenters_for_groups
mock_get.return_value = []
result = _get_workcenters_for_groups(['UNKNOWN'])
assert result == []
class TestGetLotHistoryWithWorkcenterFilter:
"""Tests for get_lot_history with workcenter_groups filter."""
def test_no_filter_returns_all(self):
"""When no workcenter_groups, should not add filter to SQL."""
from unittest.mock import patch, MagicMock
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.SQLLoader') as mock_loader:
from mes_dashboard.services.query_tool_service import get_lot_history
mock_loader.load.return_value = 'SELECT * FROM t WHERE c = :container_id {{ WORKCENTER_FILTER }}'
mock_read.return_value = pd.DataFrame({
'CONTAINERID': ['abc123'],
'WORKCENTERNAME': ['DB_1'],
})
result = get_lot_history('abc123', workcenter_groups=None)
assert 'error' not in result
assert result['filtered_by_groups'] == []
# Verify SQL does not contain WORKCENTERNAME IN
sql_called = mock_read.call_args[0][0]
assert 'WORKCENTERNAME IN' not in sql_called
assert '{{ WORKCENTER_FILTER }}' not in sql_called
def test_with_filter_adds_condition(self):
"""When workcenter_groups provided, should filter by workcenters."""
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.SQLLoader') as mock_loader:
with patch('mes_dashboard.services.filter_cache.get_workcenters_for_groups') as mock_get_wc:
from mes_dashboard.services.query_tool_service import get_lot_history
mock_loader.load.return_value = 'SELECT * FROM t WHERE c = :container_id {{ WORKCENTER_FILTER }}'
mock_get_wc.return_value = ['DB_1', 'DB_2']
mock_read.return_value = pd.DataFrame({
'CONTAINERID': ['abc123'],
'WORKCENTERNAME': ['DB_1'],
})
result = get_lot_history('abc123', workcenter_groups=['DB'])
mock_get_wc.assert_called_once_with(['DB'])
assert result['filtered_by_groups'] == ['DB']
# Verify SQL contains filter
sql_called = mock_read.call_args[0][0]
assert 'WORKCENTERNAME' in sql_called
def test_empty_groups_list_no_filter(self):
"""Empty groups list should return all (no filter)."""
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.SQLLoader') as mock_loader:
from mes_dashboard.services.query_tool_service import get_lot_history
mock_loader.load.return_value = 'SELECT * FROM t WHERE c = :container_id {{ WORKCENTER_FILTER }}'
mock_read.return_value = pd.DataFrame({
'CONTAINERID': ['abc123'],
'WORKCENTERNAME': ['DB_1'],
})
result = get_lot_history('abc123', workcenter_groups=[])
assert result['filtered_by_groups'] == []
# Verify SQL does not contain WORKCENTERNAME IN
sql_called = mock_read.call_args[0][0]
assert 'WORKCENTERNAME IN' not in sql_called
def test_filter_with_empty_workcenters_result(self):
"""When group has no workcenters, should not add filter."""
from unittest.mock import patch
import pandas as pd
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
with patch('mes_dashboard.services.query_tool_service.SQLLoader') as mock_loader:
with patch('mes_dashboard.services.filter_cache.get_workcenters_for_groups') as mock_get_wc:
from mes_dashboard.services.query_tool_service import get_lot_history
mock_loader.load.return_value = 'SELECT * FROM t WHERE c = :container_id {{ WORKCENTER_FILTER }}'
mock_get_wc.return_value = [] # No workcenters for this group
mock_read.return_value = pd.DataFrame({
'CONTAINERID': ['abc123'],
'WORKCENTERNAME': ['DB_1'],
})
result = get_lot_history('abc123', workcenter_groups=['UNKNOWN'])
# Should still succeed, just no filter applied
assert 'error' not in result