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:
@@ -104,15 +104,24 @@ def query_lot_history():
|
||||
|
||||
Query params:
|
||||
container_id: CONTAINERID (16-char hex)
|
||||
workcenter_groups: Optional comma-separated list of WORKCENTER_GROUP names
|
||||
|
||||
Returns production history records.
|
||||
"""
|
||||
container_id = request.args.get('container_id')
|
||||
workcenter_groups_param = request.args.get('workcenter_groups')
|
||||
|
||||
if not container_id:
|
||||
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:
|
||||
return jsonify(result), 400
|
||||
@@ -317,6 +326,33 @@ def get_equipment_list():
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
@@ -432,11 +432,28 @@ def _resolve_by_work_order(work_orders: List[str]) -> Dict[str, Any]:
|
||||
# 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.
|
||||
|
||||
Args:
|
||||
container_id: CONTAINERID (16-char hex)
|
||||
workcenter_groups: Optional list of WORKCENTER_GROUP names to filter by
|
||||
|
||||
Returns:
|
||||
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")
|
||||
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)
|
||||
data = _df_to_records(df)
|
||||
|
||||
@@ -457,6 +488,7 @@ def get_lot_history(container_id: str) -> Dict[str, Any]:
|
||||
'data': data,
|
||||
'total': len(data),
|
||||
'container_id': container_id,
|
||||
'filtered_by_groups': workcenter_groups or [],
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
--
|
||||
-- Parameters:
|
||||
-- :container_id - CONTAINERID to query (16-char hex)
|
||||
-- {{ WORKCENTER_FILTER }} - Optional workcenter name filter (replaced by service)
|
||||
--
|
||||
-- Note: Uses EQUIPMENTID/EQUIPMENTNAME (NOT RESOURCEID/RESOURCENAME)
|
||||
-- Time fields: TRACKINTIMESTAMP/TRACKOUTTIMESTAMP (NOT TXNDATETIME)
|
||||
@@ -33,6 +34,7 @@ WITH ranked_history AS (
|
||||
WHERE h.CONTAINERID = :container_id
|
||||
AND h.EQUIPMENTID IS NOT NULL
|
||||
AND h.TRACKINTIMESTAMP IS NOT NULL
|
||||
{{ WORKCENTER_FILTER }}
|
||||
)
|
||||
SELECT
|
||||
CONTAINERID,
|
||||
|
||||
@@ -20,6 +20,10 @@ const QueryToolState = {
|
||||
timelineSelectedLots: new Set(), // Set of indices for timeline display
|
||||
currentLotIndex: 0, // For association highlight
|
||||
|
||||
// Workcenter group filter
|
||||
workcenterGroups: [], // All available groups [{name, sequence}]
|
||||
selectedWorkcenterGroups: new Set(), // Selected group names for filtering
|
||||
|
||||
// Equipment query
|
||||
allEquipments: [],
|
||||
selectedEquipments: new Set(),
|
||||
@@ -46,6 +50,13 @@ function clearQueryState() {
|
||||
QueryToolState.timelineSelectedLots = new Set();
|
||||
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
|
||||
QueryToolState.equipmentResults = null;
|
||||
// Note: Keep allEquipments and selectedEquipments as they are reused
|
||||
@@ -99,6 +110,7 @@ window.clearQueryState = clearQueryState;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadEquipments();
|
||||
loadWorkcenterGroups(); // Load workcenter groups for filtering
|
||||
setLast30Days();
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
@@ -116,6 +128,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (lotSelector && !lotSelector.contains(e.target)) {
|
||||
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
|
||||
@@ -259,9 +278,15 @@ async function executeLotQuery() {
|
||||
// Initialize with empty selection - user must confirm
|
||||
QueryToolState.timelineSelectedLots = new Set();
|
||||
|
||||
// Clear workcenter group selection for new query
|
||||
QueryToolState.selectedWorkcenterGroups = new Set();
|
||||
|
||||
// Hide LOT info bar initially
|
||||
document.getElementById('lotInfoBar').style.display = 'none';
|
||||
|
||||
// Show workcenter group selector for filtering
|
||||
showWorkcenterGroupSelector();
|
||||
|
||||
if (resolveResult.data.length === 1) {
|
||||
// Single result - auto-select and show directly
|
||||
QueryToolState.timelineSelectedLots.add(0);
|
||||
@@ -372,8 +397,14 @@ async function confirmLotSelection() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dropdown
|
||||
// Close dropdowns
|
||||
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
|
||||
const count = selectedIndices.length;
|
||||
@@ -383,24 +414,29 @@ async function confirmLotSelection() {
|
||||
document.getElementById('lotInfoBar').style.display = 'none';
|
||||
|
||||
const panel = document.getElementById('lotResultsContent');
|
||||
const filterInfo = wcGroupsParam ? ` (篩選: ${wcGroups.length} 個站點群組)` : '';
|
||||
panel.innerHTML = `
|
||||
<div class="loading">
|
||||
<div class="loading-spinner"></div>
|
||||
<br>載入所選批次資料...
|
||||
<br>載入所選批次資料...${filterInfo}
|
||||
</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 {
|
||||
await Promise.all(selectedIndices.map(async (idx) => {
|
||||
const lot = QueryToolState.resolvedLots[idx];
|
||||
if (!QueryToolState.lotHistories[lot.container_id]) {
|
||||
const result = await MesApi.get('/api/query-tool/lot-history', {
|
||||
params: { container_id: lot.container_id }
|
||||
});
|
||||
if (!result.error) {
|
||||
QueryToolState.lotHistories[lot.container_id] = result.data || [];
|
||||
}
|
||||
const params = { container_id: lot.container_id };
|
||||
if (wcGroupsParam) {
|
||||
params.workcenter_groups = wcGroupsParam;
|
||||
}
|
||||
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
@@ -196,6 +196,136 @@
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -991,6 +1121,14 @@
|
||||
<!-- LOT options will be populated here -->
|
||||
</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>
|
||||
|
||||
<!-- Equipment Query Filter Bar (hidden by default) -->
|
||||
|
||||
@@ -536,3 +536,105 @@ class TestEquipmentListEndpoint:
|
||||
assert response.status_code == 500
|
||||
data = json.loads(response.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']
|
||||
|
||||
@@ -292,3 +292,129 @@ class TestServiceConstants:
|
||||
def test_max_equipments_is_reasonable(self):
|
||||
"""Max equipments should be sensible."""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user