diff --git a/frontend/src/query-tool/App.vue b/frontend/src/query-tool/App.vue index 2fbd691..64a4def 100644 --- a/frontend/src/query-tool/App.vue +++ b/frontend/src/query-tool/App.vue @@ -20,8 +20,8 @@ const TAB_EQUIPMENT = 'equipment'; const VALID_TABS = new Set([TAB_LOT, TAB_REVERSE, TAB_EQUIPMENT]); const tabItems = Object.freeze([ - { key: TAB_LOT, label: '批次追蹤(正向)', subtitle: '由批次展開下游血緣與明細' }, - { key: TAB_REVERSE, label: '流水批反查(反向)', subtitle: '由成品流水號回溯上游批次' }, + { key: TAB_LOT, label: '批次追蹤(正向)', subtitle: '由 Wafer LOT / GA-GC 工單展開下游血緣與明細' }, + { key: TAB_REVERSE, label: '流水批反查(反向)', subtitle: '由成品流水號 / GD 工單 / GD LOT 回溯上游批次' }, { key: TAB_EQUIPMENT, label: '設備生產批次追蹤', subtitle: '設備紀錄與時序視圖' }, ]); @@ -48,6 +48,7 @@ function readStateFromUrl() { lotSubTab: normalizeText(params.get('lot_sub_tab')) || 'history', lotWorkcenterGroups: parseArrayParam(params, 'workcenter_groups'), + reverseInputType: normalizeText(params.get('reverse_input_type')) || (tab === TAB_REVERSE ? legacyInputType : '') || 'serial_number', reverseInputText: parseArrayParam(params, 'reverse_values').join('\n') || (tab === TAB_REVERSE ? legacyInputText : ''), reverseSelectedContainerId: normalizeText(params.get('reverse_container_id')) || (tab === TAB_REVERSE ? legacySelectedContainerId : ''), reverseSubTab: normalizeText(params.get('reverse_sub_tab')) || (tab === TAB_REVERSE ? legacyLotSubTab : 'history'), @@ -68,13 +69,13 @@ const activeTab = ref(initialState.tab); const lotResolve = useLotResolve({ inputType: initialState.lotInputType, inputText: initialState.lotInputText, - allowedTypes: ['lot_id', 'work_order'], + allowedTypes: ['wafer_lot', 'lot_id', 'work_order'], }); const reverseResolve = useLotResolve({ - inputType: 'serial_number', + inputType: initialState.reverseInputType, inputText: initialState.reverseInputText, - allowedTypes: ['serial_number'], + allowedTypes: ['serial_number', 'gd_work_order', 'gd_lot_id'], }); const lotLineage = useLotLineage({ @@ -151,6 +152,7 @@ function buildUrlState() { parseInputValues(reverseResolve.inputText.value).forEach((value) => { params.append('reverse_values', value); }); + params.set('reverse_input_type', reverseResolve.inputType.value); if (lotDetail.selectedContainerId.value) { params.set('lot_container_id', lotDetail.selectedContainerId.value); @@ -202,7 +204,7 @@ function buildUrlState() { params.set('container_id', lotDetail.selectedContainerId.value); } } else if (activeTab.value === TAB_REVERSE) { - params.set('input_type', 'serial_number'); + params.set('input_type', reverseResolve.inputType.value); parseInputValues(reverseResolve.inputText.value).forEach((value) => { params.append('values', value); }); @@ -242,7 +244,7 @@ async function applyStateFromUrl() { lotResolve.setInputType(state.lotInputType); lotResolve.setInputText(state.lotInputText); - reverseResolve.setInputType('serial_number'); + reverseResolve.setInputType(state.reverseInputType); reverseResolve.setInputText(state.reverseInputText); lotDetail.activeSubTab.value = state.lotSubTab; @@ -395,6 +397,7 @@ watch( lotDetail.selectedWorkcenterGroups, reverseResolve.inputText, + reverseResolve.inputType, reverseDetail.selectedContainerId, reverseDetail.activeSubTab, reverseDetail.selectedWorkcenterGroups, @@ -476,6 +479,8 @@ watch( :not-found="lotResolve.notFound.value" :lineage-map="lotLineage.lineageMap" :name-map="lotLineage.nameMap" + :node-meta-map="lotLineage.nodeMetaMap" + :edge-type-map="lotLineage.edgeTypeMap" :leaf-serials="lotLineage.leafSerials" :lineage-loading="lotLineage.lineageLoading.value" :selected-container-ids="lotLineage.selectedContainerIds.value" @@ -513,6 +518,8 @@ watch( :not-found="reverseResolve.notFound.value" :lineage-map="reverseLineage.lineageMap" :name-map="reverseLineage.nameMap" + :node-meta-map="reverseLineage.nodeMetaMap" + :edge-type-map="reverseLineage.edgeTypeMap" :leaf-serials="reverseLineage.leafSerials" :lineage-loading="reverseLineage.lineageLoading.value" :selected-container-ids="reverseLineage.selectedContainerIds.value" diff --git a/frontend/src/query-tool/components/LineageTreeChart.vue b/frontend/src/query-tool/components/LineageTreeChart.vue index bdec11f..656d3be 100644 --- a/frontend/src/query-tool/components/LineageTreeChart.vue +++ b/frontend/src/query-tool/components/LineageTreeChart.vue @@ -12,12 +12,30 @@ import { normalizeText } from '../utils/values.js'; use([CanvasRenderer, TreeChart, TooltipComponent]); const NODE_COLORS = { + wafer: '#2563EB', + gc: '#06B6D4', + ga: '#10B981', + gd: '#EF4444', root: '#3B82F6', branch: '#10B981', leaf: '#F59E0B', serial: '#94A3B8', }; +const EDGE_STYLES = Object.freeze({ + split_from: { color: '#CBD5E1', type: 'solid', width: 1.5 }, + merge_source: { color: '#F59E0B', type: 'dashed', width: 1.8 }, + wafer_origin: { color: '#2563EB', type: 'dotted', width: 1.8 }, + gd_rework_source: { color: '#EF4444', type: 'dashed', width: 1.8 }, + default: { color: '#CBD5E1', type: 'solid', width: 1.5 }, +}); + +const LABEL_BASE_STYLE = Object.freeze({ + backgroundColor: 'rgba(255,255,255,0.92)', + borderRadius: 3, + padding: [1, 4], +}); + const props = defineProps({ treeRoots: { type: Array, @@ -31,6 +49,14 @@ const props = defineProps({ type: Object, default: () => new Map(), }, + nodeMetaMap: { + type: Object, + default: () => new Map(), + }, + edgeTypeMap: { + type: Object, + default: () => new Map(), + }, leafSerials: { type: Object, default: () => new Map(), @@ -84,6 +110,20 @@ const allSerialNames = computed(() => { }); function detectNodeType(cid, entry, serials) { + const explicitType = normalizeText(props.nodeMetaMap?.get?.(cid)?.node_type).toUpperCase(); + if (explicitType === 'WAFER') { + return 'wafer'; + } + if (explicitType === 'GC') { + return 'gc'; + } + if (explicitType === 'GA') { + return 'ga'; + } + if (explicitType === 'GD') { + return 'gd'; + } + if (rootsSet.value.has(cid)) { return 'root'; } @@ -97,7 +137,20 @@ function detectNodeType(cid, entry, serials) { return 'branch'; } -function buildNode(cid, visited) { +function lookupEdgeType(parentCid, childCid) { + const parent = normalizeText(parentCid); + const child = normalizeText(childCid); + if (!parent || !child) { + return ''; + } + const direct = normalizeText(props.edgeTypeMap?.get?.(`${parent}->${child}`)); + if (direct) { + return direct; + } + return normalizeText(props.edgeTypeMap?.get?.(`${child}->${parent}`)); +} + +function buildNode(cid, visited, parentCid = '') { const id = normalizeText(cid); if (!id || visited.has(id)) { return null; @@ -112,7 +165,7 @@ function buildNode(cid, visited) { const isSelected = selectedSet.value.has(id); const children = childIds - .map((childId) => buildNode(childId, visited)) + .map((childId) => buildNode(childId, visited, id)) .filter(Boolean); if (children.length === 0 && serials.length > 0) { @@ -141,10 +194,12 @@ function buildNode(cid, visited) { && allSerialNames.value.has(name); const effectiveType = isSerialLike ? 'serial' : nodeType; const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch; + const incomingEdgeType = lookupEdgeType(parentCid, id); + const incomingEdgeStyle = EDGE_STYLES[incomingEdgeType] || EDGE_STYLES.default; return { name, - value: { cid: id, type: effectiveType }, + value: { cid: id, type: effectiveType, edgeType: incomingEdgeType || '' }, children, itemStyle: { color, @@ -152,12 +207,16 @@ function buildNode(cid, visited) { borderWidth: isSelected ? 3 : 1, }, label: { + ...LABEL_BASE_STYLE, + position: children.length > 0 ? 'top' : 'right', + distance: children.length > 0 ? 8 : 6, fontWeight: isSelected ? 'bold' : 'normal', fontSize: isSerialLike ? 10 : 11, color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'), }, symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'), symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10), + lineStyle: incomingEdgeStyle, }; } @@ -200,11 +259,13 @@ const TREE_SERIES_DEFAULTS = Object.freeze({ label: { show: true, position: 'right', + distance: 6, fontSize: 11, color: '#334155', overflow: 'truncate', ellipsis: '…', width: 160, + ...LABEL_BASE_STYLE, }, lineStyle: { width: 1.5, @@ -238,6 +299,14 @@ const chartOption = computed(() => { const lines = [`${data.name}`]; if (val.type === 'serial') { lines.push('成品序列號'); + } else if (val.type === 'wafer') { + lines.push('Wafer LOT'); + } else if (val.type === 'gc') { + lines.push('GC LOT'); + } else if (val.type === 'ga') { + lines.push('GA LOT'); + } else if (val.type === 'gd') { + lines.push('GD LOT(重工)'); } else if (val.type === 'root') { lines.push('根節點(晶批)'); } else if (val.type === 'leaf') { @@ -245,6 +314,9 @@ const chartOption = computed(() => { } else if (val.type === 'branch') { lines.push('中間節點'); } + if (val.edgeType) { + lines.push(`關係: ${val.edgeType}`); + } if (val.cid && val.cid !== data.name) { lines.push(`CID: ${val.cid}`); } @@ -325,21 +397,45 @@ function handleNodeClick(params) {
- - 晶批 + + Wafer - - 中間 + + GC + + + + GA + + + + GD - 末端 + 其他 LOT 序列號 + + + split + + + + merge + + + + wafer + + + + gd-rework +
diff --git a/frontend/src/query-tool/components/LotTimeline.vue b/frontend/src/query-tool/components/LotTimeline.vue index 61218c7..3edd485 100644 --- a/frontend/src/query-tool/components/LotTimeline.vue +++ b/frontend/src/query-tool/components/LotTimeline.vue @@ -24,6 +24,10 @@ function safeDate(value) { return parsed ? parsed : null; } +function normalizedKey(value) { + return normalizeText(value).toUpperCase(); +} + // ── Tracks: group by (WORKCENTER_GROUP × LOT ID × Equipment) ── const tracks = computed(() => { const grouped = new Map(); @@ -44,7 +48,13 @@ const tracks = computed(() => { } if (!grouped.has(trackKey)) { - grouped.set(trackKey, { groupName, lotId, equipment, bars: [] }); + grouped.set(trackKey, { + groupName, + lotId, + equipment, + containerId: normalizeText(row?.CONTAINERID), + bars: [], + }); } grouped.get(trackKey).bars.push({ @@ -56,9 +66,10 @@ const tracks = computed(() => { }); }); - return [...grouped.entries()].map(([trackKey, { groupName, lotId, equipment, bars }]) => ({ + return [...grouped.entries()].map(([trackKey, { groupName, lotId, equipment, containerId, bars }]) => ({ id: trackKey, group: groupName, + containerId, label: groupName, sublabels: [ lotId ? `LOT ID: ${lotId}` : '', @@ -74,20 +85,128 @@ const tracks = computed(() => { })); }); -// ── Events: resolve trackId to compound key via group matching ── +// ── Events: resolve event-to-track mapping ── const groupToFirstTrackId = computed(() => { const map = new Map(); tracks.value.forEach((track) => { - if (!map.has(track.group)) { - map.set(track.group, track.id); + const key = normalizedKey(track.group); + if (key && !map.has(key)) { + map.set(key, track.id); } }); return map; }); -function resolveEventTrackId(row) { - const group = normalizeText(row?.WORKCENTER_GROUP) || normalizeText(row?.WORKCENTERNAME) || ''; - return groupToFirstTrackId.value.get(group) || group; +const containerToTrackIds = computed(() => { + const map = new Map(); + tracks.value.forEach((track) => { + const cid = normalizedKey(track.containerId); + if (!cid) { + return; + } + if (!map.has(cid)) { + map.set(cid, []); + } + map.get(cid).push(track.id); + }); + return map; +}); + +const containerSpecWindows = computed(() => { + const map = new Map(); + tracks.value.forEach((track) => { + const containerKey = normalizedKey(track.containerId); + if (!containerKey) { + return; + } + (track.layers || []).forEach((layer) => { + (layer.bars || []).forEach((bar) => { + const specKey = normalizedKey(bar?.label || bar?.type); + const startMs = bar?.start instanceof Date ? bar.start.getTime() : null; + const endMs = bar?.end instanceof Date ? bar.end.getTime() : null; + if (!specKey || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return; + } + + const key = `${containerKey}||${specKey}`; + if (!map.has(key)) { + map.set(key, []); + } + + map.get(key).push({ + trackId: track.id, + startMs, + endMs: endMs > startMs ? endMs : startMs, + }); + }); + }); + }); + return map; +}); + +function pickClosestTrack(windows, timeMs) { + if (!Array.isArray(windows) || windows.length === 0) { + return ''; + } + if (!Number.isFinite(timeMs)) { + return windows[0]?.trackId || ''; + } + + let best = ''; + let bestDistance = Number.POSITIVE_INFINITY; + windows.forEach((window) => { + if (!window?.trackId) { + return; + } + if (timeMs >= window.startMs && timeMs <= window.endMs) { + if (0 < bestDistance) { + best = window.trackId; + bestDistance = 0; + } + return; + } + const distance = timeMs < window.startMs + ? (window.startMs - timeMs) + : (timeMs - window.endMs); + if (distance < bestDistance) { + best = window.trackId; + bestDistance = distance; + } + }); + + return best; +} + +function resolveHoldTrackId(row) { + const groupKey = normalizedKey(row?.WORKCENTER_GROUP) || normalizedKey(row?.WORKCENTERNAME); + if (groupKey) { + const trackId = groupToFirstTrackId.value.get(groupKey); + if (trackId) { + return trackId; + } + } + + const containerKey = normalizedKey(row?.CONTAINERID); + if (containerKey) { + const byContainer = containerToTrackIds.value.get(containerKey) || []; + if (byContainer.length > 0) { + return byContainer[0]; + } + } + + return ''; +} + +function resolveMaterialTrackId(row, time) { + const specKey = normalizedKey(row?.SPECNAME); + const containerKey = normalizedKey(row?.CONTAINERID); + if (!specKey || !containerKey) { + return ''; + } + + const windows = containerSpecWindows.value.get(`${containerKey}||${specKey}`) || []; + const timeMs = time instanceof Date ? time.getTime() : null; + return pickClosestTrack(windows, timeMs); } const events = computed(() => { @@ -98,10 +217,14 @@ const events = computed(() => { if (!time) { return; } + const trackId = resolveHoldTrackId(row); + if (!trackId) { + return; + } markers.push({ id: `hold-${index}`, - trackId: resolveEventTrackId(row), + trackId, time, type: 'HOLD', shape: 'diamond', @@ -115,10 +238,14 @@ const events = computed(() => { if (!time) { return; } + const trackId = resolveMaterialTrackId(row, time); + if (!trackId) { + return; + } markers.push({ id: `material-${index}`, - trackId: resolveEventTrackId(row), + trackId, time, type: 'MATERIAL', shape: 'triangle', @@ -130,6 +257,28 @@ const events = computed(() => { return markers; }); +const materialMappingStats = computed(() => { + let total = 0; + let mapped = 0; + + props.materialRows.forEach((row) => { + const time = safeDate(row?.TXNDATE); + if (!time) { + return; + } + total += 1; + if (resolveMaterialTrackId(row, time)) { + mapped += 1; + } + }); + + return { + total, + mapped, + unmapped: Math.max(0, total - mapped), + }; +}); + const colorMap = computed(() => { const colors = { HOLD: '#f59e0b', @@ -182,6 +331,12 @@ const timeRange = computed(() => {
{{ formatDateTime(timeRange.start) }} — {{ formatDateTime(timeRange.end) }} Hold / Material 事件已覆蓋標記 + + 扣料對應 {{ materialMappingStats.mapped }} / {{ materialMappingStats.total }} + +
diff --git a/frontend/src/query-tool/components/LotTraceView.vue b/frontend/src/query-tool/components/LotTraceView.vue index fb1981c..4c9b077 100644 --- a/frontend/src/query-tool/components/LotTraceView.vue +++ b/frontend/src/query-tool/components/LotTraceView.vue @@ -48,6 +48,14 @@ const props = defineProps({ type: Object, default: () => new Map(), }, + nodeMetaMap: { + type: Object, + default: () => new Map(), + }, + edgeTypeMap: { + type: Object, + default: () => new Map(), + }, leafSerials: { type: Object, default: () => new Map(), @@ -144,6 +152,8 @@ const emit = defineEmits([ :not-found="notFound" :lineage-map="lineageMap" :name-map="nameMap" + :node-meta-map="nodeMetaMap" + :edge-type-map="edgeTypeMap" :leaf-serials="leafSerials" :selected-container-ids="selectedContainerIds" :loading="lineageLoading" diff --git a/frontend/src/query-tool/components/QueryBar.vue b/frontend/src/query-tool/components/QueryBar.vue index 0893339..7ca7a6d 100644 --- a/frontend/src/query-tool/components/QueryBar.vue +++ b/frontend/src/query-tool/components/QueryBar.vue @@ -40,6 +40,11 @@ const inputCount = computed(() => { .length; }); +const inputTypeLabel = computed(() => { + const selected = (props.inputTypeOptions || []).find((option) => option?.value === props.inputType); + return selected?.label || '查詢條件'; +}); + function handleResolve() { emit('resolve'); } @@ -82,10 +87,14 @@ function handleResolve() {