feat(query-tool): align lineage model and tighten timeline mapping

This commit is contained in:
egg
2026-02-22 17:36:47 +08:00
parent 6016c31e4d
commit 9890586191
29 changed files with 2090 additions and 299 deletions

View File

@@ -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"

View File

@@ -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 = [`<b>${data.name}</b>`];
if (val.type === 'serial') {
lines.push('<span style="color:#64748B">成品序列號</span>');
} else if (val.type === 'wafer') {
lines.push('<span style="color:#2563EB">Wafer LOT</span>');
} else if (val.type === 'gc') {
lines.push('<span style="color:#06B6D4">GC LOT</span>');
} else if (val.type === 'ga') {
lines.push('<span style="color:#10B981">GA LOT</span>');
} else if (val.type === 'gd') {
lines.push('<span style="color:#EF4444">GD LOT重工</span>');
} else if (val.type === 'root') {
lines.push('<span style="color:#3B82F6">根節點(晶批)</span>');
} else if (val.type === 'leaf') {
@@ -245,6 +314,9 @@ const chartOption = computed(() => {
} else if (val.type === 'branch') {
lines.push('<span style="color:#10B981">中間節點</span>');
}
if (val.edgeType) {
lines.push(`<span style="color:#94A3B8;font-size:11px">關係: ${val.edgeType}</span>`);
}
if (val.cid && val.cid !== data.name) {
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
}
@@ -325,21 +397,45 @@ function handleNodeClick(params) {
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-[10px] text-slate-500">
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.root }" />
晶批
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.wafer }" />
Wafer
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.branch }" />
中間
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.gc }" />
GC
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.ga }" />
GA
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.gd }" />
GD
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
末端
其他 LOT
</span>
<span v-if="showSerialLegend" class="inline-flex items-center gap-1">
<span class="inline-block size-2.5 rotate-45" :style="{ background: NODE_COLORS.serial, width: '8px', height: '8px' }" />
序列號
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 bg-slate-300" />
split
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-amber-500" />
merge
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dotted border-blue-600" />
wafer
</span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-red-500" />
gd-rework
</span>
</div>
</div>
</div>

View File

