Two changes combined: 1. historical-query-slow-connection: Migrate all historical query pages to read_sql_df_slow with semaphore concurrency control (max 3), raise DB slow timeout to 300s, gunicorn timeout to 360s, and unify frontend timeouts to 360s for all historical pages. 2. hold-resource-history-dataset-cache: Convert hold-history and resource-history from multi-query to single-query + dataset cache pattern (L1 ProcessLevelCache + L2 Redis parquet/base64, TTL=900s). Replace old GET endpoints with POST /query + GET /view two-phase API. Frontend auto-retries on 410 cache_expired. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
600 lines
17 KiB
Vue
600 lines
17 KiB
Vue
<script setup>
|
||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||
|
||
import { apiGet, apiPost, ensureMesApiAvailable } from '../core/api.js';
|
||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||
import {
|
||
buildResourceHistoryQueryParams,
|
||
deriveResourceFamilyOptions,
|
||
deriveResourceMachineOptions,
|
||
pruneResourceFilterSelections,
|
||
toResourceFilterSnapshot,
|
||
} from '../core/resource-history-filters.js';
|
||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||
|
||
import ComparisonChart from './components/ComparisonChart.vue';
|
||
import DetailSection from './components/DetailSection.vue';
|
||
import FilterBar from './components/FilterBar.vue';
|
||
import HeatmapChart from './components/HeatmapChart.vue';
|
||
import KpiCards from './components/KpiCards.vue';
|
||
import StackedChart from './components/StackedChart.vue';
|
||
import TrendChart from './components/TrendChart.vue';
|
||
|
||
ensureMesApiAvailable();
|
||
|
||
const API_TIMEOUT = 360000;
|
||
const MAX_QUERY_DAYS = 730;
|
||
|
||
function createDefaultFilters() {
|
||
return toResourceFilterSnapshot({
|
||
granularity: 'day',
|
||
workcenterGroups: [],
|
||
families: [],
|
||
machines: [],
|
||
isProduction: false,
|
||
isKey: false,
|
||
isMonitor: false,
|
||
});
|
||
}
|
||
|
||
const draftFilters = reactive(createDefaultFilters());
|
||
const committedFilters = reactive(createDefaultFilters());
|
||
|
||
const options = reactive({
|
||
workcenterGroups: [],
|
||
families: [],
|
||
resources: [],
|
||
});
|
||
|
||
const summaryData = ref({
|
||
kpi: {},
|
||
trend: [],
|
||
heatmap: [],
|
||
workcenter_comparison: [],
|
||
});
|
||
const detailData = ref([]);
|
||
const hierarchyState = reactive({});
|
||
|
||
const loading = reactive({
|
||
initial: true,
|
||
querying: false,
|
||
options: false,
|
||
});
|
||
|
||
const queryError = ref('');
|
||
const detailWarning = ref('');
|
||
const exportMessage = ref('');
|
||
const autoPruneHint = ref('');
|
||
|
||
const queryId = ref('');
|
||
const lastPrimarySnapshot = ref('');
|
||
|
||
const draftWatchReady = ref(false);
|
||
let suppressDraftPrune = false;
|
||
|
||
function runWithDraftPruneSuppressed(callback) {
|
||
suppressDraftPrune = true;
|
||
try {
|
||
callback();
|
||
} finally {
|
||
suppressDraftPrune = false;
|
||
}
|
||
}
|
||
|
||
function resetHierarchyState() {
|
||
Object.keys(hierarchyState).forEach((key) => {
|
||
delete hierarchyState[key];
|
||
});
|
||
}
|
||
|
||
function toDateString(value) {
|
||
return value.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function setDefaultDates(target) {
|
||
const today = new Date();
|
||
const endDate = new Date(today);
|
||
endDate.setDate(endDate.getDate() - 1);
|
||
|
||
const startDate = new Date(endDate);
|
||
startDate.setDate(startDate.getDate() - 6);
|
||
|
||
target.startDate = toDateString(startDate);
|
||
target.endDate = toDateString(endDate);
|
||
}
|
||
|
||
function assignFilterState(target, source) {
|
||
const snapshot = toResourceFilterSnapshot(source);
|
||
target.startDate = snapshot.startDate;
|
||
target.endDate = snapshot.endDate;
|
||
target.granularity = snapshot.granularity;
|
||
target.workcenterGroups = [...snapshot.workcenterGroups];
|
||
target.families = [...snapshot.families];
|
||
target.machines = [...snapshot.machines];
|
||
target.isProduction = snapshot.isProduction;
|
||
target.isKey = snapshot.isKey;
|
||
target.isMonitor = snapshot.isMonitor;
|
||
}
|
||
|
||
function resetToDefaultFilters(target) {
|
||
const defaults = createDefaultFilters();
|
||
setDefaultDates(defaults);
|
||
assignFilterState(target, defaults);
|
||
}
|
||
|
||
function unwrapApiResult(result, fallbackMessage) {
|
||
if (result?.success === true) {
|
||
return result;
|
||
}
|
||
if (result?.success === false) {
|
||
throw new Error(result.error || fallbackMessage);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function mergeComputedKpi(source) {
|
||
return {
|
||
...source,
|
||
...buildResourceKpiFromHours(source),
|
||
};
|
||
}
|
||
|
||
function appendArrayParams(params, key, values) {
|
||
for (const value of values || []) {
|
||
params.append(key, value);
|
||
}
|
||
}
|
||
|
||
function buildQueryStringFromFilters(filters) {
|
||
const queryParams = buildResourceHistoryQueryParams(filters);
|
||
const params = new URLSearchParams();
|
||
|
||
params.append('start_date', queryParams.start_date);
|
||
params.append('end_date', queryParams.end_date);
|
||
params.append('granularity', queryParams.granularity);
|
||
appendArrayParams(params, 'workcenter_groups', queryParams.workcenter_groups || []);
|
||
appendArrayParams(params, 'families', queryParams.families || []);
|
||
appendArrayParams(params, 'resource_ids', queryParams.resource_ids || []);
|
||
|
||
if (queryParams.is_production) {
|
||
params.append('is_production', queryParams.is_production);
|
||
}
|
||
if (queryParams.is_key) {
|
||
params.append('is_key', queryParams.is_key);
|
||
}
|
||
if (queryParams.is_monitor) {
|
||
params.append('is_monitor', queryParams.is_monitor);
|
||
}
|
||
|
||
return params.toString();
|
||
}
|
||
|
||
function readArrayParam(params, key) {
|
||
const repeated = params.getAll(key).map((value) => String(value || '').trim()).filter(Boolean);
|
||
if (repeated.length > 0) {
|
||
return repeated;
|
||
}
|
||
return String(params.get(key) || '')
|
||
.split(',')
|
||
.map((value) => value.trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function readBooleanParam(params, key) {
|
||
const value = String(params.get(key) || '').trim().toLowerCase();
|
||
return value === '1' || value === 'true' || value === 'yes';
|
||
}
|
||
|
||
function restoreCommittedFiltersFromUrl() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const next = {
|
||
startDate: String(params.get('start_date') || '').trim(),
|
||
endDate: String(params.get('end_date') || '').trim(),
|
||
granularity: String(params.get('granularity') || '').trim(),
|
||
workcenterGroups: readArrayParam(params, 'workcenter_groups'),
|
||
families: readArrayParam(params, 'families'),
|
||
machines: readArrayParam(params, 'resource_ids'),
|
||
isProduction: readBooleanParam(params, 'is_production'),
|
||
isKey: readBooleanParam(params, 'is_key'),
|
||
isMonitor: readBooleanParam(params, 'is_monitor'),
|
||
};
|
||
|
||
if (next.startDate) {
|
||
committedFilters.startDate = next.startDate;
|
||
}
|
||
if (next.endDate) {
|
||
committedFilters.endDate = next.endDate;
|
||
}
|
||
if (next.granularity) {
|
||
committedFilters.granularity = next.granularity;
|
||
}
|
||
if (next.workcenterGroups.length > 0) {
|
||
committedFilters.workcenterGroups = next.workcenterGroups;
|
||
}
|
||
if (next.families.length > 0) {
|
||
committedFilters.families = next.families;
|
||
}
|
||
if (next.machines.length > 0) {
|
||
committedFilters.machines = next.machines;
|
||
}
|
||
committedFilters.isProduction = next.isProduction;
|
||
committedFilters.isKey = next.isKey;
|
||
committedFilters.isMonitor = next.isMonitor;
|
||
}
|
||
|
||
function updateUrlState() {
|
||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||
const nextUrl = queryString ? `/resource-history?${queryString}` : '/resource-history';
|
||
replaceRuntimeHistory(nextUrl);
|
||
}
|
||
|
||
function validateDateRange(filters) {
|
||
if (!filters.startDate || !filters.endDate) {
|
||
return '請先設定開始與結束日期';
|
||
}
|
||
|
||
const start = new Date(filters.startDate);
|
||
const end = new Date(filters.endDate);
|
||
const diffDays = (end - start) / (1000 * 60 * 60 * 24);
|
||
|
||
if (diffDays < 0) {
|
||
return '結束日期必須大於起始日期';
|
||
}
|
||
if (diffDays > MAX_QUERY_DAYS) {
|
||
return '查詢範圍不可超過兩年';
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
async function loadOptions() {
|
||
loading.options = true;
|
||
|
||
try {
|
||
const response = await apiGet('/api/resource/history/options', {
|
||
timeout: API_TIMEOUT,
|
||
silent: true,
|
||
});
|
||
|
||
const payload = unwrapApiResult(response, '載入篩選選項失敗');
|
||
const data = payload.data || {};
|
||
|
||
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 familyOptions = computed(() => {
|
||
return deriveResourceFamilyOptions(options.resources, draftFilters);
|
||
});
|
||
|
||
const machineOptions = computed(() => {
|
||
return deriveResourceMachineOptions(options.resources, draftFilters);
|
||
});
|
||
|
||
const filterBarOptions = computed(() => {
|
||
return {
|
||
workcenterGroups: options.workcenterGroups,
|
||
families: familyOptions.value,
|
||
};
|
||
});
|
||
|
||
function formatPruneHint(removed) {
|
||
const parts = [];
|
||
if (removed.families.length > 0) {
|
||
parts.push(`型號: ${removed.families.join(', ')}`);
|
||
}
|
||
if (removed.machines.length > 0) {
|
||
parts.push(`機台: ${removed.machines.join(', ')}`);
|
||
}
|
||
if (parts.length === 0) {
|
||
return '';
|
||
}
|
||
return `已自動清除失效篩選:${parts.join(';')}`;
|
||
}
|
||
|
||
function pruneDraftSelections({ showHint = true } = {}) {
|
||
const result = pruneResourceFilterSelections(draftFilters, {
|
||
familyOptions: familyOptions.value,
|
||
machineOptions: machineOptions.value,
|
||
});
|
||
if (result.removedCount > 0) {
|
||
runWithDraftPruneSuppressed(() => {
|
||
draftFilters.families = [...result.filters.families];
|
||
draftFilters.machines = [...result.filters.machines];
|
||
});
|
||
if (showHint) {
|
||
autoPruneHint.value = formatPruneHint(result.removed);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
const pruneSignature = computed(() => {
|
||
return JSON.stringify({
|
||
workcenterGroups: draftFilters.workcenterGroups,
|
||
families: draftFilters.families,
|
||
machines: draftFilters.machines,
|
||
isProduction: draftFilters.isProduction,
|
||
isKey: draftFilters.isKey,
|
||
isMonitor: draftFilters.isMonitor,
|
||
familyOptions: familyOptions.value,
|
||
machineOptions: machineOptions.value.map((item) => item.value),
|
||
});
|
||
});
|
||
|
||
watch(pruneSignature, () => {
|
||
if (!draftWatchReady.value || suppressDraftPrune) {
|
||
return;
|
||
}
|
||
pruneDraftSelections({ showHint: true });
|
||
});
|
||
|
||
// ─── Two-phase helpers ──────────────────────────────────────
|
||
|
||
/**
|
||
* Build a snapshot string of "primary" params (those that require a new Oracle query).
|
||
* Granularity is NOT primary — it can be re-derived from cache.
|
||
*/
|
||
function buildPrimarySnapshot(filters) {
|
||
const p = buildResourceHistoryQueryParams(filters);
|
||
return JSON.stringify({
|
||
start_date: p.start_date,
|
||
end_date: p.end_date,
|
||
workcenter_groups: p.workcenter_groups || [],
|
||
families: p.families || [],
|
||
resource_ids: p.resource_ids || [],
|
||
is_production: p.is_production || '',
|
||
is_key: p.is_key || '',
|
||
is_monitor: p.is_monitor || '',
|
||
});
|
||
}
|
||
|
||
function applyViewResult(result) {
|
||
const summary = result.summary || {};
|
||
summaryData.value = {
|
||
kpi: mergeComputedKpi(summary.kpi || {}),
|
||
trend: (summary.trend || []).map((item) => mergeComputedKpi(item || {})),
|
||
heatmap: summary.heatmap || [],
|
||
workcenter_comparison: summary.workcenter_comparison || [],
|
||
};
|
||
|
||
const detail = result.detail || {};
|
||
detailData.value = Array.isArray(detail.data) ? detail.data : [];
|
||
resetHierarchyState();
|
||
|
||
if (detail.truncated) {
|
||
detailWarning.value = `明細資料超過 ${detail.max_records} 筆,僅顯示前 ${detail.max_records} 筆。`;
|
||
} else {
|
||
detailWarning.value = '';
|
||
}
|
||
}
|
||
|
||
async function executePrimaryQuery() {
|
||
const validationError = validateDateRange(committedFilters);
|
||
if (validationError) {
|
||
queryError.value = validationError;
|
||
loading.initial = false;
|
||
return;
|
||
}
|
||
|
||
loading.querying = true;
|
||
queryError.value = '';
|
||
detailWarning.value = '';
|
||
exportMessage.value = '';
|
||
|
||
try {
|
||
updateUrlState();
|
||
|
||
const queryParams = buildResourceHistoryQueryParams(committedFilters);
|
||
const body = {
|
||
start_date: queryParams.start_date,
|
||
end_date: queryParams.end_date,
|
||
granularity: queryParams.granularity,
|
||
workcenter_groups: queryParams.workcenter_groups || [],
|
||
families: queryParams.families || [],
|
||
resource_ids: queryParams.resource_ids || [],
|
||
is_production: !!queryParams.is_production,
|
||
is_key: !!queryParams.is_key,
|
||
is_monitor: !!queryParams.is_monitor,
|
||
};
|
||
|
||
const response = await apiPost('/api/resource/history/query', body, {
|
||
timeout: API_TIMEOUT,
|
||
silent: true,
|
||
});
|
||
|
||
const payload = unwrapApiResult(response, '查詢失敗');
|
||
queryId.value = payload.query_id || '';
|
||
lastPrimarySnapshot.value = buildPrimarySnapshot(committedFilters);
|
||
applyViewResult(payload);
|
||
} catch (error) {
|
||
if (error?.name === 'AbortError') {
|
||
queryError.value = '查詢逾時,請縮小日期範圍或資源篩選後重試';
|
||
} else {
|
||
queryError.value = error?.message || '查詢失敗';
|
||
}
|
||
summaryData.value = { kpi: {}, trend: [], heatmap: [], workcenter_comparison: [] };
|
||
detailData.value = [];
|
||
resetHierarchyState();
|
||
} finally {
|
||
loading.querying = false;
|
||
loading.initial = false;
|
||
}
|
||
}
|
||
|
||
async function refreshView() {
|
||
if (!queryId.value) {
|
||
await executePrimaryQuery();
|
||
return;
|
||
}
|
||
|
||
loading.querying = true;
|
||
queryError.value = '';
|
||
|
||
try {
|
||
updateUrlState();
|
||
|
||
const response = await apiGet('/api/resource/history/view', {
|
||
timeout: API_TIMEOUT,
|
||
silent: true,
|
||
params: {
|
||
query_id: queryId.value,
|
||
granularity: committedFilters.granularity || 'day',
|
||
},
|
||
});
|
||
|
||
if (response?.success === false && response?.error === 'cache_expired') {
|
||
await executePrimaryQuery();
|
||
return;
|
||
}
|
||
|
||
const payload = unwrapApiResult(response, '查詢失敗');
|
||
applyViewResult(payload);
|
||
} catch (error) {
|
||
if (error?.message === 'cache_expired' || error?.status === 410) {
|
||
await executePrimaryQuery();
|
||
return;
|
||
}
|
||
queryError.value = error?.message || '查詢失敗';
|
||
} finally {
|
||
loading.querying = false;
|
||
}
|
||
}
|
||
|
||
// ─── Filter actions ─────────────────────────────────────────
|
||
|
||
async function applyFilters() {
|
||
const validationError = validateDateRange(draftFilters);
|
||
if (validationError) {
|
||
queryError.value = validationError;
|
||
return;
|
||
}
|
||
pruneDraftSelections({ showHint: true });
|
||
assignFilterState(committedFilters, draftFilters);
|
||
|
||
const currentPrimary = buildPrimarySnapshot(committedFilters);
|
||
if (queryId.value && currentPrimary === lastPrimarySnapshot.value) {
|
||
await refreshView();
|
||
} else {
|
||
await executePrimaryQuery();
|
||
}
|
||
}
|
||
|
||
async function clearFilters() {
|
||
runWithDraftPruneSuppressed(() => {
|
||
resetToDefaultFilters(draftFilters);
|
||
});
|
||
autoPruneHint.value = '';
|
||
assignFilterState(committedFilters, draftFilters);
|
||
await executePrimaryQuery();
|
||
}
|
||
|
||
function updateFilters(nextFilters) {
|
||
runWithDraftPruneSuppressed(() => {
|
||
assignFilterState(draftFilters, nextFilters);
|
||
});
|
||
pruneDraftSelections({ showHint: true });
|
||
}
|
||
|
||
function handleToggleRow(rowId) {
|
||
hierarchyState[rowId] = !hierarchyState[rowId];
|
||
}
|
||
|
||
function handleToggleAllRows({ expand, rowIds }) {
|
||
(rowIds || []).forEach((rowId) => {
|
||
hierarchyState[rowId] = Boolean(expand);
|
||
});
|
||
}
|
||
|
||
function exportCsv() {
|
||
if (!committedFilters.startDate || !committedFilters.endDate) {
|
||
queryError.value = '請先設定查詢條件';
|
||
return;
|
||
}
|
||
|
||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||
const link = document.createElement('a');
|
||
link.href = `/api/resource/history/export?${queryString}`;
|
||
link.download = `resource_history_${committedFilters.startDate}_to_${committedFilters.endDate}.csv`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
exportMessage.value = 'CSV 匯出中...';
|
||
}
|
||
|
||
async function initPage() {
|
||
resetToDefaultFilters(committedFilters);
|
||
restoreCommittedFiltersFromUrl();
|
||
runWithDraftPruneSuppressed(() => {
|
||
assignFilterState(draftFilters, committedFilters);
|
||
});
|
||
|
||
try {
|
||
await loadOptions();
|
||
pruneDraftSelections({ showHint: true });
|
||
assignFilterState(committedFilters, draftFilters);
|
||
} catch (error) {
|
||
queryError.value = error?.message || '載入篩選選項失敗';
|
||
}
|
||
|
||
draftWatchReady.value = true;
|
||
await executePrimaryQuery();
|
||
}
|
||
|
||
onMounted(() => {
|
||
void initPage();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="resource-page">
|
||
<div class="dashboard">
|
||
<header class="header-gradient history-header">
|
||
<h1>設備歷史績效</h1>
|
||
</header>
|
||
|
||
<FilterBar
|
||
:filters="draftFilters"
|
||
:options="filterBarOptions"
|
||
:machine-options="machineOptions"
|
||
:loading="loading.options || loading.querying"
|
||
@update-filters="updateFilters"
|
||
@query="applyFilters"
|
||
@clear="clearFilters"
|
||
/>
|
||
|
||
<p v-if="queryError" class="error-banner query-error">{{ queryError }}</p>
|
||
<p v-if="autoPruneHint" class="filter-indicator">{{ autoPruneHint }}</p>
|
||
<p v-if="detailWarning" class="filter-indicator active">{{ detailWarning }}</p>
|
||
<p v-if="exportMessage" class="filter-indicator active">{{ exportMessage }}</p>
|
||
|
||
<KpiCards :kpi="summaryData.kpi" />
|
||
|
||
<section class="section-card">
|
||
<div class="section-inner">
|
||
<div class="chart-grid">
|
||
<TrendChart :trend="summaryData.trend || []" />
|
||
<StackedChart :trend="summaryData.trend || []" />
|
||
<ComparisonChart :comparison="summaryData.workcenter_comparison || []" />
|
||
<HeatmapChart :heatmap="summaryData.heatmap || []" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<DetailSection
|
||
:detail-data="detailData"
|
||
:expanded-state="hierarchyState"
|
||
:loading="loading.querying"
|
||
@toggle-row="handleToggleRow"
|
||
@toggle-all="handleToggleAllRows"
|
||
@export-csv="exportCsv"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|