fix(query-tool): batch detail loading, UX polish, and docs cleanup
- Fix multi-WO display: auto-select all tree roots after resolve so detail panel loads data for every work order, not just the first seed CID - Disable scroll-wheel zoom on lineage tree (roam: 'move') to prevent accidental layout jumps while preserving drag-pan - Add batch API endpoints (get_lot_history_batch, get_lot_associations_batch) to avoid N parallel requests hitting rate limits - Remove redundant Split sub-tab from LOT detail (tree already shows splits) - Rename 退貨 → 報廢 to match actual reject/scrap data semantics - Hide internal ID columns (CONTAINERID, EQUIPMENTID, RESOURCEID) from history table display - Add timeline scroll container and time range header for long timelines - Remove obsolete migration and architecture docs no longer needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -194,19 +194,15 @@ async function handleResolveLots() {
|
||||
|
||||
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
|
||||
|
||||
const rootIds = lotLineage.rootContainerIds.value;
|
||||
if (rootIds.length === 0) {
|
||||
const treeRootIds = lotLineage.treeRoots.value;
|
||||
if (treeRootIds.length === 0) {
|
||||
await lotDetail.setSelectedContainerId('');
|
||||
lotLineage.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredSelection = lotDetail.selectedContainerId.value && rootIds.includes(lotDetail.selectedContainerId.value)
|
||||
? lotDetail.selectedContainerId.value
|
||||
: rootIds[0];
|
||||
|
||||
lotLineage.selectNode(preferredSelection);
|
||||
await lotDetail.setSelectedContainerId(preferredSelection);
|
||||
// Auto-select all tree roots and load detail for their full subtrees
|
||||
await handleSelectNodes(treeRootIds);
|
||||
}
|
||||
|
||||
async function handleSelectNodes(containerIds) {
|
||||
|
||||
@@ -216,8 +216,7 @@ const chartOption = computed(() => {
|
||||
orient: 'LR',
|
||||
expandAndCollapse: true,
|
||||
initialTreeDepth: -1,
|
||||
roam: true,
|
||||
scaleLimit: { min: 0.5, max: 3 },
|
||||
roam: 'move',
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
label: {
|
||||
|
||||
@@ -70,9 +70,8 @@ const emit = defineEmits(['change-sub-tab', 'update-workcenter-groups', 'export-
|
||||
const tabMeta = Object.freeze({
|
||||
history: { label: '歷程', emptyText: '無歷程資料' },
|
||||
materials: { label: '物料', emptyText: '無物料資料' },
|
||||
rejects: { label: '退貨', emptyText: '無退貨資料' },
|
||||
rejects: { label: '報廢', emptyText: '無報廢資料' },
|
||||
holds: { label: 'Hold', emptyText: '無 Hold 資料' },
|
||||
splits: { label: 'Split', emptyText: '無 Split 資料' },
|
||||
jobs: { label: 'Job', emptyText: '無 Job 資料' },
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:workcenterGroups']);
|
||||
|
||||
const columns = computed(() => Object.keys(props.rows[0] || {}));
|
||||
const HIDDEN_COLUMNS = new Set(['CONTAINERID', 'EQUIPMENTID', 'RESOURCEID']);
|
||||
|
||||
const columns = computed(() =>
|
||||
Object.keys(props.rows[0] || {}).filter((col) => !HIDDEN_COLUMNS.has(col)),
|
||||
);
|
||||
|
||||
const workcenterOptions = computed(() => {
|
||||
return props.workcenterGroups.map((group) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import TimelineChart from '../../shared-ui/components/TimelineChart.vue';
|
||||
import { hashColor, normalizeText, parseDateTime } from '../utils/values.js';
|
||||
import { formatDateTime, hashColor, normalizeText, parseDateTime } from '../utils/values.js';
|
||||
|
||||
const props = defineProps({
|
||||
historyRows: {
|
||||
@@ -151,24 +151,28 @@ const timeRange = computed(() => {
|
||||
|
||||
<template>
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">LOT 生產 Timeline</h4>
|
||||
<p class="text-xs text-slate-500">Hold / Material 事件已覆蓋標記</p>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-500">
|
||||
<span v-if="timeRange">{{ formatDateTime(timeRange.start) }} — {{ formatDateTime(timeRange.end) }}</span>
|
||||
<span>Hold / Material 事件已覆蓋標記</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tracks.length === 0" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-5 text-center text-xs text-slate-500">
|
||||
歷程資料不足,無法產生 Timeline
|
||||
</div>
|
||||
|
||||
<TimelineChart
|
||||
v-else
|
||||
:tracks="tracks"
|
||||
:events="events"
|
||||
:time-range="timeRange"
|
||||
:color-map="colorMap"
|
||||
:label-width="180"
|
||||
:track-row-height="46"
|
||||
:min-chart-width="1040"
|
||||
/>
|
||||
<div v-else class="max-h-[420px] overflow-y-auto rounded-card border border-stroke-soft">
|
||||
<TimelineChart
|
||||
:tracks="tracks"
|
||||
:events="events"
|
||||
:time-range="timeRange"
|
||||
:color-map="colorMap"
|
||||
:label-width="180"
|
||||
:track-row-height="46"
|
||||
:min-chart-width="1040"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -9,18 +9,16 @@ const LOT_SUB_TABS = Object.freeze([
|
||||
'materials',
|
||||
'rejects',
|
||||
'holds',
|
||||
'splits',
|
||||
'jobs',
|
||||
]);
|
||||
|
||||
const ASSOCIATION_TABS = new Set(['materials', 'rejects', 'holds', 'splits', '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',
|
||||
splits: 'lot_splits',
|
||||
jobs: 'lot_jobs',
|
||||
});
|
||||
|
||||
@@ -30,7 +28,6 @@ function emptyTabFlags() {
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
};
|
||||
}
|
||||
@@ -42,7 +39,6 @@ function emptyTabErrors() {
|
||||
materials: '',
|
||||
rejects: '',
|
||||
holds: '',
|
||||
splits: '',
|
||||
jobs: '',
|
||||
};
|
||||
}
|
||||
@@ -52,7 +48,6 @@ function emptyAssociations() {
|
||||
materials: [],
|
||||
rejects: [],
|
||||
holds: [],
|
||||
splits: [],
|
||||
jobs: [],
|
||||
};
|
||||
}
|
||||
@@ -62,41 +57,6 @@ function normalizeSubTab(value) {
|
||||
return LOT_SUB_TABS.includes(tab) ? tab : 'history';
|
||||
}
|
||||
|
||||
function flattenSplitPayload(payload) {
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
const productionHistory = Array.isArray(payload?.production_history)
|
||||
? payload.production_history.map((item) => ({
|
||||
RECORD_TYPE: 'PRODUCTION_HISTORY',
|
||||
...item,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const serialRows = Array.isArray(payload?.serial_numbers)
|
||||
? payload.serial_numbers.flatMap((item) => {
|
||||
const serialNumber = item?.serial_number || '';
|
||||
const totalGoodDie = item?.total_good_die || null;
|
||||
const lots = Array.isArray(item?.lots) ? item.lots : [];
|
||||
|
||||
return lots.map((lot) => ({
|
||||
RECORD_TYPE: 'SERIAL_MAPPING',
|
||||
SERIAL_NUMBER: serialNumber,
|
||||
TOTAL_GOOD_DIE: totalGoodDie,
|
||||
LOT_ID: lot?.lot_id || '',
|
||||
WORK_ORDER: lot?.work_order || '',
|
||||
COMBINE_RATIO: lot?.combine_ratio,
|
||||
COMBINE_RATIO_PCT: lot?.combine_ratio_pct || '',
|
||||
GOOD_DIE_QTY: lot?.good_die_qty,
|
||||
ORIGINAL_START_DATE: lot?.original_start_date,
|
||||
}));
|
||||
})
|
||||
: [];
|
||||
|
||||
return [...productionHistory, ...serialRows];
|
||||
}
|
||||
|
||||
function resolveTimeRangeFromHistory(rows) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return null;
|
||||
@@ -168,7 +128,6 @@ export function useLotDetail(initial = {}) {
|
||||
materials: false,
|
||||
rejects: false,
|
||||
holds: false,
|
||||
splits: false,
|
||||
jobs: false,
|
||||
});
|
||||
|
||||
@@ -234,38 +193,24 @@ export function useLotDetail(initial = {}) {
|
||||
errors.history = '';
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
cids.map((cid) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cid);
|
||||
if (selectedWorkcenterGroups.value.length > 0) {
|
||||
params.set('workcenter_groups', selectedWorkcenterGroups.value.join(','));
|
||||
}
|
||||
return apiGet(`/api/query-tool/lot-history?${params.toString()}`, {
|
||||
timeout: 60000,
|
||||
silent: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const allRows = [];
|
||||
const failedCids = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const rows = Array.isArray(result.value?.data) ? result.value.data : [];
|
||||
allRows.push(...rows);
|
||||
} else {
|
||||
failedCids.push(cids[index]);
|
||||
}
|
||||
});
|
||||
|
||||
historyRows.value = allRows;
|
||||
loaded.history = true;
|
||||
|
||||
if (failedCids.length > 0) {
|
||||
errors.history = `部分節點歷程載入失敗:${failedCids.join(', ')}`;
|
||||
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: 120000,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
historyRows.value = Array.isArray(payload?.data) ? payload.data : [];
|
||||
loaded.history = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
errors.history = error?.message || '載入 LOT 歷程失敗';
|
||||
@@ -324,29 +269,21 @@ export function useLotDetail(initial = {}) {
|
||||
|
||||
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
|
||||
} else {
|
||||
// Non-jobs tabs: load in parallel for all selected CIDs
|
||||
const results = await Promise.allSettled(
|
||||
cids.map((cid) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('container_id', cid);
|
||||
params.set('type', associationType);
|
||||
return apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
// 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 allRows = [];
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
const rows = associationType === 'splits'
|
||||
? flattenSplitPayload(result.value)
|
||||
: (Array.isArray(result.value?.data) ? result.value.data : []);
|
||||
allRows.push(...rows);
|
||||
}
|
||||
const payload = await apiGet(`/api/query-tool/lot-associations?${params.toString()}`, {
|
||||
timeout: 120000,
|
||||
silent: true,
|
||||
});
|
||||
associationRows[associationType] = allRows;
|
||||
|
||||
associationRows[associationType] = Array.isArray(payload?.data) ? payload.data : [];
|
||||
}
|
||||
|
||||
loaded[associationType] = true;
|
||||
|
||||
Reference in New Issue
Block a user