feat(query-tool): align lineage model and tighten timeline mapping
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user