feat(query-tool): split serial reverse trace tab and use reverse lineage

This commit is contained in:
egg
2026-02-22 09:52:06 +08:00
parent 75fbdf2f88
commit 9687deb9ad
8 changed files with 925 additions and 55 deletions

View File

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

View File

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

View 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>

View File

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

View 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,
};
}

View File

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

View File

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

View File

@@ -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'),