feat(query-tool): align lineage merge semantics and add tree exports
This commit is contained in:
@@ -481,6 +481,7 @@ watch(
|
|||||||
:name-map="lotLineage.nameMap"
|
:name-map="lotLineage.nameMap"
|
||||||
:node-meta-map="lotLineage.nodeMetaMap"
|
:node-meta-map="lotLineage.nodeMetaMap"
|
||||||
:edge-type-map="lotLineage.edgeTypeMap"
|
:edge-type-map="lotLineage.edgeTypeMap"
|
||||||
|
:graph-edges="lotLineage.graphEdges.value"
|
||||||
:leaf-serials="lotLineage.leafSerials"
|
:leaf-serials="lotLineage.leafSerials"
|
||||||
:lineage-loading="lotLineage.lineageLoading.value"
|
:lineage-loading="lotLineage.lineageLoading.value"
|
||||||
:selected-container-ids="lotLineage.selectedContainerIds.value"
|
:selected-container-ids="lotLineage.selectedContainerIds.value"
|
||||||
@@ -520,6 +521,7 @@ watch(
|
|||||||
:name-map="reverseLineage.nameMap"
|
:name-map="reverseLineage.nameMap"
|
||||||
:node-meta-map="reverseLineage.nodeMetaMap"
|
:node-meta-map="reverseLineage.nodeMetaMap"
|
||||||
:edge-type-map="reverseLineage.edgeTypeMap"
|
:edge-type-map="reverseLineage.edgeTypeMap"
|
||||||
|
:graph-edges="reverseLineage.graphEdges.value"
|
||||||
:leaf-serials="reverseLineage.leafSerials"
|
:leaf-serials="reverseLineage.leafSerials"
|
||||||
:lineage-loading="reverseLineage.lineageLoading.value"
|
:lineage-loading="reverseLineage.lineageLoading.value"
|
||||||
:selected-container-ids="reverseLineage.selectedContainerIds.value"
|
:selected-container-ids="reverseLineage.selectedContainerIds.value"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
import VChart from 'vue-echarts';
|
import VChart from 'vue-echarts';
|
||||||
import { use } from 'echarts/core';
|
import { use } from 'echarts/core';
|
||||||
@@ -7,6 +7,7 @@ import { CanvasRenderer } from 'echarts/renderers';
|
|||||||
import { TreeChart } from 'echarts/charts';
|
import { TreeChart } from 'echarts/charts';
|
||||||
import { TooltipComponent } from 'echarts/components';
|
import { TooltipComponent } from 'echarts/components';
|
||||||
|
|
||||||
|
import ExportButton from './ExportButton.vue';
|
||||||
import { normalizeText } from '../utils/values.js';
|
import { normalizeText } from '../utils/values.js';
|
||||||
|
|
||||||
use([CanvasRenderer, TreeChart, TooltipComponent]);
|
use([CanvasRenderer, TreeChart, TooltipComponent]);
|
||||||
@@ -30,6 +31,20 @@ const EDGE_STYLES = Object.freeze({
|
|||||||
default: { color: '#CBD5E1', type: 'solid', width: 1.5 },
|
default: { color: '#CBD5E1', type: 'solid', width: 1.5 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EDGE_TAGS = Object.freeze({
|
||||||
|
split_from: { forward: '←拆', reverse: '→拆' },
|
||||||
|
merge_source: { forward: '←併', reverse: '→併' },
|
||||||
|
wafer_origin: { forward: '←晶', reverse: '→晶' },
|
||||||
|
gd_rework_source: { forward: '←重', reverse: '→重' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const RELATION_TYPE_LABELS = Object.freeze({
|
||||||
|
split_from: '拆批',
|
||||||
|
merge_source: '併批',
|
||||||
|
wafer_origin: '晶圓來源',
|
||||||
|
gd_rework_source: '重工來源',
|
||||||
|
});
|
||||||
|
|
||||||
const LABEL_BASE_STYLE = Object.freeze({
|
const LABEL_BASE_STYLE = Object.freeze({
|
||||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -57,6 +72,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
},
|
},
|
||||||
|
graphEdges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
leafSerials: {
|
leafSerials: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
@@ -92,6 +111,10 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select-nodes']);
|
const emit = defineEmits(['select-nodes']);
|
||||||
|
const chartRef = ref(null);
|
||||||
|
const exportingTreeImage = ref(false);
|
||||||
|
const exportingRelationCsv = ref(false);
|
||||||
|
const exportErrorMessage = ref('');
|
||||||
|
|
||||||
const selectedSet = computed(() => new Set(props.selectedContainerIds.map(normalizeText).filter(Boolean)));
|
const selectedSet = computed(() => new Set(props.selectedContainerIds.map(normalizeText).filter(Boolean)));
|
||||||
|
|
||||||
@@ -109,6 +132,47 @@ const allSerialNames = computed(() => {
|
|||||||
return names;
|
return names;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relationRows = computed(() => {
|
||||||
|
const rows = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const source = Array.isArray(props.graphEdges) ? props.graphEdges : [];
|
||||||
|
|
||||||
|
source.forEach((edge) => {
|
||||||
|
if (!edge || typeof edge !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fromCid = normalizeText(edge.from_cid);
|
||||||
|
const toCid = normalizeText(edge.to_cid);
|
||||||
|
const edgeType = normalizeText(edge.edge_type);
|
||||||
|
if (!fromCid || !toCid || !edgeType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${fromCid}->${toCid}:${edgeType}`;
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
fromCid,
|
||||||
|
toCid,
|
||||||
|
fromName: normalizeText(props.nameMap?.get?.(fromCid) || fromCid),
|
||||||
|
toName: normalizeText(props.nameMap?.get?.(toCid) || toCid),
|
||||||
|
edgeType,
|
||||||
|
edgeLabel: RELATION_TYPE_LABELS[edgeType] || edgeType,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.sort((a, b) => (
|
||||||
|
a.edgeLabel.localeCompare(b.edgeLabel, 'zh-Hant')
|
||||||
|
|| a.fromName.localeCompare(b.fromName, 'zh-Hant')
|
||||||
|
|| a.toName.localeCompare(b.toName, 'zh-Hant')
|
||||||
|
));
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
function detectNodeType(cid, entry, serials) {
|
function detectNodeType(cid, entry, serials) {
|
||||||
const explicitType = normalizeText(props.nodeMetaMap?.get?.(cid)?.node_type).toUpperCase();
|
const explicitType = normalizeText(props.nodeMetaMap?.get?.(cid)?.node_type).toUpperCase();
|
||||||
if (explicitType === 'WAFER') {
|
if (explicitType === 'WAFER') {
|
||||||
@@ -137,17 +201,62 @@ function detectNodeType(cid, entry, serials) {
|
|||||||
return 'branch';
|
return 'branch';
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookupEdgeType(parentCid, childCid) {
|
function lookupEdgeMeta(parentCid, childCid) {
|
||||||
const parent = normalizeText(parentCid);
|
const parent = normalizeText(parentCid);
|
||||||
const child = normalizeText(childCid);
|
const child = normalizeText(childCid);
|
||||||
if (!parent || !child) {
|
if (!parent || !child) {
|
||||||
return '';
|
return { edgeType: '', reversed: false };
|
||||||
}
|
}
|
||||||
const direct = normalizeText(props.edgeTypeMap?.get?.(`${parent}->${child}`));
|
const direct = normalizeText(props.edgeTypeMap?.get?.(`${parent}->${child}`));
|
||||||
if (direct) {
|
if (direct) {
|
||||||
return direct;
|
return { edgeType: direct, reversed: false };
|
||||||
}
|
}
|
||||||
return normalizeText(props.edgeTypeMap?.get?.(`${child}->${parent}`));
|
const reverse = normalizeText(props.edgeTypeMap?.get?.(`${child}->${parent}`));
|
||||||
|
if (reverse) {
|
||||||
|
return { edgeType: reverse, reversed: true };
|
||||||
|
}
|
||||||
|
return { edgeType: '', reversed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationTag(edgeType, reversed) {
|
||||||
|
const spec = EDGE_TAGS[normalizeText(edgeType)];
|
||||||
|
if (!spec) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return reversed ? spec.reverse : spec.forward;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationSentence({ edgeType, reversed, leftName, currentName }) {
|
||||||
|
const left = normalizeText(leftName);
|
||||||
|
const current = normalizeText(currentName);
|
||||||
|
if (!edgeType || !left || !current) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgeType === 'split_from') {
|
||||||
|
return reversed
|
||||||
|
? `${left} 拆自 ${current}`
|
||||||
|
: `${current} 拆自 ${left}`;
|
||||||
|
}
|
||||||
|
if (edgeType === 'merge_source') {
|
||||||
|
return reversed
|
||||||
|
? `${left} 由 ${current} 併批而來`
|
||||||
|
: `${current} 由 ${left} 併批而來`;
|
||||||
|
}
|
||||||
|
if (edgeType === 'wafer_origin') {
|
||||||
|
return reversed
|
||||||
|
? `${left} 對應 Wafer ${current}`
|
||||||
|
: `${current} 源自 Wafer ${left}`;
|
||||||
|
}
|
||||||
|
if (edgeType === 'gd_rework_source') {
|
||||||
|
return reversed
|
||||||
|
? `${left} 由 ${current} 重工而來`
|
||||||
|
: `${current} 由 ${left} 重工而來`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reversed
|
||||||
|
? `${left} 與 ${current}(${edgeType})`
|
||||||
|
: `${current} 與 ${left}(${edgeType})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildNode(cid, visited, parentCid = '') {
|
function buildNode(cid, visited, parentCid = '') {
|
||||||
@@ -194,12 +303,24 @@ function buildNode(cid, visited, parentCid = '') {
|
|||||||
&& allSerialNames.value.has(name);
|
&& allSerialNames.value.has(name);
|
||||||
const effectiveType = isSerialLike ? 'serial' : nodeType;
|
const effectiveType = isSerialLike ? 'serial' : nodeType;
|
||||||
const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch;
|
const color = NODE_COLORS[effectiveType] || NODE_COLORS.branch;
|
||||||
const incomingEdgeType = lookupEdgeType(parentCid, id);
|
const incomingMeta = lookupEdgeMeta(parentCid, id);
|
||||||
|
const incomingEdgeType = incomingMeta.edgeType;
|
||||||
|
const incomingEdgeReversed = incomingMeta.reversed;
|
||||||
const incomingEdgeStyle = EDGE_STYLES[incomingEdgeType] || EDGE_STYLES.default;
|
const incomingEdgeStyle = EDGE_STYLES[incomingEdgeType] || EDGE_STYLES.default;
|
||||||
|
const parentName = normalizeText(props.nameMap?.get?.(normalizeText(parentCid)) || parentCid);
|
||||||
|
const shortTag = relationTag(incomingEdgeType, incomingEdgeReversed);
|
||||||
|
const displayLabel = shortTag ? `${shortTag} ${name}` : name;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
value: { cid: id, type: effectiveType, edgeType: incomingEdgeType || '' },
|
value: {
|
||||||
|
cid: id,
|
||||||
|
type: effectiveType,
|
||||||
|
edgeType: incomingEdgeType || '',
|
||||||
|
edgeReversed: incomingEdgeReversed,
|
||||||
|
parentName,
|
||||||
|
relationTag: shortTag,
|
||||||
|
},
|
||||||
children,
|
children,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color,
|
color,
|
||||||
@@ -213,6 +334,7 @@ function buildNode(cid, visited, parentCid = '') {
|
|||||||
fontWeight: isSelected ? 'bold' : 'normal',
|
fontWeight: isSelected ? 'bold' : 'normal',
|
||||||
fontSize: isSerialLike ? 10 : 11,
|
fontSize: isSerialLike ? 10 : 11,
|
||||||
color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'),
|
color: isSelected ? '#1E3A8A' : (isSerialLike ? '#64748B' : '#334155'),
|
||||||
|
formatter: () => displayLabel,
|
||||||
},
|
},
|
||||||
symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'),
|
symbol: isSerialLike ? 'diamond' : (nodeType === 'root' ? 'roundRect' : 'circle'),
|
||||||
symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10),
|
symbolSize: isSerialLike ? 6 : (nodeType === 'root' ? 14 : 10),
|
||||||
@@ -226,9 +348,8 @@ const treesData = computed(() => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalVisited = new Set();
|
|
||||||
return props.treeRoots
|
return props.treeRoots
|
||||||
.map((rootId) => buildNode(rootId, globalVisited))
|
.map((rootId) => buildNode(rootId, new Set()))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,6 +368,73 @@ const chartHeight = computed(() => {
|
|||||||
return `${base}px`;
|
return `${base}px`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function countGraphemes(text) {
|
||||||
|
return Array.from(normalizeText(text)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkTreeMetrics(node, depth, metrics) {
|
||||||
|
if (!node || typeof node !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.maxDepth = Math.max(metrics.maxDepth, depth);
|
||||||
|
const relationTag = normalizeText(node?.value?.relationTag);
|
||||||
|
const labelText = relationTag
|
||||||
|
? `${relationTag} ${normalizeText(node.name)}`
|
||||||
|
: normalizeText(node.name);
|
||||||
|
metrics.maxLabelChars = Math.max(metrics.maxLabelChars, countGraphemes(labelText));
|
||||||
|
|
||||||
|
const children = Array.isArray(node.children) ? node.children : [];
|
||||||
|
children.forEach((child) => walkTreeMetrics(child, depth + 1, metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeMetrics = computed(() => {
|
||||||
|
const metrics = {
|
||||||
|
maxDepth: 1,
|
||||||
|
maxLabelChars: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
treesData.value.forEach((tree) => walkTreeMetrics(tree, 1, metrics));
|
||||||
|
return metrics;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clampNumber(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelWidthPx = computed(() => clampNumber(
|
||||||
|
treeMetrics.value.maxLabelChars * 7 + 14,
|
||||||
|
120,
|
||||||
|
360,
|
||||||
|
));
|
||||||
|
|
||||||
|
const depthSpacingPx = computed(() => clampNumber(
|
||||||
|
88 + Math.round(treeMetrics.value.maxLabelChars * 1.4),
|
||||||
|
96,
|
||||||
|
132,
|
||||||
|
));
|
||||||
|
|
||||||
|
const rootLabelWidthPx = computed(() => {
|
||||||
|
const maxChars = props.treeRoots.reduce((max, rootCid) => {
|
||||||
|
const rootId = normalizeText(rootCid);
|
||||||
|
const rootName = normalizeText(props.nameMap?.get?.(rootId) || rootId);
|
||||||
|
return Math.max(max, countGraphemes(rootName));
|
||||||
|
}, 8);
|
||||||
|
return clampNumber(maxChars * 7 + 24, 72, 260);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartLayout = computed(() => {
|
||||||
|
const left = rootLabelWidthPx.value;
|
||||||
|
const right = labelWidthPx.value + 18;
|
||||||
|
const depthSpacing = depthSpacingPx.value;
|
||||||
|
const depthCount = Math.max(1, treeMetrics.value.maxDepth - 1);
|
||||||
|
const requiredWidth = left + right + (depthCount * depthSpacing) + 120;
|
||||||
|
const minWidth = clampNumber(requiredWidth, 760, 3000);
|
||||||
|
return { left, right, minWidth };
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartMinWidth = computed(() => `${chartLayout.value.minWidth}px`);
|
||||||
|
|
||||||
const TREE_SERIES_DEFAULTS = Object.freeze({
|
const TREE_SERIES_DEFAULTS = Object.freeze({
|
||||||
type: 'tree',
|
type: 'tree',
|
||||||
layout: 'orthogonal',
|
layout: 'orthogonal',
|
||||||
@@ -262,9 +450,7 @@ const TREE_SERIES_DEFAULTS = Object.freeze({
|
|||||||
distance: 6,
|
distance: 6,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#334155',
|
color: '#334155',
|
||||||
overflow: 'truncate',
|
overflow: 'break',
|
||||||
ellipsis: '…',
|
|
||||||
width: 160,
|
|
||||||
...LABEL_BASE_STYLE,
|
...LABEL_BASE_STYLE,
|
||||||
},
|
},
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
@@ -315,7 +501,17 @@ const chartOption = computed(() => {
|
|||||||
lines.push('<span style="color:#10B981">中間節點</span>');
|
lines.push('<span style="color:#10B981">中間節點</span>');
|
||||||
}
|
}
|
||||||
if (val.edgeType) {
|
if (val.edgeType) {
|
||||||
lines.push(`<span style="color:#94A3B8;font-size:11px">關係: ${val.edgeType}</span>`);
|
const sentence = relationSentence({
|
||||||
|
edgeType: val.edgeType,
|
||||||
|
reversed: Boolean(val.edgeReversed),
|
||||||
|
leftName: val.parentName,
|
||||||
|
currentName: data.name,
|
||||||
|
});
|
||||||
|
if (sentence) {
|
||||||
|
lines.push(`<span style="color:#0F172A;font-size:11px">讀法: ${sentence}</span>`);
|
||||||
|
}
|
||||||
|
const directionTag = val.relationTag ? `(${val.relationTag})` : '';
|
||||||
|
lines.push(`<span style="color:#94A3B8;font-size:11px">關係型別: ${val.edgeType}${directionTag}</span>`);
|
||||||
}
|
}
|
||||||
if (val.cid && val.cid !== data.name) {
|
if (val.cid && val.cid !== data.name) {
|
||||||
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
|
lines.push(`<span style="color:#94A3B8;font-size:11px">CID: ${val.cid}</span>`);
|
||||||
@@ -330,10 +526,14 @@ const chartOption = computed(() => {
|
|||||||
tooltip,
|
tooltip,
|
||||||
series: [{
|
series: [{
|
||||||
...TREE_SERIES_DEFAULTS,
|
...TREE_SERIES_DEFAULTS,
|
||||||
left: 40,
|
left: chartLayout.value.left,
|
||||||
right: 180,
|
right: chartLayout.value.right,
|
||||||
top: 20,
|
top: 20,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
|
label: {
|
||||||
|
...TREE_SERIES_DEFAULTS.label,
|
||||||
|
width: labelWidthPx.value,
|
||||||
|
},
|
||||||
data: [trees[0]],
|
data: [trees[0]],
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
@@ -355,10 +555,14 @@ const chartOption = computed(() => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...TREE_SERIES_DEFAULTS,
|
...TREE_SERIES_DEFAULTS,
|
||||||
left: 40,
|
left: chartLayout.value.left,
|
||||||
right: 180,
|
right: chartLayout.value.right,
|
||||||
top: `${topPercent}%`,
|
top: `${topPercent}%`,
|
||||||
height: `${heightPercent}%`,
|
height: `${heightPercent}%`,
|
||||||
|
label: {
|
||||||
|
...TREE_SERIES_DEFAULTS.label,
|
||||||
|
width: labelWidthPx.value,
|
||||||
|
},
|
||||||
data: [tree],
|
data: [tree],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -384,6 +588,122 @@ function handleNodeClick(params) {
|
|||||||
}
|
}
|
||||||
emit('select-nodes', [...current]);
|
emit('select-nodes', [...current]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildExportFileName(ext = 'png') {
|
||||||
|
const now = new Date();
|
||||||
|
const ts = [
|
||||||
|
String(now.getFullYear()).padStart(4, '0'),
|
||||||
|
String(now.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(now.getDate()).padStart(2, '0'),
|
||||||
|
String(now.getHours()).padStart(2, '0'),
|
||||||
|
String(now.getMinutes()).padStart(2, '0'),
|
||||||
|
String(now.getSeconds()).padStart(2, '0'),
|
||||||
|
].join('');
|
||||||
|
const rawBase = normalizeText(props.title) || 'lineage_tree';
|
||||||
|
const safeBase = rawBase
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '-')
|
||||||
|
.replace(/\s+/g, '_');
|
||||||
|
return `${safeBase}_${ts}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownloadByUrl(url, filename) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.rel = 'noopener';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChartInstance() {
|
||||||
|
const chartComponent = chartRef.value;
|
||||||
|
if (!chartComponent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (typeof chartComponent.getEchartsInstance === 'function') {
|
||||||
|
return chartComponent.getEchartsInstance();
|
||||||
|
}
|
||||||
|
return chartComponent.chart || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvField(value) {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (text === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (/[",\n\r]/.test(text)) {
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCsvContent() {
|
||||||
|
const headers = ['來源批次', '來源CID', '目標批次', '目標CID', '關係', '關係代碼'];
|
||||||
|
const lines = [headers.join(',')];
|
||||||
|
|
||||||
|
relationRows.value.forEach((row) => {
|
||||||
|
lines.push([
|
||||||
|
escapeCsvField(row.fromName),
|
||||||
|
escapeCsvField(row.fromCid),
|
||||||
|
escapeCsvField(row.toName),
|
||||||
|
escapeCsvField(row.toCid),
|
||||||
|
escapeCsvField(row.edgeLabel),
|
||||||
|
escapeCsvField(row.edgeType),
|
||||||
|
].join(','));
|
||||||
|
});
|
||||||
|
|
||||||
|
return `\uFEFF${lines.join('\r\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportTreeAsPng() {
|
||||||
|
if (!hasData.value || exportingTreeImage.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportingTreeImage.value = true;
|
||||||
|
exportErrorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nextTick();
|
||||||
|
const instance = getChartInstance();
|
||||||
|
if (!instance || typeof instance.getDataURL !== 'function') {
|
||||||
|
throw new Error('無法取得樹圖實例');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = instance.getDataURL({
|
||||||
|
type: 'png',
|
||||||
|
pixelRatio: Math.max(2, Math.min(4, window.devicePixelRatio || 2)),
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
});
|
||||||
|
triggerDownloadByUrl(dataUrl, buildExportFileName('png'));
|
||||||
|
} catch (error) {
|
||||||
|
exportErrorMessage.value = error?.message || '樹圖匯出失敗';
|
||||||
|
} finally {
|
||||||
|
exportingTreeImage.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRelationCsv() {
|
||||||
|
if (!hasData.value || exportingRelationCsv.value || relationRows.value.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportingRelationCsv.value = true;
|
||||||
|
exportErrorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csv = buildCsvContent();
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const href = URL.createObjectURL(blob);
|
||||||
|
triggerDownloadByUrl(href, buildExportFileName('csv'));
|
||||||
|
URL.revokeObjectURL(href);
|
||||||
|
} catch (error) {
|
||||||
|
exportErrorMessage.value = error?.message || '關係 CSV 匯出失敗';
|
||||||
|
} finally {
|
||||||
|
exportingRelationCsv.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -392,9 +712,25 @@ function handleNodeClick(params) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
|
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
|
||||||
<p class="text-xs text-slate-500">{{ description }}</p>
|
<p class="text-xs text-slate-500">{{ description }}</p>
|
||||||
|
<p class="text-[11px] text-slate-500">
|
||||||
|
讀圖方向由左至右;節點前綴 <code>←拆/←併/←晶/←重</code> 代表本節點由左側來源而來,
|
||||||
|
<code>→拆/→併/→晶/→重</code> 代表左側節點由本節點而來。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<ExportButton
|
||||||
|
:disabled="!hasData || loading"
|
||||||
|
:loading="exportingTreeImage"
|
||||||
|
label="匯出樹圖 PNG"
|
||||||
|
@click="exportTreeAsPng"
|
||||||
|
/>
|
||||||
|
<ExportButton
|
||||||
|
:disabled="!hasData || loading || relationRows.length === 0"
|
||||||
|
:loading="exportingRelationCsv"
|
||||||
|
label="匯出關係 CSV"
|
||||||
|
@click="exportRelationCsv"
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-2 text-[10px] text-slate-500">
|
<div class="flex items-center gap-2 text-[10px] text-slate-500">
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.wafer }" />
|
<span class="inline-block size-2.5 rounded-sm" :style="{ background: NODE_COLORS.wafer }" />
|
||||||
@@ -422,23 +758,26 @@ function handleNodeClick(params) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<span class="inline-block h-0.5 w-3 bg-slate-300" />
|
<span class="inline-block h-0.5 w-3 bg-slate-300" />
|
||||||
split
|
split(拆批)
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<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" />
|
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-amber-500" />
|
||||||
merge
|
merge(併批)
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<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" />
|
<span class="inline-block h-0.5 w-3 border-t-2 border-dotted border-blue-600" />
|
||||||
wafer
|
wafer(晶圓來源)
|
||||||
</span>
|
</span>
|
||||||
<span class="inline-flex items-center gap-1">
|
<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" />
|
<span class="inline-block h-0.5 w-3 border-t-2 border-dashed border-red-500" />
|
||||||
gd-rework
|
gd-rework(重工來源)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="exportErrorMessage" class="mb-2 rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">
|
||||||
|
{{ exportErrorMessage }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
|
<div v-if="loading" class="flex items-center justify-center rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 py-16">
|
||||||
@@ -454,16 +793,54 @@ function handleNodeClick(params) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ECharts Tree -->
|
<!-- ECharts Tree -->
|
||||||
<div v-else class="relative">
|
<div v-else class="relative overflow-x-auto">
|
||||||
<VChart
|
<VChart
|
||||||
|
ref="chartRef"
|
||||||
class="lineage-tree-chart"
|
class="lineage-tree-chart"
|
||||||
:style="{ height: chartHeight }"
|
:style="{ height: chartHeight, width: '100%', minWidth: chartMinWidth }"
|
||||||
:option="chartOption"
|
:option="chartOption"
|
||||||
autoresize
|
autoresize
|
||||||
@click="handleNodeClick"
|
@click="handleNodeClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details v-if="relationRows.length > 0" class="mt-3 rounded-card border border-stroke-soft bg-surface-muted/50 px-3 py-2">
|
||||||
|
<summary class="cursor-pointer text-xs font-medium text-slate-700">
|
||||||
|
關係清單({{ relationRows.length }})
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 max-h-56 overflow-auto rounded border border-stroke-soft bg-white">
|
||||||
|
<table class="min-w-full text-left text-xs text-slate-700">
|
||||||
|
<thead class="bg-slate-50 text-[11px] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1.5 font-medium">來源批次</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium">目標批次</th>
|
||||||
|
<th class="px-2 py-1.5 font-medium">關係</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in relationRows.slice(0, 200)"
|
||||||
|
:key="row.key"
|
||||||
|
class="border-t border-slate-100"
|
||||||
|
>
|
||||||
|
<td class="px-2 py-1.5 font-mono text-[11px]">
|
||||||
|
{{ row.fromName }}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 font-mono text-[11px]">
|
||||||
|
{{ row.toName }}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1.5 text-[11px]">
|
||||||
|
{{ row.edgeLabel }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-if="relationRows.length > 200" class="mt-1 text-[11px] text-slate-500">
|
||||||
|
僅顯示前 200 筆,請搭配上方樹圖與節點點選進一步縮小範圍。
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Not found warning -->
|
<!-- Not found warning -->
|
||||||
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
<div v-if="notFound.length > 0" class="mt-3 rounded-card border border-state-warning/40 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||||
未命中:{{ notFound.join(', ') }}
|
未命中:{{ notFound.join(', ') }}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
},
|
},
|
||||||
|
graphEdges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
leafSerials: {
|
leafSerials: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
@@ -154,6 +158,7 @@ const emit = defineEmits([
|
|||||||
:name-map="nameMap"
|
:name-map="nameMap"
|
||||||
:node-meta-map="nodeMetaMap"
|
:node-meta-map="nodeMetaMap"
|
||||||
:edge-type-map="edgeTypeMap"
|
:edge-type-map="edgeTypeMap"
|
||||||
|
:graph-edges="graphEdges"
|
||||||
:leaf-serials="leafSerials"
|
:leaf-serials="leafSerials"
|
||||||
:selected-container-ids="selectedContainerIds"
|
:selected-container-ids="selectedContainerIds"
|
||||||
:loading="lineageLoading"
|
:loading="lineageLoading"
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
},
|
},
|
||||||
|
graphEdges: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
leafSerials: {
|
leafSerials: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => new Map(),
|
default: () => new Map(),
|
||||||
@@ -154,6 +158,7 @@ const emit = defineEmits([
|
|||||||
:name-map="nameMap"
|
:name-map="nameMap"
|
||||||
:node-meta-map="nodeMetaMap"
|
:node-meta-map="nodeMetaMap"
|
||||||
:edge-type-map="edgeTypeMap"
|
:edge-type-map="edgeTypeMap"
|
||||||
|
:graph-edges="graphEdges"
|
||||||
:leaf-serials="leafSerials"
|
:leaf-serials="leafSerials"
|
||||||
:selected-container-ids="selectedContainerIds"
|
:selected-container-ids="selectedContainerIds"
|
||||||
:loading="lineageLoading"
|
:loading="lineageLoading"
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function useLotLineage(initial = {}) {
|
|||||||
const nameMap = reactive(new Map());
|
const nameMap = reactive(new Map());
|
||||||
const nodeMetaMap = reactive(new Map());
|
const nodeMetaMap = reactive(new Map());
|
||||||
const edgeTypeMap = reactive(new Map());
|
const edgeTypeMap = reactive(new Map());
|
||||||
|
const graphEdges = ref([]);
|
||||||
const leafSerials = reactive(new Map());
|
const leafSerials = reactive(new Map());
|
||||||
const expandedNodes = ref(new Set());
|
const expandedNodes = ref(new Set());
|
||||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||||
@@ -255,18 +256,23 @@ export function useLotLineage(initial = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
edgeTypeMap.clear();
|
edgeTypeMap.clear();
|
||||||
|
const normalizedEdges = [];
|
||||||
if (Array.isArray(typedEdges)) {
|
if (Array.isArray(typedEdges)) {
|
||||||
typedEdges.forEach((edge) => {
|
typedEdges.forEach((edge) => {
|
||||||
if (!edge || typeof edge !== 'object') {
|
if (!edge || typeof edge !== 'object') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = edgeKey(edge.from_cid, edge.to_cid);
|
const from = normalizeText(edge.from_cid);
|
||||||
|
const to = normalizeText(edge.to_cid);
|
||||||
|
const key = edgeKey(from, to);
|
||||||
const type = normalizeText(edge.edge_type);
|
const type = normalizeText(edge.edge_type);
|
||||||
if (key && type) {
|
if (key && type) {
|
||||||
edgeTypeMap.set(key, type);
|
edgeTypeMap.set(key, type);
|
||||||
|
normalizedEdges.push({ from_cid: from, to_cid: to, edge_type: type });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
graphEdges.value = normalizedEdges;
|
||||||
|
|
||||||
// Store leaf serial numbers
|
// Store leaf serial numbers
|
||||||
Object.entries(serialsData).forEach(([cid, serials]) => {
|
Object.entries(serialsData).forEach(([cid, serials]) => {
|
||||||
@@ -463,6 +469,7 @@ export function useLotLineage(initial = {}) {
|
|||||||
nameMap.clear();
|
nameMap.clear();
|
||||||
nodeMetaMap.clear();
|
nodeMetaMap.clear();
|
||||||
edgeTypeMap.clear();
|
edgeTypeMap.clear();
|
||||||
|
graphEdges.value = [];
|
||||||
leafSerials.clear();
|
leafSerials.clear();
|
||||||
expandedNodes.value = new Set();
|
expandedNodes.value = new Set();
|
||||||
selectedContainerIds.value = [];
|
selectedContainerIds.value = [];
|
||||||
@@ -508,6 +515,7 @@ export function useLotLineage(initial = {}) {
|
|||||||
nameMap,
|
nameMap,
|
||||||
nodeMetaMap,
|
nodeMetaMap,
|
||||||
edgeTypeMap,
|
edgeTypeMap,
|
||||||
|
graphEdges,
|
||||||
leafSerials,
|
leafSerials,
|
||||||
expandedNodes,
|
expandedNodes,
|
||||||
selectedContainerId,
|
selectedContainerId,
|
||||||
|
|||||||
@@ -71,6 +71,31 @@ function edgeKey(fromCid, toCid) {
|
|||||||
return `${from}->${to}`;
|
return `${from}->${to}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectAncestors(parentMap, startCid) {
|
||||||
|
const start = normalizeText(startCid);
|
||||||
|
if (!start) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const stack = [start];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
const parents = Array.isArray(parentMap?.[current]) ? parentMap[current] : [];
|
||||||
|
parents.forEach((parentId) => {
|
||||||
|
const parent = normalizeText(parentId);
|
||||||
|
if (!parent || visited.has(parent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(parent);
|
||||||
|
stack.push(parent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return visited;
|
||||||
|
}
|
||||||
|
|
||||||
export function useReverseLineage(initial = {}) {
|
export function useReverseLineage(initial = {}) {
|
||||||
ensureMesApiAvailable();
|
ensureMesApiAvailable();
|
||||||
|
|
||||||
@@ -78,6 +103,7 @@ export function useReverseLineage(initial = {}) {
|
|||||||
const nameMap = reactive(new Map());
|
const nameMap = reactive(new Map());
|
||||||
const nodeMetaMap = reactive(new Map());
|
const nodeMetaMap = reactive(new Map());
|
||||||
const edgeTypeMap = reactive(new Map());
|
const edgeTypeMap = reactive(new Map());
|
||||||
|
const graphEdges = ref([]);
|
||||||
const leafSerials = reactive(new Map());
|
const leafSerials = reactive(new Map());
|
||||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||||
const selectedContainerIds = ref(
|
const selectedContainerIds = ref(
|
||||||
@@ -227,6 +253,81 @@ export function useReverseLineage(initial = {}) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveDisplayRoots(candidateRoots, parentMap) {
|
||||||
|
const roots = uniqueValues((candidateRoots || []).map((cid) => normalizeText(cid)).filter(Boolean));
|
||||||
|
if (roots.length <= 1) {
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateSet = new Set(roots);
|
||||||
|
const groupedRoots = [];
|
||||||
|
const groupsByInput = new Map();
|
||||||
|
const assigned = new Set();
|
||||||
|
|
||||||
|
// Keep reduction within each query input token to avoid cross-token interference.
|
||||||
|
rootRows.value.forEach((row) => {
|
||||||
|
const cid = extractContainerId(row);
|
||||||
|
if (!candidateSet.has(cid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputToken = normalizeText(row?.input_value || row?.inputValue || row?.INPUT_VALUE);
|
||||||
|
const key = inputToken || `__${cid}`;
|
||||||
|
|
||||||
|
if (!groupsByInput.has(key)) {
|
||||||
|
groupsByInput.set(key, []);
|
||||||
|
groupedRoots.push(groupsByInput.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groupsByInput.get(key);
|
||||||
|
if (!group.includes(cid)) {
|
||||||
|
group.push(cid);
|
||||||
|
assigned.add(cid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roots not found in rootRows still need a standalone group.
|
||||||
|
roots.forEach((cid) => {
|
||||||
|
if (assigned.has(cid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groupedRoots.push([cid]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const reduced = [];
|
||||||
|
|
||||||
|
groupedRoots.forEach((group) => {
|
||||||
|
if (group.length <= 1) {
|
||||||
|
const only = group[0];
|
||||||
|
if (only && !reduced.includes(only)) {
|
||||||
|
reduced.push(only);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ancestorCache = new Map();
|
||||||
|
const getAncestors = (cid) => {
|
||||||
|
if (!ancestorCache.has(cid)) {
|
||||||
|
ancestorCache.set(cid, collectAncestors(parentMap, cid));
|
||||||
|
}
|
||||||
|
return ancestorCache.get(cid);
|
||||||
|
};
|
||||||
|
|
||||||
|
const kept = group.filter((cid) => !group.some((otherCid) => (
|
||||||
|
otherCid !== cid && getAncestors(otherCid).has(cid)
|
||||||
|
)));
|
||||||
|
|
||||||
|
const finalGroup = kept.length > 0 ? kept : group;
|
||||||
|
finalGroup.forEach((cid) => {
|
||||||
|
if (cid && !reduced.includes(cid)) {
|
||||||
|
reduced.push(cid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
function populateReverseTree(payload, requestedRoots = []) {
|
function populateReverseTree(payload, requestedRoots = []) {
|
||||||
const parentMap = normalizeParentMap(payload);
|
const parentMap = normalizeParentMap(payload);
|
||||||
const names = payload?.names;
|
const names = payload?.names;
|
||||||
@@ -256,18 +357,23 @@ export function useReverseLineage(initial = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
edgeTypeMap.clear();
|
edgeTypeMap.clear();
|
||||||
|
const normalizedEdges = [];
|
||||||
if (Array.isArray(typedEdges)) {
|
if (Array.isArray(typedEdges)) {
|
||||||
typedEdges.forEach((edge) => {
|
typedEdges.forEach((edge) => {
|
||||||
if (!edge || typeof edge !== 'object') {
|
if (!edge || typeof edge !== 'object') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const key = edgeKey(edge.from_cid, edge.to_cid);
|
const from = normalizeText(edge.from_cid);
|
||||||
|
const to = normalizeText(edge.to_cid);
|
||||||
|
const key = edgeKey(from, to);
|
||||||
const type = normalizeText(edge.edge_type);
|
const type = normalizeText(edge.edge_type);
|
||||||
if (key && type) {
|
if (key && type) {
|
||||||
edgeTypeMap.set(key, type);
|
edgeTypeMap.set(key, type);
|
||||||
|
normalizedEdges.push({ from_cid: from, to_cid: to, edge_type: type });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
graphEdges.value = normalizedEdges;
|
||||||
|
|
||||||
Object.entries(parentMap).forEach(([childId, parentIds]) => {
|
Object.entries(parentMap).forEach(([childId, parentIds]) => {
|
||||||
patchEntry(childId, {
|
patchEntry(childId, {
|
||||||
@@ -317,7 +423,7 @@ export function useReverseLineage(initial = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
treeRoots.value = roots;
|
treeRoots.value = deriveDisplayRoots(roots, parentMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLineage(containerIds, { force = false } = {}) {
|
async function fetchLineage(containerIds, { force = false } = {}) {
|
||||||
@@ -392,6 +498,7 @@ export function useReverseLineage(initial = {}) {
|
|||||||
nameMap.clear();
|
nameMap.clear();
|
||||||
nodeMetaMap.clear();
|
nodeMetaMap.clear();
|
||||||
edgeTypeMap.clear();
|
edgeTypeMap.clear();
|
||||||
|
graphEdges.value = [];
|
||||||
leafSerials.clear();
|
leafSerials.clear();
|
||||||
rootRows.value = [];
|
rootRows.value = [];
|
||||||
rootContainerIds.value = [];
|
rootContainerIds.value = [];
|
||||||
@@ -416,6 +523,7 @@ export function useReverseLineage(initial = {}) {
|
|||||||
nameMap,
|
nameMap,
|
||||||
nodeMetaMap,
|
nodeMetaMap,
|
||||||
edgeTypeMap,
|
edgeTypeMap,
|
||||||
|
graphEdges,
|
||||||
leafSerials,
|
leafSerials,
|
||||||
selectedContainerId,
|
selectedContainerId,
|
||||||
selectedContainerIds,
|
selectedContainerIds,
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ def _build_parent_map(
|
|||||||
child_to_parent: Dict[str, str],
|
child_to_parent: Dict[str, str],
|
||||||
merge_child_to_parent: Dict[str, str],
|
merge_child_to_parent: Dict[str, str],
|
||||||
merge_source_map: Dict[str, List[str]],
|
merge_source_map: Dict[str, List[str]],
|
||||||
cid_to_name: Dict[str, str],
|
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Build per-node direct parent lists and merge edge lists.
|
"""Build per-node direct parent lists and merge edge lists.
|
||||||
|
|
||||||
@@ -98,6 +97,9 @@ def _build_parent_map(
|
|||||||
(parent_map, merge_edges) where:
|
(parent_map, merge_edges) where:
|
||||||
- parent_map: {child_cid: [direct_parent_cids]}
|
- parent_map: {child_cid: [direct_parent_cids]}
|
||||||
- merge_edges: {child_cid: [merge_source_cids]}
|
- merge_edges: {child_cid: [merge_source_cids]}
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
merge_source_map is keyed by target/child CID.
|
||||||
"""
|
"""
|
||||||
parent_map: Dict[str, List[str]] = defaultdict(list)
|
parent_map: Dict[str, List[str]] = defaultdict(list)
|
||||||
merge_edges: Dict[str, List[str]] = defaultdict(list)
|
merge_edges: Dict[str, List[str]] = defaultdict(list)
|
||||||
@@ -109,17 +111,18 @@ def _build_parent_map(
|
|||||||
if parent not in parent_map[child]:
|
if parent not in parent_map[child]:
|
||||||
parent_map[child].append(parent)
|
parent_map[child].append(parent)
|
||||||
|
|
||||||
if merge_source_map and cid_to_name:
|
if merge_source_map:
|
||||||
name_to_cids: Dict[str, List[str]] = defaultdict(list)
|
for owner_cid, source_cids in merge_source_map.items():
|
||||||
for cid, name in cid_to_name.items():
|
child = _safe_str(owner_cid)
|
||||||
name_to_cids[name].append(cid)
|
if not child:
|
||||||
|
continue
|
||||||
for name, source_cids in merge_source_map.items():
|
for source_cid in source_cids:
|
||||||
for owner_cid in name_to_cids.get(name, []):
|
source = _safe_str(source_cid)
|
||||||
for source_cid in source_cids:
|
if not source or source == child:
|
||||||
if source_cid != owner_cid and source_cid not in parent_map[owner_cid]:
|
continue
|
||||||
parent_map[owner_cid].append(source_cid)
|
if source not in parent_map[child]:
|
||||||
merge_edges[owner_cid].append(source_cid)
|
parent_map[child].append(source)
|
||||||
|
merge_edges[child].append(source)
|
||||||
|
|
||||||
return dict(parent_map), dict(merge_edges)
|
return dict(parent_map), dict(merge_edges)
|
||||||
|
|
||||||
@@ -361,23 +364,23 @@ class LineageEngine:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_merge_sources(
|
def resolve_merge_sources(
|
||||||
container_names: List[str],
|
target_cids: List[str],
|
||||||
) -> Dict[str, List[str]]:
|
) -> Dict[str, List[str]]:
|
||||||
"""Resolve merge source lots from FINISHEDNAME."""
|
"""Resolve merge source lots by target LOT CID (COMBINE.LOTID)."""
|
||||||
normalized_names = _normalize_list(container_names)
|
normalized_target_cids = _normalize_list(target_cids)
|
||||||
if not normalized_names:
|
if not normalized_target_cids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
result: Dict[str, Set[str]] = defaultdict(set)
|
result: Dict[str, Set[str]] = defaultdict(set)
|
||||||
|
|
||||||
for i in range(0, len(normalized_names), ORACLE_IN_BATCH_SIZE):
|
for i in range(0, len(normalized_target_cids), ORACLE_IN_BATCH_SIZE):
|
||||||
batch = normalized_names[i:i + ORACLE_IN_BATCH_SIZE]
|
batch = normalized_target_cids[i:i + ORACLE_IN_BATCH_SIZE]
|
||||||
builder = QueryBuilder()
|
builder = QueryBuilder()
|
||||||
builder.add_in_condition("ca.FINISHEDNAME", batch)
|
builder.add_in_condition("ca.LOTID", batch)
|
||||||
|
|
||||||
sql = SQLLoader.load_with_params(
|
sql = SQLLoader.load_with_params(
|
||||||
"lineage/merge_sources",
|
"lineage/merge_sources",
|
||||||
FINISHED_NAME_FILTER=builder.get_conditions_sql(),
|
TARGET_CID_FILTER=builder.get_conditions_sql(),
|
||||||
)
|
)
|
||||||
|
|
||||||
df = read_sql_df(sql, builder.params)
|
df = read_sql_df(sql, builder.params)
|
||||||
@@ -385,16 +388,16 @@ class LineageEngine:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
finished_name = _safe_str(row.get("FINISHEDNAME"))
|
target_cid = _safe_str(row.get("FINISHED_CID"))
|
||||||
source_cid = _safe_str(row.get("SOURCE_CID"))
|
source_cid = _safe_str(row.get("SOURCE_CID"))
|
||||||
if not finished_name or not source_cid:
|
if not target_cid or not source_cid or source_cid == target_cid:
|
||||||
continue
|
continue
|
||||||
result[finished_name].add(source_cid)
|
result[target_cid].add(source_cid)
|
||||||
|
|
||||||
mapped = {k: sorted(v) for k, v in result.items()}
|
mapped = {k: sorted(v) for k, v in result.items()}
|
||||||
logger.info(
|
logger.info(
|
||||||
"Merge source resolution completed: finished_names=%s, mapped=%s",
|
"Merge source resolution completed: target_cids=%s, mapped=%s",
|
||||||
len(normalized_names),
|
len(normalized_target_cids),
|
||||||
len(mapped),
|
len(mapped),
|
||||||
)
|
)
|
||||||
return mapped
|
return mapped
|
||||||
@@ -565,6 +568,7 @@ class LineageEngine:
|
|||||||
for parent, children in split_children_map.items():
|
for parent, children in split_children_map.items():
|
||||||
for child in children:
|
for child in children:
|
||||||
split_edges.append((parent, child, EDGE_TYPE_SPLIT))
|
split_edges.append((parent, child, EDGE_TYPE_SPLIT))
|
||||||
|
split_pairs = {(parent, child) for parent, child, _ in split_edges}
|
||||||
|
|
||||||
# Collect all nodes in the tree
|
# Collect all nodes in the tree
|
||||||
all_nodes: Set[str] = set(roots)
|
all_nodes: Set[str] = set(roots)
|
||||||
@@ -578,6 +582,26 @@ class LineageEngine:
|
|||||||
# Step 4: Query serial numbers for leaf nodes
|
# Step 4: Query serial numbers for leaf nodes
|
||||||
leaf_serials = LineageEngine.resolve_leaf_serials(leaf_cids) if leaf_cids else {}
|
leaf_serials = LineageEngine.resolve_leaf_serials(leaf_cids) if leaf_cids else {}
|
||||||
|
|
||||||
|
# Step 4b: Resolve merge relations for known nodes by target CID.
|
||||||
|
merge_edges: List[Tuple[str, str, str]] = []
|
||||||
|
try:
|
||||||
|
merge_source_map = LineageEngine.resolve_merge_sources(list(all_nodes))
|
||||||
|
for target_cid, source_cids in merge_source_map.items():
|
||||||
|
target = _safe_str(target_cid)
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
for source_cid in source_cids:
|
||||||
|
source = _safe_str(source_cid)
|
||||||
|
if not source or source == target:
|
||||||
|
continue
|
||||||
|
if (source, target) in split_pairs:
|
||||||
|
continue
|
||||||
|
merge_edges.append((source, target, EDGE_TYPE_MERGE))
|
||||||
|
all_nodes.add(source)
|
||||||
|
all_nodes.add(target)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Forward merge enrichment skipped due to merge lookup error: %s", exc)
|
||||||
|
|
||||||
# Step 5: Build semantic links (wafer origin / GD rework) and augment tree.
|
# Step 5: Build semantic links (wafer origin / GD rework) and augment tree.
|
||||||
snapshots: Dict[str, Dict[str, Optional[str]]] = {}
|
snapshots: Dict[str, Dict[str, Optional[str]]] = {}
|
||||||
semantic_edges: List[Tuple[str, str, str]] = []
|
semantic_edges: List[Tuple[str, str, str]] = []
|
||||||
@@ -614,15 +638,16 @@ class LineageEngine:
|
|||||||
roots = sorted([cid for cid in all_nodes if cid not in incoming])
|
roots = sorted([cid for cid in all_nodes if cid not in incoming])
|
||||||
|
|
||||||
typed_nodes = LineageEngine._build_nodes_payload(all_nodes, snapshots, cid_to_name, wafer_ids)
|
typed_nodes = LineageEngine._build_nodes_payload(all_nodes, snapshots, cid_to_name, wafer_ids)
|
||||||
typed_edges = _to_edge_payload(split_edges + semantic_edges)
|
typed_edges = _to_edge_payload(split_edges + merge_edges + semantic_edges)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Forward tree resolution completed: seeds=%s, roots=%s, nodes=%s, leaves=%s, serials=%s, semantic_edges=%s",
|
"Forward tree resolution completed: seeds=%s, roots=%s, nodes=%s, leaves=%s, serials=%s, merge_edges=%s, semantic_edges=%s",
|
||||||
len(seed_cids),
|
len(seed_cids),
|
||||||
len(roots),
|
len(roots),
|
||||||
len(all_nodes),
|
len(all_nodes),
|
||||||
len(leaf_cids),
|
len(leaf_cids),
|
||||||
len(leaf_serials),
|
len(leaf_serials),
|
||||||
|
len(merge_edges),
|
||||||
len(semantic_edges),
|
len(semantic_edges),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -686,18 +711,25 @@ class LineageEngine:
|
|||||||
if _safe_str(parent) and _safe_str(child)
|
if _safe_str(parent) and _safe_str(child)
|
||||||
]
|
]
|
||||||
|
|
||||||
all_names = [name for name in cid_to_name.values() if _safe_str(name)]
|
merge_lookup_targets = sorted(
|
||||||
merge_source_map = LineageEngine.resolve_merge_sources(all_names)
|
{
|
||||||
|
cid
|
||||||
|
for cid in (
|
||||||
|
list(seed_cids)
|
||||||
|
+ list(child_to_parent.keys())
|
||||||
|
+ list(child_to_parent.values())
|
||||||
|
)
|
||||||
|
if _safe_str(cid)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
merge_source_map = LineageEngine.resolve_merge_sources(merge_lookup_targets)
|
||||||
merge_child_to_parent: Dict[str, str] = {}
|
merge_child_to_parent: Dict[str, str] = {}
|
||||||
merge_source_cids_all: Set[str] = set()
|
merge_source_cids_all: Set[str] = set()
|
||||||
if merge_source_map:
|
if merge_source_map:
|
||||||
for seed in seed_cids:
|
for seed in seed_cids:
|
||||||
self_and_ancestors = ancestors[seed] | {seed}
|
self_and_ancestors = ancestors[seed] | {seed}
|
||||||
for cid in list(self_and_ancestors):
|
for cid in list(self_and_ancestors):
|
||||||
name = cid_to_name.get(cid)
|
for source_cid in merge_source_map.get(cid, []):
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
for source_cid in merge_source_map.get(name, []):
|
|
||||||
if source_cid == cid or source_cid in self_and_ancestors:
|
if source_cid == cid or source_cid in self_and_ancestors:
|
||||||
continue
|
continue
|
||||||
ancestors[seed].add(source_cid)
|
ancestors[seed].add(source_cid)
|
||||||
@@ -722,7 +754,7 @@ class LineageEngine:
|
|||||||
ancestors[seed].add(parent)
|
ancestors[seed].add(parent)
|
||||||
current = parent
|
current = parent
|
||||||
|
|
||||||
pm, me = _build_parent_map(child_to_parent, merge_child_to_parent, merge_source_map, cid_to_name)
|
pm, me = _build_parent_map(child_to_parent, merge_child_to_parent, merge_source_map)
|
||||||
|
|
||||||
for child, parent in merge_child_to_parent.items():
|
for child, parent in merge_child_to_parent.items():
|
||||||
if _safe_str(parent) and _safe_str(child):
|
if _safe_str(parent) and _safe_str(child):
|
||||||
|
|||||||
@@ -503,46 +503,126 @@ def _resolve_by_gd_lot_id(gd_lot_ids: List[str]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_by_serial_number(serial_numbers: List[str]) -> Dict[str, Any]:
|
def _resolve_by_serial_number(serial_numbers: List[str]) -> Dict[str, Any]:
|
||||||
"""Resolve serial numbers (FINISHEDNAME) to CONTAINERID.
|
"""Resolve serial-related inputs to CONTAINERID.
|
||||||
|
|
||||||
Note: One serial number may map to multiple CONTAINERIDs.
|
Matching sources (in priority order):
|
||||||
|
1. DW_MES_PJ_COMBINEDASSYLOTS.FINISHEDNAME (new serial path)
|
||||||
Args:
|
2. DW_MES_CONTAINER.CONTAINERNAME (old serial / lot-id style inputs)
|
||||||
serial_numbers: List of serial numbers
|
3. DW_MES_CONTAINER.FIRSTNAME (bridge from serial to related lots)
|
||||||
|
"""
|
||||||
Returns:
|
tokens = _normalize_search_tokens(serial_numbers)
|
||||||
Resolution result dict.
|
if not tokens:
|
||||||
"""
|
return {
|
||||||
builder = QueryBuilder()
|
'data': [],
|
||||||
_add_exact_or_pattern_condition(builder, "p.FINISHEDNAME", serial_numbers)
|
'total': 0,
|
||||||
sql = SQLLoader.load_with_params(
|
'input_count': 0,
|
||||||
"query_tool/lot_resolve_serial",
|
'not_found': [],
|
||||||
SERIAL_FILTER=builder.get_conditions_sql(),
|
'expansion_info': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
source_configs = [
|
||||||
|
{
|
||||||
|
'name': 'finished_name',
|
||||||
|
'priority': 0,
|
||||||
|
'sql_name': 'query_tool/lot_resolve_serial',
|
||||||
|
'filter_key': 'SERIAL_FILTER',
|
||||||
|
'filter_column': 'p.FINISHEDNAME',
|
||||||
|
'match_key': 'FINISHEDNAME',
|
||||||
|
'extra_conditions': [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'container_name',
|
||||||
|
'priority': 1,
|
||||||
|
'sql_name': 'query_tool/lot_resolve_id',
|
||||||
|
'filter_key': 'CONTAINER_FILTER',
|
||||||
|
'filter_column': 'CONTAINERNAME',
|
||||||
|
'match_key': 'CONTAINERNAME',
|
||||||
|
'extra_conditions': ["OBJECTTYPE = 'LOT'"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'first_name',
|
||||||
|
'priority': 2,
|
||||||
|
'sql_name': 'query_tool/lot_resolve_wafer_lot',
|
||||||
|
'filter_key': 'WAFER_FILTER',
|
||||||
|
'filter_column': 'FIRSTNAME',
|
||||||
|
'match_key': 'FIRSTNAME',
|
||||||
|
'extra_conditions': ["OBJECTTYPE = 'LOT'"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
best_match_by_key: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
for config in source_configs:
|
||||||
|
builder = QueryBuilder()
|
||||||
|
_add_exact_or_pattern_condition(builder, config['filter_column'], tokens)
|
||||||
|
for cond in config['extra_conditions']:
|
||||||
|
builder.add_condition(cond)
|
||||||
|
|
||||||
|
if not builder.conditions:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sql = SQLLoader.load_with_params(
|
||||||
|
config['sql_name'],
|
||||||
|
**{config['filter_key']: builder.get_conditions_sql()},
|
||||||
|
)
|
||||||
|
df = read_sql_df(sql, builder.params)
|
||||||
|
data = _df_to_records(df)
|
||||||
|
matched, _, _ = _match_rows_by_tokens(
|
||||||
|
tokens,
|
||||||
|
data,
|
||||||
|
row_key=config['match_key'],
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in matched:
|
||||||
|
input_value = str(row.get('input_value') or '').strip()
|
||||||
|
cid = str(row.get('CONTAINERID') or '').strip()
|
||||||
|
if not input_value or not cid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
candidate = {
|
||||||
|
'container_id': cid,
|
||||||
|
'lot_id': row.get('CONTAINERNAME') or cid,
|
||||||
|
'input_value': input_value,
|
||||||
|
'spec_name': row.get('SPECNAME'),
|
||||||
|
'match_source': config['name'],
|
||||||
|
'_priority': config['priority'],
|
||||||
|
}
|
||||||
|
key = (input_value, cid)
|
||||||
|
existing = best_match_by_key.get(key)
|
||||||
|
if existing is None or candidate['_priority'] < existing['_priority']:
|
||||||
|
best_match_by_key[key] = candidate
|
||||||
|
|
||||||
|
grouped_by_input: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
|
for item in best_match_by_key.values():
|
||||||
|
grouped_by_input.setdefault(item['input_value'], []).append(item)
|
||||||
|
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
not_found: List[str] = []
|
||||||
|
expansion_info: Dict[str, int] = {}
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
rows = grouped_by_input.get(token, [])
|
||||||
|
rows.sort(key=lambda row: (row.get('_priority', 999), str(row.get('lot_id') or '')))
|
||||||
|
if not rows:
|
||||||
|
not_found.append(token)
|
||||||
|
continue
|
||||||
|
|
||||||
|
expansion_info[token] = len(rows)
|
||||||
|
for row in rows:
|
||||||
|
row.pop('_priority', None)
|
||||||
|
results.append(row)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Serial number resolution: %s containers from %s inputs (not_found=%s)",
|
||||||
|
len(results),
|
||||||
|
len(tokens),
|
||||||
|
len(not_found),
|
||||||
)
|
)
|
||||||
|
|
||||||
df = read_sql_df(sql, builder.params)
|
|
||||||
data = _df_to_records(df)
|
|
||||||
matched, not_found, expansion_info = _match_rows_by_tokens(
|
|
||||||
serial_numbers,
|
|
||||||
data,
|
|
||||||
row_key='FINISHEDNAME',
|
|
||||||
)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for row in matched:
|
|
||||||
results.append({
|
|
||||||
'container_id': row.get('CONTAINERID'),
|
|
||||||
'lot_id': row.get('CONTAINERNAME'),
|
|
||||||
'input_value': row.get('input_value'),
|
|
||||||
'spec_name': row.get('SPECNAME'),
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"Serial number resolution: {len(results)} containers from {len(serial_numbers)} inputs")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'data': results,
|
'data': results,
|
||||||
'total': len(results),
|
'total': len(results),
|
||||||
'input_count': len(serial_numbers),
|
'input_count': len(tokens),
|
||||||
'not_found': not_found,
|
'not_found': not_found,
|
||||||
'expansion_info': expansion_info,
|
'expansion_info': expansion_info,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
-- Unified LineageEngine - Merge Sources
|
-- Unified LineageEngine - Merge Sources
|
||||||
-- Find source lots merged into finished lots from DW_MES_PJ_COMBINEDASSYLOTS.
|
-- Find source lots merged into target LOT CIDs from DW_MES_PJ_COMBINEDASSYLOTS.
|
||||||
--
|
--
|
||||||
-- Parameters:
|
-- Parameters:
|
||||||
-- FINISHED_NAME_FILTER - QueryBuilder-generated condition on ca.FINISHEDNAME
|
-- TARGET_CID_FILTER - QueryBuilder-generated condition on ca.LOTID
|
||||||
--
|
--
|
||||||
SELECT
|
SELECT
|
||||||
ca.CONTAINERID AS SOURCE_CID,
|
ca.CONTAINERID AS SOURCE_CID,
|
||||||
@@ -10,4 +10,4 @@ SELECT
|
|||||||
ca.FINISHEDNAME,
|
ca.FINISHEDNAME,
|
||||||
ca.LOTID AS FINISHED_CID
|
ca.LOTID AS FINISHED_CID
|
||||||
FROM DWH.DW_MES_PJ_COMBINEDASSYLOTS ca
|
FROM DWH.DW_MES_PJ_COMBINEDASSYLOTS ca
|
||||||
WHERE {{ FINISHED_NAME_FILTER }}
|
WHERE {{ TARGET_CID_FILTER }}
|
||||||
|
|||||||
@@ -67,35 +67,35 @@ def test_resolve_split_ancestors_batches_and_enforces_max_depth(mock_read_sql_df
|
|||||||
|
|
||||||
@patch("mes_dashboard.services.lineage_engine.read_sql_df")
|
@patch("mes_dashboard.services.lineage_engine.read_sql_df")
|
||||||
def test_resolve_merge_sources_batches_and_returns_mapping(mock_read_sql_df):
|
def test_resolve_merge_sources_batches_and_returns_mapping(mock_read_sql_df):
|
||||||
names = [f"FN{i:04d}" for i in range(1001)]
|
target_cids = [f"T{i:04d}" for i in range(1001)]
|
||||||
mock_read_sql_df.side_effect = [
|
mock_read_sql_df.side_effect = [
|
||||||
pd.DataFrame(
|
pd.DataFrame(
|
||||||
[
|
[
|
||||||
{"FINISHEDNAME": "FN0000", "SOURCE_CID": "SRC-A"},
|
{"FINISHED_CID": "T0000", "SOURCE_CID": "SRC-A"},
|
||||||
{"FINISHEDNAME": "FN0000", "SOURCE_CID": "SRC-B"},
|
{"FINISHED_CID": "T0000", "SOURCE_CID": "SRC-B"},
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
pd.DataFrame(
|
pd.DataFrame(
|
||||||
[
|
[
|
||||||
{"FINISHEDNAME": "FN1000", "SOURCE_CID": "SRC-C"},
|
{"FINISHED_CID": "T1000", "SOURCE_CID": "SRC-C"},
|
||||||
{"FINISHEDNAME": "FN1000", "SOURCE_CID": "SRC-C"},
|
{"FINISHED_CID": "T1000", "SOURCE_CID": "SRC-C"},
|
||||||
{"FINISHEDNAME": None, "SOURCE_CID": "SRC-INVALID"},
|
{"FINISHED_CID": None, "SOURCE_CID": "SRC-INVALID"},
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
result = LineageEngine.resolve_merge_sources(names)
|
result = LineageEngine.resolve_merge_sources(target_cids)
|
||||||
|
|
||||||
assert mock_read_sql_df.call_count == 2
|
assert mock_read_sql_df.call_count == 2
|
||||||
first_sql, first_params = mock_read_sql_df.call_args_list[0].args
|
first_sql, first_params = mock_read_sql_df.call_args_list[0].args
|
||||||
second_sql, second_params = mock_read_sql_df.call_args_list[1].args
|
second_sql, second_params = mock_read_sql_df.call_args_list[1].args
|
||||||
assert "{{ FINISHED_NAME_FILTER }}" not in first_sql
|
assert "{{ TARGET_CID_FILTER }}" not in first_sql
|
||||||
assert "{{ FINISHED_NAME_FILTER }}" not in second_sql
|
assert "{{ TARGET_CID_FILTER }}" not in second_sql
|
||||||
assert len(first_params) == 1000
|
assert len(first_params) == 1000
|
||||||
assert len(second_params) == 1
|
assert len(second_params) == 1
|
||||||
|
|
||||||
assert result["FN0000"] == ["SRC-A", "SRC-B"]
|
assert result["T0000"] == ["SRC-A", "SRC-B"]
|
||||||
assert result["FN1000"] == ["SRC-C"]
|
assert result["T1000"] == ["SRC-C"]
|
||||||
|
|
||||||
|
|
||||||
@patch("mes_dashboard.services.lineage_engine.LineageEngine.resolve_merge_sources")
|
@patch("mes_dashboard.services.lineage_engine.LineageEngine.resolve_merge_sources")
|
||||||
@@ -126,7 +126,7 @@ def test_resolve_full_genealogy_combines_split_and_merge(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
mock_resolve_merge_sources.return_value = {"LOT-B": ["M1"]}
|
mock_resolve_merge_sources.return_value = {"B": ["M1"]}
|
||||||
|
|
||||||
result = LineageEngine.resolve_full_genealogy(["A"], {"A": "LOT-A"})
|
result = LineageEngine.resolve_full_genealogy(["A"], {"A": "LOT-A"})
|
||||||
|
|
||||||
|
|||||||
@@ -281,28 +281,68 @@ class TestResolveQueriesUseBindParams:
|
|||||||
assert "OBJECTTYPE = 'LOT'" in sql_params['WAFER_FILTER']
|
assert "OBJECTTYPE = 'LOT'" in sql_params['WAFER_FILTER']
|
||||||
|
|
||||||
def test_resolve_by_serial_number_uses_query_builder_params(self):
|
def test_resolve_by_serial_number_uses_query_builder_params(self):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
|
with patch('mes_dashboard.services.query_tool_service.SQLLoader.load_with_params') as mock_load:
|
||||||
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
|
with patch('mes_dashboard.services.query_tool_service.read_sql_df') as mock_read:
|
||||||
mock_load.return_value = "SELECT * FROM DUAL"
|
mock_load.side_effect = [
|
||||||
mock_read.return_value = pd.DataFrame([
|
"SELECT * FROM COMBINE",
|
||||||
{
|
"SELECT * FROM CONTAINER_NAME",
|
||||||
'CONTAINERID': 'CID-1',
|
"SELECT * FROM FIRSTNAME",
|
||||||
'FINISHEDNAME': 'SN-1',
|
]
|
||||||
'CONTAINERNAME': 'LOT-1',
|
mock_read.side_effect = [
|
||||||
'SPECNAME': 'SPEC-1',
|
pd.DataFrame([
|
||||||
}
|
{
|
||||||
])
|
'CONTAINERID': 'CID-FIN',
|
||||||
|
'FINISHEDNAME': 'SN-1',
|
||||||
result = _resolve_by_serial_number(['SN-1'])
|
'CONTAINERNAME': 'LOT-FIN',
|
||||||
|
'SPECNAME': 'SPEC-1',
|
||||||
assert result['total'] == 1
|
}
|
||||||
sql_params = mock_load.call_args.kwargs
|
]),
|
||||||
assert ':p0' in sql_params['SERIAL_FILTER']
|
pd.DataFrame([
|
||||||
_, query_params = mock_read.call_args.args
|
{
|
||||||
assert query_params == {'p0': 'SN-1'}
|
'CONTAINERID': 'CID-NAME',
|
||||||
|
'CONTAINERNAME': 'SN-1',
|
||||||
|
'SPECNAME': 'SPEC-2',
|
||||||
|
'MFGORDERNAME': None,
|
||||||
|
'QTY': 1,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
pd.DataFrame([
|
||||||
|
{
|
||||||
|
'CONTAINERID': 'CID-FIRST',
|
||||||
|
'CONTAINERNAME': 'GD25000001-A01',
|
||||||
|
'FIRSTNAME': 'SN-1',
|
||||||
|
'SPECNAME': 'SPEC-3',
|
||||||
|
'QTY': 1,
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
result = _resolve_by_serial_number(['SN-1'])
|
||||||
|
|
||||||
|
assert result['total'] == 3
|
||||||
|
assert {row['match_source'] for row in result['data']} == {
|
||||||
|
'finished_name',
|
||||||
|
'container_name',
|
||||||
|
'first_name',
|
||||||
|
}
|
||||||
|
|
||||||
|
assert [call.args[0] for call in mock_load.call_args_list] == [
|
||||||
|
'query_tool/lot_resolve_serial',
|
||||||
|
'query_tool/lot_resolve_id',
|
||||||
|
'query_tool/lot_resolve_wafer_lot',
|
||||||
|
]
|
||||||
|
assert ':p0' in mock_load.call_args_list[0].kwargs['SERIAL_FILTER']
|
||||||
|
assert ':p0' in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
|
||||||
|
assert ':p0' in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
|
||||||
|
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[1].kwargs['CONTAINER_FILTER']
|
||||||
|
assert "OBJECTTYPE = 'LOT'" in mock_load.call_args_list[2].kwargs['WAFER_FILTER']
|
||||||
|
|
||||||
|
assert mock_read.call_args_list[0].args[1] == {'p0': 'SN-1'}
|
||||||
|
assert mock_read.call_args_list[1].args[1] == {'p0': 'SN-1'}
|
||||||
|
assert mock_read.call_args_list[2].args[1] == {'p0': 'SN-1'}
|
||||||
|
|
||||||
def test_resolve_by_work_order_uses_query_builder_params(self):
|
def test_resolve_by_work_order_uses_query_builder_params(self):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|||||||
Reference in New Issue
Block a user