feat: relax query limits for query-tool and mid-section-defect

This commit is contained in:
egg
2026-03-02 08:20:55 +08:00
parent 5d58ac551d
commit cdf6f67c54
15 changed files with 1177 additions and 103 deletions

View File

@@ -23,6 +23,15 @@ const props = defineProps({
});
const collapsed = ref(false);
const INPUT_TYPE_LABELS = Object.freeze({
lot: 'LOT ID',
lot_id: 'LOT ID',
work_order: '工單',
wafer_lot: 'WAFER LOT',
serial_number: '成品流水號',
gd_work_order: 'GD 工單',
gd_lot_id: 'GD LOT ID',
});
// Restore from sessionStorage
try {
@@ -44,6 +53,10 @@ function formatNumber(v) {
if (v == null || v === 0) return '0';
return Number(v).toLocaleString();
}
function formatInputType(value) {
return INPUT_TYPE_LABELS[value] || value || 'LOT ID';
}
</script>
<template>
@@ -61,7 +74,7 @@ function formatNumber(v) {
<ul class="block-list">
<li>偵測站{{ stationLabel }}</li>
<template v-if="queryParams.queryMode === 'container'">
<li>輸入方式{{ queryParams.containerInputType === 'lot' ? 'LOT ID' : queryParams.containerInputType }}</li>
<li>輸入方式{{ formatInputType(queryParams.containerInputType) }}</li>
<li v-if="queryParams.resolvedCount != null">解析數量{{ queryParams.resolvedCount }} </li>
<li v-if="queryParams.notFoundCount > 0">未找到{{ queryParams.notFoundCount }} </li>
</template>

View File

@@ -103,12 +103,15 @@ function updateFilters(patch) {
:value="containerInputType"
:disabled="loading"
@change="$emit('update:containerInputType', $event.target.value)"
>
<option value="lot">LOT</option>
<option value="work_order">工單</option>
<option value="wafer_lot">WAFER LOT</option>
</select>
</div>
>
<option value="lot">LOT</option>
<option value="work_order">工單</option>
<option value="wafer_lot">WAFER LOT</option>
<option value="serial_number">成品流水號</option>
<option value="gd_work_order">GD 工單</option>
<option value="gd_lot_id">GD LOT ID</option>
</select>
</div>
<!-- Shared: direction -->
<div class="filter-field">

View File

@@ -13,12 +13,12 @@ const INPUT_TYPE_OPTIONS = Object.freeze([
]);
const INPUT_LIMITS = Object.freeze({
wafer_lot: 50,
lot_id: 50,
serial_number: 50,
work_order: 10,
gd_work_order: 10,
gd_lot_id: 50,
wafer_lot: 100,
lot_id: 100,
serial_number: 100,
work_order: 50,
gd_work_order: 100,
gd_lot_id: 100,
});
function normalizeInputType(value) {
@@ -66,7 +66,7 @@ export function useLotResolve(initial = {}) {
const inputTypeOptions = optionPool;
const inputValues = computed(() => parseInputValues(inputText.value));
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || 50);
const inputLimit = computed(() => INPUT_LIMITS[inputType.value] || INPUT_LIMITS.lot_id);
function clearMessages() {
errorMessage.value = '';
@@ -103,7 +103,7 @@ export function useLotResolve(initial = {}) {
return labels ? `請輸入 ${labels} 條件` : '請輸入查詢條件';
}
const limit = INPUT_LIMITS[inputType.value] || 50;
const limit = INPUT_LIMITS[inputType.value] || INPUT_LIMITS.lot_id;
if (values.length > limit) {
return `輸入數量超過上限 (${limit} 筆)`;
}

View File

@@ -0,0 +1,239 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { useEquipmentQuery } from '../src/query-tool/composables/useEquipmentQuery.js';
import { useLotDetail } from '../src/query-tool/composables/useLotDetail.js';
import { useLotLineage } from '../src/query-tool/composables/useLotLineage.js';
import { useLotResolve } from '../src/query-tool/composables/useLotResolve.js';
function setupWindowMesApi({ get, post } = {}) {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
globalThis.window = {
MesApi: {
get: get || (async () => ({})),
post: post || (async () => ({})),
},
setTimeout: globalThis.setTimeout.bind(globalThis),
clearTimeout: globalThis.clearTimeout.bind(globalThis),
};
globalThis.document = {
querySelector: () => null,
};
return () => {
globalThis.window = originalWindow;
globalThis.document = originalDocument;
};
}
test('useLotResolve validates multi-query size and resolves deduplicated inputs', async () => {
const postCalls = [];
const restore = setupWindowMesApi({
post: async (url, payload) => {
postCalls.push({ url, payload });
return {
data: payload.values.map((value, index) => ({
container_id: `CID-${index + 1}`,
input_value: value,
})),
not_found: [],
expansion_info: {},
};
},
});
try {
const resolver = useLotResolve({
inputType: 'work_order',
allowedTypes: ['work_order', 'lot_id'],
});
resolver.setInputText(Array.from({ length: 51 }, (_, idx) => `WO-${idx + 1}`).join('\n'));
const overLimit = await resolver.resolveLots();
assert.equal(overLimit.ok, false);
assert.equal(overLimit.reason, 'validation');
assert.match(resolver.errorMessage.value, /50/);
assert.equal(postCalls.length, 0);
resolver.setInputText('WO-001\nWO-001, WO-002');
const resolved = await resolver.resolveLots();
assert.equal(resolved.ok, true);
assert.equal(postCalls.length, 1);
assert.deepEqual(postCalls[0].payload.values, ['WO-001', 'WO-002']);
assert.equal(resolver.resolvedLots.value.length, 2);
assert.match(resolver.successMessage.value, /解析完成/);
} finally {
restore();
}
});
test('useLotLineage deduplicates in-flight lineage requests and stores graph data', async () => {
const postCalls = [];
const restore = setupWindowMesApi({
post: async (url, payload) => {
postCalls.push({ url, payload });
await new Promise((resolve) => setTimeout(resolve, 15));
return {
roots: ['CID-ROOT'],
children_map: {
'CID-ROOT': ['CID-CHILD'],
'CID-CHILD': [],
},
names: {
'CID-ROOT': 'LOT-ROOT',
'CID-CHILD': 'LOT-CHILD',
},
nodes: {
'CID-ROOT': { container_name: 'LOT-ROOT', container_id: 'CID-ROOT' },
'CID-CHILD': { container_name: 'LOT-CHILD', container_id: 'CID-CHILD' },
},
edges: [
{
from_cid: 'CID-ROOT',
to_cid: 'CID-CHILD',
edge_type: 'split',
},
],
leaf_serials: {
'CID-CHILD': ['SN-001'],
},
};
},
});
try {
const lineage = useLotLineage();
await Promise.all([
lineage.fetchLineage(['CID-ROOT']),
lineage.fetchLineage(['CID-ROOT']),
]);
assert.equal(postCalls.length, 1);
assert.deepEqual(postCalls[0].payload.container_ids, ['CID-ROOT']);
assert.deepEqual(lineage.getChildren('CID-ROOT'), ['CID-CHILD']);
assert.deepEqual(lineage.getSerials('CID-CHILD'), ['SN-001']);
assert.equal(lineage.nameMap.get('CID-ROOT'), 'LOT-ROOT');
assert.equal(lineage.graphEdges.value.length, 1);
} finally {
restore();
}
});
test('useEquipmentQuery performs timeline multi-query and keeps validation errors user-friendly', async () => {
const postCalls = [];
const restore = setupWindowMesApi({
get: async (url) => {
if (url === '/api/query-tool/equipment-list') {
return {
data: [
{ RESOURCEID: 'EQ-1', RESOURCENAME: 'EQ Alpha' },
{ RESOURCEID: 'EQ-2', RESOURCENAME: 'EQ Beta' },
],
};
}
throw new Error(`unexpected GET url: ${url}`);
},
post: async (url, payload) => {
postCalls.push({ url, payload });
if (payload.query_type === 'status_hours') {
return { data: [{ STATUSNAME: 'RUN', HOURS: 12 }] };
}
if (payload.query_type === 'lots') {
return { data: [{ CONTAINERID: 'CID-1001' }] };
}
if (payload.query_type === 'jobs') {
return { data: [{ JOBID: 'JOB-001' }] };
}
return { data: [] };
},
});
try {
const equipment = useEquipmentQuery({
selectedEquipmentIds: ['EQ-1'],
startDate: '2026-02-01',
endDate: '2026-02-10',
activeSubTab: 'timeline',
});
const bootstrapped = await equipment.bootstrap();
assert.equal(bootstrapped, true);
assert.equal(equipment.equipmentOptions.value.length, 2);
const queried = await equipment.queryTimeline();
assert.equal(queried, true);
assert.deepEqual(
postCalls.map((call) => call.payload.query_type).sort(),
['jobs', 'lots', 'status_hours'],
);
assert.equal(equipment.queried.timeline, true);
assert.equal(equipment.statusRows.value.length, 1);
assert.equal(equipment.jobsRows.value.length, 1);
assert.equal(equipment.lotsRows.value.length, 1);
equipment.setSelectedEquipmentIds([]);
const invalid = await equipment.queryLots();
assert.equal(invalid, false);
assert.match(equipment.errors.filters, /至少一台設備/);
} finally {
restore();
}
});
test('useLotDetail batches selected container ids and preserves workcenter filters in follow-up query', async () => {
const getCalls = [];
const restore = setupWindowMesApi({
get: async (url) => {
getCalls.push(url);
const parsed = new URL(url, 'http://local.test');
if (parsed.pathname === '/api/query-tool/lot-history') {
return {
data: [
{
CONTAINERID: 'CID-001',
EQUIPMENTID: 'EQ-01',
TRACKINTIMESTAMP: '2026-02-01 08:00:00',
TRACKOUTTIMESTAMP: '2026-02-01 08:30:00',
},
],
};
}
if (parsed.pathname === '/api/query-tool/lot-associations') {
const assocType = parsed.searchParams.get('type');
return { data: [{ TYPE: assocType, CONTAINERID: 'CID-001' }] };
}
if (parsed.pathname === '/api/query-tool/workcenter-groups') {
return { data: [{ name: 'WB', sequence: 1 }] };
}
throw new Error(`unexpected GET url: ${url}`);
},
});
try {
const detail = useLotDetail({ activeSubTab: 'history' });
const ok = await detail.setSelectedContainerIds(['CID-001', 'CID-002']);
assert.equal(ok, true);
assert.equal(detail.historyRows.value.length, 1);
assert.equal(detail.associationRows.holds.length, 1);
assert.equal(detail.associationRows.materials.length, 1);
const historyCall = getCalls.find((url) => url.startsWith('/api/query-tool/lot-history?'));
assert.ok(historyCall, 'lot-history API should be called');
const historyParams = new URL(historyCall, 'http://local.test').searchParams;
assert.equal(historyParams.get('container_ids'), 'CID-001,CID-002');
await detail.setSelectedWorkcenterGroups(['WB']);
const latestHistoryCall = getCalls.filter((url) => url.startsWith('/api/query-tool/lot-history?')).at(-1);
const latestParams = new URL(latestHistoryCall, 'http://local.test').searchParams;
assert.equal(latestParams.get('workcenter_groups'), 'WB');
} finally {
restore();
}
});