feat: dataset cache for hold/resource history + slow connection migration
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>
This commit is contained in:
@@ -132,7 +132,7 @@ export function useExcelQueryData() {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const payload = await apiUpload('/api/excel-query/upload', formData, { timeout: 120000, silent: true });
|
||||
const payload = await apiUpload('/api/excel-query/upload', formData, { timeout: 360000, silent: true });
|
||||
excelColumns.value = Array.isArray(payload?.columns) ? payload.columns : [];
|
||||
excelPreview.value = Array.isArray(payload?.preview) ? payload.preview : [];
|
||||
uploadState.fileName = String(file.name || '');
|
||||
@@ -252,7 +252,7 @@ export function useExcelQueryData() {
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
},
|
||||
{ timeout: 120000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
queryResult.rows = Array.isArray(payload?.data) ? payload.data : [];
|
||||
queryResult.columns = Array.isArray(payload?.columns) ? payload.columns : filters.returnColumns;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import { apiGet } from '../core/api.js';
|
||||
import { apiGet, apiPost } from '../core/api.js';
|
||||
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
import DailyTrend from './components/DailyTrend.vue';
|
||||
@@ -13,7 +13,7 @@ import RecordTypeFilter from './components/RecordTypeFilter.vue';
|
||||
import ReasonPareto from './components/ReasonPareto.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const API_TIMEOUT = 360000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
|
||||
const filterBar = reactive({
|
||||
@@ -22,6 +22,7 @@ const filterBar = reactive({
|
||||
holdType: 'quality',
|
||||
});
|
||||
|
||||
const queryId = ref('');
|
||||
const reasonFilter = ref('');
|
||||
const durationFilter = ref('');
|
||||
const recordType = ref(['new']);
|
||||
@@ -158,74 +159,23 @@ function updateUrlState() {
|
||||
replaceRuntimeHistory(`/hold-history?${params.toString()}`);
|
||||
}
|
||||
|
||||
function commonParams({
|
||||
includeHoldType = true,
|
||||
includeReason = false,
|
||||
includeRecordType = false,
|
||||
includeDuration = false,
|
||||
} = {}) {
|
||||
const params = {
|
||||
start_date: filterBar.startDate,
|
||||
end_date: filterBar.endDate,
|
||||
};
|
||||
function recordTypeCsv() {
|
||||
const rt = Array.isArray(recordType.value) ? recordType.value : [recordType.value];
|
||||
return rt.join(',');
|
||||
}
|
||||
|
||||
if (includeHoldType) {
|
||||
params.hold_type = filterBar.holdType;
|
||||
function applyViewResult(result, { listOnly = false } = {}) {
|
||||
if (!listOnly) {
|
||||
trendData.value = result.trend || trendData.value;
|
||||
reasonParetoData.value = result.reason_pareto || reasonParetoData.value;
|
||||
durationData.value = result.duration || durationData.value;
|
||||
}
|
||||
|
||||
if (includeRecordType) {
|
||||
const rt = Array.isArray(recordType.value) ? recordType.value : [recordType.value];
|
||||
params.record_type = rt.join(',');
|
||||
}
|
||||
|
||||
if (includeReason && reasonFilter.value) {
|
||||
params.reason = reasonFilter.value;
|
||||
}
|
||||
|
||||
if (includeDuration && durationFilter.value) {
|
||||
params.duration_range = durationFilter.value;
|
||||
}
|
||||
|
||||
return params;
|
||||
detailData.value = normalizeListPayload(result.list);
|
||||
}
|
||||
|
||||
async function fetchTrend() {
|
||||
const response = await apiGet('/api/hold-history/trend', {
|
||||
params: commonParams({ includeHoldType: false }),
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
return unwrapApiResult(response, '載入 trend 資料失敗');
|
||||
}
|
||||
// ---- Primary query (POST /query -> Oracle -> cache) ----
|
||||
|
||||
async function fetchReasonPareto() {
|
||||
const response = await apiGet('/api/hold-history/reason-pareto', {
|
||||
params: commonParams({ includeHoldType: true, includeRecordType: true }),
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
return unwrapApiResult(response, '載入 pareto 資料失敗');
|
||||
}
|
||||
|
||||
async function fetchDuration() {
|
||||
const response = await apiGet('/api/hold-history/duration', {
|
||||
params: commonParams({ includeHoldType: true, includeRecordType: true }),
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
return unwrapApiResult(response, '載入 duration 資料失敗');
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
const response = await apiGet('/api/hold-history/list', {
|
||||
params: {
|
||||
...commonParams({ includeHoldType: true, includeReason: true, includeRecordType: true, includeDuration: true }),
|
||||
page: page.value,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
},
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
return unwrapApiResult(response, '載入明細資料失敗');
|
||||
}
|
||||
|
||||
async function loadAllData({ includeTrend = true, showOverlay = false } = {}) {
|
||||
async function executePrimaryQuery({ showOverlay = false } = {}) {
|
||||
const requestId = nextRequestId();
|
||||
|
||||
if (showOverlay) {
|
||||
@@ -237,90 +187,90 @@ async function loadAllData({ includeTrend = true, showOverlay = false } = {}) {
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const requests = [];
|
||||
if (includeTrend) {
|
||||
requests.push(fetchTrend());
|
||||
}
|
||||
requests.push(fetchReasonPareto(), fetchDuration(), fetchList());
|
||||
const body = {
|
||||
start_date: filterBar.startDate,
|
||||
end_date: filterBar.endDate,
|
||||
hold_type: filterBar.holdType,
|
||||
record_type: recordTypeCsv(),
|
||||
};
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
const resp = await apiPost('/api/hold-history/query', body, { timeout: API_TIMEOUT });
|
||||
if (isStaleRequest(requestId)) return;
|
||||
|
||||
let cursor = 0;
|
||||
if (includeTrend) {
|
||||
trendData.value = responses[cursor] || { days: [] };
|
||||
cursor += 1;
|
||||
}
|
||||
const result = unwrapApiResult(resp, '主查詢執行失敗');
|
||||
|
||||
reasonParetoData.value = responses[cursor] || { items: [] };
|
||||
cursor += 1;
|
||||
durationData.value = responses[cursor] || { items: [] };
|
||||
cursor += 1;
|
||||
detailData.value = normalizeListPayload(responses[cursor]);
|
||||
queryId.value = result.query_id;
|
||||
applyViewResult(result);
|
||||
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
if (isStaleRequest(requestId)) return;
|
||||
if (error?.name === 'AbortError') {
|
||||
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
|
||||
} else {
|
||||
errorMessage.value = error?.message || '主查詢執行失敗';
|
||||
}
|
||||
errorMessage.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
if (isStaleRequest(requestId)) return;
|
||||
loading.global = false;
|
||||
loading.list = false;
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReasonDependents() {
|
||||
// ---- View refresh (GET /view -> read cache -> filter) ----
|
||||
|
||||
async function refreshView({ listOnly = false } = {}) {
|
||||
if (!queryId.value) return;
|
||||
|
||||
const requestId = nextRequestId();
|
||||
if (!listOnly) {
|
||||
loading.global = true;
|
||||
}
|
||||
loading.list = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const list = await fetchList();
|
||||
if (isStaleRequest(requestId)) {
|
||||
const params = {
|
||||
query_id: queryId.value,
|
||||
hold_type: filterBar.holdType,
|
||||
record_type: recordTypeCsv(),
|
||||
page: page.value,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
};
|
||||
|
||||
if (reasonFilter.value) {
|
||||
params.reason = reasonFilter.value;
|
||||
}
|
||||
if (durationFilter.value) {
|
||||
params.duration_range = durationFilter.value;
|
||||
}
|
||||
|
||||
const resp = await apiGet('/api/hold-history/view', {
|
||||
params,
|
||||
timeout: API_TIMEOUT,
|
||||
});
|
||||
if (isStaleRequest(requestId)) return;
|
||||
|
||||
// Cache expired -> auto re-execute primary query
|
||||
if (resp?.success === false && resp?.error === 'cache_expired') {
|
||||
await executePrimaryQuery();
|
||||
return;
|
||||
}
|
||||
detailData.value = normalizeListPayload(list);
|
||||
|
||||
const result = unwrapApiResult(resp, '視圖查詢失敗');
|
||||
applyViewResult(result, { listOnly });
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
errorMessage.value = error?.message || '載入明細資料失敗';
|
||||
if (isStaleRequest(requestId)) return;
|
||||
errorMessage.value = error?.message || '載入資料失敗';
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
if (isStaleRequest(requestId)) return;
|
||||
loading.global = false;
|
||||
loading.list = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListOnly() {
|
||||
const requestId = nextRequestId();
|
||||
loading.list = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const list = await fetchList();
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
detailData.value = normalizeListPayload(list);
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
errorMessage.value = error?.message || '載入明細資料失敗';
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) {
|
||||
return;
|
||||
}
|
||||
loading.list = false;
|
||||
}
|
||||
}
|
||||
// ---- Computed ----
|
||||
|
||||
function estimateAvgHoldHours(items) {
|
||||
const bucketHours = {
|
||||
@@ -400,6 +350,8 @@ const holdTypeLabel = computed(() => {
|
||||
return '品質異常';
|
||||
});
|
||||
|
||||
// ---- Event handlers ----
|
||||
|
||||
function handleFilterChange(next) {
|
||||
const nextStartDate = next?.startDate || '';
|
||||
const nextEndDate = next?.endDate || '';
|
||||
@@ -421,7 +373,13 @@ function handleFilterChange(next) {
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
|
||||
void loadAllData({ includeTrend: dateChanged, showOverlay: false });
|
||||
if (dateChanged) {
|
||||
// Date changed -> new primary query (Oracle)
|
||||
void executePrimaryQuery();
|
||||
} else {
|
||||
// Only hold_type changed -> view refresh (cache only)
|
||||
void refreshView();
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecordTypeChange() {
|
||||
@@ -429,7 +387,7 @@ function handleRecordTypeChange() {
|
||||
durationFilter.value = '';
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadAllData({ includeTrend: false, showOverlay: false });
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function handleReasonToggle(reason) {
|
||||
@@ -441,7 +399,7 @@ function handleReasonToggle(reason) {
|
||||
reasonFilter.value = reasonFilter.value === nextReason ? '' : nextReason;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadReasonDependents();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function clearReasonFilter() {
|
||||
@@ -451,7 +409,7 @@ function clearReasonFilter() {
|
||||
reasonFilter.value = '';
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadReasonDependents();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function handleDurationToggle(range) {
|
||||
@@ -463,7 +421,7 @@ function handleDurationToggle(range) {
|
||||
durationFilter.value = durationFilter.value === nextRange ? '' : nextRange;
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadReasonDependents();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function clearDurationFilter() {
|
||||
@@ -473,7 +431,7 @@ function clearDurationFilter() {
|
||||
durationFilter.value = '';
|
||||
page.value = 1;
|
||||
updateUrlState();
|
||||
void loadReasonDependents();
|
||||
void refreshView();
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
@@ -482,7 +440,7 @@ function prevPage() {
|
||||
}
|
||||
page.value -= 1;
|
||||
updateUrlState();
|
||||
void loadListOnly();
|
||||
void refreshView({ listOnly: true });
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
@@ -492,13 +450,15 @@ function nextPage() {
|
||||
}
|
||||
page.value += 1;
|
||||
updateUrlState();
|
||||
void loadListOnly();
|
||||
void refreshView({ listOnly: true });
|
||||
}
|
||||
|
||||
async function manualRefresh() {
|
||||
page.value = 1;
|
||||
reasonFilter.value = '';
|
||||
durationFilter.value = '';
|
||||
updateUrlState();
|
||||
await loadAllData({ includeTrend: true, showOverlay: false });
|
||||
await executePrimaryQuery();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -519,7 +479,7 @@ onMounted(() => {
|
||||
page.value = parsedPage;
|
||||
}
|
||||
updateUrlState();
|
||||
void loadAllData({ includeTrend: true, showOverlay: true });
|
||||
void executePrimaryQuery({ showOverlay: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -174,7 +174,7 @@ export function useJobQueryData() {
|
||||
loadingResources.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const payload = await apiGet('/api/job-query/resources', { timeout: 60000, silent: true });
|
||||
const payload = await apiGet('/api/job-query/resources', { timeout: 360000, silent: true });
|
||||
resources.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || '載入設備清單失敗';
|
||||
@@ -206,7 +206,7 @@ export function useJobQueryData() {
|
||||
start_date: filters.startDate,
|
||||
end_date: filters.endDate,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
jobs.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
return true;
|
||||
@@ -229,7 +229,7 @@ export function useJobQueryData() {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
|
||||
timeout: 60000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
|
||||
@@ -16,7 +16,7 @@ import SuspectContextPanel from './components/SuspectContextPanel.vue';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const API_TIMEOUT = 120000;
|
||||
const API_TIMEOUT = 360000;
|
||||
const PAGE_SIZE = 200;
|
||||
const SESSION_CACHE_KEY = 'msd:cache';
|
||||
const SESSION_CACHE_TTL = 5 * 60 * 1000; // 5 min, matches backend Redis TTL
|
||||
|
||||
@@ -135,7 +135,7 @@ async function loadTxn(jobId) {
|
||||
|
||||
try {
|
||||
const payload = await apiGet(`/api/job-query/txn/${encodeURIComponent(id)}`, {
|
||||
timeout: 60000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
txnRows.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
|
||||
@@ -120,7 +120,7 @@ export function useEquipmentQuery(initial = {}) {
|
||||
const payload = await apiPost(
|
||||
'/api/query-tool/equipment-period',
|
||||
buildQueryPayload(queryType),
|
||||
{ timeout: 120000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
@@ -132,7 +132,7 @@ export function useEquipmentQuery(initial = {}) {
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/equipment-list', {
|
||||
timeout: 60000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
equipmentOptions.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
|
||||
@@ -164,7 +164,7 @@ export function useLotDetail(initial = {}) {
|
||||
|
||||
try {
|
||||
const payload = await apiGet('/api/query-tool/workcenter-groups', {
|
||||
timeout: 60000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ export function useLotDetail(initial = {}) {
|
||||
}
|
||||
|
||||
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ export function useLotDetail(initial = {}) {
|
||||
params.set('time_end', timeRange.time_end);
|
||||
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
@@ -279,7 +279,7 @@ export function useLotDetail(initial = {}) {
|
||||
params.set('type', associationType);
|
||||
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
timeout: 360000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ export function useLotLineage(initial = {}) {
|
||||
profile: 'query_tool',
|
||||
container_ids: containerIds,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useLotResolve(initial = {}) {
|
||||
input_type: inputType.value,
|
||||
values,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
|
||||
resolvedLots.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
|
||||
@@ -216,7 +216,7 @@ export function useReverseLineage(initial = {}) {
|
||||
profile: 'query_tool_reverse',
|
||||
container_ids: containerIds,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
{ timeout: 360000, silent: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
|
||||
@@ -15,7 +15,7 @@ import ParetoSection from './components/ParetoSection.vue';
|
||||
import SummaryCards from './components/SummaryCards.vue';
|
||||
import TrendChart from './components/TrendChart.vue';
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const API_TIMEOUT = 360000;
|
||||
const DEFAULT_PER_PAGE = 50;
|
||||
|
||||
// ---- Primary query form state ----
|
||||
@@ -214,7 +214,11 @@ async function executePrimaryQuery() {
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) return;
|
||||
errorMessage.value = error?.message || '主查詢執行失敗';
|
||||
if (error?.name === 'AbortError') {
|
||||
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
|
||||
} else {
|
||||
errorMessage.value = error?.message || '主查詢執行失敗';
|
||||
}
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) return;
|
||||
loading.querying = false;
|
||||
@@ -264,7 +268,11 @@ async function refreshView() {
|
||||
updateUrlState();
|
||||
} catch (error) {
|
||||
if (isStaleRequest(requestId)) return;
|
||||
errorMessage.value = error?.message || '視圖查詢失敗';
|
||||
if (error?.name === 'AbortError') {
|
||||
errorMessage.value = '查詢逾時,請縮短日期範圍後重試';
|
||||
} else {
|
||||
errorMessage.value = error?.message || '視圖查詢失敗';
|
||||
}
|
||||
} finally {
|
||||
if (isStaleRequest(requestId)) return;
|
||||
loading.list = false;
|
||||
@@ -360,7 +368,9 @@ async function fetchDimensionPareto(dim) {
|
||||
} catch (err) {
|
||||
if (myId !== activeDimRequestId) return;
|
||||
dimensionParetoItems.value = [];
|
||||
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
|
||||
if (err?.name !== 'AbortError') {
|
||||
errorMessage.value = err.message || '查詢維度 Pareto 失敗';
|
||||
}
|
||||
} finally {
|
||||
if (myId === activeDimRequestId) {
|
||||
dimensionParetoLoading.value = false;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { apiGet, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { apiGet, apiPost, ensureMesApiAvailable } from '../core/api.js';
|
||||
import { buildResourceKpiFromHours } from '../core/compute.js';
|
||||
import {
|
||||
buildResourceHistoryQueryParams,
|
||||
@@ -22,7 +22,7 @@ import TrendChart from './components/TrendChart.vue';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const API_TIMEOUT = 60000;
|
||||
const API_TIMEOUT = 360000;
|
||||
const MAX_QUERY_DAYS = 730;
|
||||
|
||||
function createDefaultFilters() {
|
||||
@@ -66,6 +66,9 @@ const detailWarning = ref('');
|
||||
const exportMessage = ref('');
|
||||
const autoPruneHint = ref('');
|
||||
|
||||
const queryId = ref('');
|
||||
const lastPrimarySnapshot = ref('');
|
||||
|
||||
const draftWatchReady = ref(false);
|
||||
let suppressDraftPrune = false;
|
||||
|
||||
@@ -330,7 +333,47 @@ watch(pruneSignature, () => {
|
||||
pruneDraftSelections({ showHint: true });
|
||||
});
|
||||
|
||||
async function executeCommittedQuery() {
|
||||
// ─── 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;
|
||||
@@ -344,46 +387,37 @@ async function executeCommittedQuery() {
|
||||
exportMessage.value = '';
|
||||
|
||||
try {
|
||||
const queryString = buildQueryStringFromFilters(committedFilters);
|
||||
updateUrlState();
|
||||
|
||||
const [summaryResponse, detailResponse] = await Promise.all([
|
||||
apiGet(`/api/resource/history/summary?${queryString}`, {
|
||||
timeout: API_TIMEOUT,
|
||||
silent: true,
|
||||
}),
|
||||
apiGet(`/api/resource/history/detail?${queryString}`, {
|
||||
timeout: API_TIMEOUT,
|
||||
silent: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const summaryPayload = unwrapApiResult(summaryResponse, '查詢摘要失敗');
|
||||
const detailPayload = unwrapApiResult(detailResponse, '查詢明細失敗');
|
||||
|
||||
const rawSummary = summaryPayload.data || {};
|
||||
summaryData.value = {
|
||||
...rawSummary,
|
||||
kpi: mergeComputedKpi(rawSummary.kpi || {}),
|
||||
trend: (rawSummary.trend || []).map((item) => mergeComputedKpi(item || {})),
|
||||
heatmap: rawSummary.heatmap || [],
|
||||
workcenter_comparison: rawSummary.workcenter_comparison || [],
|
||||
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,
|
||||
};
|
||||
|
||||
detailData.value = Array.isArray(detailPayload.data) ? detailPayload.data : [];
|
||||
resetHierarchyState();
|
||||
const response = await apiPost('/api/resource/history/query', body, {
|
||||
timeout: API_TIMEOUT,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
if (detailPayload.truncated) {
|
||||
detailWarning.value = `明細資料超過 ${detailPayload.max_records} 筆,僅顯示前 ${detailPayload.max_records} 筆。`;
|
||||
}
|
||||
const payload = unwrapApiResult(response, '查詢失敗');
|
||||
queryId.value = payload.query_id || '';
|
||||
lastPrimarySnapshot.value = buildPrimarySnapshot(committedFilters);
|
||||
applyViewResult(payload);
|
||||
} catch (error) {
|
||||
queryError.value = error?.message || '查詢失敗';
|
||||
summaryData.value = {
|
||||
kpi: {},
|
||||
trend: [],
|
||||
heatmap: [],
|
||||
workcenter_comparison: [],
|
||||
};
|
||||
if (error?.name === 'AbortError') {
|
||||
queryError.value = '查詢逾時,請縮小日期範圍或資源篩選後重試';
|
||||
} else {
|
||||
queryError.value = error?.message || '查詢失敗';
|
||||
}
|
||||
summaryData.value = { kpi: {}, trend: [], heatmap: [], workcenter_comparison: [] };
|
||||
detailData.value = [];
|
||||
resetHierarchyState();
|
||||
} finally {
|
||||
@@ -392,6 +426,47 @@ async function executeCommittedQuery() {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -400,7 +475,13 @@ async function applyFilters() {
|
||||
}
|
||||
pruneDraftSelections({ showHint: true });
|
||||
assignFilterState(committedFilters, draftFilters);
|
||||
await executeCommittedQuery();
|
||||
|
||||
const currentPrimary = buildPrimarySnapshot(committedFilters);
|
||||
if (queryId.value && currentPrimary === lastPrimarySnapshot.value) {
|
||||
await refreshView();
|
||||
} else {
|
||||
await executePrimaryQuery();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
@@ -409,7 +490,7 @@ async function clearFilters() {
|
||||
});
|
||||
autoPruneHint.value = '';
|
||||
assignFilterState(committedFilters, draftFilters);
|
||||
await executeCommittedQuery();
|
||||
await executePrimaryQuery();
|
||||
}
|
||||
|
||||
function updateFilters(nextFilters) {
|
||||
@@ -462,7 +543,7 @@ async function initPage() {
|
||||
}
|
||||
|
||||
draftWatchReady.value = true;
|
||||
await executeCommittedQuery();
|
||||
await executePrimaryQuery();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { apiPost, ensureMesApiAvailable } from '../core/api.js';
|
||||
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const DEFAULT_STAGE_TIMEOUT_MS = 60000;
|
||||
const DEFAULT_STAGE_TIMEOUT_MS = 360000;
|
||||
const PROFILE_DOMAINS = Object.freeze({
|
||||
query_tool: ['history', 'materials', 'rejects', 'holds', 'jobs'],
|
||||
mid_section_defect: ['upstream_history', 'materials'],
|
||||
|
||||
Reference in New Issue
Block a user