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 EquipmentView from './components/EquipmentView.vue';
|
||||||
import LotTraceView from './components/LotTraceView.vue';
|
import LotTraceView from './components/LotTraceView.vue';
|
||||||
|
import SerialReverseTraceView from './components/SerialReverseTraceView.vue';
|
||||||
import { useEquipmentQuery } from './composables/useEquipmentQuery.js';
|
import { useEquipmentQuery } from './composables/useEquipmentQuery.js';
|
||||||
import { useLotDetail } from './composables/useLotDetail.js';
|
import { useLotDetail } from './composables/useLotDetail.js';
|
||||||
import { useLotLineage } from './composables/useLotLineage.js';
|
import { useLotLineage } from './composables/useLotLineage.js';
|
||||||
import { useLotResolve } from './composables/useLotResolve.js';
|
import { useLotResolve } from './composables/useLotResolve.js';
|
||||||
|
import { useReverseLineage } from './composables/useReverseLineage.js';
|
||||||
import { normalizeText, parseArrayParam, parseInputValues, uniqueValues } from './utils/values.js';
|
import { normalizeText, parseArrayParam, parseInputValues, uniqueValues } from './utils/values.js';
|
||||||
|
|
||||||
const TAB_LOT = 'lot';
|
const TAB_LOT = 'lot';
|
||||||
|
const TAB_REVERSE = 'reverse';
|
||||||
const TAB_EQUIPMENT = 'equipment';
|
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([
|
const tabItems = Object.freeze([
|
||||||
{ key: TAB_LOT, label: 'LOT 追蹤', subtitle: '血緣樹與批次詳情' },
|
{ key: TAB_LOT, label: '批次追蹤(正向)', subtitle: '由批次展開下游血緣與明細' },
|
||||||
{ key: TAB_EQUIPMENT, label: '設備查詢', subtitle: '設備紀錄與時序視圖' },
|
{ key: TAB_REVERSE, label: '流水批反查(反向)', subtitle: '由成品流水號回溯上游批次' },
|
||||||
|
{ key: TAB_EQUIPMENT, label: '設備生產批次追蹤', subtitle: '設備紀錄與時序視圖' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function normalizeTopTab(value) {
|
function normalizeTopTab(value) {
|
||||||
@@ -29,13 +33,28 @@ function normalizeTopTab(value) {
|
|||||||
function readStateFromUrl() {
|
function readStateFromUrl() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
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 {
|
return {
|
||||||
tab: normalizeTopTab(params.get('tab')),
|
tab,
|
||||||
inputType: normalizeText(params.get('input_type')) || 'lot_id',
|
lotInputType: normalizeText(params.get('lot_input_type')) || (tab === TAB_LOT ? legacyInputType : '') || 'lot_id',
|
||||||
inputText: parseArrayParam(params, 'values').join('\n'),
|
lotInputText: parseArrayParam(params, 'lot_values').join('\n') || (tab === TAB_LOT ? legacyInputText : ''),
|
||||||
selectedContainerId: normalizeText(params.get('container_id')),
|
lotSelectedContainerId: normalizeText(params.get('lot_container_id')) || (tab === TAB_LOT ? legacySelectedContainerId : ''),
|
||||||
lotSubTab: normalizeText(params.get('lot_sub_tab')) || 'history',
|
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'),
|
equipmentIds: parseArrayParam(params, 'equipment_ids'),
|
||||||
startDate: normalizeText(params.get('start_date')),
|
startDate: normalizeText(params.get('start_date')),
|
||||||
endDate: normalizeText(params.get('end_date')),
|
endDate: normalizeText(params.get('end_date')),
|
||||||
@@ -47,18 +66,35 @@ const initialState = readStateFromUrl();
|
|||||||
const activeTab = ref(initialState.tab);
|
const activeTab = ref(initialState.tab);
|
||||||
|
|
||||||
const lotResolve = useLotResolve({
|
const lotResolve = useLotResolve({
|
||||||
inputType: initialState.inputType,
|
inputType: initialState.lotInputType,
|
||||||
inputText: initialState.inputText,
|
inputText: initialState.lotInputText,
|
||||||
|
allowedTypes: ['lot_id', 'work_order'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const reverseResolve = useLotResolve({
|
||||||
|
inputType: 'serial_number',
|
||||||
|
inputText: initialState.reverseInputText,
|
||||||
|
allowedTypes: ['serial_number'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const lotLineage = useLotLineage({
|
const lotLineage = useLotLineage({
|
||||||
selectedContainerId: initialState.selectedContainerId,
|
selectedContainerId: initialState.lotSelectedContainerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reverseLineage = useReverseLineage({
|
||||||
|
selectedContainerId: initialState.reverseSelectedContainerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lotDetail = useLotDetail({
|
const lotDetail = useLotDetail({
|
||||||
selectedContainerId: initialState.selectedContainerId,
|
selectedContainerId: initialState.lotSelectedContainerId,
|
||||||
activeSubTab: initialState.lotSubTab,
|
activeSubTab: initialState.lotSubTab,
|
||||||
workcenterGroups: initialState.workcenterGroups,
|
workcenterGroups: initialState.lotWorkcenterGroups,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reverseDetail = useLotDetail({
|
||||||
|
selectedContainerId: initialState.reverseSelectedContainerId,
|
||||||
|
activeSubTab: initialState.reverseSubTab,
|
||||||
|
workcenterGroups: initialState.reverseWorkcenterGroups,
|
||||||
});
|
});
|
||||||
|
|
||||||
const equipmentQuery = useEquipmentQuery({
|
const equipmentQuery = useEquipmentQuery({
|
||||||
@@ -75,6 +111,11 @@ const selectedContainerName = computed(() => {
|
|||||||
return cid ? (lotLineage.nameMap.get(cid) || '') : '';
|
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.
|
// Compatibility placeholders for existing table parity tests.
|
||||||
const resolvedColumns = computed(() => Object.keys(lotResolve.resolvedLots.value[0] || {}));
|
const resolvedColumns = computed(() => Object.keys(lotResolve.resolvedLots.value[0] || {}));
|
||||||
const historyColumns = computed(() => Object.keys(lotDetail.historyRows.value[0] || {}));
|
const historyColumns = computed(() => Object.keys(lotDetail.historyRows.value[0] || {}));
|
||||||
@@ -101,24 +142,40 @@ function buildUrlState() {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
params.set('tab', activeTab.value);
|
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) => {
|
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) {
|
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) {
|
if (lotDetail.activeSubTab.value) {
|
||||||
params.set('lot_sub_tab', 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) => {
|
uniqueValues(lotDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||||
params.append('workcenter_groups', group);
|
params.append('workcenter_groups', group);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
uniqueValues(reverseDetail.selectedWorkcenterGroups.value).forEach((group) => {
|
||||||
|
params.append('reverse_workcenter_groups', group);
|
||||||
|
});
|
||||||
|
|
||||||
uniqueValues(equipmentQuery.selectedEquipmentIds.value).forEach((id) => {
|
uniqueValues(equipmentQuery.selectedEquipmentIds.value).forEach((id) => {
|
||||||
params.append('equipment_ids', id);
|
params.append('equipment_ids', id);
|
||||||
});
|
});
|
||||||
@@ -135,6 +192,29 @@ function buildUrlState() {
|
|||||||
params.set('equipment_sub_tab', equipmentQuery.activeSubTab.value);
|
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();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,11 +239,17 @@ async function applyStateFromUrl() {
|
|||||||
|
|
||||||
activeTab.value = state.tab;
|
activeTab.value = state.tab;
|
||||||
|
|
||||||
lotResolve.setInputType(state.inputType);
|
lotResolve.setInputType(state.lotInputType);
|
||||||
lotResolve.setInputText(state.inputText);
|
lotResolve.setInputText(state.lotInputText);
|
||||||
|
|
||||||
|
reverseResolve.setInputType('serial_number');
|
||||||
|
reverseResolve.setInputText(state.reverseInputText);
|
||||||
|
|
||||||
lotDetail.activeSubTab.value = state.lotSubTab;
|
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.selectedEquipmentIds.value = state.equipmentIds;
|
||||||
equipmentQuery.startDate.value = state.startDate || equipmentQuery.startDate.value;
|
equipmentQuery.startDate.value = state.startDate || equipmentQuery.startDate.value;
|
||||||
@@ -172,9 +258,14 @@ async function applyStateFromUrl() {
|
|||||||
|
|
||||||
suppressUrlSync.value = false;
|
suppressUrlSync.value = false;
|
||||||
|
|
||||||
if (state.selectedContainerId) {
|
if (state.lotSelectedContainerId) {
|
||||||
lotLineage.selectNode(state.selectedContainerId);
|
lotLineage.selectNode(state.lotSelectedContainerId);
|
||||||
await lotDetail.setSelectedContainerId(state.selectedContainerId);
|
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.
|
// 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);
|
await lotLineage.primeResolvedLots(lotResolve.resolvedLots.value);
|
||||||
lotLineage.clearSelection();
|
lotLineage.clearSelection();
|
||||||
lotDetail.clearTabData();
|
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) {
|
async function handleSelectNodes(containerIds) {
|
||||||
lotLineage.setSelectedNodes(containerIds);
|
lotLineage.setSelectedNodes(containerIds);
|
||||||
|
|
||||||
@@ -211,18 +312,41 @@ async function handleSelectNodes(containerIds) {
|
|||||||
await lotDetail.setSelectedContainerIds([...seen]);
|
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) {
|
async function handleChangeLotSubTab(tab) {
|
||||||
await lotDetail.setActiveSubTab(tab);
|
await lotDetail.setActiveSubTab(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleChangeReverseSubTab(tab) {
|
||||||
|
await reverseDetail.setActiveSubTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWorkcenterGroupChange(groups) {
|
async function handleWorkcenterGroupChange(groups) {
|
||||||
await lotDetail.setSelectedWorkcenterGroups(groups);
|
await lotDetail.setSelectedWorkcenterGroups(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReverseWorkcenterGroupChange(groups) {
|
||||||
|
await reverseDetail.setSelectedWorkcenterGroups(groups);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExportLotTab(tab) {
|
async function handleExportLotTab(tab) {
|
||||||
await lotDetail.exportSubTab(tab);
|
await lotDetail.exportSubTab(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExportReverseTab(tab) {
|
||||||
|
await reverseDetail.exportSubTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleChangeEquipmentSubTab(tab) {
|
async function handleChangeEquipmentSubTab(tab) {
|
||||||
await equipmentQuery.setActiveSubTab(tab, { autoQuery: true });
|
await equipmentQuery.setActiveSubTab(tab, { autoQuery: true });
|
||||||
}
|
}
|
||||||
@@ -239,12 +363,18 @@ onMounted(async () => {
|
|||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
lotDetail.loadWorkcenterGroups(),
|
lotDetail.loadWorkcenterGroups(),
|
||||||
|
reverseDetail.loadWorkcenterGroups(),
|
||||||
equipmentQuery.bootstrap(),
|
equipmentQuery.bootstrap(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (initialState.selectedContainerId) {
|
if (initialState.lotSelectedContainerId) {
|
||||||
lotLineage.selectNode(initialState.selectedContainerId);
|
lotLineage.selectNode(initialState.lotSelectedContainerId);
|
||||||
await lotDetail.setSelectedContainerId(initialState.selectedContainerId);
|
await lotDetail.setSelectedContainerId(initialState.lotSelectedContainerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialState.reverseSelectedContainerId) {
|
||||||
|
reverseLineage.selectNode(initialState.reverseSelectedContainerId);
|
||||||
|
await reverseDetail.setSelectedContainerId(initialState.reverseSelectedContainerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
syncUrlState();
|
syncUrlState();
|
||||||
@@ -257,11 +387,18 @@ onBeforeUnmount(() => {
|
|||||||
watch(
|
watch(
|
||||||
[
|
[
|
||||||
activeTab,
|
activeTab,
|
||||||
|
|
||||||
lotResolve.inputType,
|
lotResolve.inputType,
|
||||||
lotResolve.inputText,
|
lotResolve.inputText,
|
||||||
lotDetail.selectedContainerId,
|
lotDetail.selectedContainerId,
|
||||||
lotDetail.activeSubTab,
|
lotDetail.activeSubTab,
|
||||||
lotDetail.selectedWorkcenterGroups,
|
lotDetail.selectedWorkcenterGroups,
|
||||||
|
|
||||||
|
reverseResolve.inputText,
|
||||||
|
reverseDetail.selectedContainerId,
|
||||||
|
reverseDetail.activeSubTab,
|
||||||
|
reverseDetail.selectedWorkcenterGroups,
|
||||||
|
|
||||||
equipmentQuery.selectedEquipmentIds,
|
equipmentQuery.selectedEquipmentIds,
|
||||||
equipmentQuery.startDate,
|
equipmentQuery.startDate,
|
||||||
equipmentQuery.endDate,
|
equipmentQuery.endDate,
|
||||||
@@ -281,13 +418,22 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => reverseLineage.selectedContainerId.value,
|
||||||
|
(nextSelection) => {
|
||||||
|
if (nextSelection && nextSelection !== reverseDetail.selectedContainerId.value) {
|
||||||
|
void reverseDetail.setSelectedContainerId(nextSelection);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="u-content-shell space-y-3 p-3 lg:p-5">
|
<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">
|
<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>
|
<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>
|
</header>
|
||||||
|
|
||||||
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
|
<section class="rounded-shell border border-stroke-panel bg-surface-card shadow-panel">
|
||||||
@@ -354,6 +500,43 @@ watch(
|
|||||||
@export-lot-tab="handleExportLotTab"
|
@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
|
<EquipmentView
|
||||||
v-show="activeTab === TAB_EQUIPMENT"
|
v-show="activeTab === TAB_EQUIPMENT"
|
||||||
:equipment-options="equipmentQuery.equipmentOptionItems.value"
|
:equipment-options="equipmentQuery.equipmentOptionItems.value"
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
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']);
|
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">
|
<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 class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-semibold text-slate-800">批次血緣樹</h3>
|
<h3 class="text-sm font-semibold text-slate-800">{{ title }}</h3>
|
||||||
<p class="text-xs text-slate-500">生產流程追溯:晶批 → 切割 → 封裝 → 成品(點擊節點可多選)</p>
|
<p class="text-xs text-slate-500">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<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 class="inline-block size-2.5 rounded-full" :style="{ background: NODE_COLORS.leaf }" />
|
||||||
末端
|
末端
|
||||||
</span>
|
</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 class="inline-block size-2.5 rotate-45" :style="{ background: NODE_COLORS.serial, width: '8px', height: '8px' }" />
|
||||||
序列號
|
序列號
|
||||||
</span>
|
</span>
|
||||||
@@ -338,7 +354,7 @@ function handleNodeClick(params) {
|
|||||||
|
|
||||||
<!-- Empty state -->
|
<!-- 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ECharts Tree -->
|
<!-- 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';
|
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 = {}) {
|
export function useLotResolve(initial = {}) {
|
||||||
ensureMesApiAvailable();
|
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));
|
const inputType = ref(normalizeInputType(initial.inputType));
|
||||||
|
if (!allowedTypes.includes(inputType.value)) {
|
||||||
|
inputType.value = defaultType;
|
||||||
|
}
|
||||||
const inputText = ref(String(initial.inputText || ''));
|
const inputText = ref(String(initial.inputText || ''));
|
||||||
|
|
||||||
const resolvedLots = ref([]);
|
const resolvedLots = ref([]);
|
||||||
@@ -40,7 +58,7 @@ export function useLotResolve(initial = {}) {
|
|||||||
resolving: false,
|
resolving: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inputTypeOptions = INPUT_TYPE_OPTIONS;
|
const inputTypeOptions = optionPool;
|
||||||
const inputValues = computed(() => parseInputValues(inputText.value));
|
const inputValues = computed(() => parseInputValues(inputText.value));
|
||||||
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
|
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
|
||||||
|
|
||||||
@@ -62,7 +80,8 @@ export function useLotResolve(initial = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setInputType(nextType) {
|
function setInputType(nextType) {
|
||||||
inputType.value = normalizeInputType(nextType);
|
const normalized = normalizeInputType(nextType);
|
||||||
|
inputType.value = allowedTypes.includes(normalized) ? normalized : defaultType;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInputText(text) {
|
function setInputText(text) {
|
||||||
@@ -71,7 +90,11 @@ export function useLotResolve(initial = {}) {
|
|||||||
|
|
||||||
function validateInput(values) {
|
function validateInput(values) {
|
||||||
if (values.length === 0) {
|
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;
|
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
|
TRACE_CACHE_TTL_SECONDS = 300
|
||||||
|
|
||||||
PROFILE_QUERY_TOOL = "query_tool"
|
PROFILE_QUERY_TOOL = "query_tool"
|
||||||
|
PROFILE_QUERY_TOOL_REVERSE = "query_tool_reverse"
|
||||||
PROFILE_MID_SECTION_DEFECT = "mid_section_defect"
|
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"}
|
QUERY_TOOL_RESOLVE_TYPES = {"lot_id", "serial_number", "work_order"}
|
||||||
SUPPORTED_EVENT_DOMAINS = {
|
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)}"
|
return f"trace:seed:{profile}:{_hash_payload(params)}"
|
||||||
|
|
||||||
|
|
||||||
def _lineage_cache_key(container_ids: List[str]) -> str:
|
def _lineage_cache_key(profile: str, container_ids: List[str]) -> str:
|
||||||
return f"trace:lineage:{_short_hash(sorted(container_ids))}"
|
return f"trace:lineage:{profile}:{_short_hash(sorted(container_ids))}"
|
||||||
|
|
||||||
|
|
||||||
def _events_cache_key(profile: str, domains: List[str], container_ids: List[str]) -> str:
|
def _events_cache_key(profile: str, domains: List[str], container_ids: List[str]) -> str:
|
||||||
@@ -353,7 +358,7 @@ def seed_resolve():
|
|||||||
)
|
)
|
||||||
|
|
||||||
started = time.monotonic()
|
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)
|
resolved, route_error = _seed_resolve_query_tool(params)
|
||||||
else:
|
else:
|
||||||
resolved, route_error = _seed_resolve_mid_section_defect(params)
|
resolved, route_error = _seed_resolve_mid_section_defect(params)
|
||||||
@@ -391,7 +396,7 @@ def lineage():
|
|||||||
if not container_ids:
|
if not container_ids:
|
||||||
return _error("INVALID_PARAMS", "container_ids must contain at least one id", 400)
|
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)
|
cached = cache_get(lineage_cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return jsonify(cached)
|
return jsonify(cached)
|
||||||
@@ -405,7 +410,27 @@ def lineage():
|
|||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
try:
|
try:
|
||||||
forward_tree = LineageEngine.resolve_forward_tree(container_ids)
|
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:
|
except Exception as exc:
|
||||||
if _is_timeout_exception(exc):
|
if _is_timeout_exception(exc):
|
||||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
||||||
@@ -416,15 +441,6 @@ def lineage():
|
|||||||
if elapsed > TRACE_LINEAGE_TIMEOUT_SECONDS:
|
if elapsed > TRACE_LINEAGE_TIMEOUT_SECONDS:
|
||||||
return _error("LINEAGE_TIMEOUT", "lineage query timed out", 504)
|
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)
|
cache_set(lineage_cache_key, response, ttl=TRACE_CACHE_TTL_SECONDS)
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -262,10 +262,12 @@ class TestQueryToolPageE2E:
|
|||||||
heading = page.get_by_role("heading", name="批次追蹤工具")
|
heading = page.get_by_role("heading", name="批次追蹤工具")
|
||||||
expect(heading).to_be_visible()
|
expect(heading).to_be_visible()
|
||||||
|
|
||||||
# Both tab buttons should exist
|
# All tab buttons should exist
|
||||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
lot_tab = page.locator("button", has_text="批次追蹤(正向)")
|
||||||
equipment_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(lot_tab).to_be_visible()
|
||||||
|
expect(reverse_tab).to_be_visible()
|
||||||
expect(equipment_tab).to_be_visible()
|
expect(equipment_tab).to_be_visible()
|
||||||
|
|
||||||
def test_lot_tab_resolve_work_order(self, page: Page, app_server: str):
|
def test_lot_tab_resolve_work_order(self, page: Page, app_server: str):
|
||||||
@@ -388,12 +390,12 @@ class TestQueryToolPageE2E:
|
|||||||
textarea.fill("GA26010001")
|
textarea.fill("GA26010001")
|
||||||
|
|
||||||
# Switch to equipment tab
|
# Switch to equipment tab
|
||||||
equipment_tab = page.locator("button", has_text="設備查詢")
|
equipment_tab = page.locator("button", has_text="設備生產批次追蹤")
|
||||||
equipment_tab.click()
|
equipment_tab.click()
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
|
|
||||||
# Switch back to LOT tab
|
# Switch back to LOT tab
|
||||||
lot_tab = page.locator("button", has_text="LOT 追蹤")
|
lot_tab = page.locator("button", has_text="批次追蹤(正向)")
|
||||||
lot_tab.click()
|
lot_tab.click()
|
||||||
page.wait_for_timeout(500)
|
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:')
|
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():
|
def test_seed_resolve_invalid_profile_returns_400():
|
||||||
client = _client()
|
client = _client()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -122,6 +154,38 @@ def test_lineage_success_returns_forward_tree(mock_resolve_tree):
|
|||||||
assert payload['names']['CID-A'] == 'LOT-A'
|
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(
|
@patch(
|
||||||
'mes_dashboard.routes.trace_routes.LineageEngine.resolve_forward_tree',
|
'mes_dashboard.routes.trace_routes.LineageEngine.resolve_forward_tree',
|
||||||
side_effect=TimeoutError('lineage timed out'),
|
side_effect=TimeoutError('lineage timed out'),
|
||||||
|
|||||||
Reference in New Issue
Block a user