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>
449 lines
11 KiB
JavaScript
449 lines
11 KiB
JavaScript
import { reactive, ref } from 'vue';
|
|
|
|
import { apiGet, ensureMesApiAvailable } from '../../core/api.js';
|
|
import { exportCsv } from '../utils/csv.js';
|
|
import { normalizeText, parseDateTime, uniqueValues, formatDateTime } from '../utils/values.js';
|
|
|
|
const LOT_SUB_TABS = Object.freeze([
|
|
'history',
|
|
'materials',
|
|
'rejects',
|
|
'holds',
|
|
'jobs',
|
|
]);
|
|
|
|
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'jobs']);
|
|
|
|
const EXPORT_TYPE_MAP = Object.freeze({
|
|
history: 'lot_history',
|
|
materials: 'lot_materials',
|
|
rejects: 'lot_rejects',
|
|
holds: 'lot_holds',
|
|
jobs: 'lot_jobs',
|
|
});
|
|
|
|
function emptyTabFlags() {
|
|
return {
|
|
history: false,
|
|
materials: false,
|
|
rejects: false,
|
|
holds: false,
|
|
jobs: false,
|
|
};
|
|
}
|
|
|
|
function emptyTabErrors() {
|
|
return {
|
|
workcenterGroups: '',
|
|
history: '',
|
|
materials: '',
|
|
rejects: '',
|
|
holds: '',
|
|
jobs: '',
|
|
};
|
|
}
|
|
|
|
function emptyAssociations() {
|
|
return {
|
|
materials: [],
|
|
rejects: [],
|
|
holds: [],
|
|
jobs: [],
|
|
};
|
|
}
|
|
|
|
function normalizeSubTab(value) {
|
|
const tab = normalizeText(value).toLowerCase();
|
|
return LOT_SUB_TABS.includes(tab) ? tab : 'history';
|
|
}
|
|
|
|
function resolveTimeRangeFromHistory(rows) {
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let minTrackIn = null;
|
|
let maxTrackOut = null;
|
|
|
|
rows.forEach((row) => {
|
|
const trackIn = parseDateTime(row?.TRACKINTIMESTAMP || row?.TRACKINTIME);
|
|
const trackOut = parseDateTime(row?.TRACKOUTTIMESTAMP || row?.TRACKOUTTIME);
|
|
|
|
if (trackIn && (!minTrackIn || trackIn < minTrackIn)) {
|
|
minTrackIn = trackIn;
|
|
}
|
|
|
|
if (trackOut && (!maxTrackOut || trackOut > maxTrackOut)) {
|
|
maxTrackOut = trackOut;
|
|
}
|
|
|
|
if (!maxTrackOut && trackIn && (!maxTrackOut || trackIn > maxTrackOut)) {
|
|
maxTrackOut = trackIn;
|
|
}
|
|
});
|
|
|
|
if (!minTrackIn || !maxTrackOut) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
time_start: formatDateTime(minTrackIn),
|
|
time_end: formatDateTime(maxTrackOut),
|
|
};
|
|
}
|
|
|
|
function resolveEquipmentIdFromHistory(rows) {
|
|
if (!Array.isArray(rows) || rows.length === 0) {
|
|
return '';
|
|
}
|
|
|
|
for (const row of rows) {
|
|
const equipmentId = normalizeText(row?.EQUIPMENTID || row?.RESOURCEID);
|
|
if (equipmentId) {
|
|
return equipmentId;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
export function useLotDetail(initial = {}) {
|
|
ensureMesApiAvailable();
|
|
|
|
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
|
const selectedContainerIds = ref(
|
|
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
|
);
|
|
const activeSubTab = ref(normalizeSubTab(initial.activeSubTab));
|
|
|
|
const workcenterGroups = ref([]);
|
|
const selectedWorkcenterGroups = ref(uniqueValues(initial.workcenterGroups || []));
|
|
|
|
const historyRows = ref([]);
|
|
const associationRows = reactive(emptyAssociations());
|
|
|
|
const loading = reactive({
|
|
workcenterGroups: false,
|
|
history: false,
|
|
materials: false,
|
|
rejects: false,
|
|
holds: false,
|
|
jobs: false,
|
|
});
|
|
|
|
const loaded = reactive(emptyTabFlags());
|
|
const exporting = reactive(emptyTabFlags());
|
|
const errors = reactive(emptyTabErrors());
|
|
|
|
function clearTabData() {
|
|
historyRows.value = [];
|
|
const nextAssociations = emptyAssociations();
|
|
Object.keys(nextAssociations).forEach((key) => {
|
|
associationRows[key] = nextAssociations[key];
|
|
});
|
|
|
|
const nextLoaded = emptyTabFlags();
|
|
Object.keys(nextLoaded).forEach((key) => {
|
|
loaded[key] = nextLoaded[key];
|
|
exporting[key] = false;
|
|
errors[key] = '';
|
|
});
|
|
}
|
|
|
|
function getActiveCids() {
|
|
if (selectedContainerIds.value.length > 0) {
|
|
return selectedContainerIds.value;
|
|
}
|
|
const single = selectedContainerId.value;
|
|
return single ? [single] : [];
|
|
}
|
|
|
|
async function loadWorkcenterGroups() {
|
|
loading.workcenterGroups = true;
|
|
errors.workcenterGroups = '';
|
|
|
|
try {
|
|
const payload = await apiGet('/api/query-tool/workcenter-groups', {
|
|
timeout: 360000,
|
|
silent: true,
|
|
});
|
|
|
|
workcenterGroups.value = Array.isArray(payload?.data) ? payload.data : [];
|
|
return true;
|
|
} catch (error) {
|
|
errors.workcenterGroups = error?.message || '載入站點群組失敗';
|
|
workcenterGroups.value = [];
|
|
return false;
|
|
} finally {
|
|
loading.workcenterGroups = false;
|
|
}
|
|
}
|
|
|
|
async function loadHistory({ force = false } = {}) {
|
|
const cids = getActiveCids();
|
|
if (cids.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (!force && loaded.history) {
|
|
return true;
|
|
}
|
|
|
|
loading.history = true;
|
|
errors.history = '';
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
// Batch mode: send all CIDs in one request
|
|
if (cids.length > 1) {
|
|
params.set('container_ids', cids.join(','));
|
|
} else {
|
|
params.set('container_id', cids[0]);
|
|
}
|
|
if (selectedWorkcenterGroups.value.length > 0) {
|
|
params.set('workcenter_groups', selectedWorkcenterGroups.value.join(','));
|
|
}
|
|
|
|
const payload = await apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
|
timeout: 360000,
|
|
silent: true,
|
|
});
|
|
|
|
historyRows.value = Array.isArray(payload?.data) ? payload.data : [];
|
|
loaded.history = true;
|
|
return true;
|
|
} catch (error) {
|
|
errors.history = error?.message || '載入 LOT 歷程失敗';
|
|
historyRows.value = [];
|
|
return false;
|
|
} finally {
|
|
loading.history = false;
|
|
}
|
|
}
|
|
|
|
async function loadAssociation(tab, { force = false, silentError = false } = {}) {
|
|
const associationType = normalizeSubTab(tab);
|
|
if (!ASSOCIATION_TABS.has(associationType)) {
|
|
return false;
|
|
}
|
|
|
|
const cids = getActiveCids();
|
|
if (cids.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (!force && loaded[associationType]) {
|
|
return true;
|
|
}
|
|
|
|
loading[associationType] = true;
|
|
if (!silentError) {
|
|
errors[associationType] = '';
|
|
}
|
|
|
|
try {
|
|
if (associationType === 'jobs') {
|
|
// Jobs derive equipment/time from merged history — use first CID as anchor
|
|
if (historyRows.value.length === 0) {
|
|
await loadHistory();
|
|
}
|
|
|
|
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
|
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
|
|
|
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
|
throw new Error('無法從 LOT 歷程推導 JOB 查詢條件,請先確認歷程資料');
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
params.set('container_id', cids[0]);
|
|
params.set('type', associationType);
|
|
params.set('equipment_id', equipmentId);
|
|
params.set('time_start', timeRange.time_start);
|
|
params.set('time_end', timeRange.time_end);
|
|
|
|
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
|
timeout: 360000,
|
|
silent: true,
|
|
});
|
|
|
|
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
|
|
} else {
|
|
// Non-jobs tabs: batch all CIDs into a single request
|
|
const params = new URLSearchParams();
|
|
if (cids.length > 1) {
|
|
params.set('container_ids', cids.join(','));
|
|
} else {
|
|
params.set('container_id', cids[0]);
|
|
}
|
|
params.set('type', associationType);
|
|
|
|
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
|
timeout: 360000,
|
|
silent: true,
|
|
});
|
|
|
|
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
|
|
}
|
|
|
|
loaded[associationType] = true;
|
|
return true;
|
|
} catch (error) {
|
|
associationRows[associationType] = [];
|
|
if (!silentError) {
|
|
errors[associationType] = error?.message || '載入關聯資料失敗';
|
|
}
|
|
return false;
|
|
} finally {
|
|
loading[associationType] = false;
|
|
}
|
|
}
|
|
|
|
async function ensureActiveSubTabData() {
|
|
if (activeSubTab.value === 'history') {
|
|
const historyOk = await loadHistory();
|
|
if (historyOk) {
|
|
// History timeline uses hold/material events as marker sources.
|
|
await Promise.allSettled([
|
|
loadAssociation('holds', { silentError: true }),
|
|
loadAssociation('materials', { silentError: true }),
|
|
]);
|
|
}
|
|
return historyOk;
|
|
}
|
|
|
|
return loadAssociation(activeSubTab.value);
|
|
}
|
|
|
|
async function setActiveSubTab(tab) {
|
|
activeSubTab.value = normalizeSubTab(tab);
|
|
return ensureActiveSubTabData();
|
|
}
|
|
|
|
async function setSelectedContainerId(containerId) {
|
|
const nextId = normalizeText(containerId);
|
|
selectedContainerIds.value = nextId ? [nextId] : [];
|
|
|
|
if (nextId === selectedContainerId.value) {
|
|
return ensureActiveSubTabData();
|
|
}
|
|
|
|
selectedContainerId.value = nextId;
|
|
clearTabData();
|
|
|
|
if (!nextId) {
|
|
return false;
|
|
}
|
|
|
|
return ensureActiveSubTabData();
|
|
}
|
|
|
|
async function setSelectedContainerIds(cids) {
|
|
const normalized = uniqueValues(
|
|
(Array.isArray(cids) ? cids : []).map(normalizeText).filter(Boolean),
|
|
);
|
|
selectedContainerIds.value = normalized;
|
|
selectedContainerId.value = normalized[0] || '';
|
|
clearTabData();
|
|
|
|
if (normalized.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return ensureActiveSubTabData();
|
|
}
|
|
|
|
async function setSelectedWorkcenterGroups(groups) {
|
|
selectedWorkcenterGroups.value = uniqueValues(groups || []);
|
|
|
|
if (!selectedContainerId.value) {
|
|
return false;
|
|
}
|
|
|
|
loaded.history = false;
|
|
return loadHistory({ force: true });
|
|
}
|
|
|
|
function getRowsByTab(tab) {
|
|
const normalized = normalizeSubTab(tab);
|
|
if (normalized === 'history') {
|
|
return historyRows.value;
|
|
}
|
|
if (!ASSOCIATION_TABS.has(normalized)) {
|
|
return [];
|
|
}
|
|
return associationRows[normalized] || [];
|
|
}
|
|
|
|
async function exportSubTab(tab) {
|
|
const normalized = normalizeSubTab(tab);
|
|
const exportType = EXPORT_TYPE_MAP[normalized];
|
|
const cids = getActiveCids();
|
|
|
|
if (!exportType || cids.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
exporting[normalized] = true;
|
|
errors[normalized] = '';
|
|
|
|
try {
|
|
const params = {
|
|
container_ids: cids,
|
|
};
|
|
|
|
if (normalized === 'jobs') {
|
|
if (historyRows.value.length === 0) {
|
|
await loadHistory();
|
|
}
|
|
const equipmentId = resolveEquipmentIdFromHistory(historyRows.value);
|
|
const timeRange = resolveTimeRangeFromHistory(historyRows.value);
|
|
if (!equipmentId || !timeRange?.time_start || !timeRange?.time_end) {
|
|
throw new Error('無法取得 JOB 匯出所需條件');
|
|
}
|
|
params.equipment_id = equipmentId;
|
|
params.time_start = timeRange.time_start;
|
|
params.time_end = timeRange.time_end;
|
|
}
|
|
|
|
await exportCsv({
|
|
exportType,
|
|
params,
|
|
});
|
|
|
|
return true;
|
|
} catch (error) {
|
|
errors[normalized] = error?.message || '匯出失敗';
|
|
return false;
|
|
} finally {
|
|
exporting[normalized] = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
lotSubTabs: LOT_SUB_TABS,
|
|
selectedContainerId,
|
|
selectedContainerIds,
|
|
activeSubTab,
|
|
workcenterGroups,
|
|
selectedWorkcenterGroups,
|
|
historyRows,
|
|
associationRows,
|
|
loading,
|
|
loaded,
|
|
exporting,
|
|
errors,
|
|
loadWorkcenterGroups,
|
|
loadHistory,
|
|
loadAssociation,
|
|
ensureActiveSubTabData,
|
|
setActiveSubTab,
|
|
setSelectedContainerId,
|
|
setSelectedContainerIds,
|
|
setSelectedWorkcenterGroups,
|
|
getRowsByTab,
|
|
exportSubTab,
|
|
clearTabData,
|
|
};
|
|
}
|