feat(query-tool): split serial reverse trace tab and use reverse lineage
This commit is contained in:
@@ -5,20 +5,24 @@ import { replaceRuntimeHistory } from '../core/shell-navigation.js';
|
||||
|
||||
import EquipmentView from './components/EquipmentView.vue';
|
||||
import LotTraceView from './components/LotTraceView.vue';
|
||||
import SerialReverseTraceView from './components/SerialReverseTraceView.vue';
|
||||
import { useEquipmentQuery } from './composables/useEquipmentQuery.js';
|
||||
import { useLotDetail } from './composables/useLotDetail.js';
|
||||
import { useLotLineage } from './composables/useLotLineage.js';
|
||||
import { useLotResolve } from './composables/useLotResolve.js';
|
||||
import { useReverseLineage } from './composables/useReverseLineage.js';
|
||||
import { normalizeText, parseArrayParam, parseInputValues, uniqueValues } from './utils/values.js';
|
||||
|
||||
const TAB_LOT = 'lot';
|
||||
const TAB_REVERSE = 'reverse';
|
||||
const TAB_EQUIPMENT = 'equipment';
|
||||
|
||||
const VALID_TABS = new Set([TAB_LOT, TAB_EQUIPMENT]);
|
||||
const VALID_TABS = new Set([TAB_LOT, TAB_REVERSE, TAB_EQUIPMENT]);
|
||||
|
||||
const tabItems = Object.freeze([
|
||||
{ key: TAB_LOT, label: 'LOT 追蹤', subtitle: '血緣樹與批次詳情' },
|
||||
{ key: TAB_EQUIPMENT, label: '設備查詢', subtitle: '設備紀錄與時序視圖' },
|
||||
{ key: TAB_LOT, label: '批次追蹤(正向)', subtitle: '由批次展開下游血緣與明細' },
|
||||
{ key: TAB_REVERSE, label: '流水批反查(反向)', subtitle: '由成品流水號回溯上游批次' },
|
||||
{ key: TAB_EQUIPMENT, label: '設備生產批次追蹤', subtitle: '設備紀錄與時序視圖' },
|
||||
]);
|
||||
|
||||
function normalizeTopTab(value) {
|
||||
@@ -29,13 +33,28 @@ function normalizeTopTab(value) {
|
||||
function readStateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
const tab = normalizeTopTab(params.get('tab'));
|
||||
const legacyInputType = normalizeText(params.get('input_type'));
|
||||
const legacyInputText = parseArrayParam(params, 'values').join('\n');
|
||||
const legacySelectedContainerId = normalizeText(params.get('container_id'));
|
||||
const legacyLotSubTab = normalizeText(params.get('lot_sub_tab')) || 'history';
|
||||
const legacyWorkcenterGroups = parseArrayParam(params, 'workcenter_groups');
|
||||
|
||||
return {
|
||||
tab: normalizeTopTab(params.get('tab')),
|
||||
inputType: normalizeText(params.get('input_type')) || 'lot_id',
|
||||
inputText: parseArrayParam(params, 'values').join('\n'),
|
||||
selectedContainerId: normalizeText(params.get('container_id')),
|
||||
tab,
|
||||
lotInputType: normalizeText(params.get('lot_input_type')) || (tab === TAB_LOT ? legacyInputType : '') || 'lot_id',
|
||||
lotInputText: parseArrayParam(params, 'lot_values').join('\n') || (tab === TAB_LOT ? legacyInputText : ''),
|
||||
lotSelectedContainerId: normalizeText(params.get('lot_container_id')) || (tab === TAB_LOT ? legacySelectedContainerId : ''),
|
||||
lotSubTab: normalizeText(params.get('lot_sub_tab')) || 'history',
|
||||
workcenterGroups: parseArrayParam(params, 'workcenter_groups'),
|
||||
lotWorkcenterGroups: parseArrayParam(params, 'workcenter_groups'),
|
||||
|
||||
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'),
|
||||
reverseWorkcenterGroups: parseArrayParam(params, 'reverse_workcenter_groups').length
|
||||
? parseArrayParam(params, 'reverse_workcenter_groups')
|
||||
: (tab === TAB_REVERSE ? legacyWorkcenterGroups : []),
|
||||
|
||||
equipmentIds: parseArrayParam(params, 'equipment_ids'),
|
||||
startDate: normalizeText(params.get('start_date')),
|
||||
endDate: normalizeText(params.get('end_date')),
|
||||
@@ -47,18 +66,35 @@ const initialState = readStateFromUrl();
|
||||
const activeTab = ref(initialState.tab);
|
||||
|
||||
const lotResolve = useLotResolve({
|
||||
inputType: initialState.inputType,
|
||||
inputText: initialState.inputText,
|
||||
inputType: initialState.lotInputType,
|
||||
inputText: initialState.lotInputText,
|
||||
allowedTypes: ['lot_id', 'work_order'],
|
||||
});
|
||||
|
||||
const reverseResolve = useLotResolve({
|
||||
inputType: 'serial_number',
|
||||
inputText: initialState.reverseInputText,
|
||||
allowedTypes: ['serial_number'],
|
||||
});
|
||||
|
||||
const lotLineage = useLotLineage({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
selectedContainerId: initialState.lotSelectedContainerId,
|
||||
});
|
||||
|
||||
const reverseLineage = useReverseLineage({
|
||||
selectedContainerId: initialState.reverseSelectedContainerId,
|
||||
});
|
||||
|
||||
const lotDetail = useLotDetail({
|
||||
selectedContainerId: initialState.selectedContainerId,
|
||||
selectedContainerId: initialState.lotSelectedContainerId,
|
||||
activeSubTab: initialState.lotSubTab,
|
||||
workcenterGroups: initialState.workcenterGroups,
|
||||
workcenterGroups: initialState.lotWorkcenterGroups,
|
||||
});
|
||||
|
||||
const reverseDetail = useLotDetail({
|
||||
selectedContainerId: initialState.reverseSelectedContainerId,
|
||||
activeSubTab: initialState.reverseSubTab,
|
||||
workcenterGroups: initialState.reverseWorkcenterGroups,
|
||||
});
|
||||
|
||||
const equipmentQuery = useEquipmentQuery({
|
||||
@@ -75,6 +111,11 @@ const selectedContainerName = computed(() => {
|
||||
return cid ? (lotLineage.nameMap.get(cid) || '') : '';
|
||||
});
|
||||
|
||||
const reverseSelectedContainerName = computed(() => {
|
||||
const cid = reverseDetail.selectedContainerId.value;
|
||||
return cid ? (reverseLineage.nameMap.get(cid) || '') : '';
|
||||
});
|
||||
|
||||
// Compatibility placeholders for existing table parity tests.
|
||||
const resolvedColumns = computed(() => Object.keys(lotResolve.resolvedLots.value[0] || {}));
|
||||
const historyColumns = computed(() => Object.keys(lotDetail.historyRows.value[0] || {}));
|
||||
@@ -101,24 +142,40 @@ function buildUrlState() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.set('tab', activeTab.value);
|
||||
params.set('input_type', lotResolve.inputType.value);
|
||||
|
||||
params.set('lot_input_type', lotResolve.inputType.value);
|
||||
parseInputValues(lotResolve.inputText.value).forEach((value) => {
|
||||
params.append('values', value);
|
||||
params.append('lot_values', value);
|
||||
});
|
||||
|
||||
parseInputValues(reverseResolve.inputText.value).forEach((value) => {
|
||||
params.append('reverse_values', value);
|
||||
});
|
||||
|
||||
if (lotDetail.selectedContainerId.value) {
|
||||
params.set('container_id', lotDetail.selectedContainerId.value);
|
||||
params.set('lot_container_id', lotDetail.selectedContainerId.value);
|
||||
}
|
||||
|
||||
if (reverseDetail.selectedContainerId.value) {
|
||||
params.set('reverse_container_id', reverseDetail.selectedContainerId.value);
|
||||
}
|
||||
|
||||
if (lotDetail.activeSubTab.value) {
|
||||
params.set('lot_sub_tab', lotDetail.activeSubTab.value);
|
||||
}
|
||||
|
||||
if (reverseDetail.activeSubTab.value) {
|
||||
params.set('reverse_sub_tab', reverseDetail.activeSubTab.value);
|
||||
}
|
||||
|
||||
uniqueValues(lotDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||
params.append('workcenter_groups', group);
|
||||
});
|
||||
|
||||
uniqueValues(reverseDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||
params.append('reverse_workcenter_groups', group);
|
||||
});
|
||||
|
||||
uniqueValues(equipmentQuery.selectedEquipmentIds.value).forEach((id) => {
|
||||
params.append('equipment_ids', id);
|
||||
});
|
||||
@@ -135,6 +192,29 @@ function buildUrlState() {
|
||||
params.set('equipment_sub_tab', equipmentQuery.activeSubTab.value);
|
||||
}
|
||||
|
||||
// Backward-compatible URL keys for deep links and existing tests.
|
||||
if (activeTab.value === TAB_LOT) {
|
||||
params.set('input_type', lotResolve.inputType.value);
|
||||
parseInputValues(lotResolve.inputText.value).forEach((value) => {
|
||||
params.append('values', value);
|
||||
});
|
||||
if (lotDetail.selectedContainerId.value) {
|
||||
params.set('container_id', lotDetail.selectedContainerId.value);
|
||||
}
|
||||
} else if (activeTab.value === TAB_REVERSE) {
|
||||
params.set('input_type', 'serial_number');
|
||||
parseInputValues(reverseResolve.inputText.value).forEach((value) => {
|
||||
params.append('values', value);
|
||||
});
|
||||
if (reverseDetail.selectedContainerId.value) {
|
||||
params.set('container_id', reverseDetail.selectedContainerId.value);
|
||||
}
|
||||
params.set('lot_sub_tab', reverseDetail.activeSubTab.value);
|
||||
uniqueValues(reverseDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||
params.append('workcenter_groups', group);
|
||||
});
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
@@ -159,11 +239,17 @@ async function applyStateFromUrl() {
|
||||
|
||||
activeTab.value = state.tab;
|
||||
|
||||
lotResolve.setInputType(state.inputType);
|
||||
lotResolve.setInputText(state.inputText);
|
||||
lotResolve.setInputType(state.lotInputType);
|
||||
lotResolve.setInputText(state.lotInputText);
|
||||
|
||||
reverseResolve.setInputType('serial_number');
|
||||
reverseResolve.setInputText(state.reverseInputText);
|
||||
|
||||
lotDetail.activeSubTab.value = state.lotSubTab;
|
||||
lotDetail.selectedWorkcenterGroups.value = state.workcenterGroups;
|
||||
lotDetail.selectedWorkcenterGroups.value = state.lotWorkcenterGroups;
|
||||
|
||||
reverseDetail.activeSubTab.value = state.reverseSubTab;
|
||||
reverseDetail.selectedWorkcenterGroups.value = state.reverseWorkcenterGroups;
|
||||
|
||||
equipmentQuery.selectedEquipmentIds.value = state.equipmentIds;
|
||||
equipmentQuery.startDate.value = state.startDate || equipmentQuery.startDate.value;
|
||||
@@ -172,9 +258,14 @@ async function applyStateFromUrl() {
|
||||
|
||||
suppressUrlSync.value = false;
|
||||
|
||||
if (state.selectedContainerId) {
|
||||
lotLineage.selectNode(state.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(state.selectedContainerId);
|
||||
if (state.lotSelectedContainerId) {
|
||||
lotLineage.selectNode(state.lotSelectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(state.lotSelectedContainerId);
|
||||
}
|
||||
|
||||
if (state.reverseSelectedContainerId) {
|
||||
reverseLineage.selectNode(state.reverseSelectedContainerId);
|
||||
await reverseDetail.setSelectedContainerId(state.reverseSelectedContainerId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,12 +284,22 @@ async function handleResolveLots() {
|
||||
}
|
||||
|
||||
// Build tree only — don't auto-select or load detail data.
|
||||
// User clicks a node to trigger detail/timeline loading on demand.
|
||||
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
|
||||
lotLineage.clearSelection();
|
||||
lotDetail.clearTabData();
|
||||
}
|
||||
|
||||
async function handleResolveReverse() {
|
||||
const result = await reverseResolve.resolveLots();
|
||||
if (!result?.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await reverseLineage.primeResolvedLots(reverseResolve.resolvedLots.value);
|
||||
reverseLineage.clearSelection();
|
||||
reverseDetail.clearTabData();
|
||||
}
|
||||
|
||||
async function handleSelectNodes(containerIds) {
|
||||
lotLineage.setSelectedNodes(containerIds);
|
||||
|
||||
@@ -211,18 +312,41 @@ async function handleSelectNodes(containerIds) {
|
||||
await lotDetail.setSelectedContainerIds([...seen]);
|
||||
}
|
||||
|
||||
async function handleSelectReverseNodes(containerIds) {
|
||||
reverseLineage.setSelectedNodes(containerIds);
|
||||
|
||||
const seen = new Set();
|
||||
containerIds.forEach((cid) => {
|
||||
reverseLineage.getSubtreeCids(cid).forEach((id) => seen.add(id));
|
||||
});
|
||||
|
||||
await reverseDetail.setSelectedContainerIds([...seen]);
|
||||
}
|
||||
|
||||
async function handleChangeLotSubTab(tab) {
|
||||
await lotDetail.setActiveSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleChangeReverseSubTab(tab) {
|
||||
await reverseDetail.setActiveSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleWorkcenterGroupChange(groups) {
|
||||
await lotDetail.setSelectedWorkcenterGroups(groups);
|
||||
}
|
||||
|
||||
async function handleReverseWorkcenterGroupChange(groups) {
|
||||
await reverseDetail.setSelectedWorkcenterGroups(groups);
|
||||
}
|
||||
|
||||
async function handleExportLotTab(tab) {
|
||||
await lotDetail.exportSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleExportReverseTab(tab) {
|
||||
await reverseDetail.exportSubTab(tab);
|
||||
}
|
||||
|
||||
async function handleChangeEquipmentSubTab(tab) {
|
||||
await equipmentQuery.setActiveSubTab(tab, { autoQuery: true });
|
||||
}
|
||||
@@ -239,12 +363,18 @@ onMounted(async () => {
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
await Promise.all([
|
||||
lotDetail.loadWorkcenterGroups(),
|
||||
reverseDetail.loadWorkcenterGroups(),
|
||||
equipmentQuery.bootstrap(),
|
||||
]);
|
||||
|
||||
if (initialState.selectedContainerId) {
|
||||
lotLineage.selectNode(initialState.selectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(initialState.selectedContainerId);
|
||||
if (initialState.lotSelectedContainerId) {
|
||||
lotLineage.selectNode(initialState.lotSelectedContainerId);
|
||||
await lotDetail.setSelectedContainerId(initialState.lotSelectedContainerId);
|
||||
}
|
||||
|
||||
if (initialState.reverseSelectedContainerId) {
|
||||
reverseLineage.selectNode(initialState.reverseSelectedContainerId);
|
||||
await reverseDetail.setSelectedContainerId(initialState.reverseSelectedContainerId);
|
||||
}
|
||||
|
||||
syncUrlState();
|
||||
@@ -257,11 +387,18 @@ onBeforeUnmount(() => {
|
||||
watch(
|
||||
[
|
||||
activeTab,
|
||||
|
||||
lotResolve.inputType,
|
||||
lotResolve.inputText,
|
||||
lotDetail.selectedContainerId,
|
||||
lotDetail.activeSubTab,
|
||||
lotDetail.selectedWorkcenterGroups,
|
||||
|
||||
reverseResolve.inputText,
|
||||
reverseDetail.selectedContainerId,
|
||||
reverseDetail.activeSubTab,
|
||||
reverseDetail.selectedWorkcenterGroups,
|
||||
|
||||
equipmentQuery.selectedEquipmentIds,
|
||||
equipmentQuery.startDate,
|
||||
equipmentQuery.endDate,
|
||||
@@ -281,13 +418,22 @@ watch(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => reverseLineage.selectedContainerId.value,
|
||||
(nextSelection) => {
|
||||
if (nextSelection && nextSelection !== reverseDetail.selectedContainerId.value) {
|
||||
void reverseDetail.setSelectedContainerId(nextSelection);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="u-content-shell space-y-3 p-3 lg:p-5">
|
||||
<header class="rounded-shell bg-gradient-to-r from-brand-500 to-accent-500 px-5 py-4 text-white shadow-shell">
|
||||
<h1 class="text-xl font-semibold tracking-wide">批次追蹤工具</h1>
|
||||
<p class="mt-1 text-xs text-indigo-100">LOT 追蹤與設備查詢整合入口</p>
|
||||
<p class="mt-1 text-xs text-indigo-100">正向/反向批次追溯與設備生產批次查詢整合入口</p>
|
||||
</header>
|
||||
|
||||
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
|
||||
@@ -354,6 +500,43 @@ watch(
|
||||
@export-lot-tab="handleExportLotTab"
|
||||
/>
|
||||
|
||||
<SerialReverseTraceView
|
||||
v-show="activeTab === TAB_REVERSE"
|
||||
:input-type="reverseResolve.inputType.value"
|
||||
:input-text="reverseResolve.inputText.value"
|
||||
:input-type-options="reverseResolve.inputTypeOptions"
|
||||
:input-limit="reverseResolve.inputLimit.value"
|
||||
:resolving="reverseResolve.loading.resolving"
|
||||
:resolve-error-message="reverseResolve.errorMessage.value"
|
||||
:resolve-success-message="reverseResolve.successMessage.value"
|
||||
:tree-roots="reverseLineage.treeRoots.value"
|
||||
:not-found="reverseResolve.notFound.value"
|
||||
:lineage-map="reverseLineage.lineageMap"
|
||||
:name-map="reverseLineage.nameMap"
|
||||
:leaf-serials="reverseLineage.leafSerials"
|
||||
:lineage-loading="reverseLineage.lineageLoading.value"
|
||||
:selected-container-ids="reverseLineage.selectedContainerIds.value"
|
||||
:selected-container-id="reverseDetail.selectedContainerId.value"
|
||||
:selected-container-name="reverseSelectedContainerName"
|
||||
:detail-container-ids="reverseDetail.selectedContainerIds.value"
|
||||
:detail-loading="reverseDetail.loading"
|
||||
:detail-loaded="reverseDetail.loaded"
|
||||
:detail-exporting="reverseDetail.exporting"
|
||||
:detail-errors="reverseDetail.errors"
|
||||
:active-sub-tab="reverseDetail.activeSubTab.value"
|
||||
:history-rows="reverseDetail.historyRows.value"
|
||||
:association-rows="reverseDetail.associationRows"
|
||||
:workcenter-groups="reverseDetail.workcenterGroups.value"
|
||||
:selected-workcenter-groups="reverseDetail.selectedWorkcenterGroups.value"
|
||||
@update:input-type="reverseResolve.setInputType($event)"
|
||||
@update:input-text="reverseResolve.setInputText($event)"
|
||||
@resolve="handleResolveReverse"
|
||||
@select-nodes="handleSelectReverseNodes"
|
||||
@change-sub-tab="handleChangeReverseSubTab"
|
||||
@update-workcenter-groups="handleReverseWorkcenterGroupChange"
|
||||
@export-lot-tab="handleExportReverseTab"
|
||||
/>
|
||||
|
||||
<EquipmentView
|
||||
v-show="activeTab === TAB_EQUIPMENT"
|
||||
:equipment-options="equipmentQuery.equipmentOptionItems.value"
|
||||
|
||||
@@ -47,6 +47,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '批次血緣樹',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)',
|
||||
},
|
||||
emptyMessage: {
|
||||
type: String,
|
||||
default: '目前尚無 LOT 根節點,請先在上方解析。',
|
||||
},
|
||||
showSerialLegend: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select-nodes']);
|
||||
@@ -302,8 +318,8 @@ function handleNodeClick(params) {
|
||||
<section class="rounded-card border border-stroke-soft bg-white p-3 shadow-soft">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-800">批次血緣樹</h3>
|
||||
<p class="text-xs text-slate-500">生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)</p>
|
||||
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
|
||||
<p class="text-xs text-slate-500">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -320,7 +336,7 @@ function handleNodeClick(params) {
|
||||
<span class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
|
||||
末端
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<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>
|
||||
@@ -338,7 +354,7 @@ function handleNodeClick(params) {
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="!hasData" class="rounded-card border border-dashed border-stroke-soft bg-surface-muted/40 px-3 py-10 text-center text-xs text-slate-500">
|
||||
目前尚無 LOT 根節點,請先在上方解析。
|
||||
{{ emptyMessage }}
|
||||
</div>
|
||||
|
||||
<!-- ECharts Tree -->
|
||||
|
||||
177
frontend/src/query-tool/components/SerialReverseTraceView.vue
Normal file
177
frontend/src/query-tool/components/SerialReverseTraceView.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import QueryBar from './QueryBar.vue';
|
||||
import LineageTreeChart from './LineageTreeChart.vue';
|
||||
import LotDetail from './LotDetail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inputType: {
|
||||
type: String,
|
||||
default: 'serial_number',
|
||||
},
|
||||
inputText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputTypeOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
inputLimit: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
resolving: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
resolveErrorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
resolveSuccessMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
treeRoots: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
notFound: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
lineageMap: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
nameMap: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
leafSerials: {
|
||||
type: Object,
|
||||
default: () => new Map(),
|
||||
},
|
||||
lineageLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContainerId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedContainerName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
detailContainerIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
detailLoading: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailLoaded: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailExporting: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
detailErrors: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeSubTab: {
|
||||
type: String,
|
||||
default: 'history',
|
||||
},
|
||||
historyRows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
associationRows: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
workcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedWorkcenterGroups: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:inputType',
|
||||
'update:inputText',
|
||||
'resolve',
|
||||
'select-nodes',
|
||||
'change-sub-tab',
|
||||
'update-workcenter-groups',
|
||||
'export-lot-tab',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<QueryBar
|
||||
:input-type="inputType"
|
||||
:input-text="inputText"
|
||||
:input-type-options="inputTypeOptions"
|
||||
:input-limit="inputLimit"
|
||||
:resolving="resolving"
|
||||
:error-message="resolveErrorMessage"
|
||||
@update:input-type="emit('update:inputType', $event)"
|
||||
@update:input-text="emit('update:inputText', $event)"
|
||||
@resolve="emit('resolve')"
|
||||
/>
|
||||
|
||||
<p v-if="resolveSuccessMessage" class="rounded-card border border-state-success/40 bg-emerald-50 px-3 py-2 text-xs text-emerald-700">
|
||||
{{ resolveSuccessMessage }}
|
||||
</p>
|
||||
|
||||
<LineageTreeChart
|
||||
:tree-roots="treeRoots"
|
||||
:not-found="notFound"
|
||||
:lineage-map="lineageMap"
|
||||
:name-map="nameMap"
|
||||
:leaf-serials="leafSerials"
|
||||
:selected-container-ids="selectedContainerIds"
|
||||
:loading="lineageLoading"
|
||||
title="流水批反查樹"
|
||||
description="由成品流水號往上追溯來源批次與祖先節點(點擊節點可多選)"
|
||||
empty-message="目前尚無可反查節點,請先輸入成品流水號。"
|
||||
:show-serial-legend="false"
|
||||
@select-nodes="emit('select-nodes', $event)"
|
||||
/>
|
||||
|
||||
<LotDetail
|
||||
:selected-container-id="selectedContainerId"
|
||||
:selected-container-name="selectedContainerName"
|
||||
:selected-container-ids="detailContainerIds"
|
||||
:clicked-container-ids="selectedContainerIds"
|
||||
:name-map="nameMap"
|
||||
:active-sub-tab="activeSubTab"
|
||||
:loading="detailLoading"
|
||||
:loaded="detailLoaded"
|
||||
:exporting="detailExporting"
|
||||
:errors="detailErrors"
|
||||
:history-rows="historyRows"
|
||||
:association-rows="associationRows"
|
||||
:workcenter-groups="workcenterGroups"
|
||||
:selected-workcenter-groups="selectedWorkcenterGroups"
|
||||
@change-sub-tab="emit('change-sub-tab', $event)"
|
||||
@update-workcenter-groups="emit('update-workcenter-groups', $event)"
|
||||
@export-tab="emit('export-lot-tab', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,10 +23,28 @@ function normalizeInputType(value) {
|
||||
return 'lot_id';
|
||||
}
|
||||
|
||||
function normalizeAllowedTypes(input) {
|
||||
const values = Array.isArray(input)
|
||||
? input.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const filtered = values.filter((value) => Boolean(INPUT_LIMITS[value]));
|
||||
if (filtered.length === 0) {
|
||||
return ['lot_id', 'serial_number', 'work_order'];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function useLotResolve(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const allowedTypes = normalizeAllowedTypes(initial.allowedTypes);
|
||||
const optionPool = INPUT_TYPE_OPTIONS.filter((option) => allowedTypes.includes(option.value));
|
||||
const defaultType = optionPool[0]?.value || 'lot_id';
|
||||
|
||||
const inputType = ref(normalizeInputType(initial.inputType));
|
||||
if (!allowedTypes.includes(inputType.value)) {
|
||||
inputType.value = defaultType;
|
||||
}
|
||||
const inputText = ref(String(initial.inputText || ''));
|
||||
|
||||
const resolvedLots = ref([]);
|
||||
@@ -40,7 +58,7 @@ export function useLotResolve(initial = {}) {
|
||||
resolving: false,
|
||||
});
|
||||
|
||||
const inputTypeOptions = INPUT_TYPE_OPTIONS;
|
||||
const inputTypeOptions = optionPool;
|
||||
const inputValues = computed(() => parseInputValues(inputText.value));
|
||||
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
|
||||
|
||||
@@ -62,7 +80,8 @@ export function useLotResolve(initial = {}) {
|
||||
}
|
||||
|
||||
function setInputType(nextType) {
|
||||
inputType.value = normalizeInputType(nextType);
|
||||
const normalized = normalizeInputType(nextType);
|
||||
inputType.value = allowedTypes.includes(normalized) ? normalized : defaultType;
|
||||
}
|
||||
|
||||
function setInputText(text) {
|
||||
@@ -71,7 +90,11 @@ export function useLotResolve(initial = {}) {
|
||||
|
||||
function validateInput(values) {
|
||||
if (values.length === 0) {
|
||||
return '請輸入 LOT/流水號/工單條件';
|
||||
const labels = inputTypeOptions
|
||||
.map((option) => option.label)
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
return labels ? `請輸入 ${labels} 條件` : '請輸入查詢條件';
|
||||
}
|
||||
|
||||
const limit = INPUT_LIMITS[inputType.value] || 50;
|
||||
|
||||
389
frontend/src/query-tool/composables/useReverseLineage.js
Normal file
389
frontend/src/query-tool/composables/useReverseLineage.js
Normal file
@@ -0,0 +1,389 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { apiPost, ensureMesApiAvailable } from '../../core/api.js';
|
||||
import { normalizeText, uniqueValues } from '../utils/values.js';
|
||||
|
||||
const MAX_CONCURRENCY = 3;
|
||||
const MAX_429_RETRY = 3;
|
||||
|
||||
function emptyLineageEntry() {
|
||||
return {
|
||||
children: [],
|
||||
loading: false,
|
||||
error: '',
|
||||
fetched: false,
|
||||
lastUpdatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function extractContainerId(row) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
return '';
|
||||
}
|
||||
return normalizeText(row.container_id || row.CONTAINERID || row.containerId);
|
||||
}
|
||||
|
||||
function createSemaphore(maxConcurrency) {
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
|
||||
function pump() {
|
||||
if (active >= maxConcurrency || queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = queue.shift();
|
||||
active += 1;
|
||||
|
||||
Promise.resolve()
|
||||
.then(item.task)
|
||||
.then(item.resolve)
|
||||
.catch(item.reject)
|
||||
.finally(() => {
|
||||
active = Math.max(0, active - 1);
|
||||
pump();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(task) {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({ task, resolve, reject });
|
||||
pump();
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
queue.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function useReverseLineage(initial = {}) {
|
||||
ensureMesApiAvailable();
|
||||
|
||||
const lineageMap = reactive(new Map());
|
||||
const nameMap = reactive(new Map());
|
||||
const leafSerials = reactive(new Map());
|
||||
const selectedContainerId = ref(normalizeText(initial.selectedContainerId));
|
||||
const selectedContainerIds = ref(
|
||||
initial.selectedContainerId ? [normalizeText(initial.selectedContainerId)] : [],
|
||||
);
|
||||
const rootRows = ref([]);
|
||||
const rootContainerIds = ref([]);
|
||||
const treeRoots = ref([]);
|
||||
|
||||
const inFlight = new Map();
|
||||
const semaphore = createSemaphore(MAX_CONCURRENCY);
|
||||
let generation = 0;
|
||||
|
||||
function ensureEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!lineageMap.has(id)) {
|
||||
lineageMap.set(id, emptyLineageEntry());
|
||||
}
|
||||
|
||||
return lineageMap.get(id);
|
||||
}
|
||||
|
||||
function patchEntry(containerId, patch) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = ensureEntry(id) || emptyLineageEntry();
|
||||
lineageMap.set(id, {
|
||||
...previous,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
function getEntry(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return emptyLineageEntry();
|
||||
}
|
||||
return lineageMap.get(id) || emptyLineageEntry();
|
||||
}
|
||||
|
||||
function getChildren(containerId) {
|
||||
const entry = getEntry(containerId);
|
||||
return Array.isArray(entry.children) ? entry.children : [];
|
||||
}
|
||||
|
||||
function getSubtreeCids(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
if (!id) {
|
||||
return [];
|
||||
}
|
||||
const result = [];
|
||||
const visited = new Set();
|
||||
function walk(nodeId) {
|
||||
if (!nodeId || visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(nodeId);
|
||||
result.push(nodeId);
|
||||
getChildren(nodeId).forEach(walk);
|
||||
}
|
||||
walk(id);
|
||||
return result;
|
||||
}
|
||||
|
||||
function selectNode(containerId) {
|
||||
const id = normalizeText(containerId);
|
||||
selectedContainerId.value = id;
|
||||
if (id && !selectedContainerIds.value.includes(id)) {
|
||||
selectedContainerIds.value = [id];
|
||||
}
|
||||
}
|
||||
|
||||
function setSelectedNodes(cids) {
|
||||
const normalized = (Array.isArray(cids) ? cids : [])
|
||||
.map(normalizeText)
|
||||
.filter(Boolean);
|
||||
selectedContainerIds.value = uniqueValues(normalized);
|
||||
selectedContainerId.value = normalized[0] || '';
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedContainerId.value = '';
|
||||
selectedContainerIds.value = [];
|
||||
}
|
||||
|
||||
const lineageLoading = computed(() => {
|
||||
for (const entry of lineageMap.values()) {
|
||||
if (entry.loading) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
async function requestLineageWithRetry(containerIds) {
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= MAX_429_RETRY) {
|
||||
try {
|
||||
return await apiPost(
|
||||
'/api/trace/lineage',
|
||||
{
|
||||
profile: 'query_tool_reverse',
|
||||
container_ids: containerIds,
|
||||
},
|
||||
{ timeout: 60000, silent: true },
|
||||
);
|
||||
} catch (error) {
|
||||
const status = Number(error?.status || 0);
|
||||
if (status !== 429 || attempt >= MAX_429_RETRY) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const retryAfter = Number(error?.retryAfterSeconds || 0);
|
||||
const fallbackSeconds = 2 ** attempt;
|
||||
const waitSeconds = Math.max(1, Math.min(30, retryAfter || fallbackSeconds));
|
||||
await sleep(waitSeconds * 1000);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeParentMap(payload) {
|
||||
const parentMapData = payload?.parent_map;
|
||||
if (!parentMapData || typeof parentMapData !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
Object.entries(parentMapData).forEach(([childId, parentIds]) => {
|
||||
const child = normalizeText(childId);
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
const values = Array.isArray(parentIds) ? parentIds : [];
|
||||
normalized[child] = uniqueValues(values.map((parentId) => normalizeText(parentId)));
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function populateReverseTree(payload, requestedRoots = []) {
|
||||
const parentMap = normalizeParentMap(payload);
|
||||
const names = payload?.names;
|
||||
|
||||
if (names && typeof names === 'object') {
|
||||
Object.entries(names).forEach(([cid, name]) => {
|
||||
if (cid && name) {
|
||||
nameMap.set(normalizeText(cid), String(name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.entries(parentMap).forEach(([childId, parentIds]) => {
|
||||
patchEntry(childId, {
|
||||
children: uniqueValues(parentIds || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
const allParentIds = new Set();
|
||||
Object.values(parentMap).forEach((parentIds) => {
|
||||
(parentIds || []).forEach((parentId) => {
|
||||
const normalized = normalizeText(parentId);
|
||||
if (normalized) {
|
||||
allParentIds.add(normalized);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
allParentIds.forEach((parentId) => {
|
||||
if (!parentMap[parentId] && !getEntry(parentId).fetched) {
|
||||
patchEntry(parentId, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const roots = (payload?.roots || requestedRoots || [])
|
||||
.map((cid) => normalizeText(cid))
|
||||
.filter(Boolean);
|
||||
|
||||
roots.forEach((cid) => {
|
||||
if (!getEntry(cid).fetched) {
|
||||
patchEntry(cid, {
|
||||
children: uniqueValues(parentMap[cid] || []),
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: '',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
treeRoots.value = roots;
|
||||
}
|
||||
|
||||
async function fetchLineage(containerIds, { force = false } = {}) {
|
||||
const ids = (Array.isArray(containerIds) ? containerIds : [containerIds])
|
||||
.map((c) => normalizeText(c))
|
||||
.filter(Boolean);
|
||||
|
||||
if (ids.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!force && ids.length === 1) {
|
||||
const existing = getEntry(ids[0]);
|
||||
if (existing.fetched) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const cacheKey = [...ids].sort().join(',');
|
||||
if (inFlight.has(cacheKey)) {
|
||||
return inFlight.get(cacheKey);
|
||||
}
|
||||
|
||||
const runGeneration = generation;
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, { loading: true, error: '' });
|
||||
});
|
||||
|
||||
const promise = semaphore
|
||||
.schedule(async () => {
|
||||
try {
|
||||
const payload = await requestLineageWithRetry(ids);
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
populateReverseTree(payload, ids);
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} catch (error) {
|
||||
if (runGeneration !== generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ids.forEach((id) => {
|
||||
patchEntry(id, {
|
||||
children: [],
|
||||
loading: false,
|
||||
fetched: true,
|
||||
error: error?.message || '反向血緣查詢失敗',
|
||||
lastUpdatedAt: Date.now(),
|
||||
});
|
||||
});
|
||||
return ids.length === 1 ? getEntry(ids[0]) : null;
|
||||
} finally {
|
||||
inFlight.delete(cacheKey);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
inFlight.delete(cacheKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
inFlight.set(cacheKey, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
function resetLineageState() {
|
||||
generation += 1;
|
||||
inFlight.clear();
|
||||
semaphore.clear();
|
||||
lineageMap.clear();
|
||||
nameMap.clear();
|
||||
leafSerials.clear();
|
||||
rootRows.value = [];
|
||||
rootContainerIds.value = [];
|
||||
treeRoots.value = [];
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function primeResolvedLots(rows) {
|
||||
resetLineageState();
|
||||
rootRows.value = Array.isArray(rows) ? [...rows] : [];
|
||||
|
||||
const ids = uniqueValues(rootRows.value.map((row) => extractContainerId(row)).filter(Boolean));
|
||||
rootContainerIds.value = ids;
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await fetchLineage(ids, { force: true });
|
||||
}
|
||||
|
||||
return {
|
||||
lineageMap,
|
||||
nameMap,
|
||||
leafSerials,
|
||||
selectedContainerId,
|
||||
selectedContainerIds,
|
||||
rootRows,
|
||||
rootContainerIds,
|
||||
treeRoots,
|
||||
lineageLoading,
|
||||
selectNode,
|
||||
setSelectedNodes,
|
||||
clearSelection,
|
||||
getSubtreeCids,
|
||||
fetchLineage,
|
||||
resetLineageState,
|
||||
primeResolvedLots,
|
||||
};
|
||||
}
|
||||
@@ -38,8 +38,13 @@ TRACE_LINEAGE_TIMEOUT_SECONDS = 60.0
|
||||
TRACE_CACHE_TTL_SECONDS = 300
|
||||
|
||||
PROFILE_QUERY_TOOL = "query_tool"
|
||||
PROFILE_QUERY_TOOL_REVERSE = "query_tool_reverse"
|
||||
PROFILE_MID_SECTION_DEFECT = "mid_section_defect"
|
||||
SUPPORTED_PROFILES = {PROFILE_QUERY_TOOL, PROFILE_MID_SECTION_DEFECT}
|
||||
SUPPORTED_PROFILES = {
|
||||
PROFILE_QUERY_TOOL,
|
||||
PROFILE_QUERY_TOOL_REVERSE,
|
||||
PROFILE_MID_SECTION_DEFECT,
|
||||
}
|
||||
|
||||
QUERY_TOOL_RESOLVE_TYPES = {"lot_id", "serial_number", "work_order"}
|
||||
SUPPORTED_EVENT_DOMAINS = {
|
||||
@@ -113,8 +118,8 @@ def _seed_cache_key(profile: str, params: Dict[str, Any]) -> str:
|
||||
return f"trace:seed:{profile}:{_hash_payload(params)}"
|
||||
|
||||
|
||||
def _lineage_cache_key(container_ids: List[str]) -> str:
|
||||
return f"trace:lineage:{_short_hash(sorted(container_ids))}"
|
||||
def _lineage_cache_key(profile: str, container_ids: List[str]) -> str:
|
||||
return f"trace:lineage:{profile}:{_short_hash(sorted(container_ids))}"
|
||||
|
||||
|
||||
def _events_cache_key(profile: str, domains: List[str], container_ids: List[str]) -> str:
|
||||
@@ -353,7 +358,7 @@ def seed_resolve():
|
||||
)
|
||||
|
||||
started = time.monotonic()
|
||||
if profile == PROFILE_QUERY_TOOL:
|
||||
if profile in {PROFILE_QUERY_TOOL, PROFILE_QUERY_TOOL_REVERSE}:
|
||||
resolved, route_error = _seed_resolve_query_tool(params)
|
||||
else:
|
||||
resolved, route_error = _seed_resolve_mid_section_defect(params)
|
||||
@@ -391,7 +396,7 @@ def lineage():
|
||||
if not container_ids:
|
||||
return _error("INVALID_PARAMS", "container_ids must contain at least one id", 400)
|
||||
|
||||
lineage_cache_key = _lineage_cache_key(container_ids)
|
||||
lineage_cache_key = _lineage_cache_key(profile, container_ids)
|
||||
cached = cache_get(lineage_cache_key)
|
||||
if cached is not None:
|
||||
return jsonify(cached)
|
||||
@@ -405,7 +410,27 @@ def lineage():
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
if profile == PROFILE_QUERY_TOOL_REVERSE:
|
||||
reverse_graph = LineageEngine.resolve_full_genealogy(container_ids)
|
||||
response = _build_lineage_response(
|
||||
container_ids,
|
||||
reverse_graph.get("ancestors", {}),
|
||||
cid_to_name=reverse_graph.get("cid_to_name"),
|
||||
parent_map=reverse_graph.get("parent_map"),
|
||||
merge_edges=reverse_graph.get("merge_edges"),
|
||||
)
|
||||
response["roots"] = list(container_ids)
|
||||
else:
|
||||
forward_tree = LineageEngine.resolve_forward_tree(container_ids)
|
||||
cid_to_name = forward_tree.get("cid_to_name") or {}
|
||||
response = {
|
||||
"stage": "lineage",
|
||||
"roots": forward_tree.get("roots", []),
|
||||
"children_map": forward_tree.get("children_map", {}),
|
||||
"leaf_serials": forward_tree.get("leaf_serials", {}),
|
||||
"names": {cid: name for cid, name in cid_to_name.items() if name},
|
||||
"total_nodes": forward_tree.get("total_nodes", 0),
|
||||
}
|
||||
except Exception as exc:
|
||||
if _is_timeout_exception(exc):
|
||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
||||
@@ -416,15 +441,6 @@ def lineage():
|
||||
if elapsed > TRACE_LINEAGE_TIMEOUT_SECONDS:
|
||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
||||
|
||||
cid_to_name = forward_tree.get("cid_to_name") or {}
|
||||
response: Dict[str, Any] = {
|
||||
"stage": "lineage",
|
||||
"roots": forward_tree.get("roots", []),
|
||||
"children_map": forward_tree.get("children_map", {}),
|
||||
"leaf_serials": forward_tree.get("leaf_serials", {}),
|
||||
"names": {cid: name for cid, name in cid_to_name.items() if name},
|
||||
"total_nodes": forward_tree.get("total_nodes", 0),
|
||||
}
|
||||
cache_set(lineage_cache_key, response, ttl=TRACE_CACHE_TTL_SECONDS)
|
||||
return jsonify(response)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""E2E coverage for the Query Tool page (LOT 追蹤 + 設備查詢 tabs)."""
|
||||
"""E2E coverage for the Query Tool page tabs and core query flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -262,10 +262,12 @@ class TestQueryToolPageE2E:
|
||||
heading = page.get_by_role("heading", name="批次追蹤工具")
|
||||
expect(heading).to_be_visible()
|
||||
|
||||
# Both tab buttons should exist
|
||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
||||
equipment_tab = page.locator("button", has_text="設備查詢")
|
||||
# All tab buttons should exist
|
||||
lot_tab = page.locator("button", has_text="批次追蹤(正向)")
|
||||
reverse_tab = page.locator("button", has_text="流水批反查(反向)")
|
||||
equipment_tab = page.locator("button", has_text="設備生產批次追蹤")
|
||||
expect(lot_tab).to_be_visible()
|
||||
expect(reverse_tab).to_be_visible()
|
||||
expect(equipment_tab).to_be_visible()
|
||||
|
||||
def test_lot_tab_resolve_work_order(self, page: Page, app_server: str):
|
||||
@@ -388,12 +390,12 @@ class TestQueryToolPageE2E:
|
||||
textarea.fill("GA26010001")
|
||||
|
||||
# Switch to equipment tab
|
||||
equipment_tab = page.locator("button", has_text="設備查詢")
|
||||
equipment_tab = page.locator("button", has_text="設備生產批次追蹤")
|
||||
equipment_tab.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
# Switch back to LOT tab
|
||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
||||
lot_tab = page.locator("button", has_text="批次追蹤(正向)")
|
||||
lot_tab.click()
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
|
||||
@@ -59,6 +59,38 @@ def test_seed_resolve_query_tool_success(mock_resolve_lots):
|
||||
assert payload['cache_key'].startswith('trace:seed:query_tool:')
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.trace_routes.resolve_lots')
|
||||
def test_seed_resolve_query_tool_reverse_success(mock_resolve_lots):
|
||||
mock_resolve_lots.return_value = {
|
||||
'data': [
|
||||
{
|
||||
'container_id': 'CID-SN',
|
||||
'lot_id': 'LOT-SN',
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
client = _client()
|
||||
response = client.post(
|
||||
'/api/trace/seed-resolve',
|
||||
json={
|
||||
'profile': 'query_tool_reverse',
|
||||
'params': {
|
||||
'resolve_type': 'serial_number',
|
||||
'values': ['SN-001'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload['stage'] == 'seed-resolve'
|
||||
assert payload['seed_count'] == 1
|
||||
assert payload['seeds'][0]['container_id'] == 'CID-SN'
|
||||
assert payload['seeds'][0]['container_name'] == 'LOT-SN'
|
||||
assert payload['cache_key'].startswith('trace:seed:query_tool_reverse:')
|
||||
|
||||
|
||||
def test_seed_resolve_invalid_profile_returns_400():
|
||||
client = _client()
|
||||
response = client.post(
|
||||
@@ -122,6 +154,38 @@ def test_lineage_success_returns_forward_tree(mock_resolve_tree):
|
||||
assert payload['names']['CID-A'] == 'LOT-A'
|
||||
|
||||
|
||||
@patch('mes_dashboard.routes.trace_routes.LineageEngine.resolve_full_genealogy')
|
||||
def test_lineage_reverse_profile_returns_ancestors(mock_resolve_genealogy):
|
||||
mock_resolve_genealogy.return_value = {
|
||||
'ancestors': {'CID-SN': {'CID-A', 'CID-B'}},
|
||||
'cid_to_name': {
|
||||
'CID-SN': 'LOT-SN',
|
||||
'CID-A': 'LOT-A',
|
||||
'CID-B': 'LOT-B',
|
||||
},
|
||||
'parent_map': {'CID-SN': ['CID-A'], 'CID-A': ['CID-B']},
|
||||
'merge_edges': {'CID-SN': ['CID-A']},
|
||||
}
|
||||
|
||||
client = _client()
|
||||
response = client.post(
|
||||
'/api/trace/lineage',
|
||||
json={
|
||||
'profile': 'query_tool_reverse',
|
||||
'container_ids': ['CID-SN'],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload['stage'] == 'lineage'
|
||||
assert payload['roots'] == ['CID-SN']
|
||||
assert sorted(payload['ancestors']['CID-SN']) == ['CID-A', 'CID-B']
|
||||
assert payload['parent_map']['CID-SN'] == ['CID-A']
|
||||
assert payload['merge_edges']['CID-SN'] == ['CID-A']
|
||||
assert payload['names']['CID-A'] == 'LOT-A'
|
||||
|
||||
|
||||
@patch(
|
||||
'mes_dashboard.routes.trace_routes.LineageEngine.resolve_forward_tree',
|
||||
side_effect=TimeoutError('lineage timed out'),
|
||||
|
||||
Reference in New Issue
Block a user