@@ -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(() => {
<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>
<span v-if="materialMappingStats.total > 0">
扣料對應 {{ materialMappingStats.mapped }} / {{ materialMappingStats.total }}
<template v-if="materialMappingStats.unmapped > 0">
未對應 {{ materialMappingStats.unmapped }}
</template>
</span>
</div>
</div>

View File

@@ -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"

View File

@@ -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() {
<textarea
:value="inputText"
class="min-h-28 w-full rounded-card border border-stroke-soft bg-surface-muted/40 px-3 py-2 text-sm text-slate-700 outline-none transition focus:border-brand-500"
:placeholder="`輸入多筆(換行或逗號分隔),最多 ${inputLimit} 筆`"
:placeholder="`輸入 ${inputTypeLabel}(換行或逗號分隔),最多 ${inputLimit} 筆`"
:disabled="resolving"
@input="emit('update:inputText', $event.target.value)"
/>
<p class="mt-2 text-xs text-slate-500">
支援萬用字元<code>%</code>任意長度<code>_</code>單一字元也可用 <code>*</code> 代表 <code>%</code>
例如<code>GA25%01</code><code>GA25%</code><code>GMSN-1173%</code>
</p>
<div class="mt-2 flex items-center justify-between text-xs">
<p class="text-slate-500">已輸入 {{ inputCount }} / {{ inputLimit }}</p>
<p v-if="errorMessage" class="text-state-danger">{{ errorMessage }}</p>

View File

@@ -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"

View File

@@ -62,11 +62,22 @@ function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function edgeKey(fromCid, toCid) {
const from = normalizeText(fromCid);
const to = normalizeText(toCid);
if (!from || !to) {
return '';
}
return `${from}->${to}`;
}
export function useLotLineage(initial = {}) {
ensureMesApiAvailable();
const lineageMap = reactive(new Map());
const nameMap = reactive(new Map());
const nodeMetaMap = reactive(new Map());
const edgeTypeMap = reactive(new Map());
const leafSerials = reactive(new Map());
const expandedNodes = ref(new Set());
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
@@ -217,6 +228,8 @@ export function useLotLineage(initial = {}) {
const rootsList = payload?.roots || [];
const serialsData = payload?.leaf_serials || {};
const names = payload?.names;
const typedNodes = payload?.nodes;
const typedEdges = payload?.edges;
// Merge name mapping
if (names && typeof names === 'object') {
@@ -227,6 +240,34 @@ export function useLotLineage(initial = {}) {
});
}
if (typedNodes && typeof typedNodes === 'object') {
Object.entries(typedNodes).forEach(([cid, node]) => {
const normalizedCid = normalizeText(cid);
if (!normalizedCid || !node || typeof node !== 'object') {
return;
}
nodeMetaMap.set(normalizedCid, node);
const displayName = normalizeText(node.container_name);
if (displayName) {
nameMap.set(normalizedCid, displayName);
}
});
}
edgeTypeMap.clear();
if (Array.isArray(typedEdges)) {
typedEdges.forEach((edge) => {
if (!edge || typeof edge !== 'object') {
return;
}
const key = edgeKey(edge.from_cid, edge.to_cid);
const type = normalizeText(edge.edge_type);
if (key && type) {
edgeTypeMap.set(key, type);
}
});
}
// Store leaf serial numbers
Object.entries(serialsData).forEach(([cid, serials]) => {
const id = normalizeText(cid);
@@ -420,6 +461,8 @@ export function useLotLineage(initial = {}) {
inFlight.clear();
lineageMap.clear();
nameMap.clear();
nodeMetaMap.clear();
edgeTypeMap.clear();
leafSerials.clear();
expandedNodes.value = new Set();
selectedContainerIds.value = [];
@@ -463,6 +506,8 @@ export function useLotLineage(initial = {}) {
return {
lineageMap,
nameMap,
nodeMetaMap,
edgeTypeMap,
leafSerials,
expandedNodes,
selectedContainerId,

View File

@@ -4,15 +4,21 @@ import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
import { parseInputValues } from '../utils/values.js';
const INPUT_TYPE_OPTIONS = Object.freeze([
{ value: 'wafer_lot', label: 'Wafer LOT' },
{ value: 'lot_id', label: 'LOT ID' },
{ value: 'serial_number', label: '流水號' },
{ value: 'work_order', label: '工單' },
{ value: 'gd_work_order', label: 'GD 工單' },
{ value: 'gd_lot_id', label: 'GD LOT ID' },
]);
const INPUT_LIMITS = Object.freeze({
wafer_lot: 50,
lot_id: 50,
serial_number: 50,
work_order: 10,
gd_work_order: 10,
gd_lot_id: 50,
});
function normalizeInputType(value) {
@@ -29,7 +35,7 @@ function normalizeAllowedTypes(input) {
: [];
const filtered = values.filter((value) => Boolean(INPUT_LIMITS[value]));
if (filtered.length === 0) {
return ['lot_id', 'serial_number', 'work_order'];
return ['wafer_lot', 'lot_id', 'serial_number', 'work_order', 'gd_work_order', 'gd_lot_id'];
}
return filtered;
}

View File

@@ -62,11 +62,22 @@ function sleep(ms) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function edgeKey(fromCid, toCid) {
const from = normalizeText(fromCid);
const to = normalizeText(toCid);
if (!from || !to) {
return '';
}
return `${from}->${to}`;
}
export function useReverseLineage(initial = {}) {
ensureMesApiAvailable();
const lineageMap = reactive(new Map());
const nameMap = reactive(new Map());
const nodeMetaMap = reactive(new Map());
const edgeTypeMap = reactive(new Map());
const leafSerials = reactive(new Map());
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
const selectedContainerIds = ref(
@@ -219,6 +230,8 @@ export function useReverseLineage(initial = {}) {
function populateReverseTree(payload, requestedRoots = []) {
const parentMap = normalizeParentMap(payload);
const names = payload?.names;
const typedNodes = payload?.nodes;
const typedEdges = payload?.edges;
if (names && typeof names === 'object') {
Object.entries(names).forEach(([cid, name]) => {
@@ -228,6 +241,34 @@ export function useReverseLineage(initial = {}) {
});
}
if (typedNodes && typeof typedNodes === 'object') {
Object.entries(typedNodes).forEach(([cid, node]) => {
const normalizedCid = normalizeText(cid);
if (!normalizedCid || !node || typeof node !== 'object') {
return;
}
nodeMetaMap.set(normalizedCid, node);
const displayName = normalizeText(node.container_name);
if (displayName) {
nameMap.set(normalizedCid, displayName);
}
});
}
edgeTypeMap.clear();
if (Array.isArray(typedEdges)) {
typedEdges.forEach((edge) => {
if (!edge || typeof edge !== 'object') {
return;
}
const key = edgeKey(edge.from_cid, edge.to_cid);
const type = normalizeText(edge.edge_type);
if (key && type) {
edgeTypeMap.set(key, type);
}
});
}
Object.entries(parentMap).forEach(([childId, parentIds]) => {
patchEntry(childId, {
children: uniqueValues(parentIds || []),
@@ -349,6 +390,8 @@ export function useReverseLineage(initial = {}) {
semaphore.clear();
lineageMap.clear();
nameMap.clear();
nodeMetaMap.clear();
edgeTypeMap.clear();
leafSerials.clear();
rootRows.value = [];
rootContainerIds.value = [];
@@ -371,6 +414,8 @@ export function useReverseLineage(initial = {}) {
return {
lineageMap,
nameMap,
nodeMetaMap,
edgeTypeMap,
leafSerials,
selectedContainerId,
selectedContainerIds,