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:
|
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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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) -->
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user