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> <script setup>
import { reactive, ref } from 'vue'; import { computed, reactive, ref } from 'vue';
import { apiGet, ensureMesApiAvailable } from '../core/api.js'; import { apiGet, ensureMesApiAvailable } from '../core/api.js';
import { buildResourceKpiFromHours } from '../core/compute.js'; import { buildResourceKpiFromHours } from '../core/compute.js';
@@ -23,6 +23,7 @@ const filters = reactive({
granularity: 'day', granularity: 'day',
workcenterGroups: [], workcenterGroups: [],
families: [], families: [],
machines: [],
isProduction: false, isProduction: false,
isKey: false, isKey: false,
isMonitor: false, isMonitor: false,
@@ -31,6 +32,7 @@ const filters = reactive({
const options = reactive({ const options = reactive({
workcenterGroups: [], workcenterGroups: [],
families: [], families: [],
resources: [],
}); });
const summaryData = ref({ const summaryData = ref({
@@ -104,6 +106,9 @@ function buildQueryString() {
filters.families.forEach((family) => { filters.families.forEach((family) => {
params.append('families', family); params.append('families', family);
}); });
filters.machines.forEach((machine) => {
params.append('resource_ids', machine);
});
if (filters.isProduction) { if (filters.isProduction) {
params.append('is_production', '1'); params.append('is_production', '1');
@@ -151,11 +156,35 @@ async function loadOptions() {
options.workcenterGroups = Array.isArray(data.workcenter_groups) ? data.workcenter_groups : []; options.workcenterGroups = Array.isArray(data.workcenter_groups) ? data.workcenter_groups : [];
options.families = Array.isArray(data.families) ? data.families : []; options.families = Array.isArray(data.families) ? data.families : [];
options.resources = Array.isArray(data.resources) ? data.resources : [];
} finally { } finally {
loading.options = false; 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() { async function executeQuery() {
const validationError = validateDateRange(); const validationError = validateDateRange();
if (validationError) { if (validationError) {
@@ -216,6 +245,13 @@ async function executeQuery() {
} }
function updateFilters(nextFilters) { 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.startDate = nextFilters.startDate || '';
filters.endDate = nextFilters.endDate || ''; filters.endDate = nextFilters.endDate || '';
filters.granularity = nextFilters.granularity || 'day'; filters.granularity = nextFilters.granularity || 'day';
@@ -223,9 +259,14 @@ function updateFilters(nextFilters) {
? nextFilters.workcenterGroups ? nextFilters.workcenterGroups
: []; : [];
filters.families = Array.isArray(nextFilters.families) ? nextFilters.families : []; filters.families = Array.isArray(nextFilters.families) ? nextFilters.families : [];
filters.machines = Array.isArray(nextFilters.machines) ? nextFilters.machines : [];
filters.isProduction = Boolean(nextFilters.isProduction); filters.isProduction = Boolean(nextFilters.isProduction);
filters.isKey = Boolean(nextFilters.isKey); filters.isKey = Boolean(nextFilters.isKey);
filters.isMonitor = Boolean(nextFilters.isMonitor); filters.isMonitor = Boolean(nextFilters.isMonitor);
if (upstreamChanged) {
pruneInvalidMachines();
}
} }
function handleToggleRow(rowId) { function handleToggleRow(rowId) {
@@ -278,6 +319,7 @@ void initPage();
<FilterBar <FilterBar
:filters="filters" :filters="filters"
:options="options" :options="options"
:machine-options="machineOptions"
:loading="loading.options || loading.querying" :loading="loading.options || loading.querying"
@update-filters="updateFilters" @update-filters="updateFilters"
@query="executeQuery" @query="executeQuery"

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import MultiSelect from './MultiSelect.vue'; import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const GRANULARITY_ITEMS = [ const GRANULARITY_ITEMS = [
{ key: 'day', label: '日' }, { key: 'day', label: '日' },
@@ -20,6 +20,10 @@ const props = defineProps({
families: [], families: [],
}), }),
}, },
machineOptions: {
type: Array,
default: () => [],
},
loading: { loading: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -101,6 +105,18 @@ function updateFilters(patch) {
/> />
</div> </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"> <div class="checkbox-row">
<label class="checkbox-pill"> <label class="checkbox-pill">
<input <input

View File

@@ -55,93 +55,6 @@
font-weight: 700; 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 { .checkbox-row {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -18,12 +18,18 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
searchable: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
const rootRef = ref(null); const rootRef = ref(null);
const searchRef = ref(null);
const isOpen = ref(false); const isOpen = ref(false);
const searchQuery = ref('');
const normalizedOptions = computed(() => { const normalizedOptions = computed(() => {
return props.options.map((option) => { 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 selectedSet = computed(() => new Set((props.modelValue || []).map((value) => String(value))));
const selectedText = computed(() => { const selectedText = computed(() => {
@@ -89,14 +103,21 @@ function toggleOption(value) {
} }
function selectAll() { function selectAll() {
emit( const next = new Set(selectedSet.value);
'update:modelValue', for (const opt of displayedOptions.value) {
normalizedOptions.value.map((option) => option.value) next.add(opt.value);
); }
emit('update:modelValue', [...next]);
} }
function clearAll() { 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) { function handleOutsideClick(event) {
@@ -109,6 +130,13 @@ function handleOutsideClick(event) {
} }
} }
watch(isOpen, (open) => {
if (open && props.searchable) {
searchQuery.value = '';
requestAnimationFrame(() => searchRef.value?.focus());
}
});
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleOutsideClick, true); document.addEventListener('click', handleOutsideClick, true);
}); });
@@ -131,9 +159,19 @@ onBeforeUnmount(() => {
</button> </button>
<div v-if="isOpen" class="multi-select-dropdown"> <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"> <div class="multi-select-options">
<button <button
v-for="option in normalizedOptions" v-for="option in displayedOptions"
:key="option.value" :key="option.value"
type="button" type="button"
class="multi-select-option" class="multi-select-option"
@@ -142,6 +180,9 @@ onBeforeUnmount(() => {
<input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" /> <input type="checkbox" :checked="isSelected(option.value)" tabindex="-1" />
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
</button> </button>
<div v-if="searchable && displayedOptions.length === 0" class="multi-select-empty">
無符合結果
</div>
</div> </div>
<div class="multi-select-actions"> <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) { @media (max-width: 640px) {
.summary-grid { .summary-grid {
grid-template-columns: repeat(2, minmax(100px, 1fr)); grid-template-columns: repeat(2, minmax(100px, 1fr));
@@ -558,4 +670,8 @@ body {
.matrix-table td:first-child { .matrix-table td:first-child {
min-width: 220px; min-width: 220px;
} }
.multi-select {
min-width: 150px;
}
} }

View File

@@ -18,6 +18,7 @@ const API_TIMEOUT = 60000;
const allEquipment = ref([]); const allEquipment = ref([]);
const workcenterGroups = ref([]); const workcenterGroups = ref([]);
const allResources = ref([]);
const summary = ref({ const summary = ref({
totalCount: 0, totalCount: 0,
byStatus: { byStatus: {
@@ -38,6 +39,8 @@ const filterState = reactive({
isProduction: false, isProduction: false,
isKey: false, isKey: false,
isMonitor: false, isMonitor: false,
families: [],
machines: [],
}); });
const matrixFilter = ref([]); const matrixFilter = ref([]);
@@ -89,10 +92,53 @@ function buildFilterParams() {
if (filterState.isMonitor) { if (filterState.isMonitor) {
params.is_monitor = 1; 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; 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() { function resetHierarchyState() {
Object.keys(hierarchyState).forEach((key) => { Object.keys(hierarchyState).forEach((key) => {
delete hierarchyState[key]; delete hierarchyState[key];
@@ -109,6 +155,7 @@ async function loadOptions() {
}); });
const data = unwrapApiResult(result, '載入篩選選項失敗'); const data = unwrapApiResult(result, '載入篩選選項失敗');
workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : []; workcenterGroups.value = Array.isArray(data?.workcenter_groups) ? data.workcenter_groups : [];
allResources.value = Array.isArray(data?.resources) ? data.resources : [];
} finally { } finally {
loading.options = false; loading.options = false;
} }
@@ -339,6 +386,7 @@ async function applyFiltersAndReload() {
function updateGroup(group) { function updateGroup(group) {
filterState.group = group || ''; filterState.group = group || '';
pruneInvalidSelections();
void applyFiltersAndReload(); void applyFiltersAndReload();
} }
@@ -346,6 +394,18 @@ function updateFlags(nextFlags) {
filterState.isProduction = Boolean(nextFlags?.isProduction); filterState.isProduction = Boolean(nextFlags?.isProduction);
filterState.isKey = Boolean(nextFlags?.isKey); filterState.isKey = Boolean(nextFlags?.isKey);
filterState.isMonitor = Boolean(nextFlags?.isMonitor); 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(); void applyFiltersAndReload();
} }
@@ -388,9 +448,15 @@ void initPage();
:workcenter-groups="workcenterGroups" :workcenter-groups="workcenterGroups"
:selected-group="filterState.group" :selected-group="filterState.group"
:flags="filterState" :flags="filterState"
:family-options="familyOptions"
:machine-options="machineOptions"
:selected-families="filterState.families"
:selected-machines="filterState.machines"
:loading="loading.options || loading.refreshing" :loading="loading.options || loading.refreshing"
@change-group="updateGroup" @change-group="updateGroup"
@change-flags="updateFlags" @change-flags="updateFlags"
@change-families="updateFamilies"
@change-machines="updateMachines"
/> />
<p v-if="summaryError" class="error-banner">{{ summaryError }}</p> <p v-if="summaryError" class="error-banner">{{ summaryError }}</p>

View File

@@ -1,4 +1,6 @@
<script setup> <script setup>
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({ const props = defineProps({
workcenterGroups: { workcenterGroups: {
type: Array, type: Array,
@@ -16,13 +18,29 @@ const props = defineProps({
isMonitor: false, isMonitor: false,
}), }),
}, },
familyOptions: {
type: Array,
default: () => [],
},
machineOptions: {
type: Array,
default: () => [],
},
selectedFamilies: {
type: Array,
default: () => [],
},
selectedMachines: {
type: Array,
default: () => [],
},
loading: { loading: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
}); });
const emit = defineEmits(['change-group', 'change-flags']); const emit = defineEmits(['change-group', 'change-flags', 'change-families', 'change-machines']);
function updateFlag(key, checked) { function updateFlag(key, checked) {
emit('change-flags', { emit('change-flags', {
@@ -51,6 +69,29 @@ function updateFlag(key, checked) {
</select> </select>
</div> </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 }"> <label class="filter-chip" :class="{ active: flags.isProduction }">
<input <input
type="checkbox" type="checkbox"

View File

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

View File

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

View File

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

View File

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