feat(resource): add cascade machine/family filters to status and history pages

Add interdependent filter controls where upstream filters (workcenter group,
boolean flags) dynamically narrow downstream options (family, machine).
MultiSelect component moved to resource-shared with searchable support.
Backend endpoints accept families and resource_ids params, leveraging
existing Redis-cached resource metadata for client-side cascade filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-11 09:44:04 +08:00
parent e2ce75b004
commit 7b3f4b2cc1
12 changed files with 452 additions and 114 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref } from 'vue';
import { computed, reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { buildResourceKpiFromHours } from '../core/compute.js';
@@ -23,6 +23,7 @@ const filters = reactive({
granularity: 'day',
workcenterGroups: [],
families: [],
machines: [],
isProduction: false,
isKey: false,
isMonitor: false,
@@ -31,6 +32,7 @@ const filters = reactive({
const options = reactive({
workcenterGroups: [],
families: [],
resources: [],
});
const summaryData = ref({
@@ -104,6 +106,9 @@ function buildQueryString() {
filters.families.forEach((family) => {
params.append('families', family);
});
filters.machines.forEach((machine) => {
params.append('resource_ids', machine);
});
if (filters.isProduction) {
params.append('is_production', '1');
@@ -151,11 +156,35 @@ async function loadOptions() {
options.workcenterGroups = Array.isArray(data.workcenter_groups) ? data.workcenter_groups : [];
options.families = Array.isArray(data.families) ? data.families : [];
options.resources = Array.isArray(data.resources) ? data.resources : [];
} finally {
loading.options = false;
}
}
const machineOptions = computed(() => {
let list = options.resources;
if (filters.workcenterGroups.length > 0) {
const gset = new Set(filters.workcenterGroups);
list = list.filter((r) => gset.has(r.workcenterGroup));
}
if (filters.families.length > 0) {
const fset = new Set(filters.families);
list = list.filter((r) => fset.has(r.family));
}
if (filters.isProduction) list = list.filter((r) => r.isProduction);
if (filters.isKey) list = list.filter((r) => r.isKey);
if (filters.isMonitor) list = list.filter((r) => r.isMonitor);
return list
.map((r) => ({ label: r.name, value: r.id }))
.sort((a, b) => a.label.localeCompare(b.label));
});
function pruneInvalidMachines() {
const validIds = new Set(machineOptions.value.map((m) => m.value));
filters.machines = filters.machines.filter((m) => validIds.has(m));
}
async function executeQuery() {
const validationError = validateDateRange();
if (validationError) {
@@ -216,6 +245,13 @@ async function executeQuery() {
}
function updateFilters(nextFilters) {
const upstreamChanged =
'workcenterGroups' in nextFilters ||
'families' in nextFilters ||
'isProduction' in nextFilters ||
'isKey' in nextFilters ||
'isMonitor' in nextFilters;
filters.startDate = nextFilters.startDate || '';
filters.endDate = nextFilters.endDate || '';
filters.granularity = nextFilters.granularity || 'day';
@@ -223,9 +259,14 @@ function updateFilters(nextFilters) {
? nextFilters.workcenterGroups
: [];
filters.families = Array.isArray(nextFilters.families) ? nextFilters.families : [];
filters.machines = Array.isArray(nextFilters.machines) ? nextFilters.machines : [];
filters.isProduction = Boolean(nextFilters.isProduction);
filters.isKey = Boolean(nextFilters.isKey);
filters.isMonitor = Boolean(nextFilters.isMonitor);
if (upstreamChanged) {
pruneInvalidMachines();
}
}
function handleToggleRow(rowId) {
@@ -278,6 +319,7 @@ void initPage();
<FilterBar
:filters="filters"
:options="options"
:machine-options="machineOptions"
:loading="loading.options || loading.querying"
@update-filters="updateFilters"
@query="executeQuery"

View File

@@ -1,5 +1,5 @@
<script setup>
import MultiSelect from './MultiSelect.vue';
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const GRANULARITY_ITEMS = [
{ key: 'day', label: '日' },
@@ -20,6 +20,10 @@ const props = defineProps({
families: [],
}),
},
machineOptions: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
@@ -101,6 +105,18 @@ function updateFilters(patch) {
/>
</div>
<div class="filter-field">
<label>機台</label>
<MultiSelect
:model-value="filters.machines"
:options="machineOptions"
:disabled="loading"
placeholder="全部機台"
searchable
@update:model-value="updateFilters({ machines: $event })"
/>
</div>
<div class="checkbox-row">
<label class="checkbox-pill">
<input

View File

@@ -55,93 +55,6 @@
font-weight: 700;
}
.multi-select {
position: relative;
min-width: 200px;
}
.multi-select-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--resource-border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: #1f2937;
background: #ffffff;
cursor: pointer;
}
.multi-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.multi-select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.multi-select-arrow {
color: #64748b;
font-size: 11px;
}
.multi-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
border: 1px solid var(--resource-border);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.multi-select-options {
max-height: 250px;
overflow-y: auto;
padding: 8px 0;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
font-size: 13px;
color: #334155;
cursor: pointer;
text-align: left;
}
.multi-select-option:hover {
background: #f8fafc;
}
.multi-select-option input[type='checkbox'] {
margin: 0;
accent-color: #2563eb;
}
.multi-select-actions {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--resource-border);
background: #f8fafc;
}
.checkbox-row {
display: flex;
align-items: center;

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps({
modelValue: {
@@ -18,12 +18,18 @@ const props = defineProps({
type: Boolean,
default: false,
},
searchable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const rootRef = ref(null);
const searchRef = ref(null);
const isOpen = ref(false);
const searchQuery = ref('');
const normalizedOptions = computed(() => {
return props.options.map((option) => {
@@ -43,6 +49,14 @@ const normalizedOptions = computed(() => {
});
});
const displayedOptions = computed(() => {
if (!searchQuery.value) {
return normalizedOptions.value;
}
const q = searchQuery.value.toLowerCase();
return normalizedOptions.value.filter((opt) => opt.label.toLowerCase().includes(q));
});
const selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
const selectedText = computed(() => {
@@ -89,14 +103,21 @@ function toggleOption(value) {
}
function selectAll() {
emit(
'update:modelValue',
normalizedOptions.value.map((option) => option.value)
);
const next = new Set(selectedSet.value);
for (const opt of displayedOptions.value) {
next.add(opt.value);
}
emit('update:modelValue', [...next]);
}
function clearAll() {
emit('update:modelValue', []);
if (!searchQuery.value) {
emit('update:modelValue', []);
return;
}
const removing = new Set(displayedOptions.value.map((o) => o.value));
const next = props.modelValue.filter((v) => !removing.has(String(v)));
emit('update:modelValue', next);
}
function handleOutsideClick(event) {
@@ -109,6 +130,13 @@ function handleOutsideClick(event) {
}
}
watch(isOpen, (open) => {
if (open && props.searchable) {
searchQuery.value = '';
requestAnimationFrame(() => searchRef.value?.focus());
}
});
onMounted(() => {
document.addEventListener('click', handleOutsideClick, true);
});
@@ -131,9 +159,19 @@ onBeforeUnmount(() => {
</button>
<div v-if="isOpen" class="multi-select-dropdown">
<input
v-if="searchable"
ref="searchRef"
v-model="searchQuery"
type="text"
class="multi-select-search"
placeholder="搜尋..."
@click.stop
/>
<div class="multi-select-options">
<button
v-for="option in normalizedOptions"
v-for="option in displayedOptions"
:key="option.value"
type="button"
class="multi-select-option"
@@ -142,6 +180,9 @@ onBeforeUnmount(() => {
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
<span>{{ option.label }}</span>
</button>
<div v-if="searchable && displayedOptions.length === 0" class="multi-select-empty">
無符合結果
</div>
</div>
<div class="multi-select-actions">

View File

@@ -549,6 +549,118 @@ body {
}
}
/* ---- MultiSelect component ---- */
.multi-select {
position: relative;
min-width: 200px;
}
.multi-select-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--resource-border);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: #1f2937;
background: #ffffff;
cursor: pointer;
}
.multi-select-trigger:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.multi-select-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.multi-select-arrow {
color: #64748b;
font-size: 11px;
}
.multi-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 20;
border: 1px solid var(--resource-border);
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
overflow: hidden;
}
.multi-select-search {
display: block;
width: 100%;
border: none;
border-bottom: 1px solid var(--resource-border);
padding: 8px 12px;
font-size: 13px;
color: #1f2937;
outline: none;
background: #f8fafc;
}
.multi-select-search::placeholder {
color: #94a3b8;
}
.multi-select-options {
max-height: 250px;
overflow-y: auto;
padding: 8px 0;
}
.multi-select-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
border: none;
background: transparent;
font-size: 13px;
color: #334155;
cursor: pointer;
text-align: left;
}
.multi-select-option:hover {
background: #f8fafc;
}
.multi-select-option input[type='checkbox'] {
margin: 0;
accent-color: #2563eb;
}
.multi-select-empty {
padding: 12px;
text-align: center;
color: #94a3b8;
font-size: 13px;
}
.multi-select-actions {
display: flex;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid var(--resource-border);
background: #f8fafc;
}
@media (max-width: 640px) {
.summary-grid {
grid-template-columns: repeat(2, minmax(100px, 1fr));
@@ -558,4 +670,8 @@ body {
.matrix-table td:first-child {
min-width: 220px;
}
.multi-select {
min-width: 150px;
}
}

View File

@@ -18,6 +18,7 @@ const API_TIMEOUT = 60000;
const allEquipment = ref([]);
const workcenterGroups = ref([]);
const allResources = ref([]);
const summary = ref({
totalCount: 0,
byStatus: {
@@ -38,6 +39,8 @@ const filterState = reactive({
isProduction: false,
isKey: false,
isMonitor: false,
families: [],
machines: [],
});
const matrixFilter = ref([]);
@@ -89,10 +92,53 @@ function buildFilterParams() {
if (filterState.isMonitor) {
params.is_monitor = 1;
}
if (filterState.families.length) {
params.families = filterState.families.join(',');
}
if (filterState.machines.length) {
params.resource_ids = filterState.machines.join(',');
}
return params;
}
// --- Cascade: derive available family/machine options from upstream filters ---
const filteredByUpstream = computed(() => {
return allResources.value.filter((r) => {
if (filterState.group && r.workcenterGroup !== filterState.group) return false;
if (filterState.isProduction && !r.isProduction) return false;
if (filterState.isKey && !r.isKey) return false;
if (filterState.isMonitor && !r.isMonitor) return false;
return true;
});
});
const familyOptions = computed(() => {
const set = new Set();
filteredByUpstream.value.forEach((r) => {
if (r.family) set.add(r.family);
});
return [...set].sort();
});
const machineOptions = computed(() => {
let list = filteredByUpstream.value;
if (filterState.families.length > 0) {
const fset = new Set(filterState.families);
list = list.filter((r) => fset.has(r.family));
}
return list
.map((r) => ({ label: r.name, value: r.id }))
.sort((a, b) => a.label.localeCompare(b.label));
});
function pruneInvalidSelections() {
const validFamilies = new Set(familyOptions.value);
filterState.families = filterState.families.filter((f) => validFamilies.has(f));
const validMachineIds = new Set(machineOptions.value.map((m) => m.value));
filterState.machines = filterState.machines.filter((m) => validMachineIds.has(m));
}
function resetHierarchyState() {
Object.keys(hierarchyState).forEach((key) => {
delete hierarchyState[key];
@@ -109,6 +155,7 @@ async function loadOptions() {
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : [];
allResources.value = Array.isArray(data?.resources) ? data.resources : [];
} finally {
loading.options = false;
}
@@ -339,6 +386,7 @@ async function applyFiltersAndReload() {
function updateGroup(group) {
filterState.group = group || '';
pruneInvalidSelections();
void applyFiltersAndReload();
}
@@ -346,6 +394,18 @@ function updateFlags(nextFlags) {
filterState.isProduction = Boolean(nextFlags?.isProduction);
filterState.isKey = Boolean(nextFlags?.isKey);
filterState.isMonitor = Boolean(nextFlags?.isMonitor);
pruneInvalidSelections();
void applyFiltersAndReload();
}
function updateFamilies(families) {
filterState.families = families || [];
pruneInvalidSelections();
void applyFiltersAndReload();
}
function updateMachines(machines) {
filterState.machines = machines || [];
void applyFiltersAndReload();
}
@@ -388,9 +448,15 @@ void initPage();
:workcenter-groups="workcenterGroups"
:selected-group="filterState.group"
:flags="filterState"
:family-options="familyOptions"
:machine-options="machineOptions"
:selected-families="filterState.families"
:selected-machines="filterState.machines"
:loading="loading.options || loading.refreshing"
@change-group="updateGroup"
@change-flags="updateFlags"
@change-families="updateFamilies"
@change-machines="updateMachines"
/>
<p v-if="summaryError" class="error-banner">{{ summaryError }}</p>

View File

@@ -1,4 +1,6 @@
<script setup>
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({
workcenterGroups: {
type: Array,
@@ -16,13 +18,29 @@ const props = defineProps({
isMonitor: false,
}),
},
familyOptions: {
type: Array,
default: () => [],
},
machineOptions: {
type: Array,
default: () => [],
},
selectedFamilies: {
type: Array,
default: () => [],
},
selectedMachines: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change-group', 'change-flags']);
const emit = defineEmits(['change-group', 'change-flags', 'change-families', 'change-machines']);
function updateFlag(key, checked) {
emit('change-flags', {
@@ -51,6 +69,29 @@ function updateFlag(key, checked) {
</select>
</div>
<div class="filter-block">
<label>型號</label>
<MultiSelect
:model-value="selectedFamilies"
:options="familyOptions"
:disabled="loading"
placeholder="全部型號"
@update:model-value="$emit('change-families', $event)"
/>
</div>
<div class="filter-block">
<label>機台</label>
<MultiSelect
:model-value="selectedMachines"
:options="machineOptions"
:disabled="loading"
placeholder="全部機台"
searchable
@update:model-value="$emit('change-machines', $event)"
/>
</div>
<label class="filter-chip" :class="{ active: flags.isProduction }">
<input
type="checkbox"

View File

@@ -4,7 +4,7 @@
Contains Flask Blueprint for historical equipment performance analysis endpoints.
"""
from flask import Blueprint, jsonify, request, redirect, Response
from flask import Blueprint, jsonify, request, redirect, Response
from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key
from mes_dashboard.config.constants import CACHE_TTL_FILTER_OPTIONS, CACHE_TTL_TREND
@@ -27,10 +27,10 @@ resource_history_bp = Blueprint(
# Page Route (for template rendering)
# ============================================================
@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias')
def api_resource_history_page():
"""Backward-compatible alias for the migrated /resource-history page route."""
return redirect('/resource-history')
@resource_history_bp.route('/page', methods=['GET'], endpoint='page_alias')
def api_resource_history_page():
"""Backward-compatible alias for the migrated /resource-history page route."""
return redirect('/resource-history')
# ============================================================
@@ -44,7 +44,7 @@ def api_resource_history_options():
Returns:
JSON with workcenters and families lists.
"""
cache_key = make_cache_key("resource_history_options")
cache_key = make_cache_key("resource_history_options_v2")
options = cache_get(cache_key)
if options is None:
@@ -80,6 +80,7 @@ def api_resource_history_summary():
granularity = request.args.get('granularity', 'day')
workcenter_groups = request.args.getlist('workcenter_groups') or None
families = request.args.getlist('families') or None
resource_ids = request.args.getlist('resource_ids') or None
is_production = request.args.get('is_production') == '1'
is_key = request.args.get('is_key') == '1'
is_monitor = request.args.get('is_monitor') == '1'
@@ -98,6 +99,7 @@ def api_resource_history_summary():
'granularity': granularity,
'workcenter_groups': sorted(workcenter_groups) if workcenter_groups else None,
'families': sorted(families) if families else None,
'resource_ids': sorted(resource_ids) if resource_ids else None,
'is_production': is_production,
'is_key': is_key,
'is_monitor': is_monitor,
@@ -112,6 +114,7 @@ def api_resource_history_summary():
granularity=granularity,
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
@@ -149,6 +152,7 @@ def api_resource_history_detail():
granularity = request.args.get('granularity', 'day')
workcenter_groups = request.args.getlist('workcenter_groups') or None
families = request.args.getlist('families') or None
resource_ids = request.args.getlist('resource_ids') or None
is_production = request.args.get('is_production') == '1'
is_key = request.args.get('is_key') == '1'
is_monitor = request.args.get('is_monitor') == '1'
@@ -166,6 +170,7 @@ def api_resource_history_detail():
granularity=granularity,
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
@@ -201,6 +206,7 @@ def api_resource_history_export():
granularity = request.args.get('granularity', 'day')
workcenter_groups = request.args.getlist('workcenter_groups') or None
families = request.args.getlist('families') or None
resource_ids = request.args.getlist('resource_ids') or None
is_production = request.args.get('is_production') == '1'
is_key = request.args.get('is_key') == '1'
is_monitor = request.args.get('is_monitor') == '1'
@@ -223,6 +229,7 @@ def api_resource_history_export():
granularity=granularity,
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,

View File

@@ -110,6 +110,10 @@ from mes_dashboard.services.resource_service import (
get_workcenter_status_matrix,
)
from mes_dashboard.services.filter_cache import get_workcenter_groups
from mes_dashboard.services.resource_cache import (
get_resource_families,
get_resource_cascade_metadata,
)
from mes_dashboard.config.constants import STATUS_CATEGORIES
# Create Blueprint
@@ -291,6 +295,11 @@ def api_resource_status():
status_cats_param = request.args.get('status_categories')
status_categories = status_cats_param.split(',') if status_cats_param else None
families_param = request.args.get('families')
families = families_param.split(',') if families_param else None
resource_ids_param = request.args.get('resource_ids')
resource_ids = resource_ids_param.split(',') if resource_ids_param else None
try:
data = get_merged_resource_status(
workcenter_groups=workcenter_groups,
@@ -298,6 +307,8 @@ def api_resource_status():
is_key=is_key,
is_monitor=is_monitor,
status_categories=status_categories,
families=families,
resource_ids=resource_ids,
)
# Clean NaN/NaT values for valid JSON
cleaned_data = _clean_nan_values(data)
@@ -321,19 +332,26 @@ def api_resource_status():
def api_resource_status_options():
"""API: Get filter options for realtime status queries.
Returns workcenter_groups, status_categories, and other filter options.
Returns workcenter_groups, status_categories, families, and resources
metadata for client-side cascade filtering.
"""
try:
# Get workcenter groups from cache
cache_key = make_cache_key("resource_status_options")
cached = cache_get(cache_key)
if cached is not None:
return jsonify({'success': True, 'data': cached})
wc_groups = get_workcenter_groups() or []
return jsonify({
'success': True,
'data': {
'workcenter_groups': [g['name'] for g in wc_groups],
'status_categories': STATUS_CATEGORIES,
}
})
data = {
'workcenter_groups': [g['name'] for g in wc_groups],
'status_categories': STATUS_CATEGORIES,
'families': get_resource_families(),
'resources': get_resource_cascade_metadata(),
}
cache_set(cache_key, data, ttl=300)
return jsonify({'success': True, 'data': data})
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
@@ -360,12 +378,19 @@ def api_resource_status_summary():
is_key = _optional_bool_arg('is_key')
is_monitor = _optional_bool_arg('is_monitor')
families_param = request.args.get('families')
families = families_param.split(',') if families_param else None
resource_ids_param = request.args.get('resource_ids')
resource_ids = resource_ids_param.split(',') if resource_ids_param else None
try:
data = get_resource_status_summary(
workcenter_groups=workcenter_groups,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
families=families,
resource_ids=resource_ids,
)
# Clean NaN/NaT values for valid JSON
cleaned_data = _clean_nan_values(data)
@@ -395,11 +420,18 @@ def api_resource_status_matrix():
is_key = _optional_bool_arg('is_key')
is_monitor = _optional_bool_arg('is_monitor')
families_param = request.args.get('families')
families = families_param.split(',') if families_param else None
resource_ids_param = request.args.get('resource_ids')
resource_ids = resource_ids_param.split(',') if resource_ids_param else None
try:
data = get_workcenter_status_matrix(
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
families=families,
resource_ids=resource_ids,
)
# Clean NaN/NaT values for valid JSON
cleaned_data = _clean_nan_values(data)

View File

@@ -1096,3 +1096,31 @@ def get_locations() -> list[str]:
def get_vendors() -> list[str]:
"""取得供應商清單(便捷方法)."""
return get_distinct_values('VENDORNAME')
def get_resource_cascade_metadata() -> list[dict]:
"""取得所有設備的輕量 metadata 供前端 cascade 篩選.
利用已快取的 get_all_resources() + filter_cache.get_workcenter_mapping()
產生前端所需的最小資料集。
Returns:
List of dicts with keys: id, name, family, workcenter,
workcenterGroup, isProduction, isKey, isMonitor
"""
from mes_dashboard.services.filter_cache import get_workcenter_mapping
wc_mapping = get_workcenter_mapping() or {}
return [
{
'id': r.get('RESOURCEID', ''),
'name': r.get('RESOURCENAME', ''),
'family': r.get('RESOURCEFAMILYNAME', ''),
'workcenter': r.get('WORKCENTERNAME', ''),
'workcenterGroup': (wc_mapping.get(r.get('WORKCENTERNAME')) or {}).get('group', ''),
'isProduction': bool(r.get('PJ_ISPRODUCTION')),
'isKey': bool(r.get('PJ_ISKEY')),
'isMonitor': bool(r.get('PJ_ISMONITOR')),
}
for r in get_all_resources()
]

View File

@@ -42,6 +42,7 @@ E10_STATUSES = ['PRD', 'SBY', 'UDT', 'SDT', 'EGT', 'NST']
def _get_filtered_resources(
workcenter_groups: Optional[List[str]] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
is_production: bool = False,
is_key: bool = False,
is_monitor: bool = False,
@@ -53,6 +54,7 @@ def _get_filtered_resources(
Args:
workcenter_groups: Optional list of WORKCENTER_GROUP names
families: Optional list of RESOURCEFAMILYNAME values
resource_ids: Optional list of RESOURCEID values
is_production: Filter by production flag
is_key: Filter by key equipment flag
is_monitor: Filter by monitor flag
@@ -79,6 +81,8 @@ def _get_filtered_resources(
if info.get('group') in workcenter_groups:
allowed_workcenters.add(wc_name)
resource_id_set = set(resource_ids) if resource_ids else None
# Apply filters
filtered = []
for r in resources:
@@ -91,6 +95,10 @@ def _get_filtered_resources(
if families and r.get('RESOURCEFAMILYNAME') not in families:
continue
# Resource ID filter
if resource_id_set and r.get('RESOURCEID') not in resource_id_set:
continue
# Equipment flags filter
if is_production and r.get('PJ_ISPRODUCTION') != 1:
continue
@@ -184,7 +192,10 @@ def get_filter_options() -> Optional[Dict[str, Any]]:
Or None if cache loading fails.
"""
from mes_dashboard.services.filter_cache import get_workcenter_groups
from mes_dashboard.services.resource_cache import get_resource_families
from mes_dashboard.services.resource_cache import (
get_resource_families,
get_resource_cascade_metadata,
)
try:
groups = get_workcenter_groups()
@@ -196,7 +207,8 @@ def get_filter_options() -> Optional[Dict[str, Any]]:
return {
'workcenter_groups': groups,
'families': families
'families': families,
'resources': get_resource_cascade_metadata(),
}
except Exception as exc:
logger.error(f"Filter options query failed: {exc}")
@@ -213,6 +225,7 @@ def query_summary(
granularity: str = 'day',
workcenter_groups: Optional[List[str]] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
is_production: bool = False,
is_key: bool = False,
is_monitor: bool = False,
@@ -246,6 +259,7 @@ def query_summary(
resources = _get_filtered_resources(
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
@@ -330,6 +344,7 @@ def query_detail(
granularity: str = 'day',
workcenter_groups: Optional[List[str]] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
is_production: bool = False,
is_key: bool = False,
is_monitor: bool = False,
@@ -364,6 +379,7 @@ def query_detail(
resources = _get_filtered_resources(
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
@@ -418,6 +434,7 @@ def export_csv(
granularity: str = 'day',
workcenter_groups: Optional[List[str]] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
is_production: bool = False,
is_key: bool = False,
is_monitor: bool = False,
@@ -451,6 +468,7 @@ def export_csv(
resources = _get_filtered_resources(
workcenter_groups=workcenter_groups,
families=families,
resource_ids=resource_ids,
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,

View File

@@ -321,6 +321,8 @@ def get_merged_resource_status(
is_key: Optional[bool] = None,
is_monitor: Optional[bool] = None,
status_categories: Optional[List[str]] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Get merged resource status from three cache layers.
@@ -389,6 +391,10 @@ def get_merged_resource_status(
continue
if is_monitor is not None and bool(resource.get('PJ_ISMONITOR')) != is_monitor:
continue
if families and resource.get('RESOURCEFAMILYNAME') not in families:
continue
if resource_ids and str(resource_id) not in resource_ids:
continue
if status_categories and realtime.get('STATUS_CATEGORY') not in status_categories:
continue
@@ -447,6 +453,8 @@ def get_resource_status_summary(
is_production: Optional[bool] = None,
is_key: Optional[bool] = None,
is_monitor: Optional[bool] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Get resource status summary statistics.
@@ -455,6 +463,8 @@ def get_resource_status_summary(
is_production: Filter by PJ_ISPRODUCTION flag
is_key: Filter by PJ_ISKEY flag
is_monitor: Filter by PJ_ISMONITOR flag
families: Filter by RESOURCEFAMILYNAME
resource_ids: Filter by RESOURCEID
Returns:
Dict with summary statistics including OU%, Availability%, and per-status counts.
@@ -465,6 +475,8 @@ def get_resource_status_summary(
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
families=families,
resource_ids=resource_ids,
)
if not data:
@@ -537,6 +549,8 @@ def get_workcenter_status_matrix(
is_production: Optional[bool] = None,
is_key: Optional[bool] = None,
is_monitor: Optional[bool] = None,
families: Optional[List[str]] = None,
resource_ids: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
"""Get workcenter × status matrix.
@@ -546,6 +560,8 @@ def get_workcenter_status_matrix(
is_production: Filter by PJ_ISPRODUCTION flag
is_key: Filter by PJ_ISKEY flag
is_monitor: Filter by PJ_ISMONITOR flag
families: Filter by RESOURCEFAMILYNAME
resource_ids: Filter by RESOURCEID
Returns:
List of dicts with workcenter_group and status counts.
@@ -555,6 +571,8 @@ def get_workcenter_status_matrix(
is_production=is_production,
is_key=is_key,
is_monitor=is_monitor,
families=families,
resource_ids=resource_ids,
)
if not data: