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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user